From 16351b3ada7f9e9b096891752076ad16cb710c9d Mon Sep 17 00:00:00 2001 From: NewCommer00 Date: Sun, 22 Mar 2026 17:54:45 +0800 Subject: [PATCH] feat(ustx,seqtool,base): replace flat-dict USTX API with typed datamodel and multi-tempo support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: load_ustx() now returns UProject instead of dict; save_ustx() now accepts UProject instead of dict; edit_ustx_expression_curve() is removed — use UstxEditor or UVoicePart.set_curve() instead. feat(ustx): add UProject, UVoicePart, UCurve, UTrack, UTempo, UTimeSignature dataclasses mirroring OpenUtau C# models feat(ustx): add TimeAxis — piecewise tick ↔ ms converter replicating OpenUtau's BuildSegments / TickPosToMsPos / MsPosToTickPos logic, with full multi-tempo and multi-time-signature support feat(ustx): add UstxEditor — RAII context manager with exclusive FileLock, add_expression_to_part(), and add_expression_to_track() (absolute → relative tick translation with per-part windowing) feat(seqtool): replace time_to_ticks/ticks_to_time with a set_tick_converters() / reset_tick_converters() registry; all to_ticks=True paths and align_sequence_tick() now use the registered tempo-map-aware converters instead of a fixed BPM approximation feat(base): register tick converters from ustx_time_axis in ExpressionLoader.__init__(); remove threading.Lock (superseded by FileLock in UstxEditor); remove self.tempo in favour of self.ustx_time_axis; offset shifting moved to load_to_ustx() via shift_ticks_by_seconds() feat(dyn,pitd,tenc): drop tempo/ppqn kwargs from align_sequence_tick calls; remove manual utau_offset_ticks arithmetic; pitd gains RMS + RMS dynamics features to match dyn/tenc feature sets feat(examples): add テトリス and Прекрасное Далеко examples with project.ustx, reference.wav, utau.wav, expressive_config.json and README; update 明天会更好 project with reference track and corrected exp_selectors; remove expressive_config.json from .gitignore fix(wavtool): extract_wav_frequency now returns np.ndarray instead of list, matching documented return type deps: add filelock to pyproject.toml test(ustx): replace dict-based assertions with UProject attribute access throughout; add TestUProject, TestUCurve, TestTimeAxis, TestUstxEditor covering roundtrip, multi-tempo boundary, relative-tick translation, and context-manager save/no-save behaviour test(seqtool): replace TestTimeConversion with TestTickConverterRegistry using _make_tick_converters() helper and tick_converters_120bpm fixture; drop TestNumericalStability (absorbed into registry tests) test(base): replace loader.tempo with ustx_time_axis assertions; remove ustx_lock tests; add test_tick_converters_registered_after_init test(wavtool): fix list → ndarray type assertions for extract_wav_frequency return values test(expressive): fix integration test to use UProject attribute access Co-authored-by: Claude --- .gitignore | 1 - README.en.md | 88 +- README.md | 73 +- build/installer.iss | 2 +- .../README.md" | 15 + .../expressive_config.json" | 38 + .../project.ustx" | 29 +- .../README.md" | 16 + .../expressive_config.json" | 38 + .../project.ustx" | 2459 +++++++++++++++++ .../reference.wav" | 3 + .../utau.wav" | 3 + .../README.md" | 15 + .../expressive_config.json" | 38 + .../project.ustx" | 31 +- expressions/base.py | 99 +- expressions/dyn.py | 27 +- expressions/pitd.py | 95 +- expressions/tenc.py | 26 +- expressive_gui.py | 19 +- pyproject.toml | 1 + tests/conftest.py | 36 +- tests/test_expression_base.py | 157 +- tests/test_expressive.py | 5 +- tests/test_seqtool.py | 579 ++-- tests/test_ustx.py | 912 +++--- tests/test_wavtool.py | 76 +- utils/seqtool.py | 157 +- utils/ustx.py | 753 ++++- utils/wavtool.py | 8 +- 30 files changed, 4750 insertions(+), 1049 deletions(-) create mode 100644 "examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/README.md" create mode 100644 "examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/expressive_config.json" create mode 100644 "examples/\343\203\206\343\203\210\343\203\252\343\202\271/README.md" create mode 100644 "examples/\343\203\206\343\203\210\343\203\252\343\202\271/expressive_config.json" create mode 100644 "examples/\343\203\206\343\203\210\343\203\252\343\202\271/project.ustx" create mode 100644 "examples/\343\203\206\343\203\210\343\203\252\343\202\271/reference.wav" create mode 100644 "examples/\343\203\206\343\203\210\343\203\252\343\202\271/utau.wav" create mode 100644 "examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/README.md" create mode 100644 "examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/expressive_config.json" diff --git a/.gitignore b/.gitignore index 56cd7bf..bd076eb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ static/vendor/* *PitchLoader Output*.ustx *output*.ustx settings.json -expressive_config.json Expressive-GUI.spec .pytest_cache/ .coverage diff --git a/README.en.md b/README.en.md index 48e8738..93b452d 100644 --- a/README.en.md +++ b/README.en.md @@ -9,7 +9,7 @@ # Expressive -**Expressive** is a [DiffSinger](https://github.com/openvpi/diffsinger) expression parameter importer developed for [OpenUtau](https://github.com/stakira/OpenUtau). It aims to extract emotional parameters from real human vocals and import them into the appropriate tracks of your project. +**Expressive** is a [DiffSinger](https://github.com/openvpi/diffsinger) expression parameter importer developed for [OpenUtau](https://github.com/stakira/OpenUtau). It aims to extract expression parameters from real human vocals and import them into the appropriate tracks of your project. The current version supports importing the following expression parameters: @@ -21,14 +21,14 @@ The current version supports importing the following expression parameters:

-> - *OpenUtau version used from [keirokeer/OpenUtau-DiffSinger-Lunai](https://github.com/keirokeer/OpenUtau-DiffSinger-Lunai)* +> - *OpenUtau version from [keirokeer/OpenUtau-DiffSinger-Lunai](https://github.com/keirokeer/OpenUtau-DiffSinger-Lunai)* > - *Singer model from [yousa-ling-official-production/yousa-ling-diffsinger-v1](https://github.com/yousa-ling-official-production/yousa-ling-diffsinger-v1)* > [!TIP] >
> 👉 Click to expand the full voiced demo video 👈 > -> https://github.com/user-attachments/assets/4b5b7c15-947a-4f54-b80e-a14a9eefc86b +>

> >
@@ -40,7 +40,7 @@ The current version supports importing the following expression parameters: By default, this application uses [swift-f0](https://github.com/lars76/swift-f0) (based on ONNX Runtime) as the pitch extraction backend, which runs on CPU only and satisfies basic usage scenarios. -The classic [CREPE](https://github.com/marl/crepe) pitch extraction backend (depends on TensorFlow) is also available, which suits for scenarios with higher accuracy requirements. If your computer is equipped with an NVIDIA GPU and supports [CUDA 11.x](https://docs.nvidia.com/deploy/cuda-compatibility/minor-version-compatibility.html) (i.e., GPU driver version >= 450), the CREPE backend will automatically enable GPU acceleration. +The classic [CREPE](https://github.com/marl/crepe) pitch extraction backend (depends on TensorFlow) is also available for scenarios with higher accuracy requirements. If your computer is equipped with an NVIDIA GPU and supports [CUDA 11.x](https://docs.nvidia.com/deploy/cuda-compatibility/minor-version-compatibility.html) (i.e., GPU driver version >= 450), the CREPE backend will automatically enable GPU acceleration. > \* On Windows, TensorFlow 2.10 is the last version that supports GPU acceleration, and Python 3.10 is the highest Python version supported by its `.whl` files. @@ -52,13 +52,17 @@ When using a DiffSinger virtual singer for covers, users often already have an O ### Inputs +> [!TIP] +> Starting from `v0.6.0`, this application supports OpenUtau voice tracks with **multiple parts** and **multiple tempos**. + > [!TIP] > Starting from `v0.5.0`, users can define a selection region independently within the full audio of both the **Utau vocal** and the **Reference vocal**. The selected audio segment will be used as the final input. -* **Utau vocal**: Emotionless synthesized vocal output from OpenUtau (WAV format). It's recommended to keep `Tempo` and segmentation as close to the reference vocal as possible. -* **Reference vocal**: Original human vocal recording (WAV format). You can use tools like [UVR](https://github.com/Anjok07/ultimatevocalremovergui) to remove instrumental and reverb. +* **Utau vocal**: Emotionless synthesized vocal output from OpenUtau (WAV format). It is recommended to keep the segmentation and tempo as close to the **Reference vocal** as possible, as large discrepancies may affect alignment quality. +* **Reference vocal**: Original human vocal recording (WAV format). You can use tools like [UVR](https://github.com/Anjok07/ultimatevocalremovergui) or [MSST](https://github.com/SUC-DriverOld/MSST-WebUI) to remove instrumentals, harmonies, and reverb. * **Input project**: Original OpenUtau project file (USTX format). * **Output path**: Where the new processed project file will be saved. +* **Track number**: The track number in the OpenUtau project where the **Utau vocal** resides (1-based). Expression parameters will be imported into this track. ### Output @@ -74,10 +78,6 @@ A new USTX file with expression parameters added. The original project will not * [x] `Dynamics` generation * [x] `Tension` generation -## ⚠️ Known Issues - -1. The current version does not support tempo changes within a single track. It’s recommended to use a consistent tempo throughout the project. This limitation will be addressed in future updates. - ## 🚀 Direct Install You can download pre-compiled executable files directly from the [Releases](https://github.com/NewComer00/expressive/releases) page: @@ -100,7 +100,6 @@ Includes CUDA runtime libraries. When used on a computer with an NVIDIA GPU (dri > [!IMPORTANT] > This project uses [Git LFS](https://git-lfs.com/) to store large files such as example audio under `examples/`. Please ensure Git LFS is installed on your system before cloning. - ```bash git clone https://github.com/NewComer00/expressive.git --depth 1 cd expressive @@ -109,7 +108,6 @@ cd expressive ### Install the application Install the package and its dependencies in a virtual environment: - ```bash pip install -e ".[gpu,gui]" ``` @@ -129,13 +127,11 @@ After installation, you can use the `expressive` and `expressive-gui` entry poin ### Command Line Interface (CLI) Display help: - ```bash expressive --help ``` Run example in Windows PowerShell: - ```powershell expressive ` --utau_wav "examples/明天会更好/utau.wav" ` @@ -150,7 +146,6 @@ expressive ` ``` Run example in Linux shell: - ```bash expressive \ --utau_wav "examples/明天会更好/utau.wav" \ @@ -169,20 +164,24 @@ The output project file will be saved to `examples/明天会更好/output.ustx`. ### Graphical User Interface (GUI) Launch in English: - ```bash expressive-gui --lang en ``` > [!IMPORTANT] -> Due to framework limitations, the GUI launched via the `expressive-gui` command currently **does not support drag-and-drop**. To use drag-and-drop, please install the GUI [directly](#-direct-install), or run `expressive_gui.py` as a script: -> +> Due to framework limitations, the GUI launched via the `expressive-gui` command currently **does not support drag-and-drop**. To use drag-and-drop, please [install directly](#-direct-install), or run `expressive_gui.py` as a script: +> > ```bash > python expressive_gui.py --lang en > ``` -## 🔬 Algorithm Workflow +## 📂 Examples +The [`examples/` directory](examples/) contains several sample projects. You can import the `expressive_config.json` file from any example into the GUI to automatically populate all parameters with the preset values. + +If you installed the application from the installer, a shortcut named `Expressive-examples` pointing to the examples directory will appear on your desktop after installation — you can import the config files directly from there. + +## 🔬 Algorithm Workflow ```mermaid graph TB; ustx_in[/"OpenUtau Project (USTX)"/] @@ -191,11 +190,14 @@ graph TB; refwav-->feat_pitd ustx_in-.->|Export|utauwav utauwav-->feat_pitd - ustx_in-->|Tempo|time_pitd + + ustx_editor["USTX Editor"] + ustx_in-->ustx_editor + ustx_editor-->|UProject & Time Axis|PitdLoader subgraph PitdLoader direction TB - feat_pitd["Features Extraction
Pitch & MFCC"] + feat_pitd["Features Extraction
Pitch & MFCC & RMS"] time_pitd["Time Alignment
FastDTW"] feat_pitd-->time_pitd @@ -232,3 +234,47 @@ graph TB; time_tenc-->get_tenc end ``` + +## ⚠️ Troubleshooting + +### Drag-and-drop does not work on first launch after installation + +#### Symptom +On Windows 10 / 11, after installing the application from the installer for the **first time** (reinstalling after a previous uninstall does not count), the drag-and-drop functionality does not work. + +#### Possible Cause +The [NiceGUI](https://nicegui.io/) framework's support for drag-and-drop in native applications is not yet fully mature. The drag-and-drop feature in this application is currently implemented via the underlying library [pywebview](https://pywebview.flowrl.com/). + +#### Solution +Relaunching the application should restore normal functionality, and this issue will not occur again on the same system afterward. + +#### Future Plan +The NiceGUI framework has begun improving its drag-and-drop support and should resolve this in a future release. + +### PITD expression curve is overall too flat + +#### Symptom +The extracted PITD expression curve is too flat, with almost no significant variation overall. Pitch changes in the reference vocal are not reflected in the expression curve. + +#### Possible Cause +The two confidence thresholds in the PITD extractor are set **too high**, causing many pitch changes to be discarded. + +#### Solution +Try lowering both confidence thresholds. In general, the **Utau vocal** is relatively clean, so it is advisable to first adjust the confidence threshold for the **Reference vocal**. + +#### Future Plan +Introduce a better PITD backend (e.g., [RMVPE](https://github.com/Dream-High/RMVPE)). Add visualization of intermediate results. + +### PITD expression curve has sudden jumps or spikes at certain positions + +#### Symptom +The PITD expression curve changes too rapidly at certain positions, with very large jumps or spikes that clearly do not match natural vocal behavior. + +#### Possible Cause +The two confidence thresholds in the PITD extractor are set **too low**, causing erroneous detection results to be accepted. + +#### Solution +Try increasing both confidence thresholds. In general, the **Utau vocal** is relatively clean, so it is advisable to first adjust the confidence threshold for the **Reference vocal**. + +#### Future Plan +Introduce a better PITD backend (e.g., [RMVPE](https://github.com/Dream-High/RMVPE)). Add visualization of intermediate results. diff --git a/README.md b/README.md index a4fd0a5..febdf38 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ >
> 👉 点击展开完整有声演示视频 👈 > -> https://github.com/user-attachments/assets/4b5b7c15-947a-4f54-b80e-a14a9eefc86b -> +>

+> >
## ✅ 支持平台 @@ -52,13 +52,17 @@ ### 输入 +> [!TIP] +> 从 `v0.6.0` 开始,本应用支持带有**多分段**与**多曲速**的 OpenUtau 人声音轨。 + > [!TIP] > 从 `v0.5.0` 开始,用户可以分别在**歌姬音声**与**参考人声**的完整音频中划定选区,选区内的音频段落将作为最终输入。 -* **歌姬音声**:由 OpenUtau 输出的无表情虚拟歌声音频(WAV 格式)。建议节奏 (`Tempo`) 和分段尽量与参考人声一致。 -* **参考人声**:原始人声录音(WAV 格式),可使用 [UVR](https://github.com/Anjok07/ultimatevocalremovergui) 等工具去除伴奏与混响。 +* **歌姬音声**:由 OpenUtau 输出的无表情虚拟歌声音频(WAV 格式)。建议分段与曲速尽量与**参考人声**相近,若相差过大可能影响对齐效果。 +* **参考人声**:原始人声录音(WAV 格式),可使用 [UVR](https://github.com/Anjok07/ultimatevocalremovergui) 、[MSST](https://github.com/SUC-DriverOld/MSST-WebUI) 等工具去除伴奏、和声与混响。 * **输入工程**:原始 OpenUtau 工程文件(USTX 格式)。 * **输出路径**:处理完成后新工程文件的保存位置。 +* **音轨编号**:OpenUtau 工程中**歌姬音声**所在的音轨编号(从 1 开始)。表情参数会被导入到该音轨中。 ### 输出 @@ -74,10 +78,6 @@ * [x] `Dynamics` 参数生成 * [x] `Tension` 参数生成 -## ⚠️ 已知问题 - -1. 当前版本尚不支持单一轨道中的 `Tempo` 变化,建议工程全程使用统一节奏。该限制将在未来版本中解决。 - ## 🚀 直接安装 您可以直接在 [Releases](https://github.com/NewComer00/expressive/releases) 页面下载预编译的可执行文件: @@ -179,6 +179,12 @@ expressive-gui --lang zh_CN > python expressive_gui.py --lang zh_CN > ``` +## 📂 示例工程 + +项目的 [`examples/` 目录](examples/)下存放有多个示例。您可以在图形用户界面中导入相应示例的 `expressive_config.json` 配置文件,将预设的参数一键填写到应用中。 + +若您是从安装包获取的本应用,安装完毕后示例目录的快捷方式 `Expressive-examples` 将出现在您的桌面,您也可以直接导入其中的配置文件。 + ## 🔬 算法流程 ```mermaid graph TB; @@ -188,11 +194,14 @@ graph TB; refwav-->feat_pitd ustx_in-.->|Export|utauwav utauwav-->feat_pitd - ustx_in-->|Tempo|time_pitd + + ustx_editor["USTX Editor"] + ustx_in-->ustx_editor + ustx_editor-->|UProject & Time Axis|PitdLoader subgraph PitdLoader direction TB - feat_pitd["Features Extraction
Pitch & MFCC"] + feat_pitd["Features Extraction
Pitch & MFCC & RMS"] time_pitd["Time Alignment
FastDTW"] feat_pitd-->time_pitd @@ -229,3 +238,47 @@ graph TB; time_tenc-->get_tenc end ``` + +## ⚠️ 常见问题 + +### 安装后首次运行图形界面,文件拖拽功能无法正常使用 + +#### 问题现象 +在 Windows 10 / 11 平台下,通过安装包**首次**安装本应用后(先前安装过再卸载不算),应用的文件拖拽功能无法正常使用。 + +#### 可能原因 +[NiceGUI](https://nicegui.io/) 框架对原生应用的文件拖拽功能支持尚不完善。目前本应用的文件拖拽功能是基于底层库 [pywebview](https://pywebview.flowrl.com/) 实现的。 + +#### 解决方案 +重新打开应用后应当可以恢复正常,且该系统今后不会再出现此问题。 + +#### 未来计划 +NiceGUI 框架已经开始着手改进文件拖拽支持,应该在未来的版本中能够解决此问题。 + +### PITD 表情曲线整体变化过于平缓 + +#### 问题现象 +提取出的 PITD 表情曲线过于平缓,整体上几乎没有大的起伏,参考人声中的音高变化并没有反映到表情曲线上。 + +#### 可能原因 +PITD 表情提取器中,两个置信度阈值设置**过高**,许多音高变化没有被采信。 + +#### 解决方案 +尝试降低两个置信度阈值。一般来说,**歌姬音声**比较纯净,可以先调整**参考人声**的置信度阈值。 + +#### 未来计划 +引入更好的 PITD 后端(如 [RMVPE](https://github.com/Dream-High/RMVPE))。添加中间结果的可视化功能。 + +### PITD 表情曲线在某些位置变化过快,出现跳跃或毛刺 + +#### 问题现象 +PITD 表情曲线在某些位置变化过快,出现非常大的跳跃或毛刺,明显不符合人声的变化规律。 + +#### 可能原因 +PITD 表情提取器中,两个置信度阈值设置**过低**,错误的识别结果被采信。 + +#### 解决方案 +尝试增加两个置信度阈值。一般来说,**歌姬音声**比较纯净,可以先调整**参考人声**的置信度阈值。 + +#### 未来计划 +引入更好的 PITD 后端(如 [RMVPE](https://github.com/Dream-High/RMVPE))。添加中间结果的可视化功能。 diff --git a/build/installer.iss b/build/installer.iss index 491074e..e673e64 100644 --- a/build/installer.iss +++ b/build/installer.iss @@ -16,7 +16,7 @@ LZMAUseSeparateProcess=yes LZMANumBlockThreads=8 SetupIconFile=..\assets\icons\app.ico PrivilegesRequired=lowest -PrivilegesRequiredOverridesAllowed=commandline dialog +PrivilegesRequiredOverridesAllowed=commandline [Files] Source: "..\dist\Expressive-GUI\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs diff --git "a/examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/README.md" "b/examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/README.md" new file mode 100644 index 0000000..ebc4062 --- /dev/null +++ "b/examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/README.md" @@ -0,0 +1,15 @@ +# Example: Прекрасное Далеко + +## Audio +- **Source:** Прекрасное Далеко / Wonderful Faraway (из к/ф «Гостья из будущего», 1985) +- **Music:** Евгений Крылатов +- **Lyrics:** Юрий Энтин + +## Voicebank +- **Source:** [Ashera Lyre DiffSinger V110](https://vocadb.net/Ar/139321) +- **Voice Provider:** OtomeHime +- **Illustration:** OtomeHime + +## OpenUtau +- **Phonemizer:** DiffSinger Russian +- **Tested on version:** OpenUtau LUNAI Edition v0.1.1.0 diff --git "a/examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/expressive_config.json" "b/examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/expressive_config.json" new file mode 100644 index 0000000..7c0d117 --- /dev/null +++ "b/examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/expressive_config.json" @@ -0,0 +1,38 @@ +{ + "utau_wav": "examples/Прекрасное Далеко/utau.wav", + "ref_wav": "examples/Прекрасное Далеко/reference.wav", + "ustx_input": "examples/Прекрасное Далеко/project.ustx", + "ustx_output": "examples/Прекрасное Далеко/output.ustx", + "track_number": 1, + "ref_start": "0:01.71", + "ref_end": "0:22.37", + "utau_start": "0:01.75", + "utau_end": "0:22.46", + "expressions": { + "dyn": { + "selected": true, + "trim_silence": true, + "align_radius": 1, + "smoothness": 2, + "scaler": 1.3 + }, + "pitd": { + "selected": true, + "backend": "crepe", + "confidence_utau": 0.8, + "confidence_ref": 0.6, + "align_radius": 1, + "semitone_shift": 0, + "smoothness": 4, + "scaler": 2.2 + }, + "tenc": { + "selected": true, + "trim_silence": true, + "align_radius": 1, + "smoothness": 6, + "scaler": 1.0, + "bias": 10 + } + } +} \ No newline at end of file diff --git "a/examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/project.ustx" "b/examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/project.ustx" index 0cf597f..dccd374 100644 --- "a/examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/project.ustx" +++ "b/examples/\320\237\321\200\320\265\320\272\321\200\320\260\321\201\320\275\320\276\320\265 \320\224\320\260\320\273\320\265\320\272\320\276/project.ustx" @@ -213,10 +213,10 @@ expressions: exp_selectors: - dyn - pitd -- clr +- tenc - eng - vel -exp_primary: 2 +exp_primary: 1 exp_secondary: 0 key: 0 time_signatures: @@ -231,7 +231,7 @@ tracks: phonemizer: OpenUtau.Core.DiffSinger.DiffSingerRussianPhonemizer renderer_settings: renderer: DIFFSINGER - track_name: Track1 + track_name: main track_color: Blue mute: false solo: false @@ -242,9 +242,20 @@ tracks: - '01: standard' - '02: storm' - '03: jellyfish' +- phonemizer: OpenUtau.Core.DefaultPhonemizer + renderer_settings: {} + track_name: reference + track_color: Blue + mute: true + solo: false + volume: 0 + pan: 0 + track_expressions: [] + voice_color_names: + - "" voice_parts: - duration: 24453 - name: New Part + name: main comment: "" track_no: 0 position: 0 @@ -992,4 +1003,12 @@ voice_parts: - index: 2 offset: 254 curves: [] -wave_parts: [] +wave_parts: +- name: reference.wav + comment: "" + track_no: 1 + position: 0 + relative_path: reference.wav + file_duration_ms: 22371.0884 + skip_ms: 0 + trim_ms: 0 diff --git "a/examples/\343\203\206\343\203\210\343\203\252\343\202\271/README.md" "b/examples/\343\203\206\343\203\210\343\203\252\343\202\271/README.md" new file mode 100644 index 0000000..f69579e --- /dev/null +++ "b/examples/\343\203\206\343\203\210\343\203\252\343\202\271/README.md" @@ -0,0 +1,16 @@ +# Example: テトリス + +## Audio +- **Source:** [テトリス / 重音テトSV](https://youtu.be/Soy4jGPHr3g) +- **Author:** [Hiiragi Magnetite](https://www.youtube.com/channel/UCFXkDxCa_XVcNC7oOEw7U5g) + +The reference WAV and main track of USTX are clipped and retempoed from the original sources. + +## Voicebank +- **Source:** [足立レイ DS (DiffSinger)](https://tigermeat.xyz/#adachi-rei-ds) +- **Illustration:** [みさいる](https://vocadb.net/Ar/74390) +- **Managed by:** [tigermeat](https://vocadb.net/Ar/21498) + +## OpenUtau +- **Phonemizer:** DiffSinger Japanese +- **Tested on version:** 0.1.565.0 diff --git "a/examples/\343\203\206\343\203\210\343\203\252\343\202\271/expressive_config.json" "b/examples/\343\203\206\343\203\210\343\203\252\343\202\271/expressive_config.json" new file mode 100644 index 0000000..f375880 --- /dev/null +++ "b/examples/\343\203\206\343\203\210\343\203\252\343\202\271/expressive_config.json" @@ -0,0 +1,38 @@ +{ + "utau_wav": "examples/テトリス/utau.wav", + "ref_wav": "examples/テトリス/reference.wav", + "ustx_input": "examples/テトリス/project.ustx", + "ustx_output": "examples/テトリス/output.ustx", + "track_number": 1, + "ref_start": "0:23.12", + "ref_end": null, + "utau_start": "0:23.18", + "utau_end": null, + "expressions": { + "dyn": { + "selected": true, + "trim_silence": true, + "align_radius": 1, + "smoothness": 2, + "scaler": 1.5 + }, + "pitd": { + "selected": true, + "backend": "swift-f0", + "confidence_utau": 0.85, + "confidence_ref": 0.9, + "align_radius": 1, + "semitone_shift": 0, + "smoothness": 2, + "scaler": 2.0 + }, + "tenc": { + "selected": true, + "trim_silence": true, + "align_radius": 1, + "smoothness": 6, + "scaler": 1.0, + "bias": 10 + } + } +} \ No newline at end of file diff --git "a/examples/\343\203\206\343\203\210\343\203\252\343\202\271/project.ustx" "b/examples/\343\203\206\343\203\210\343\203\252\343\202\271/project.ustx" new file mode 100644 index 0000000..d6bd04a --- /dev/null +++ "b/examples/\343\203\206\343\203\210\343\203\252\343\202\271/project.ustx" @@ -0,0 +1,2459 @@ +name: New Project +comment: "" +output_dir: Vocal +cache_dir: UCache +ustx_version: "0.7" +resolution: 480 +bpm: 120 +beat_per_bar: 4 +beat_unit: 4 +expressions: + dyn: + name: dynamics (curve) + abbr: dyn + type: Curve + min: -240 + max: 120 + default_value: 0 + is_flag: false + flag: "" + pitd: + name: pitch deviation (curve) + abbr: pitd + type: Curve + min: -1200 + max: 1200 + default_value: 0 + is_flag: false + flag: "" + clr: + name: voice color + abbr: clr + type: Options + min: 0 + max: -1 + default_value: 0 + is_flag: false + options: [] + eng: + name: resampler engine + abbr: eng + type: Options + min: 0 + max: 1 + default_value: 0 + is_flag: false + options: + - "" + - worldline + vel: + name: velocity + abbr: vel + type: Numerical + min: 0 + max: 200 + default_value: 100 + is_flag: false + flag: "" + vol: + name: volume + abbr: vol + type: Numerical + min: 0 + max: 200 + default_value: 100 + is_flag: false + flag: "" + atk: + name: attack + abbr: atk + type: Numerical + min: 0 + max: 200 + default_value: 100 + is_flag: false + flag: "" + dec: + name: decay + abbr: dec + type: Numerical + min: 0 + max: 100 + default_value: 0 + is_flag: false + flag: "" + gen: + name: gender + abbr: gen + type: Numerical + min: -100 + max: 100 + default_value: 0 + is_flag: true + flag: g + genc: + name: gender (curve) + abbr: genc + type: Curve + min: -100 + max: 100 + default_value: 0 + is_flag: false + flag: "" + bre: + name: breath + abbr: bre + type: Numerical + min: 0 + max: 100 + default_value: 0 + is_flag: true + flag: B + brec: + name: breathiness (curve) + abbr: brec + type: Curve + min: -100 + max: 100 + default_value: 0 + is_flag: false + flag: "" + lpf: + name: lowpass + abbr: lpf + type: Numerical + min: 0 + max: 100 + default_value: 0 + is_flag: true + flag: H + norm: + name: normalize + abbr: norm + type: Numerical + min: 0 + max: 100 + default_value: 86 + is_flag: true + flag: P + mod: + name: modulation + abbr: mod + type: Numerical + min: 0 + max: 100 + default_value: 0 + is_flag: false + flag: "" + mod+: + name: modulation plus + abbr: mod+ + type: Numerical + min: 0 + max: 100 + default_value: 0 + is_flag: false + flag: "" + alt: + name: alternate + abbr: alt + type: Numerical + min: 0 + max: 16 + default_value: 0 + is_flag: false + flag: "" + dir: + name: direct + abbr: dir + type: Options + min: 0 + max: 1 + default_value: 0 + is_flag: false + options: + - off + - on + shft: + name: tone shift + abbr: shft + type: Numerical + min: -36 + max: 36 + default_value: 0 + is_flag: false + flag: "" + shfc: + name: tone shift (curve) + abbr: shfc + type: Curve + min: -1200 + max: 1200 + default_value: 0 + is_flag: false + flag: "" + tenc: + name: tension (curve) + abbr: tenc + type: Curve + min: -100 + max: 100 + default_value: 0 + is_flag: false + flag: "" + voic: + name: voicing (curve) + abbr: voic + type: Curve + min: 0 + max: 100 + default_value: 100 + is_flag: false + flag: "" +exp_selectors: +- dyn +- pitd +- tenc +- eng +- vel +- vol +- atk +- dec +- gen +- bre +exp_primary: 1 +exp_secondary: 0 +key: 0 +time_signatures: +- bar_position: 0 + beat_per_bar: 4 + beat_unit: 4 +tempos: +- position: 0 + bpm: 170 +- position: 53760 + bpm: 185 +- position: 69120 + bpm: 200 +tracks: +- singer: 足立レイ_DS_v111.1 + phonemizer: OpenUtau.Core.DiffSinger.DiffSingerJapanesePhonemizer + renderer_settings: + renderer: DIFFSINGER + track_name: main + track_color: Blue + mute: false + solo: false + volume: 0 + pan: 0 + track_expressions: [] + voice_color_names: + - adachi_rei +- phonemizer: OpenUtau.Core.DefaultPhonemizer + renderer_settings: {} + track_name: reference + track_color: Blue + mute: true + solo: false + volume: 0 + pan: 0 + track_expressions: [] + voice_color_names: + - "" +voice_parts: +- duration: 21120 + name: main-1 + comment: "" + track_no: 0 + position: 32160 + notes: + - position: 480 + duration: 120 + tone: 61 + lyric: ki + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 600 + duration: 120 + tone: 61 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 720 + duration: 240 + tone: 61 + lyric: mi + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 960 + duration: 120 + tone: 60 + lyric: ra + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1080 + duration: 120 + tone: 60 + lyric: i + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1200 + duration: 240 + tone: 61 + lyric: shi + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1440 + duration: 240 + tone: 60 + lyric: ka + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1680 + duration: 240 + tone: 61 + lyric: ka + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1920 + duration: 240 + tone: 63 + lyric: ta + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2160 + duration: 240 + tone: 56 + lyric: n + pitch: + data: + - {x: -40, y: 70, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2400 + duration: 120 + tone: 61 + lyric: sho + pitch: + data: + - {x: -40, y: -50, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2640 + duration: 120 + tone: 61 + lyric: pi + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2760 + duration: 120 + tone: 61 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2880 + duration: 240 + tone: 63 + lyric: mo + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3120 + duration: 120 + tone: 61 + lyric: ru + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3240 + duration: 120 + tone: 63 + lyric: no + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3360 + duration: 120 + tone: 64 + lyric: ge + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3480 + duration: 120 + tone: 64 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3600 + duration: 120 + tone: 63 + lyric: da + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3720 + duration: 120 + tone: 64 + lyric: i + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3840 + duration: 120 + tone: 66 + lyric: ko + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3960 + duration: 120 + tone: 66 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4080 + duration: 240 + tone: 59 + lyric: pyu + pitch: + data: + - {x: -40, y: 70, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4320 + duration: 240 + tone: 64 + lyric: sho + pitch: + data: + - {x: -40, y: -50, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + offset: 77 + - position: 4560 + duration: 120 + tone: 64 + lyric: wi + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4680 + duration: 120 + tone: 64 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4800 + duration: 120 + tone: 63 + lyric: do + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4920 + duration: 120 + tone: 63 + lyric: u + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 5040 + duration: 240 + tone: 64 + lyric: shi + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 5280 + duration: 240 + tone: 63 + lyric: ka + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 5520 + duration: 240 + tone: 64 + lyric: ka + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 5760 + duration: 240 + tone: 66 + lyric: ta + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 6000 + duration: 240 + tone: 59 + lyric: n + pitch: + data: + - {x: -40, y: 70, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 6240 + duration: 60 + tone: 64 + lyric: mo + pitch: + data: + - {x: -40, y: -50, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 6300 + duration: 60 + tone: 64 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 6480 + duration: 240 + tone: 64 + lyric: ti + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 6720 + duration: 240 + tone: 66 + lyric: ho + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 6960 + duration: 120 + tone: 64 + lyric: ru + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 7080 + duration: 120 + tone: 66 + lyric: no + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 7200 + duration: 120 + tone: 67 + lyric: ke + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 7320 + duration: 120 + tone: 67 + lyric: i + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 7440 + duration: 120 + tone: 66 + lyric: hi + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 7560 + duration: 120 + tone: 67 + lyric: de + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 7680 + duration: 120 + tone: 69 + lyric: pi + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 7800 + duration: 120 + tone: 69 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 7920 + duration: 120 + tone: 67 + lyric: bo + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 8040 + duration: 120 + tone: 69 + lyric: + + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 8160 + duration: 480 + tone: 71 + lyric: ru + pitch: + data: + - {x: -38.235294, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 8880 + duration: 120 + tone: 71 + lyric: o + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 9000 + duration: 120 + tone: 71 + lyric: i + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 9120 + duration: 240 + tone: 69 + lyric: ru + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 9360 + duration: 240 + tone: 67 + lyric: ma + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 9600 + duration: 240 + tone: 66 + lyric: sa + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 9840 + duration: 240 + tone: 62 + lyric: ji + pitch: + data: + - {x: -40, y: 40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 10080 + duration: 240 + tone: 64 + lyric: hya + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 10320 + duration: 60 + tone: 71 + lyric: pu + pitch: + data: + - {x: -40, y: -70, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + offset: 55 + - position: 10380 + duration: 60 + tone: 71 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 10560 + duration: 240 + tone: 69 + lyric: ko + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 10800 + duration: 240 + tone: 71 + lyric: su + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11040 + duration: 240 + tone: 69 + lyric: a + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11280 + duration: 240 + tone: 67 + lyric: shi + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11520 + duration: 240 + tone: 66 + lyric: yu + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11760 + duration: 240 + tone: 67 + lyric: tsu + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12000 + duration: 480 + tone: 64 + lyric: ki + pitch: + data: + - {x: -40, y: 30, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12720 + duration: 240 + tone: 67 + lyric: yu + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12960 + duration: 240 + tone: 66 + lyric: zu + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 13200 + duration: 240 + tone: 67 + lyric: o + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 13440 + duration: 240 + tone: 66 + lyric: so + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 13680 + duration: 240 + tone: 63 + lyric: e + pitch: + data: + - {x: -40, y: 30, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 13920 + duration: 120 + tone: 64 + lyric: te + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 14280 + duration: 120 + tone: 64 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 14640 + duration: 120 + tone: 64 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 14880 + duration: 120 + tone: 64 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 15120 + duration: 120 + tone: 62 + lyric: to + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 15360 + duration: 120 + tone: 64 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 15600 + duration: 120 + tone: 62 + lyric: to + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 15840 + duration: 120 + tone: 64 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 16200 + duration: 120 + tone: 64 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 16560 + duration: 120 + tone: 64 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 16800 + duration: 120 + tone: 64 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 17040 + duration: 120 + tone: 62 + lyric: to + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 17280 + duration: 420 + tone: 67 + lyric: ri + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 17700 + duration: 60 + tone: 67 + lyric: su + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 17760 + duration: 240 + tone: 71 + lyric: do + pitch: + data: + - {x: -40, y: -40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 18000 + duration: 240 + tone: 71 + lyric: u + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 18240 + duration: 240 + tone: 66 + lyric: shi + pitch: + data: + - {x: -40, y: 50, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 18480 + duration: 240 + tone: 67 + lyric: te + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 18720 + duration: 240 + tone: 69 + lyric: ko + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 18960 + duration: 240 + tone: 69 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 19200 + duration: 240 + tone: 67 + lyric: na + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 19440 + duration: 240 + tone: 66 + lyric: me + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 19680 + duration: 120 + tone: 64 + lyric: ni + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 20160 + duration: 120 + tone: 64 + lyric: ni + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 20640 + duration: 120 + tone: 64 + lyric: ni + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + curves: [] +- duration: 15840 + name: main-2 + comment: "" + track_no: 0 + position: 53280 + notes: + - position: 480 + duration: 240 + tone: 73 + lyric: kyo + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + offset: -47 + - position: 720 + duration: 240 + tone: 73 + lyric: u + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 960 + duration: 240 + tone: 68 + lyric: mi + pitch: + data: + - {x: -40, y: 50, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1200 + duration: 240 + tone: 69 + lyric: ga + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1440 + duration: 240 + tone: 71 + lyric: na + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1680 + duration: 240 + tone: 71 + lyric: i + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1920 + duration: 240 + tone: 69 + lyric: ko + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2160 + duration: 240 + tone: 68 + lyric: to + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2400 + duration: 240 + tone: 66 + lyric: ho + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2640 + duration: 240 + tone: 66 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2880 + duration: 240 + tone: 66 + lyric: ki + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3120 + duration: 240 + tone: 69 + lyric: ja + pitch: + data: + - {x: -40, y: -30, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + offset: -13 + - position: 3360 + duration: 240 + tone: 73 + lyric: na + pitch: + data: + - {x: -40, y: -40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3600 + duration: 240 + tone: 73 + lyric: i + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3840 + duration: 240 + tone: 71 + lyric: mo + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4080 + duration: 240 + tone: 69 + lyric: no + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4320 + duration: 240 + tone: 68 + lyric: ze + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4560 + duration: 240 + tone: 68 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4800 + duration: 240 + tone: 68 + lyric: bu + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 5040 + duration: 240 + tone: 69 + lyric: a + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 5280 + duration: 480 + tone: 71 + lyric: to + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 5760 + duration: 480 + tone: 73 + lyric: ma + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 6240 + duration: 480 + tone: 69 + lyric: wa + pitch: + data: + - {x: -40, y: 40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 6720 + duration: 480 + tone: 66 + lyric: shi + pitch: + data: + - {x: -40, y: 30, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 7200 + duration: 480 + tone: 66 + lyric: de + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + offset: -52 + - position: 8400 + duration: 480 + tone: 71 + lyric: shi + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 8880 + duration: 240 + tone: 74 + lyric: te + pitch: + data: + - {x: -40, y: -30, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + phoneme: ja/d + - position: 9120 + duration: 480 + tone: 78 + lyric: ru + pitch: + data: + - {x: -40, y: -40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 9600 + duration: 240 + tone: 76 + lyric: ko + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 9840 + duration: 240 + tone: 74 + lyric: to + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 10080 + duration: 480 + tone: 73 + lyric: wa + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 10800 + duration: 240 + tone: 69 + lyric: shi + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11040 + duration: 240 + tone: 73 + lyric: ra + pitch: + data: + - {x: -40, y: -40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11280 + duration: 240 + tone: 73 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11520 + duration: 240 + tone: 71 + lyric: pu + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11760 + duration: 240 + tone: 69 + lyric: ri + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12000 + duration: 480 + tone: 68 + lyric: wa + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12480 + duration: 240 + tone: 68 + lyric: ta + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12720 + duration: 240 + tone: 69 + lyric: shi + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12960 + duration: 480 + tone: 71 + lyric: wa + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 13440 + duration: 480 + tone: 73 + lyric: o + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 13920 + duration: 240 + tone: 69 + lyric: wa + pitch: + data: + - {x: -40, y: 40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 14400 + duration: 480 + tone: 66 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + phoneme: ja/d + offset: -51 + - position: 14880 + duration: 480 + tone: 66 + lyric: ru + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + offset: -51 + curves: [] +- duration: 15840 + name: main-3 + comment: "" + track_no: 0 + position: 68640 + notes: + - position: 480 + duration: 480 + tone: 73 + lyric: ha + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + offset: -47 + - position: 960 + duration: 240 + tone: 68 + lyric: zu + pitch: + data: + - {x: -40, y: 50, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1200 + duration: 240 + tone: 69 + lyric: ka + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1440 + duration: 480 + tone: 71 + lyric: shi + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 1920 + duration: 240 + tone: 69 + lyric: ka + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2160 + duration: 240 + tone: 68 + lyric: ko + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2400 + duration: 240 + tone: 66 + lyric: shi + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 2880 + duration: 240 + tone: 66 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + phoneme: ja/d + offset: -70 + - position: 3120 + duration: 240 + tone: 69 + lyric: ru + pitch: + data: + - {x: -40, y: -30, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 3360 + duration: 480 + tone: 73 + lyric: ya + pitch: + data: + - {x: -40, y: -40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + offset: -26 + - position: 3840 + duration: 240 + tone: 71 + lyric: tsu + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4080 + duration: 240 + tone: 69 + lyric: ra + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4320 + duration: 240 + tone: 68 + lyric: no + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 4800 + duration: 240 + tone: 68 + lyric: ki + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 5040 + duration: 240 + tone: 69 + lyric: o + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 5280 + duration: 480 + tone: 71 + lyric: ku + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 5760 + duration: 480 + tone: 73 + lyric: ke + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 6240 + duration: 480 + tone: 69 + lyric: sa + pitch: + data: + - {x: -40, y: 40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 6720 + duration: 480 + tone: 66 + lyric: se + pitch: + data: + - {x: -40, y: 30, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 7200 + duration: 480 + tone: 66 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + phoneme: ja/d + - position: 8400 + duration: 240 + tone: 71 + lyric: me + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 8640 + duration: 240 + tone: 71 + lyric: i + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 8880 + duration: 240 + tone: 74 + lyric: wa + pitch: + data: + - {x: -40, y: -30, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 9120 + duration: 480 + tone: 78 + lyric: ku + pitch: + data: + - {x: -40, y: -40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 9600 + duration: 240 + tone: 76 + lyric: ka + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 9840 + duration: 240 + tone: 74 + lyric: ke + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 10080 + duration: 480 + tone: 73 + lyric: te + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + phoneme: ja/d + - position: 10800 + duration: 240 + tone: 69 + lyric: go + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11040 + duration: 240 + tone: 73 + lyric: me + pitch: + data: + - {x: -40, y: -40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11280 + duration: 240 + tone: 73 + lyric: n + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11520 + duration: 240 + tone: 71 + lyric: te + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 11760 + duration: 240 + tone: 69 + lyric: ba + pitch: + data: + - {x: -40, y: 20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12000 + duration: 480 + tone: 68 + lyric: ne + pitch: + data: + - {x: -40, y: 10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12480 + duration: 240 + tone: 68 + lyric: da + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12720 + duration: 240 + tone: 69 + lyric: re + pitch: + data: + - {x: -40, y: -10, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 12960 + duration: 480 + tone: 71 + lyric: ka + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 13440 + duration: 480 + tone: 73 + lyric: ta + pitch: + data: + - {x: -40, y: -20, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 13920 + duration: 480 + tone: 69 + lyric: su + pitch: + data: + - {x: -40, y: 40, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 14400 + duration: 480 + tone: 66 + lyric: ke + pitch: + data: + - {x: -40, y: 30, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: [] + - position: 14880 + duration: 480 + tone: 66 + lyric: te + pitch: + data: + - {x: -40, y: 0, shape: io} + - {x: 40, y: 0, shape: io} + snap_first: true + vibrato: {length: 0, period: 175, depth: 25, in: 10, out: 10, shift: 0, drift: 0, vol_link: 0} + phoneme_expressions: [] + phoneme_overrides: + - index: 0 + phoneme: ja/d + curves: [] +wave_parts: +- name: reference.wav + comment: "" + track_no: 1 + position: 0 + relative_path: reference.wav + file_duration_ms: 59349.3541 + skip_ms: 0 + trim_ms: 0 diff --git "a/examples/\343\203\206\343\203\210\343\203\252\343\202\271/reference.wav" "b/examples/\343\203\206\343\203\210\343\203\252\343\202\271/reference.wav" new file mode 100644 index 0000000..b6276f4 --- /dev/null +++ "b/examples/\343\203\206\343\203\210\343\203\252\343\202\271/reference.wav" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d63bc49f6465c7e5eaa53a38c2d2ffb4bfd394140f44a8d34136d4f120c74ea0 +size 11395120 diff --git "a/examples/\343\203\206\343\203\210\343\203\252\343\202\271/utau.wav" "b/examples/\343\203\206\343\203\210\343\203\252\343\202\271/utau.wav" new file mode 100644 index 0000000..16e33f8 --- /dev/null +++ "b/examples/\343\203\206\343\203\210\343\203\252\343\202\271/utau.wav" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e45337c3d2363f476e7b39411cf4a171a53c71436073644a17f5039f9865162 +size 5231806 diff --git "a/examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/README.md" "b/examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/README.md" new file mode 100644 index 0000000..5a285ea --- /dev/null +++ "b/examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/README.md" @@ -0,0 +1,15 @@ +# Example: 明天会更好 + +## Audio +- **Source:** [明天会更好 / Tomorrow Will Be Better](https://www.youtube.com/watch?v=s6T4DXRKYHM) +- **Artist:** 群星 (Various Artists) +- **Composer:** 罗大佑 (Lo Ta-yu) +- **Label:** 滚石唱片 (Rock Records) + +## Voicebank +- **Source:** [泠鸢yousa DiffSinger V1.5](https://github.com/yousa-ling-official-production/yousa-ling-diffsinger-v1) +- **Voice Provider:** 泠鸢yousa + +## OpenUtau +- **Phonemizer:** DiffSinger Chinese +- **Tested on version:** 0.1.565.0 diff --git "a/examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/expressive_config.json" "b/examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/expressive_config.json" new file mode 100644 index 0000000..f4fa615 --- /dev/null +++ "b/examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/expressive_config.json" @@ -0,0 +1,38 @@ +{ + "utau_wav": "examples/明天会更好/utau.wav", + "ref_wav": "examples/明天会更好/reference.wav", + "ustx_input": "examples/明天会更好/project.ustx", + "ustx_output": "examples/明天会更好/output.ustx", + "track_number": 1, + "ref_start": "0:02.55", + "ref_end": null, + "utau_start": "0:02.47", + "utau_end": null, + "expressions": { + "dyn": { + "selected": true, + "trim_silence": true, + "align_radius": 1, + "smoothness": 2, + "scaler": 2.0 + }, + "pitd": { + "selected": true, + "backend": "swift-f0", + "confidence_utau": 0.9, + "confidence_ref": 0.93, + "align_radius": 1, + "semitone_shift": 0, + "smoothness": 2, + "scaler": 2.0 + }, + "tenc": { + "selected": true, + "trim_silence": true, + "align_radius": 1, + "smoothness": 6, + "scaler": 1.2, + "bias": 10 + } + } +} \ No newline at end of file diff --git "a/examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/project.ustx" "b/examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/project.ustx" index c2aa0b8..4b6ba5e 100644 --- "a/examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/project.ustx" +++ "b/examples/\346\230\216\345\244\251\344\274\232\346\233\264\345\245\275/project.ustx" @@ -213,11 +213,11 @@ expressions: exp_selectors: - dyn - pitd -- clr - tenc +- clr - voic -exp_primary: 0 -exp_secondary: 1 +exp_primary: 1 +exp_secondary: 0 key: 0 time_signatures: - bar_position: 0 @@ -231,7 +231,7 @@ tracks: phonemizer: OpenUtau.Core.DiffSinger.DiffSingerChinesePhonemizer renderer_settings: renderer: DIFFSINGER - track_name: Track1 + track_name: main track_color: Blue mute: false solo: false @@ -245,9 +245,20 @@ tracks: - 04:Whisper - 05:Joyful - 06:Classic +- phonemizer: OpenUtau.Core.DefaultPhonemizer + renderer_settings: {} + track_name: reference + track_color: Blue + mute: true + solo: false + volume: 0 + pan: 0 + track_expressions: [] + voice_color_names: + - "" voice_parts: - duration: 48960 - name: New Part + name: main comment: "" track_no: 0 position: 0 @@ -1061,4 +1072,12 @@ voice_parts: phoneme_expressions: [] phoneme_overrides: [] curves: [] -wave_parts: [] +wave_parts: +- name: reference.wav + comment: "" + track_no: 1 + position: 0 + relative_path: reference.wav + file_duration_ms: 29787.7324 + skip_ms: 0 + trim_ms: 0 diff --git a/expressions/base.py b/expressions/base.py index 7817fb0..af67a92 100644 --- a/expressions/base.py +++ b/expressions/base.py @@ -1,5 +1,4 @@ import logging -import threading from typing import Any from types import SimpleNamespace from dataclasses import dataclass @@ -7,8 +6,9 @@ import numpy as np from utils.i18n import _, _l +from utils.ustx import UstxEditor +from utils.seqtool import set_tick_converters from utils.wavtool import ClampedWav, sec2timestamp -from utils.ustx import load_ustx, save_ustx, edit_ustx_expression_curve @dataclass @@ -21,10 +21,54 @@ class Args: class ExpressionLoader(): + """Base class for expression loaders. + + An expression loader extracts a single OpenUtau expression curve (e.g. + ``dyn``, ``pitd``, ``tenc``) by comparing a reference audio recording + against the rendered UTAU audio, then writes the result back into a + ``.ustx`` project file. + + Subclasses must set :attr:`expression_name` and override + :meth:`get_expression`. Registering a subclass with + :func:`register_expression` makes it discoverable via + :func:`getExpressionLoader`. + + The loader opens an exclusive :class:`~utils.ustx.UstxEditor` on + *ustx_path* during ``__init__`` and holds it until the instance is + garbage-collected, so only one loader per file should be alive at a time + within a single process. Across processes the file lock prevents + concurrent writes. + + Class attributes: + expression_name (str): Short abbreviation used as the USTX curve key + (e.g. ``"dyn"``). Must be set on the subclass. + expression_info (str): Human-readable description of the expression. + args (SimpleNamespace): Declared CLI / GUI arguments for this loader. + Each value is an :class:`Args` instance. + + USTX attributes: + ustx_path (str): Path to the ``.ustx`` project file. + ustx_editor (UstxEditor): Live editor holding the file lock. + ustx_time_axis (TimeAxis): Tempo-map-aware tick converter built from + the project's tempo and time-signature maps. + + Audio attributes: + ref_path (str): Path to the (possibly trimmed) reference audio. + ref_offset (float): Start offset of the reference clip in seconds. + ref_duration (float): Duration of the reference clip in seconds. + utau_path (str): Path to the (possibly trimmed) UTAU audio. + utau_offset (float): Start offset of the UTAU clip in seconds. + utau_duration (float): Duration of the UTAU clip in seconds. + + Result attributes: + expression_tick (ndarray): Tick positions produced by the last + :meth:`get_expression` call. + expression_val (ndarray): Curve values produced by the last + :meth:`get_expression` call. + """ _id_counter: int = 0 expression_name: str = "" expression_info: str = "" - ustx_lock = threading.Lock() args = SimpleNamespace( ref_path = Args(name="ref_path" , type=str, default="" , help=_l("Path to the **reference** audio file")), # noqa: E501 utau_path = Args(name="utau_path" , type=str, default="" , help=_l("Path to the **UTAU** audio file")), # noqa: E501 @@ -43,15 +87,26 @@ def get_args_dict(cls) -> dict[str, Args]: def __init__(self, ref_path: str, utau_path: str, ustx_path: str, ref_start: str | None = None, ref_end: str | None = None, utau_start: str | None = None, utau_end: str | None = None): + # Identify this loader instance ExpressionLoader._id_counter += 1 self.id = ExpressionLoader._id_counter + + # Set up logging self.logger = logging.getLogger(f"{ExpressionLoader.__name__}.{self.expression_name}.{self.id}") self.logger = logging.LoggerAdapter(self.logger, {"expression": self.expression_name}) self.logger.setLevel(logging.DEBUG) - self.expression_tick: list | np.ndarray = [] - self.expression_val: list | np.ndarray = [] - + # Init USTX editor (with exclusive file lock) + self.ustx_path = ustx_path + self.ustx_editor = UstxEditor(self.ustx_path) + self.ustx_time_axis = self.ustx_editor.build_time_axis() + # Register tempo-map-aware tick converters + set_tick_converters( + self.ustx_time_axis.seconds_to_ticks, + self.ustx_time_axis.ticks_to_seconds, + ) + + # Clamp audio files self._clamped_ref = ClampedWav(ref_path, ref_start, ref_end, logger=self.logger) self.ref_path, self.ref_offset, self.ref_duration = ( self._clamped_ref.path, self._clamped_ref.offset_sec, self._clamped_ref.duration_sec) @@ -68,26 +123,32 @@ def __init__(self, ref_path: str, utau_path: str, ustx_path: str, sec2timestamp(self.utau_offset + self.utau_duration), self.utau_duration)) - self.ustx_path = ustx_path - self.tempo = load_ustx(self.ustx_path)["tempos"][0]["bpm"] + # Init other attributes + self.expression_tick: list | np.ndarray = [] + self.expression_val: list | np.ndarray = [] self.logger.info(_("Initialization complete.")) + def __del__(self): + self.ustx_editor.close() + def get_expression(self, *args, **kwargs): return self.expression_tick, self.expression_val def load_to_ustx(self, track_number: int): if len(self.expression_tick) > 0 and len(self.expression_val) > 0: - with self.__class__.ustx_lock: - ustx_dict = load_ustx(self.ustx_path) - edit_ustx_expression_curve( - ustx_dict, - track_number, - self.__class__.expression_name, - self.expression_tick, - self.expression_val, - ) - save_ustx(ustx_dict, self.ustx_path) - self.logger.info(_("Expression written to USTX file: '{}'").format(self.ustx_path)) + track_no = track_number - 1 + # Apply offset first + shifted_ticks = self.ustx_time_axis.shift_ticks_by_seconds( + np.asarray(self.expression_tick), self.utau_offset + ) + self.ustx_editor.add_expression_to_track( + track_no, + self.__class__.expression_name, + shifted_ticks, + self.expression_val, + ) + self.ustx_editor.save() + self.logger.info(_("Expression written to USTX file: '{}'").format(self.ustx_path)) else: self.logger.warning(_("Expression result is empty. Skipping USTX update.")) diff --git a/expressions/dyn.py b/expressions/dyn.py index 4ced865..da16a50 100644 --- a/expressions/dyn.py +++ b/expressions/dyn.py @@ -6,7 +6,6 @@ from .base import Args, ExpressionLoader, register_expression from utils.i18n import _, _l from utils.seqtool import ( - time_to_ticks, unify_sequence_time, align_sequence_tick, gaussian_filter1d_with_nan, @@ -35,7 +34,7 @@ def get_expression( ): self.logger.info(_("Extracting expression...")) - # Extract rms features from WAV files + # Extract RMS features from WAV files utau_time, utau_rms, utau_features = get_wav_features( wav_path=self.utau_path, mask_silence=trim_silence ) @@ -43,46 +42,42 @@ def get_expression( wav_path=self.ref_path, mask_silence=trim_silence ) - # Align all sequences to a common MIDI tick time base - # NOTICE: features from UTAU WAV are the reference, and those from Ref. WAV are the query + # Align all sequences to a common MIDI tick time base. + # Features from the UTAU WAV are the reference; Ref. WAV features are the query. dyn_tick, (time_aligned_ref_rms, *_unused), (time_unified_utau_rms, *_unused) = align_sequence_tick( query_time=ref_time, queries=(ref_rms, *ref_features), reference_time=utau_time, references=(utau_rms, *utau_features), - tempo=self.tempo, align_radius=align_radius, ) - # Mask positions where utau is silent (NaN) + # Mask positions where UTAU is silent (NaN) time_aligned_ref_rms[np.isnan(time_unified_utau_rms)] = np.nan + # Generate expression curve dyn_val = get_experssion_dynamics(time_aligned_ref_rms, smoothness, scaler) - # Shift ticks to absolute MIDI position using the UTAU trim offset - utau_offset_ticks = time_to_ticks(self.utau_offset, self.tempo) - self.expression_tick = dyn_tick + utau_offset_ticks - self.expression_val = dyn_val - + self.expression_tick, self.expression_val = dyn_tick, dyn_val self.logger.info(_("Expression extraction complete.")) return self.expression_tick, self.expression_val def get_wav_features(wav_path, mask_silence=True): - feature_times = [] # List of time sequences(list of lists) - feature_vals = [] # List of feature sequences(list of lists) + feature_times = [] + feature_vals = [] # Extract RMS feature rms_time, rms = extract_wav_rms(wav_path, mask_silence=mask_silence) feature_times += [rms_time] - feature_vals += [rms] + feature_vals += [rms] # Extract RMS dynamics and trends rms_dynamics_trends = seq_dynamics_trends(rms) feature_times += [rms_time] * len(rms_dynamics_trends) - feature_vals += list(rms_dynamics_trends) + feature_vals += list(rms_dynamics_trends) - # Unified time and features + # Unify time and features wav_time, (wav_rms, *wav_features) = unify_sequence_time( seq_times=feature_times, seq_vals=feature_vals ) diff --git a/expressions/pitd.py b/expressions/pitd.py index 5be6614..aed6294 100644 --- a/expressions/pitd.py +++ b/expressions/pitd.py @@ -7,14 +7,13 @@ from .base import Args, ExpressionLoader, register_expression from utils.i18n import _, _l, _lf from utils.seqtool import ( - time_to_ticks, unify_sequence_time, align_sequence_tick, gaussian_filter1d_with_nan, seq_dynamics_trends, ) from utils.log import StreamToLogger -from utils.wavtool import extract_wav_mfcc, extract_wav_frequency +from utils.wavtool import extract_wav_mfcc, extract_wav_frequency, extract_wav_rms @register_expression @@ -26,7 +25,7 @@ class PitdLoader(ExpressionLoader): "crepe": _l("classic but slow, CPU & NVIDIA GPU (TensorFlow)"), } confidence_utau_recommended = {"swift-f0": 0.95, "crepe": 0.8} - confidence_ref_recommended = {"swift-f0": 0.93, "crepe": 0.6} + confidence_ref_recommended = {"swift-f0": 0.93, "crepe": 0.6} args = SimpleNamespace( backend = Args(name="backend" , type=str , default="swift-f0", choices=list(backend_choices.keys()), help=_lf("**F0 detection backend** for extracting pitch from WAV files. Available options:\n\n%s\n\n", lambda: "\n".join([f"- `{k}`: {v}" for k, v in PitdLoader.backend_choices.items()]))), # noqa: E501 confidence_utau = Args(name="confidence_utau", type=float, default=None, help=_lf("Minimum **confidence level** for keeping detected pitch values in the **UTAU** WAV. Lower values retain more frames but may include errors. Omit to use the recommended value for the selected backend:\n\n%s\n\n", lambda: "\n".join([f"- `{k}`: {v}" for k, v in PitdLoader.confidence_utau_recommended.items()]))), # noqa: E501 @@ -60,27 +59,24 @@ def get_expression( utau_time, utau_pitch, utau_features = get_wav_features( wav_path=self.utau_path, confidence_threshold=confidence_utau, backend=backend ) - - # Extract pitch features from reference WAV file with StreamToLogger(self.logger, tee=True): ref_time, ref_pitch, ref_features = get_wav_features( wav_path=self.ref_path, confidence_threshold=confidence_ref, backend=backend ) - # Align all sequences to a common MIDI tick time base - # NOTICE: features from UTAU WAV are the reference, and those from Ref. WAV are the query + # Align all sequences to a common MIDI tick time base. + # Features from the UTAU WAV are the reference; Ref. WAV features are the query. pitd_tick, (time_aligned_ref_pitch, *_unused), (unified_utau_pitch, *_unused) = ( align_sequence_tick( query_time=ref_time, queries=(ref_pitch, *ref_features), reference_time=utau_time, references=(utau_pitch, *utau_features), - tempo=self.tempo, align_radius=align_radius, ) ) - # Align pitch sequences in pitch axis + # Align pitch sequences along the pitch axis with StreamToLogger(self.logger, tee=True): time_pitch_aligned_ref_pitch, _unused = align_sequence_pitch( time_aligned_ref_pitch, @@ -96,21 +92,14 @@ def get_expression( scaler=scaler, ) - # Shift ticks to absolute MIDI position using the UTAU trim offset - utau_offset_ticks = time_to_ticks(self.utau_offset, self.tempo) - self.expression_tick = pitd_tick + utau_offset_ticks - self.expression_val = pitd_val - + self.expression_tick, self.expression_val = pitd_tick, pitd_val self.logger.info(_("Expression extraction complete.")) return self.expression_tick, self.expression_val -# TODO: Deal with different tempo or ppqn within the same USTX file def get_wav_features(wav_path, backend="swift-f0", confidence_threshold=0.8, confidence_filter_size=9): """Extract features from a WAV file. - This function extracts pitch and MFCC features from a WAV file, aligning them to a common time base. - Args: wav_path (str): Path to the WAV file. backend (str, optional): F0 detection backend ("crepe" or "swift-f0"). Defaults to "swift-f0". @@ -118,37 +107,39 @@ def get_wav_features(wav_path, backend="swift-f0", confidence_threshold=0.8, con confidence_filter_size (int, optional): Size of the median filter for confidence. Defaults to 9. Returns: - tuple: (wav_tick, wav_pitch, wav_features), where: - - wav_tick (numpy.ndarray): MIDI ticks for the extracted features. Shape: (n_time_points). - - wav_pitch (numpy.ndarray): Extracted pitch values in Hz. Shape: (n_time_points). - - wav_features (tuple): Extracted feature sequences. Shape: (n_features, n_time_points). + tuple: (wav_time, wav_pitch, wav_features) """ - feature_times = [] # List of time sequences(list of lists) - feature_vals = [] # List of feature sequences(list of lists) + feature_times = [] + feature_vals = [] - # Extract features from WAV file time, frequency, confidence = extract_wav_frequency(wav_path, backend=backend) - mask = ( - medfilt(np.array(confidence), kernel_size=confidence_filter_size) + + mask_confidence = ( + medfilt(confidence, kernel_size=confidence_filter_size) < confidence_threshold ) - (pitch := np.array(frequency))[mask] = np.nan + (pitch := frequency)[mask_confidence] = np.nan pitch_time = time feature_times += [pitch_time] - feature_vals += [pitch] + feature_vals += [pitch] - # Extract pitch dynamics trends pitch_features = seq_dynamics_trends(pitch) feature_times += [pitch_time] * len(pitch_features) - feature_vals += list(pitch_features) + feature_vals += list(pitch_features) - # Extract MFCC features mfcc_time, mfcc = extract_wav_mfcc(wav_path) feature_times += [mfcc_time] * len(mfcc) - feature_vals += list(mfcc) + feature_vals += list(mfcc) + + rms_time, rms = extract_wav_rms(wav_path, mask_silence=True) + feature_times += [rms_time] + feature_vals += [rms] + + rms_dynamics_trends = seq_dynamics_trends(rms) + feature_times += [rms_time] * len(rms_dynamics_trends) + feature_vals += list(rms_dynamics_trends) - # Unified time and features wav_time, (wav_pitch, *wav_features) = unify_sequence_time( seq_times=feature_times, seq_vals=feature_vals ) @@ -158,45 +149,39 @@ def get_wav_features(wav_path, backend="swift-f0", confidence_threshold=0.8, con def align_sequence_pitch(query, reference, semitone_shift=None, smoothness=0): """Align pitch sequences by shifting in semitones and applying smoothing. - This function adjusts the pitch sequence to match the reference pitch, allowing for optional smoothing. - Args: - query (numpy.ndarray): Pitch values to be aligned. Shape: (n_time_points). - reference (numpy.ndarray): Target reference pitch values. Shape: (n_time_points). - semitone_shift (int, optional): Number of semitones to shift the query pitch. If None, it is calculated automatically. - smoothness (int, optional): Smoothing factor for the aligned pitch. Defaults to 0 (no smoothing). + query (numpy.ndarray): Pitch values to be aligned. + reference (numpy.ndarray): Target reference pitch values. + semitone_shift (int, optional): Semitones to shift the query pitch. + If None, estimated automatically. + smoothness (int, optional): Smoothing sigma. Defaults to 0. Returns: - tuple: (pitch_aligned_query, semitone_shift), where: - - pitch_aligned_query (numpy.ndarray): Aligned pitch values. Shape: (n_time_points). - - semitone_shift (int): Applied semitone shift. + tuple: (pitch_aligned_query, semitone_shift) """ if semitone_shift is None: - base_pitch_wav = np.nanmedian(query) + base_pitch_wav = np.nanmedian(query) base_pitch_vocal = np.nanmedian(reference) - semitone_shift = int(np.round(hz_to_midi(base_pitch_vocal)) - np.round( - hz_to_midi(base_pitch_wav) - ).astype(int)) + semitone_shift = int( + np.round(hz_to_midi(base_pitch_vocal)) + - np.round(hz_to_midi(base_pitch_wav)).astype(int) + ) print(_("Estimated Semitone-shift: {}").format(semitone_shift)) - pitch_aligned_query = query * np.exp2(semitone_shift / 12) - pitch_aligned_query = gaussian_filter1d_with_nan( - pitch_aligned_query, sigma=smoothness + query * np.exp2(semitone_shift / 12), + sigma=smoothness, ) - return pitch_aligned_query, semitone_shift def get_pitch_delta(query, reference, scaler=2.5): - """Calculate the difference between two pitch sequences. - - The delta represents the pitch correction needed to align the query sequence with the reference sequence. + """Calculate the scaled pitch difference between two sequences. Args: - query (numpy.ndarray): Pitch values from the query sequence. + query (numpy.ndarray): Pitch values from the query sequence. reference (numpy.ndarray): Pitch values from the reference sequence. - scaler (float, optional): Scaling factor for the pitch difference. Defaults to 2.5. + scaler (float, optional): Scaling factor. Defaults to 2.5. Returns: numpy.ndarray: Scaled pitch difference values. diff --git a/expressions/tenc.py b/expressions/tenc.py index e0fea4c..ded18a0 100644 --- a/expressions/tenc.py +++ b/expressions/tenc.py @@ -6,7 +6,6 @@ from .base import Args, ExpressionLoader, register_expression from utils.i18n import _, _l from utils.seqtool import ( - time_to_ticks, unify_sequence_time, align_sequence_tick, gaussian_filter1d_with_nan, @@ -45,46 +44,39 @@ def get_expression( wav_path=self.ref_path, mask_silence=trim_silence ) - # Align all sequences to a common MIDI tick time base - # NOTICE: features from UTAU WAV are the reference, and those from Ref. WAV are the query + # Align all sequences to a common MIDI tick time base. + # Features from the UTAU WAV are the reference; Ref. WAV features are the query. tenc_tick, (time_aligned_ref_rms, *_unused), (time_unified_utau_rms, *_unused) = align_sequence_tick( query_time=ref_time, queries=(ref_rms, *ref_features), reference_time=utau_time, references=(utau_rms, *utau_features), - tempo=self.tempo, align_radius=align_radius, ) - # Mask positions where utau is silent (NaN) + # Mask positions where UTAU is silent (NaN) time_aligned_ref_rms[np.isnan(time_unified_utau_rms)] = np.nan + # Generate expression curve tenc_val = get_experssion_tension(time_aligned_ref_rms, smoothness, scaler, bias) - # Shift ticks to absolute MIDI position using the UTAU trim offset - utau_offset_ticks = time_to_ticks(self.utau_offset, self.tempo) - self.expression_tick = tenc_tick + utau_offset_ticks - self.expression_val = tenc_val - + self.expression_tick, self.expression_val = tenc_tick, tenc_val self.logger.info(_("Expression extraction complete.")) return self.expression_tick, self.expression_val def get_wav_features(wav_path, mask_silence=True): - feature_times = [] # List of time sequences(list of lists) - feature_vals = [] # List of feature sequences(list of lists) + feature_times = [] + feature_vals = [] - # Extract RMS rms_time, rms = extract_wav_rms(wav_path, mask_silence=mask_silence) feature_times += [rms_time] - feature_vals += [rms] + feature_vals += [rms] - # Extract RMS dynamics and trends rms_dynamics_trends = seq_dynamics_trends(rms) feature_times += [rms_time] * len(rms_dynamics_trends) - feature_vals += list(rms_dynamics_trends) + feature_vals += list(rms_dynamics_trends) - # Unified time and features wav_time, (wav_rms, *wav_features) = unify_sequence_time( seq_times=feature_times, seq_vals=feature_vals ) diff --git a/expressive_gui.py b/expressive_gui.py index ea427ef..9bcb1df 100644 --- a/expressive_gui.py +++ b/expressive_gui.py @@ -738,16 +738,21 @@ def main(): patch_nicegui_json() patch_tooltip_md() + # Start the GUI + ui_params = { + "title": f"Expressive GUI v{VERSION}", + "native": True, + "reload": False, + "window_size": (600, 640), + "reconnect_timeout": 60, + } try: # Deal with different running mode of this nicegui app if is_root_mode(): # Run with root function (app installed from wheel) ui.run( root=create_gui, - title=f"Expressive GUI v{VERSION}", - native=True, - reload=False, - window_size=(600, 640), + **ui_params ) else: # Run in script mode (app run through this script or frozen with pyinstaller) @@ -762,11 +767,7 @@ def main(): # https://github.com/zauberzeug/nicegui/issues/5247 with patch_runpy(): ui.run( - title=f"Expressive GUI v{VERSION}", - native=True, - reload=False, - window_size=(600, 640), - reconnect_timeout=60, + **ui_params ) except KeyboardInterrupt: diff --git a/pyproject.toml b/pyproject.toml index dc2753c..8070ef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "lazy-string", "rich-argparse", "rich", + "filelock", ] [project.optional-dependencies] diff --git a/tests/conftest.py b/tests/conftest.py index 01d9227..62e537b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,12 +50,23 @@ def temp_ustx_file(temp_dir): - bpm: 120 position: 0 time_signatures: - - bar_index: 0 + - bar_position: 0 beat_per_bar: 4 beat_unit: 4 +tracks: + - track_name: Track 1 + track_color: Blue + singer: "" + phonemizer: "" + mute: false + solo: false + volume: 0.0 + pan: 0.0 voice_parts: - name: Track 1 track_no: 0 + position: 0 + duration: 1920 notes: [] curves: [] """ @@ -72,12 +83,26 @@ def sample_ustx_dict(): {'bpm': 120, 'position': 0} ], 'time_signatures': [ - {'bar_index': 0, 'beat_per_bar': 4, 'beat_unit': 4} + {'bar_position': 0, 'beat_per_bar': 4, 'beat_unit': 4} + ], + 'tracks': [ + { + 'track_name': 'Track 1', + 'track_color': 'Blue', + 'singer': '', + 'phonemizer': '', + 'mute': False, + 'solo': False, + 'volume': 0.0, + 'pan': 0.0, + } ], 'voice_parts': [ { 'name': 'Track 1', 'track_no': 0, + 'position': 0, + 'duration': 1920, 'notes': [ { 'pos': 0, @@ -98,6 +123,13 @@ def sample_ustx_dict(): } +@pytest.fixture +def sample_project(sample_ustx_dict): + """Return a parsed UProject built from sample_ustx_dict""" + from utils.ustx import UProject + return UProject.from_dict(sample_ustx_dict) + + @pytest.fixture(autouse=True) def reset_expression_loader_counter(): """Reset ExpressionLoader ID counter before each test""" diff --git a/tests/test_expression_base.py b/tests/test_expression_base.py index f579e52..23ae6c6 100644 --- a/tests/test_expression_base.py +++ b/tests/test_expression_base.py @@ -18,13 +18,17 @@ # --------------------------------------------------------------------------- -# Fixtures +# Helpers # --------------------------------------------------------------------------- def _write_silent_wav(path: str, duration: float = 3.0, sr: int = 22050) -> None: sf.write(path, np.zeros(int(duration * sr), dtype=np.float32), sr) +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + @pytest.fixture() def temp_wav_pair(tmp_path): """Return (ref_wav_path, utau_wav_path) as plain strings.""" @@ -37,8 +41,15 @@ def temp_wav_pair(tmp_path): @pytest.fixture() def temp_ustx_file(tmp_path): - """Return a Path to a minimal USTX file with BPM 120.""" - content = "tempos:\n - bpm: 120\n position: 0\nvoice_parts:\n - name: Track 1\n" + """Minimal valid USTX file (BPM 120, one voice part covering ticks 0–9600).""" + content = ( + "tempos:\n - bpm: 120\n position: 0\n" + "time_signatures:\n - bar_position: 0\n beat_per_bar: 4\n beat_unit: 4\n" + "tracks:\n - track_name: Track 1\n track_color: Blue\n singer: ''\n" + " phonemizer: ''\n mute: false\n solo: false\n volume: 0.0\n pan: 0.0\n" + "voice_parts:\n - name: Track 1\n track_no: 0\n position: 0\n duration: 9600\n" + " notes: []\n curves: []\n" + ) p = tmp_path / "test.ustx" p.write_text(content, encoding="utf-8-sig") return p @@ -102,21 +113,17 @@ def test_loader_initialization(self, temp_wav_pair, temp_ustx_file): loader = ExpressionLoader(ref, utau, str(temp_ustx_file)) # ref_path / utau_path now point to ClampedWav temp files, not the originals - assert loader.ref_path != ref - assert loader.utau_path != utau assert loader.ref_path.endswith(".wav") assert loader.utau_path.endswith(".wav") - assert loader.ustx_path == str(temp_ustx_file) - assert loader.tempo == 120 assert loader.id > 0 def test_loader_offset_and_duration_stored(self, temp_wav_pair, temp_ustx_file): ref, utau = temp_wav_pair loader = ExpressionLoader(ref, utau, str(temp_ustx_file)) - assert isinstance(loader.ref_offset, float) + assert isinstance(loader.ref_offset, float) assert isinstance(loader.ref_duration, float) - assert isinstance(loader.utau_offset, float) + assert isinstance(loader.utau_offset, float) assert isinstance(loader.utau_duration, float) assert loader.ref_offset == pytest.approx(0.0) assert loader.utau_offset == pytest.approx(0.0) @@ -134,20 +141,38 @@ def test_loader_has_logger(self, temp_wav_pair, temp_ustx_file): loader = ExpressionLoader(ref, utau, str(temp_ustx_file)) assert isinstance(loader.logger, logging.LoggerAdapter) - def test_loader_reads_tempo(self, temp_wav_pair, temp_dir): + def test_loader_builds_time_axis(self, temp_wav_pair, temp_ustx_file): + """The loader must expose a working TimeAxis, not a raw tempo value.""" + from utils.ustx import TimeAxis + ref, utau = temp_wav_pair + loader = ExpressionLoader(ref, utau, str(temp_ustx_file)) + assert isinstance(loader.ustx_time_axis, TimeAxis) + # 120 BPM, 480 PPQN → 960 ticks/second + ticks = loader.ustx_time_axis.seconds_to_ticks(np.array([1.0])) + assert ticks[0] == 960 + + def test_loader_time_axis_reflects_project_tempo(self, temp_wav_pair, temp_dir): + """TimeAxis BPM is derived from the USTX file, not hardcoded.""" ref, utau = temp_wav_pair ustx_path = temp_dir / "tempo_test.ustx" ustx_path.write_text( - "tempos:\n - bpm: 140\n position: 0\nvoice_parts:\n - name: Track 1\n", + "tempos:\n - bpm: 140\n position: 0\n" + "time_signatures:\n - bar_position: 0\n beat_per_bar: 4\n beat_unit: 4\n" + "tracks: []\n" + "voice_parts:\n - name: T\n track_no: 0\n position: 0\n duration: 9600\n" + " notes: []\n curves: []\n", encoding="utf-8-sig", ) loader = ExpressionLoader(ref, utau, str(ustx_path)) - assert loader.tempo == 140 + # 140 BPM → 140/60*480 ≈ 1120 ticks/second + ticks = loader.ustx_time_axis.seconds_to_ticks(np.array([1.0])) + expected = round(140 / 60 * 480) + assert ticks[0] == expected def test_loader_trim_start(self, temp_wav_pair, temp_ustx_file): ref, utau = temp_wav_pair loader = ExpressionLoader(ref, utau, str(temp_ustx_file), ref_start="0:01") - assert loader.ref_offset == pytest.approx(1.0, abs=0.01) + assert loader.ref_offset == pytest.approx(1.0, abs=0.01) assert loader.ref_duration == pytest.approx(2.0, abs=0.1) def test_loader_trim_end(self, temp_wav_pair, temp_ustx_file): @@ -322,14 +347,33 @@ class TestLoader(ExpressionLoader): ref, utau = temp_wav_pair loader = TestLoader(ref, utau, str(temp_ustx_file)) loader.expression_tick = np.array([0, 480, 960]) - loader.expression_val = np.array([0, 50, 100]) + loader.expression_val = np.array([0, 50, 100], dtype=float) loader.load_to_ustx(track_number=1) - ustx_dict = load_ustx(str(temp_ustx_file)) - curves = ustx_dict["voice_parts"][0]["curves"] - assert len(curves) == 1 - assert curves[0]["abbr"] == "dyn" + project = load_ustx(str(temp_ustx_file)) + curve = project.voice_parts[0].get_curve("dyn") + assert curve is not None + assert curve.abbr == "dyn" + + def test_load_to_ustx_curve_values(self, temp_wav_pair, temp_ustx_file): + """Values written should survive a save/load roundtrip.""" + from utils.ustx import load_ustx + + @register_expression + class TestLoader(ExpressionLoader): + expression_name = "dyn" + + ref, utau = temp_wav_pair + loader = TestLoader(ref, utau, str(temp_ustx_file)) + loader.expression_tick = np.array([0, 480, 960]) + loader.expression_val = np.array([0.0, 50.0, 100.0]) + + loader.load_to_ustx(track_number=1) + + project = load_ustx(str(temp_ustx_file)) + curve = project.voice_parts[0].get_curve("dyn") + assert curve.ys == [0, 50, 100] def test_load_to_ustx_empty_data_logs_warning(self, temp_wav_pair, temp_ustx_file, caplog): ref, utau = temp_wav_pair @@ -338,49 +382,26 @@ def test_load_to_ustx_empty_data_logs_warning(self, temp_wav_pair, temp_ustx_fil with caplog.at_level(logging.WARNING): loader.load_to_ustx(track_number=1) - # The actual warning message (translated or not) should indicate emptiness messages = " ".join(r.message for r in caplog.records) assert any(word in messages.lower() for word in ("empty", "空")) - def test_load_to_ustx_thread_safety(self): - assert hasattr(ExpressionLoader, "ustx_lock") - lock = ExpressionLoader.ustx_lock - assert callable(getattr(lock, "acquire", None)) - assert callable(getattr(lock, "release", None)) - - def test_load_to_ustx_uses_lock(self, temp_wav_pair, temp_ustx_file, monkeypatch): - """Verify the lock is actually acquired during load_to_ustx.""" - ref, utau = temp_wav_pair + def test_load_to_ustx_track_number_1based(self, temp_wav_pair, temp_ustx_file): + """track_number=1 must map to track_no=0 inside add_expression_to_track.""" + from utils.ustx import load_ustx @register_expression class TestLoader(ExpressionLoader): expression_name = "dyn" + ref, utau = temp_wav_pair loader = TestLoader(ref, utau, str(temp_ustx_file)) - loader.expression_tick = np.array([0]) - loader.expression_val = np.array([0]) - - # threading.Lock().acquire is a read-only slot — wrap the whole lock instead. - acquired = [] - - class SpyLock: - """Thin wrapper that records acquire() calls and delegates to the real lock.""" - def __init__(self, real): - self._real = real - def acquire(self, *args, **kwargs): - acquired.append(True) - return self._real.acquire(*args, **kwargs) - def release(self): - return self._real.release() - def __enter__(self): - self.acquire() - return self - def __exit__(self, *args): - self.release() - - monkeypatch.setattr(TestLoader, "ustx_lock", SpyLock(ExpressionLoader.ustx_lock)) + loader.expression_tick = np.array([0, 480]) + loader.expression_val = np.array([10.0, 20.0]) + loader.load_to_ustx(track_number=1) - assert len(acquired) > 0 + + project = load_ustx(str(temp_ustx_file)) + assert project.voice_parts[0].get_curve("dyn") is not None # --------------------------------------------------------------------------- @@ -421,7 +442,7 @@ def get_expression(self, *args, **kwargs): tick, val = loader.get_expression() assert list(tick) == [0, 480, 960] - assert list(val) == [10, 20, 30] + assert list(val) == [10, 20, 30] # --------------------------------------------------------------------------- @@ -450,21 +471,18 @@ def get_expression(self, smoothness=2): def test_multiple_loaders_independent(self, tmp_path, temp_ustx_file): """Two instances share no mutable state.""" - ref1 = str(tmp_path / "ref1.wav") - _write_silent_wav(ref1) - utau1 = str(tmp_path / "utau1.wav") - _write_silent_wav(utau1) - ref2 = str(tmp_path / "ref2.wav") - _write_silent_wav(ref2) - utau2 = str(tmp_path / "utau2.wav") - _write_silent_wav(utau2) + paths = {} + for name in ("ref1", "utau1", "ref2", "utau2"): + p = str(tmp_path / f"{name}.wav") + _write_silent_wav(p) + paths[name] = p @register_expression class TestLoader(ExpressionLoader): expression_name = "test" - l1 = TestLoader(ref1, utau1, str(temp_ustx_file)) - l2 = TestLoader(ref2, utau2, str(temp_ustx_file)) + l1 = TestLoader(paths["ref1"], paths["utau1"], str(temp_ustx_file)) + l2 = TestLoader(paths["ref2"], paths["utau2"], str(temp_ustx_file)) assert l1.id != l2.id assert l1.ref_path != l2.ref_path @@ -475,12 +493,7 @@ class TestLoader(ExpressionLoader): assert l1.expression_tick[1] != l2.expression_tick[1] def test_temp_files_cleaned_on_del(self, temp_wav_pair, temp_ustx_file): - """ClampedWav temp files are removed when the loader is deleted. - - We call _cleanup() directly on the underlying ClampedWav objects - rather than relying on __del__ / GC timing, which is - implementation-defined and unreliable on CPython with atexit refs. - """ + """ClampedWav temp files are removed when _cleanup() is called.""" import os ref, utau = temp_wav_pair @@ -493,3 +506,11 @@ def test_temp_files_cleaned_on_del(self, temp_wav_pair, temp_ustx_file): assert not os.path.exists(ref_tmp) assert not os.path.exists(utau_tmp) + + def test_tick_converters_registered_after_init(self, temp_wav_pair, temp_ustx_file): + """set_tick_converters must be called during __init__ so seqtool functions work.""" + from utils.seqtool import _time_to_ticks_fn, _default_time_to_ticks + ref, utau = temp_wav_pair + ExpressionLoader(ref, utau, str(temp_ustx_file)) + # After init the registered converter must not be the default error-raiser + assert _time_to_ticks_fn is not _default_time_to_ticks diff --git a/tests/test_expressive.py b/tests/test_expressive.py index d051393..c787d07 100644 --- a/tests/test_expressive.py +++ b/tests/test_expressive.py @@ -330,9 +330,8 @@ def test_process_with_real_files(self, tmp_path, has_example_files): assert Path(ustx_output).exists() from utils.ustx import load_ustx - ustx_dict = load_ustx(ustx_output) - assert "curves" in ustx_dict["voice_parts"][0] - assert any(c["abbr"] == "dyn" for c in ustx_dict["voice_parts"][0]["curves"]) + project = load_ustx(ustx_output) + assert any(c.abbr == "dyn" for c in project.voice_parts[0].curves) @patch('expressive.copy') @patch('expressive.getExpressionLoader') diff --git a/tests/test_seqtool.py b/tests/test_seqtool.py index d7a0688..5df030a 100644 --- a/tests/test_seqtool.py +++ b/tests/test_seqtool.py @@ -3,8 +3,8 @@ from numpy.testing import assert_array_almost_equal, assert_array_equal from utils.seqtool import ( - time_to_ticks, - ticks_to_time, + set_tick_converters, + reset_tick_converters, sequence_interval_intersection, sequence_interval_union, unify_sequence_time, @@ -15,412 +15,389 @@ ) -class TestTimeConversion: - """Test time and MIDI tick conversion functions""" - - def test_time_to_ticks_basic(self): - """Test basic time to tick conversion""" - # 120 BPM, 480 PPQN - # 1 second = 120/60 * 480 = 960 ticks - result = time_to_ticks(1.0, tempo=120, ppqn=480, unique=False) - assert result == 960 - - def test_time_to_ticks_array(self): - """Test array input""" - times = [0, 0.5, 1.0, 2.0] - result = time_to_ticks(times, tempo=120, ppqn=480, unique=False) - expected = np.array([0, 480, 960, 1920]) - assert_array_equal(result, expected) - - def test_time_to_ticks_unique(self): - """Test that unique=True deduplicates and sorts ticks""" - times = [0.0, 0.0, 1.0, 1.0, 2.0] - result = time_to_ticks(times, tempo=120, ppqn=480, unique=True) - expected = np.array([0, 960, 1920]) - assert_array_equal(result, expected) - - def test_ticks_to_time_basic(self): - """Test basic tick to time conversion""" - result = ticks_to_time(960, tempo=120, ppqn=480) - assert result == 1 - - def test_time_tick_roundtrip(self): - """Test roundtrip conversion consistency""" - original_time = np.array([0.5, 1.0, 1.5, 2.0]) - ticks = time_to_ticks(original_time, tempo=120, ppqn=480, unique=False) - recovered_time = ticks_to_time(ticks, tempo=120, ppqn=480) - assert_array_almost_equal(original_time, recovered_time) - - @pytest.mark.parametrize("time,tempo,ppqn,expected", [ - (1.0, 120, 480, 960), # Standard case - (0.5, 120, 480, 480), # Half second - (2.0, 120, 480, 1920), # Two seconds - (1.0, 60, 480, 480), # Slower tempo - (1.0, 240, 480, 1920), # Faster tempo - (1.0, 120, 960, 1920), # Higher resolution - ]) - def test_time_to_ticks_parametrized(self, time, tempo, ppqn, expected): - """Test time_to_ticks with multiple parameter combinations""" - result = time_to_ticks(time, tempo, ppqn, unique=False) - assert result == expected +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_tick_converters(tempo=120, ppqn=480): + """Return a (time_to_ticks, ticks_to_time) pair for a constant tempo map.""" + ticks_per_second = tempo / 60 * ppqn + + def time_to_ticks(times: np.ndarray, unique: bool = True) -> np.ndarray: + ticks = np.round(np.asarray(times, dtype=float) * ticks_per_second).astype(int) + return np.unique(ticks) if unique else ticks + + def ticks_to_time(ticks: np.ndarray) -> np.ndarray: + return np.asarray(ticks, dtype=float) / ticks_per_second + + return time_to_ticks, ticks_to_time - def test_time_to_ticks_zero(self): - """Test with zero time""" - result = time_to_ticks(0, tempo=120, ppqn=480, unique=False) - assert result == 0 - def test_time_to_ticks_negative(self): - """Test with negative time""" - result = time_to_ticks(-1.0, tempo=120, ppqn=480, unique=False) - assert result == -960 +@pytest.fixture() +def tick_converters_120bpm(): + """Register 120 BPM / 480 PPQN converters, then reset after the test.""" + t2t, t2s = _make_tick_converters(tempo=120, ppqn=480) + set_tick_converters(t2t, t2s) + yield t2t, t2s + reset_tick_converters() +# --------------------------------------------------------------------------- +# Tick converter registry +# --------------------------------------------------------------------------- + +class TestTickConverterRegistry: + """Test the set_tick_converters / reset_tick_converters machinery.""" + + def test_raises_before_registration(self): + reset_tick_converters() + with pytest.raises(RuntimeError, match="No tick converter registered"): + unify_sequence_time( + [np.array([0.0, 1.0])], + [np.array([0.0, 1.0])], + to_ticks=True, + ) + + def test_set_and_reset(self): + t2t, t2s = _make_tick_converters() + set_tick_converters(t2t, t2s) + # Should not raise + unify_sequence_time( + [np.array([0.0, 1.0])], + [np.array([0.0, 1.0])], + to_ticks=True, + ) + reset_tick_converters() + with pytest.raises(RuntimeError): + unify_sequence_time( + [np.array([0.0, 1.0])], + [np.array([0.0, 1.0])], + to_ticks=True, + ) + + def test_converter_values(self): + """Registered converters should produce correct tick values.""" + t2t, t2s = _make_tick_converters(tempo=120, ppqn=480) + set_tick_converters(t2t, t2s) + + # 120 BPM, 480 PPQN → 960 ticks/second + assert t2t(np.array([1.0]), unique=False)[0] == 960 + assert t2s(np.array([960]))[0] == pytest.approx(1.0) + + reset_tick_converters() + + @pytest.mark.parametrize("tempo,ppqn,time,expected_ticks", [ + (120, 480, 1.0, 960), + ( 60, 480, 1.0, 480), + (240, 480, 1.0, 1920), + (120, 960, 1.0, 1920), + (120, 480, 0.5, 480), + (120, 480, 2.0, 1920), + (120, 480, 0.0, 0), + ]) + def test_converter_parametrized(self, tempo, ppqn, time, expected_ticks): + t2t, _ = _make_tick_converters(tempo=tempo, ppqn=ppqn) + result = t2t(np.array([time]), unique=False) + assert result[0] == expected_ticks + + def test_converter_roundtrip(self): + t2t, t2s = _make_tick_converters(tempo=120, ppqn=480) + original = np.array([0.5, 1.0, 1.5, 2.0]) + ticks = t2t(original, unique=False) + recovered = t2s(ticks) + assert_array_almost_equal(original, recovered) + + def test_converter_roundtrip_precision(self): + """Round-trip error must stay within half a tick duration.""" + t2t, t2s = _make_tick_converters(tempo=120, ppqn=480) + original = np.linspace(0, 10, 1000) + ticks = t2t(original, unique=False) + recovered = t2s(ticks) + tick_duration = 60 / (120 * 480) + max_error = tick_duration / 2 + assert np.all(np.abs(original - recovered) <= max_error + 1e-12) + + def test_converter_unique_deduplicates(self): + t2t, _ = _make_tick_converters(tempo=120, ppqn=480) + times = np.array([0.0, 0.0, 1.0, 1.0, 2.0]) + result = t2t(times, unique=True) + assert_array_equal(result, np.array([0, 960, 1920])) + + def test_converter_zero(self): + t2t, _ = _make_tick_converters(tempo=120, ppqn=480) + assert t2t(np.array([0.0]), unique=False)[0] == 0 + + def test_converter_negative(self): + t2t, _ = _make_tick_converters(tempo=120, ppqn=480) + assert t2t(np.array([-1.0]), unique=False)[0] == -960 + + +# --------------------------------------------------------------------------- +# Sequence interval operations +# --------------------------------------------------------------------------- + class TestSequenceOperations: - """Test sequence operation functions""" + """Test sequence_interval_intersection and sequence_interval_union.""" - def test_sequence_interval_intersection_basic(self): - """Test sequence intersection - basic case""" + def test_intersection_basic(self): seqs = [[0, 1, 2, 3], [1.0, 1.1, 2.0, 4.0, 5.0]] result = sequence_interval_intersection(seqs) - # Intersection range is [1.0, 3.0] - expected = [1.0, 1.1, 2.0, 3.0] - assert result == expected + assert result == [1.0, 1.1, 2.0, 3.0] - def test_sequence_interval_intersection_no_overlap(self): - """Test sequences with no overlap""" + def test_intersection_no_overlap(self): seqs = [[0, 1, 2], [5, 6, 7]] result = sequence_interval_intersection(seqs) assert result == [] - def test_sequence_interval_intersection_complete_overlap(self): - """Test sequences with complete overlap""" + def test_intersection_complete_overlap(self): seqs = [[1, 2, 3], [1, 2, 3]] result = sequence_interval_intersection(seqs) assert result == [1, 2, 3] @pytest.mark.parametrize("seq1,seq2,expected_len", [ - ([0, 1, 2], [1, 2, 3], 2), # Overlap: [1, 2] - ([0, 1, 2], [5, 6, 7], 0), # No overlap - ([0, 1, 2, 3, 4], [2, 3, 4, 5], 3), # Overlap: [2, 3, 4] - ([1, 2, 3], [1, 2, 3], 3), # Complete overlap + ([0, 1, 2], [1, 2, 3], 2), + ([0, 1, 2], [5, 6, 7], 0), + ([0, 1, 2, 3, 4], [2, 3, 4, 5], 3), + ([1, 2, 3], [1, 2, 3], 3), ]) - def test_sequence_intersection_parametrized(self, seq1, seq2, expected_len): - """Test sequence intersection with various inputs""" + def test_intersection_parametrized(self, seq1, seq2, expected_len): result = sequence_interval_intersection([seq1, seq2]) assert len(result) == expected_len - def test_sequence_interval_union_basic(self): - """Test sequence union - basic case""" + def test_union_basic(self): seqs = [[0, 1, 2, 3], [1.0, 1.1, 2.0, 4.0, 5.0]] result = sequence_interval_union(seqs) - expected = [0.0, 1.0, 1.1, 2.0, 3.0, 4.0, 5.0] - assert result == expected + assert result == [0.0, 1.0, 1.1, 2.0, 3.0, 4.0, 5.0] - def test_sequence_interval_union_duplicates(self): - """Test union removes duplicates""" + def test_union_deduplicates(self): seqs = [[1, 2, 3], [2, 3, 4], [3, 4, 5]] result = sequence_interval_union(seqs) - expected = [1, 2, 3, 4, 5] - assert result == expected + assert result == [1, 2, 3, 4, 5] - def test_sequence_interval_union_sorted(self): - """Test union returns sorted result""" + def test_union_sorted(self): seqs = [[5, 3, 1], [4, 2, 0]] result = sequence_interval_union(seqs) - expected = [0, 1, 2, 3, 4, 5] - assert result == expected - - -class TestUnifySequenceTime: - """Test sequence time unification function""" - - def test_unify_sequence_time_basic(self): - """Test basic sequence unification""" - seq_times = [ - np.array([0, 1, 2]), - np.array([0, 1, 2]) - ] - seq_vals = [ - np.array([10, 20, 30]), - np.array([15, 25, 35]) - ] - - unified_time, unified_vals = unify_sequence_time( - seq_times, seq_vals, to_ticks=False - ) + assert result == [0, 1, 2, 3, 4, 5] - # All unified sequences should have same length - assert len(unified_vals[0]) == len(unified_vals[1]) - assert len(unified_vals[0]) == len(unified_time) - - def test_unify_sequence_time_different_lengths(self): - """Test unifying sequences with different lengths""" - seq_times = [ - np.array([0, 1, 2]), - np.array([0, 0.5, 1, 1.5, 2]) - ] - seq_vals = [ - np.array([10, 20, 30]), - np.array([15, 17, 22, 27, 32]) - ] - - unified_time, unified_vals = unify_sequence_time( - seq_times, seq_vals, to_ticks=False - ) - # All unified sequences should have same length - assert len(unified_vals[0]) == len(unified_vals[1]) - assert len(unified_vals[0]) == len(unified_time) - - def test_unify_sequence_time_to_ticks(self): - """Test unification with tick conversion""" - seq_times = [ - np.array([0, 1, 2]), - np.array([0, 1, 2]) - ] - seq_vals = [ - np.array([10, 20, 30]), - np.array([15, 25, 35]) - ] - - unified_ticks, unified_vals = unify_sequence_time( - seq_times, seq_vals, to_ticks=True, tempo=120, ppqn=480 - ) - - # Result should be in ticks (integers) - assert unified_ticks.dtype == np.int64 or unified_ticks.dtype == np.int32 - assert len(unified_vals[0]) == len(unified_vals[1]) +# --------------------------------------------------------------------------- +# unify_sequence_time +# --------------------------------------------------------------------------- +class TestUnifySequenceTime: + """Test unify_sequence_time in both seconds and ticks mode.""" + + def test_basic_same_length(self): + seq_times = [np.array([0.0, 1.0, 2.0]), np.array([0.0, 1.0, 2.0])] + seq_vals = [np.array([10., 20., 30.]), np.array([15., 25., 35.])] + unified_time, (v0, v1) = unify_sequence_time(seq_times, seq_vals) + assert len(v0) == len(v1) == len(unified_time) + + def test_different_lengths(self): + seq_times = [np.array([0.0, 1.0, 2.0]), + np.array([0.0, 0.5, 1.0, 1.5, 2.0])] + seq_vals = [np.array([10., 20., 30.]), + np.array([15., 17., 22., 27., 32.])] + unified_time, (v0, v1) = unify_sequence_time(seq_times, seq_vals) + assert len(v0) == len(v1) == len(unified_time) + + def test_to_ticks_output_dtype(self, tick_converters_120bpm): + seq_times = [np.array([0.0, 1.0, 2.0]), np.array([0.0, 1.0, 2.0])] + seq_vals = [np.array([10., 20., 30.]), np.array([15., 25., 35.])] + unified_ticks, (v0, v1) = unify_sequence_time(seq_times, seq_vals, to_ticks=True) + assert np.issubdtype(unified_ticks.dtype, np.integer) + assert len(v0) == len(v1) == len(unified_ticks) + + def test_to_ticks_values(self, tick_converters_120bpm): + """Unified ticks should match the registered converter's output.""" + seq_times = [np.array([0.0, 1.0, 2.0]), np.array([0.0, 1.0, 2.0])] + seq_vals = [np.array([0., 1., 2.]), np.array([0., 1., 2.])] + unified_ticks, _ = unify_sequence_time(seq_times, seq_vals, to_ticks=True) + # 120 BPM, 480 PPQN → 0 s=0 ticks, 1 s=960, 2 s=1920 + assert_array_equal(unified_ticks, np.array([0, 960, 1920])) + + def test_to_ticks_requires_registration(self): + reset_tick_converters() + with pytest.raises(RuntimeError): + unify_sequence_time( + [np.array([0.0, 1.0])], + [np.array([0.0, 1.0])], + to_ticks=True, + ) + + +# --------------------------------------------------------------------------- +# gaussian_filter1d_with_nan +# --------------------------------------------------------------------------- class TestGaussianFilter: - """Test Gaussian filter with NaN handling""" + """Test gaussian_filter1d_with_nan.""" - def test_gaussian_filter_no_nan(self): - """Test without NaN values""" - seq = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + def test_no_nan(self): + seq = np.array([1., 2., 3., 4., 5.]) result = gaussian_filter1d_with_nan(seq, sigma=1.0) - assert result.shape == seq.shape assert not np.any(np.isnan(result)) - # Result should be smoothed assert np.var(result) < np.var(seq) - def test_gaussian_filter_with_nan(self): - """Test with NaN values""" - seq = np.array([1.0, 2.0, np.nan, 4.0, 5.0]) + def test_with_nan(self): + seq = np.array([1., 2., np.nan, 4., 5.]) result = gaussian_filter1d_with_nan(seq, sigma=1.0) - assert result.shape == seq.shape - # Non-NaN positions should have reasonable values - assert not np.isnan(result[0]) - assert not np.isnan(result[1]) - assert not np.isnan(result[3]) - assert not np.isnan(result[4]) - - def test_gaussian_filter_zero_sigma(self): - """Test with sigma=0 (no filtering)""" - seq = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) - result = gaussian_filter1d_with_nan(seq, sigma=0) + for i in [0, 1, 3, 4]: + assert not np.isnan(result[i]) - # Should return original sequence + def test_zero_sigma_identity(self): + seq = np.array([1., 2., 3., 4., 5.]) + result = gaussian_filter1d_with_nan(seq, sigma=0) assert_array_equal(result, seq) - def test_gaussian_filter_all_nan(self): - """Test with all NaN values""" + def test_all_nan(self): seq = np.array([np.nan, np.nan, np.nan]) result = gaussian_filter1d_with_nan(seq, sigma=1.0) - - # Result should also be all NaN assert np.all(np.isnan(result)) + def test_preserves_mean(self): + np.random.seed(42) + seq = np.random.randn(100) + 10 + result = gaussian_filter1d_with_nan(seq, sigma=2.0) + assert abs(np.mean(result) - np.mean(seq)) < 0.5 + @pytest.mark.parametrize("sigma,should_smooth", [ - (0, False), # No smoothing - (0.5, True), # Light smoothing - (1.0, True), # Medium smoothing - (2.0, True), # Heavy smoothing + (0, False), + (0.5, True), + (1.0, True), + (2.0, True), ]) - def test_gaussian_filter_smoothing_levels(self, sigma, should_smooth): - """Test different smoothing levels""" - # Create a noisy signal - seq = np.array([1.0, 5.0, 2.0, 6.0, 3.0]) + def test_smoothing_levels(self, sigma, should_smooth): + seq = np.array([1., 5., 2., 6., 3.]) result = gaussian_filter1d_with_nan(seq, sigma) - if should_smooth: - # Result should be smoother (less variance) assert np.var(result) < np.var(seq) else: - # No smoothing, should be identical assert_array_almost_equal(result, seq) +# --------------------------------------------------------------------------- +# seq_dynamics_trends +# --------------------------------------------------------------------------- + class TestSeqDynamicsTrends: - """Test dynamics and trends extraction""" + """Test seq_dynamics_trends.""" - def test_seq_dynamics_trends_basic(self): - """Test basic dynamics trends extraction""" - seq = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + def test_output_shape(self): + seq = np.array([1., 2., 3., 4., 5.]) result = seq_dynamics_trends(seq, n_order=3) + assert result.shape == (6, len(seq)) # 2 * n_order rows - # Should return 2 * n_order features - assert result.shape[0] == 6 # 2 * 3 - assert result.shape[1] == len(seq) - - def test_seq_dynamics_trends_constant(self): - """Test with constant sequence""" - seq = np.array([5.0, 5.0, 5.0, 5.0, 5.0]) + def test_constant_sequence_zero_gradient(self): + seq = np.array([5., 5., 5., 5., 5.]) result = seq_dynamics_trends(seq, n_order=2) - - # Gradients should be near zero assert np.allclose(result[0], 0, atol=1e-10) - def test_seq_dynamics_trends_linear(self): - """Test with linear sequence""" - seq = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + def test_linear_sequence_unit_gradient(self): + seq = np.array([1., 2., 3., 4., 5.]) result = seq_dynamics_trends(seq, n_order=2) - - # First gradient should be constant (all 1s) assert np.allclose(result[0], 1.0, atol=0.1) - def test_seq_dynamics_trends_different_orders(self): - """Test with different orders""" - seq = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + @pytest.mark.parametrize("n_order", [1, 2, 3, 4]) + def test_various_orders(self, n_order): + seq = np.array([1., 2., 3., 4., 5.]) + result = seq_dynamics_trends(seq, n_order=n_order) + assert result.shape[0] == 2 * n_order - for n_order in [1, 2, 3, 4]: - result = seq_dynamics_trends(seq, n_order=n_order) - assert result.shape[0] == 2 * n_order +# --------------------------------------------------------------------------- +# seq_rcr +# --------------------------------------------------------------------------- class TestSeqRCR: - """Test relative change rate calculation""" + """Test seq_rcr (relative change rate).""" - def test_seq_rcr_basic(self): - """Test basic RCR calculation""" - seq = np.array([1.0, 2.0, 4.0, 8.0]) + def test_output_shape(self): + seq = np.array([1., 2., 4., 8.]) result = seq_rcr(seq) - assert result.shape == seq.shape - # First value should be duplicated - assert result[0] == result[1] - def test_seq_rcr_constant(self): - """Test with constant sequence""" - seq = np.array([5.0, 5.0, 5.0, 5.0]) + def test_first_value_duplicated(self): + seq = np.array([1., 2., 4., 8.]) result = seq_rcr(seq) + assert result[0] == result[1] - # RCR should be near zero for constant sequence + def test_constant_near_zero(self): + seq = np.array([5., 5., 5., 5.]) + result = seq_rcr(seq) assert np.all(result < 0.01) - def test_seq_rcr_zero_values(self): - """Test with zero values (should handle epsilon)""" - seq = np.array([0.0, 1.0, 2.0]) + def test_zero_values_no_nan_inf(self): + seq = np.array([0., 1., 2.]) result = seq_rcr(seq) - - # Should not produce inf or nan assert not np.any(np.isnan(result)) assert not np.any(np.isinf(result)) - def test_seq_rcr_negative_values(self): - """Test with negative values""" - seq = np.array([-1.0, -2.0, -3.0]) + def test_negative_values_no_nan_inf(self): + seq = np.array([-1., -2., -3.]) result = seq_rcr(seq) + assert not np.any(np.isnan(result)) + assert not np.any(np.isinf(result)) - # Should handle negative values + def test_very_small_values_no_nan_inf(self): + seq = np.array([1e-10, 2e-10, 3e-10]) + result = seq_rcr(seq) assert not np.any(np.isnan(result)) assert not np.any(np.isinf(result)) +# --------------------------------------------------------------------------- +# align_sequence_tick +# --------------------------------------------------------------------------- + class TestAlignSequenceTick: - """Test sequence alignment with DTW""" + """Test align_sequence_tick (DTW-based alignment).""" @pytest.mark.slow - def test_align_sequence_tick_basic(self): - """Test basic sequence alignment""" - # Create two similar sequences with slight time shift + def test_basic_alignment(self, tick_converters_120bpm): query_time = np.linspace(0, 5, 50) - reference_time = np.linspace(0, 5, 50) - - query_seq = np.sin(2 * np.pi * query_time) - reference_seq = np.sin(2 * np.pi * reference_time) + ref_time = np.linspace(0, 5, 50) + query_seq = np.sin(2 * np.pi * query_time) + ref_seq = np.sin(2 * np.pi * ref_time) unified_tick, aligned_queries, unified_refs = align_sequence_tick( - query_time, - (query_seq,), - reference_time, - (reference_seq,), - tempo=120, - ppqn=480, - align_radius=1 + query_time, (query_seq,), + ref_time, (ref_seq,), + align_radius=1, ) - # Check output shapes assert len(aligned_queries) == 1 - assert len(unified_refs) == 1 + assert len(unified_refs) == 1 assert len(aligned_queries[0]) == len(unified_tick) - assert len(unified_refs[0]) == len(unified_tick) + assert len(unified_refs[0]) == len(unified_tick) @pytest.mark.slow - def test_align_sequence_tick_multiple_features(self): - """Test alignment with multiple features""" + def test_multiple_features(self, tick_converters_120bpm): query_time = np.linspace(0, 5, 50) - reference_time = np.linspace(0, 5, 50) - - # Multiple features - query_seq1 = np.sin(2 * np.pi * query_time) - query_seq2 = np.cos(2 * np.pi * query_time) - reference_seq1 = np.sin(2 * np.pi * reference_time) - reference_seq2 = np.cos(2 * np.pi * reference_time) + ref_time = np.linspace(0, 5, 50) + qs = (np.sin(2 * np.pi * query_time), np.cos(2 * np.pi * query_time)) + rs = (np.sin(2 * np.pi * ref_time), np.cos(2 * np.pi * ref_time)) unified_tick, aligned_queries, unified_refs = align_sequence_tick( - query_time, - (query_seq1, query_seq2), - reference_time, - (reference_seq1, reference_seq2), - tempo=120, - ppqn=480, - align_radius=1 + query_time, qs, ref_time, rs, align_radius=1, ) - # Check output shapes assert len(aligned_queries) == 2 - assert len(unified_refs) == 2 - - -class TestNumericalStability: - """Test numerical stability and precision""" - - def test_time_tick_roundtrip_precision(self): - """Test precision in roundtrip conversion. + assert len(unified_refs) == 2 + for aq in aligned_queries: + assert len(aq) == len(unified_tick) + for ur in unified_refs: + assert len(ur) == len(unified_tick) - time_to_ticks rounds to the nearest integer tick, so the round-trip - cannot recover sub-tick precision. The maximum quantization error is - half a tick duration: 60 / (tempo * ppqn * 2). - """ - original_times = np.linspace(0, 10, 1000) - ticks = time_to_ticks(original_times, tempo=120, ppqn=480, unique=False) - recovered_times = ticks_to_time(ticks, tempo=120, ppqn=480) - - tick_duration = 60 / (120 * 480) # ~0.001042 s — one full tick - max_error = tick_duration / 2 # ~0.000521 s — half tick (worst case) - - abs_diff = np.abs(original_times - recovered_times) - assert np.all(abs_diff <= max_error + 1e-12), ( - f"Max error {abs_diff.max():.6f}s exceeds half-tick bound {max_error:.6f}s" - ) - - def test_gaussian_filter_preserves_mean(self): - """Test that Gaussian filter approximately preserves mean""" - np.random.seed(42) - seq = np.random.randn(100) + 10 - result = gaussian_filter1d_with_nan(seq, sigma=2.0) - - # Mean should be approximately preserved - assert abs(np.mean(result) - np.mean(seq)) < 0.5 - - def test_seq_rcr_with_very_small_values(self): - """Test RCR with very small values""" - seq = np.array([1e-10, 2e-10, 3e-10]) - result = seq_rcr(seq) - - # Should not produce inf or nan - assert not np.any(np.isnan(result)) - assert not np.any(np.isinf(result)) + @pytest.mark.slow + def test_requires_registration(self): + reset_tick_converters() + query_time = np.linspace(0, 2, 20) + ref_time = np.linspace(0, 2, 20) + with pytest.raises(RuntimeError, match="No tick converter registered"): + align_sequence_tick( + query_time, (np.ones(20),), + ref_time, (np.ones(20),), + ) diff --git a/tests/test_ustx.py b/tests/test_ustx.py index e8d263f..84ab6e4 100644 --- a/tests/test_ustx.py +++ b/tests/test_ustx.py @@ -1,383 +1,603 @@ +""" +Tests for utils/ustx.py. + +The public API under test: + - load_ustx(path) -> UProject + - save_ustx(project, path) -> None + - UProject / UVoicePart / UCurve / UTrack / UTempo / UTimeSignature + - TimeAxis (tick ↔ ms ↔ seconds conversions) + - UstxEditor (context-manager editing session) +""" + import pytest import numpy as np - -from utils.ustx import load_ustx, save_ustx, edit_ustx_expression_curve - +from numpy.testing import assert_array_almost_equal, assert_array_equal + +from utils.ustx import ( + load_ustx, + save_ustx, + UProject, + UVoicePart, + UCurve, + UTrack, + UTempo, + UTimeSignature, + TimeAxis, + UstxEditor, + RESOLUTION, + SUPPORTED_EXPRESSIONS, +) + + +# =========================================================================== +# load_ustx / save_ustx +# =========================================================================== class TestLoadUSTX: - """Test USTX file loading""" - - def test_load_ustx_basic(self, temp_ustx_file): - """Test loading a basic USTX file""" - result = load_ustx(str(temp_ustx_file)) - - assert isinstance(result, dict) - assert "tempos" in result - assert "voice_parts" in result - assert result["tempos"][0]["bpm"] == 120 - - def test_load_ustx_with_utf8_bom(self, temp_dir): - """Test loading USTX file with UTF-8 BOM""" - ustx_content = """tempos: - - bpm: 140 - position: 0 -voice_parts: - - name: Track 1 -""" - ustx_path = temp_dir / "test_bom.ustx" - ustx_path.write_text(ustx_content, encoding='utf-8-sig') - - result = load_ustx(str(ustx_path)) - assert result["tempos"][0]["bpm"] == 140 + """Test USTX file loading.""" + + def test_load_returns_uproject(self, temp_ustx_file): + project = load_ustx(str(temp_ustx_file)) + assert isinstance(project, UProject) + + def test_load_tempos(self, temp_ustx_file): + project = load_ustx(str(temp_ustx_file)) + assert len(project.tempos) == 1 + assert project.tempos[0].bpm == 120 + + def test_load_voice_parts(self, temp_ustx_file): + project = load_ustx(str(temp_ustx_file)) + assert len(project.voice_parts) == 1 + assert project.voice_parts[0].name == "Track 1" + + def test_load_utf8_bom(self, temp_dir): + content = ( + "tempos:\n - bpm: 140\n position: 0\n" + "time_signatures:\n - bar_position: 0\n beat_per_bar: 4\n beat_unit: 4\n" + "tracks: []\n" + "voice_parts:\n - name: BOM Track\n track_no: 0\n position: 0\n duration: 960\n" + " notes: []\n curves: []\n" + ) + path = temp_dir / "bom.ustx" + path.write_text(content, encoding="utf-8-sig") + project = load_ustx(str(path)) + assert project.tempos[0].bpm == 140 - def test_load_ustx_nonexistent_file(self): - """Test loading non-existent file raises error""" + def test_load_nonexistent_file(self): with pytest.raises(FileNotFoundError): load_ustx("nonexistent_file.ustx") - def test_load_ustx_preserves_structure(self, sample_ustx_dict, temp_dir): - """Test that loading preserves USTX structure""" - ustx_path = temp_dir / "test_structure.ustx" - save_ustx(sample_ustx_dict, str(ustx_path)) + def test_load_preserves_voice_part_count(self, sample_ustx_dict, temp_dir): + path = temp_dir / "counts.ustx" + project = UProject.from_dict(sample_ustx_dict) + save_ustx(project, str(path)) + loaded = load_ustx(str(path)) + assert len(loaded.voice_parts) == len(project.voice_parts) - loaded = load_ustx(str(ustx_path)) - - assert "tempos" in loaded - assert "time_signatures" in loaded - assert "voice_parts" in loaded - assert len(loaded["voice_parts"]) == len(sample_ustx_dict["voice_parts"]) + def test_resolution_always_480(self, temp_ustx_file): + project = load_ustx(str(temp_ustx_file)) + assert project.resolution == RESOLUTION == 480 class TestSaveUSTX: - """Test USTX file saving""" - - def test_save_ustx_basic(self, sample_ustx_dict, temp_dir): - """Test saving a basic USTX file""" - ustx_path = temp_dir / "output.ustx" + """Test USTX file saving.""" - save_ustx(sample_ustx_dict, str(ustx_path)) + def test_save_creates_file(self, sample_project, temp_dir): + path = temp_dir / "out.ustx" + assert not path.exists() + save_ustx(sample_project, str(path)) + assert path.exists() - assert ustx_path.exists() - # Verify file is not empty - assert ustx_path.stat().st_size > 0 + def test_save_nonempty(self, sample_project, temp_dir): + path = temp_dir / "out.ustx" + save_ustx(sample_project, str(path)) + assert path.stat().st_size > 0 - def test_save_ustx_creates_file(self, sample_ustx_dict, temp_dir): - """Test that save creates new file if it doesn't exist""" - ustx_path = temp_dir / "new_file.ustx" + def test_save_overwrites_existing(self, sample_project, temp_dir): + path = temp_dir / "existing.ustx" + path.write_text("old content", encoding="utf-8-sig") + save_ustx(sample_project, str(path)) + loaded = load_ustx(str(path)) + assert loaded.tempos[0].bpm == 120 - assert not ustx_path.exists() - save_ustx(sample_ustx_dict, str(ustx_path)) - assert ustx_path.exists() - - def test_save_ustx_overwrites_existing(self, sample_ustx_dict, temp_dir): - """Test that save overwrites existing file""" - ustx_path = temp_dir / "existing.ustx" - - # Create initial file - ustx_path.write_text("old content", encoding='utf-8-sig') - - # Save new content - save_ustx(sample_ustx_dict, str(ustx_path)) - - # Verify content was overwritten - loaded = load_ustx(str(ustx_path)) - assert loaded["tempos"][0]["bpm"] == 120 - - def test_save_ustx_utf8_bom(self, sample_ustx_dict, temp_dir): - """Test that saved file uses UTF-8 with BOM""" - ustx_path = temp_dir / "test_encoding.ustx" - - save_ustx(sample_ustx_dict, str(ustx_path)) - - # Read raw bytes to check for BOM - with open(ustx_path, 'rb') as f: - first_bytes = f.read(3) - # UTF-8 BOM is EF BB BF - assert first_bytes == b'\xef\xbb\xbf' + def test_save_utf8_bom(self, sample_project, temp_dir): + path = temp_dir / "encoding.ustx" + save_ustx(sample_project, str(path)) + assert path.read_bytes()[:3] == b"\xef\xbb\xbf" class TestSaveLoadRoundtrip: - """Test save and load roundtrip consistency""" - - def test_roundtrip_basic(self, sample_ustx_dict, temp_dir): - """Test basic save/load roundtrip""" - ustx_path = temp_dir / "roundtrip.ustx" - - # Save - save_ustx(sample_ustx_dict, str(ustx_path)) - - # Load - loaded = load_ustx(str(ustx_path)) - - # Verify key fields - assert loaded["tempos"][0]["bpm"] == sample_ustx_dict["tempos"][0]["bpm"] - assert len(loaded["voice_parts"]) == len(sample_ustx_dict["voice_parts"]) - assert loaded["voice_parts"][0]["name"] == sample_ustx_dict["voice_parts"][0]["name"] - - def test_roundtrip_preserves_order(self, temp_dir): - """Test that roundtrip preserves key order""" - # Create dict with specific order - ustx_dict = { + """Save → load roundtrip consistency.""" + + def test_roundtrip_bpm(self, sample_project, temp_dir): + path = temp_dir / "rt.ustx" + save_ustx(sample_project, str(path)) + loaded = load_ustx(str(path)) + assert loaded.tempos[0].bpm == sample_project.tempos[0].bpm + + def test_roundtrip_voice_part_count(self, sample_project, temp_dir): + path = temp_dir / "rt.ustx" + save_ustx(sample_project, str(path)) + loaded = load_ustx(str(path)) + assert len(loaded.voice_parts) == len(sample_project.voice_parts) + + def test_roundtrip_voice_part_name(self, sample_project, temp_dir): + path = temp_dir / "rt.ustx" + save_ustx(sample_project, str(path)) + loaded = load_ustx(str(path)) + assert loaded.voice_parts[0].name == sample_project.voice_parts[0].name + + def test_roundtrip_with_curves(self, sample_project, temp_dir): + path = temp_dir / "curves_rt.ustx" + part = sample_project.voice_parts[0] + part.set_curve("dyn", np.array([0, 480, 960]), np.array([0.0, 50.0, 100.0])) + save_ustx(sample_project, str(path)) + + loaded = load_ustx(str(path)) + curve = loaded.voice_parts[0].get_curve("dyn") + assert curve is not None + assert curve.xs == [0, 480, 960] + assert curve.ys == [0, 50, 100] + + +# =========================================================================== +# UProject +# =========================================================================== + +class TestUProject: + """Test UProject data model.""" + + def test_from_dict_tempos(self, sample_ustx_dict): + project = UProject.from_dict(sample_ustx_dict) + assert isinstance(project.tempos[0], UTempo) + assert project.tempos[0].bpm == 120 + + def test_from_dict_time_signatures(self, sample_ustx_dict): + project = UProject.from_dict(sample_ustx_dict) + ts = project.time_signatures[0] + assert isinstance(ts, UTimeSignature) + assert ts.bar_position == 0 + assert ts.beat_per_bar == 4 + assert ts.beat_unit == 4 + + def test_from_dict_tracks(self, sample_ustx_dict): + project = UProject.from_dict(sample_ustx_dict) + assert len(project.tracks) == 1 + assert isinstance(project.tracks[0], UTrack) + + def test_from_dict_voice_parts(self, sample_ustx_dict): + project = UProject.from_dict(sample_ustx_dict) + assert len(project.voice_parts) == 1 + assert isinstance(project.voice_parts[0], UVoicePart) + + def test_from_dict_legacy_bpm_fallback(self): + """A dict with no ``tempos`` key but a top-level ``bpm`` is accepted.""" + d = {"bpm": 90.0, "voice_parts": []} + project = UProject.from_dict(d) + assert project.tempos[0].bpm == 90.0 + + def test_from_dict_legacy_time_sig_fallback(self): + d = { "tempos": [{"bpm": 120, "position": 0}], - "time_signatures": [{"bar_index": 0, "beat_per_bar": 4, "beat_unit": 4}], - "voice_parts": [{"name": "Track 1"}] + "beat_per_bar": 3, + "beat_unit": 4, + "voice_parts": [], } + project = UProject.from_dict(d) + assert project.time_signatures[0].beat_per_bar == 3 - ustx_path = temp_dir / "order_test.ustx" - - # Save and load - save_ustx(ustx_dict, str(ustx_path)) - loaded = load_ustx(str(ustx_path)) - - # Verify keys exist (order checking is harder in Python dicts) - assert "tempos" in loaded - assert "time_signatures" in loaded - assert "voice_parts" in loaded - - def test_roundtrip_with_curves(self, sample_ustx_dict, temp_dir): - """Test roundtrip with expression curves""" - # Add curves to sample dict - sample_ustx_dict["voice_parts"][0]["curves"] = [ - { - "abbr": "dyn", - "xs": [0, 480, 960], - "ys": [0, 50, 100] - } - ] - - ustx_path = temp_dir / "curves_test.ustx" + def test_get_track_valid(self, sample_project): + track = sample_project.get_track(0) + assert isinstance(track, UTrack) - # Save and load - save_ustx(sample_ustx_dict, str(ustx_path)) - loaded = load_ustx(str(ustx_path)) + def test_get_track_out_of_range(self, sample_project): + with pytest.raises(IndexError): + sample_project.get_track(99) - # Verify curves preserved - assert "curves" in loaded["voice_parts"][0] - assert len(loaded["voice_parts"][0]["curves"]) == 1 - assert loaded["voice_parts"][0]["curves"][0]["abbr"] == "dyn" - - -class TestEditUSTXExpressionCurve: - """Test editing expression curves in USTX""" - - def test_edit_expression_curve_new_curve(self, sample_ustx_dict): - """Test adding a new expression curve""" - tick_seq = np.array([0, 480, 960, 1440]) - exp_seq = np.array([0.0, 50.0, 100.0, 75.0]) - - edit_ustx_expression_curve( - sample_ustx_dict, - ustx_track_number=1, - expression="dyn", - tick_seq=tick_seq, - exp_seq=exp_seq - ) - - # Verify curve was added - assert "curves" in sample_ustx_dict["voice_parts"][0] - curves = sample_ustx_dict["voice_parts"][0]["curves"] - assert len(curves) == 1 - assert curves[0]["abbr"] == "dyn" - assert curves[0]["xs"] == [0, 480, 960, 1440] - assert curves[0]["ys"] == [0, 50, 100, 75] - - def test_edit_expression_curve_update_existing(self, sample_ustx_dict): - """Test updating an existing expression curve""" - # Add initial curve - sample_ustx_dict["voice_parts"][0]["curves"] = [ - {"xs": [0, 480], "ys": [0, 50], "abbr": "dyn"} - ] - - # Update curve - tick_seq = np.array([0, 960]) - exp_seq = np.array([100.0, 200.0]) - - edit_ustx_expression_curve( - sample_ustx_dict, - ustx_track_number=1, - expression="dyn", - tick_seq=tick_seq, - exp_seq=exp_seq - ) - - curves = sample_ustx_dict["voice_parts"][0]["curves"] - # Should still have only one curve (updated) - assert len(curves) == 1 - assert curves[0]["xs"] == [0, 960] - assert curves[0]["ys"] == [100, 200] - - def test_edit_expression_curve_with_nan(self, sample_ustx_dict): - """Test that NaN values are filtered out""" - tick_seq = np.array([0, 480, 960, 1440]) - exp_seq = np.array([0.0, np.nan, 100.0, 75.0]) - - edit_ustx_expression_curve( - sample_ustx_dict, - ustx_track_number=1, - expression="dyn", - tick_seq=tick_seq, - exp_seq=exp_seq - ) - - curves = sample_ustx_dict["voice_parts"][0]["curves"] - # NaN value should be filtered - assert len(curves[0]["xs"]) == 3 - assert 480 not in curves[0]["xs"] # NaN position filtered - assert curves[0]["xs"] == [0, 960, 1440] - assert curves[0]["ys"] == [0, 100, 75] - - def test_edit_expression_curve_all_nan(self, sample_ustx_dict): - """Test with all NaN values""" - tick_seq = np.array([0, 480, 960]) - exp_seq = np.array([np.nan, np.nan, np.nan]) - - edit_ustx_expression_curve( - sample_ustx_dict, - ustx_track_number=1, - expression="dyn", - tick_seq=tick_seq, - exp_seq=exp_seq - ) + def test_get_parts_for_track(self, sample_project): + parts = sample_project.get_parts_for_track(0) + assert len(parts) == 1 + assert all(p.track_no == 0 for p in parts) - curves = sample_ustx_dict["voice_parts"][0]["curves"] - # Should create curve but with empty data - assert len(curves) == 1 - assert curves[0]["xs"] == [] - assert curves[0]["ys"] == [] - - def test_edit_expression_curve_multiple_expressions(self, sample_ustx_dict): - """Test adding multiple different expressions""" - # Add dyn - edit_ustx_expression_curve( - sample_ustx_dict, 1, "dyn", - np.array([0, 480]), np.array([0.0, 50.0]) - ) - - # Add pitd - edit_ustx_expression_curve( - sample_ustx_dict, 1, "pitd", - np.array([0, 480]), np.array([10.0, 20.0]) - ) - - # Add tenc - edit_ustx_expression_curve( - sample_ustx_dict, 1, "tenc", - np.array([0, 480]), np.array([30.0, 40.0]) - ) - - curves = sample_ustx_dict["voice_parts"][0]["curves"] - assert len(curves) == 3 - - # Verify each expression - abbrs = [c["abbr"] for c in curves] - assert "dyn" in abbrs - assert "pitd" in abbrs - assert "tenc" in abbrs - - @pytest.mark.parametrize("expression", ["dyn", "pitd", "tenc"]) - def test_edit_expression_curve_supported_types(self, sample_ustx_dict, expression): - """Test all supported expression types""" - tick_seq = np.array([0, 480]) - exp_seq = np.array([0.0, 50.0]) - - edit_ustx_expression_curve( - sample_ustx_dict, 1, expression, - tick_seq, exp_seq - ) - - curves = sample_ustx_dict["voice_parts"][0]["curves"] - assert len(curves) == 1 - assert curves[0]["abbr"] == expression - - def test_edit_expression_curve_invalid_type(self, sample_ustx_dict): - """Test that invalid expression type raises error""" - with pytest.raises(ValueError, match="Unsupported expression type"): - edit_ustx_expression_curve( - sample_ustx_dict, 1, "invalid_expr", - np.array([0]), np.array([0]) - ) - - def test_edit_expression_curve_rounding(self, sample_ustx_dict): - """Test that values are rounded to integers""" - tick_seq = np.array([0, 480]) - exp_seq = np.array([10.7, 50.3]) - - edit_ustx_expression_curve( - sample_ustx_dict, 1, "dyn", - tick_seq, exp_seq - ) - - curves = sample_ustx_dict["voice_parts"][0]["curves"] - # Values should be rounded - assert curves[0]["ys"] == [11, 50] - - def test_edit_expression_curve_negative_values(self, sample_ustx_dict): - """Test with negative values""" - tick_seq = np.array([0, 480]) - exp_seq = np.array([-10.0, -20.0]) - - edit_ustx_expression_curve( - sample_ustx_dict, 1, "dyn", - tick_seq, exp_seq - ) - - curves = sample_ustx_dict["voice_parts"][0]["curves"] - assert curves[0]["ys"] == [-10, -20] - - def test_edit_expression_curve_track_number(self, sample_ustx_dict): - """Test that track number is correctly handled (1-indexed)""" - # Add a second track + def test_get_parts_for_track_sorted(self, sample_ustx_dict): + # Add a second part with an earlier position sample_ustx_dict["voice_parts"].append({ - "name": "Track 2", - "track_no": 1, + "name": "Early", + "track_no": 0, + "position": 0, + "duration": 480, "notes": [], - "curves": [] + "curves": [], }) + sample_ustx_dict["voice_parts"][0]["position"] = 960 + project = UProject.from_dict(sample_ustx_dict) + parts = project.get_parts_for_track(0) + positions = [p.position for p in parts] + assert positions == sorted(positions) + + def test_resolution_fixed(self, sample_project): + assert sample_project.resolution == 480 + + def test_to_dict_roundtrip_keys(self, sample_project): + d = sample_project.to_dict() + assert "tempos" in d + assert "time_signatures" in d + assert "voice_parts" in d + + +# =========================================================================== +# UCurve / UVoicePart curve helpers +# =========================================================================== + +class TestUCurve: + """Test UCurve and UVoicePart curve helpers.""" + + def test_get_curve_existing(self): + part = UVoicePart(track_no=0, position=0, duration=960) + part.curves.append(UCurve(abbr="dyn", xs=[0], ys=[0])) + assert part.get_curve("dyn") is not None + + def test_get_curve_missing(self): + part = UVoicePart(track_no=0, position=0, duration=960) + assert part.get_curve("dyn") is None + + def test_get_or_create_curve_creates(self): + part = UVoicePart(track_no=0, position=0, duration=960) + curve = part.get_or_create_curve("dyn") + assert curve.abbr == "dyn" + assert len(part.curves) == 1 + + def test_get_or_create_curve_reuses(self): + part = UVoicePart(track_no=0, position=0, duration=960) + c1 = part.get_or_create_curve("dyn") + c2 = part.get_or_create_curve("dyn") + assert c1 is c2 + assert len(part.curves) == 1 + + def test_set_curve_basic(self): + part = UVoicePart(track_no=0, position=0, duration=960) + part.set_curve("dyn", np.array([0, 480, 960]), np.array([0.0, 50.0, 100.0])) + curve = part.get_curve("dyn") + assert curve.xs == [0, 480, 960] + assert curve.ys == [0, 50, 100] + + def test_set_curve_filters_nan(self): + part = UVoicePart(track_no=0, position=0, duration=960) + part.set_curve("dyn", + np.array([0, 480, 960, 1440]), + np.array([0.0, np.nan, 100.0, 75.0])) + curve = part.get_curve("dyn") + assert 480 not in curve.xs + assert curve.xs == [0, 960, 1440] + assert curve.ys == [0, 100, 75] + + def test_set_curve_all_nan(self): + part = UVoicePart(track_no=0, position=0, duration=960) + part.set_curve("dyn", np.array([0, 480]), np.array([np.nan, np.nan])) + curve = part.get_curve("dyn") + assert curve.xs == [] + assert curve.ys == [] + + def test_set_curve_rounds_values(self): + part = UVoicePart(track_no=0, position=0, duration=960) + part.set_curve("dyn", np.array([0, 480]), np.array([10.7, 50.3])) + assert part.get_curve("dyn").ys == [11, 50] + + def test_set_curve_negative_values(self): + part = UVoicePart(track_no=0, position=0, duration=960) + part.set_curve("dyn", np.array([0, 480]), np.array([-10.0, -20.0])) + assert part.get_curve("dyn").ys == [-10, -20] + + def test_set_curve_overwrites(self): + part = UVoicePart(track_no=0, position=0, duration=960) + part.set_curve("dyn", np.array([0, 480]), np.array([0.0, 50.0])) + part.set_curve("dyn", np.array([0, 960]), np.array([100.0, 200.0])) + assert len(part.curves) == 1 + assert part.get_curve("dyn").xs == [0, 960] + + @pytest.mark.parametrize("abbr", sorted(SUPPORTED_EXPRESSIONS)) + def test_set_curve_supported_expressions(self, abbr): + part = UVoicePart(track_no=0, position=0, duration=960) + part.set_curve(abbr, np.array([0, 480]), np.array([0.0, 50.0])) + assert part.get_curve(abbr) is not None + + def test_set_curve_unsupported_expression(self): + part = UVoicePart(track_no=0, position=0, duration=960) + with pytest.raises(ValueError, match="Unsupported expression"): + part.set_curve("invalid_expr", np.array([0]), np.array([0.0])) + + def test_set_curve_multiple_expressions(self): + part = UVoicePart(track_no=0, position=0, duration=960) + for abbr in SUPPORTED_EXPRESSIONS: + part.set_curve(abbr, np.array([0, 480]), np.array([0.0, 50.0])) + assert len(part.curves) == len(SUPPORTED_EXPRESSIONS) + + +# =========================================================================== +# TimeAxis +# =========================================================================== + +class TestTimeAxis: + """Test TimeAxis tick ↔ ms ↔ seconds conversions.""" + + @pytest.fixture + def axis_120bpm(self): + tempos = [UTempo(position=0, bpm=120.0)] + time_sigs = [UTimeSignature(bar_position=0, beat_per_bar=4, beat_unit=4)] + return TimeAxis.build(tempos, time_sigs) + + # --- basic scalar conversions --- + + def test_tick_to_ms_zero(self, axis_120bpm): + assert axis_120bpm.tick_pos_to_ms(0) == pytest.approx(0.0) + + def test_tick_to_ms_one_beat(self, axis_120bpm): + # 120 BPM → 500 ms/beat → 480 ticks/beat → 500 ms + assert axis_120bpm.tick_pos_to_ms(480) == pytest.approx(500.0) + + def test_ms_to_tick_zero(self, axis_120bpm): + assert axis_120bpm.ms_pos_to_tick(0.0) == pytest.approx(0.0) + + def test_ms_to_tick_500ms(self, axis_120bpm): + assert axis_120bpm.ms_pos_to_tick(500.0) == pytest.approx(480.0) + + def test_ms_between_ticks(self, axis_120bpm): + assert axis_120bpm.ms_between_ticks(0, 480) == pytest.approx(500.0) + + # --- vectorised API --- + + def test_ticks_to_ms_array(self, axis_120bpm): + result = axis_120bpm.ticks_to_ms(np.array([0, 480, 960])) + assert_array_almost_equal(result, [0.0, 500.0, 1000.0]) + + def test_ms_to_ticks_array(self, axis_120bpm): + result = axis_120bpm.ms_to_ticks(np.array([0.0, 500.0, 1000.0])) + assert_array_equal(result, [0, 480, 960]) + + def test_ms_to_ticks_unique(self, axis_120bpm): + result = axis_120bpm.ms_to_ticks(np.array([0.0, 0.0, 500.0]), unique=True) + assert_array_equal(result, [0, 480]) + + # --- seconds wrappers --- + + def test_ticks_to_seconds(self, axis_120bpm): + result = axis_120bpm.ticks_to_seconds(np.array([0, 480, 960])) + assert_array_almost_equal(result, [0.0, 0.5, 1.0]) + + def test_seconds_to_ticks(self, axis_120bpm): + result = axis_120bpm.seconds_to_ticks(np.array([0.0, 0.5, 1.0])) + assert_array_equal(result, [0, 480, 960]) + + def test_seconds_to_ticks_unique(self, axis_120bpm): + result = axis_120bpm.seconds_to_ticks(np.array([0.0, 0.0, 1.0]), unique=True) + assert_array_equal(result, [0, 960]) + + # --- roundtrip --- + + def test_roundtrip_tick_ms(self, axis_120bpm): + original = np.array([0, 240, 480, 720, 960], dtype=float) + ms = axis_120bpm.ticks_to_ms(original) + recovered = axis_120bpm.ms_to_ticks(ms) + assert_array_equal(recovered, original.astype(int)) + + def test_roundtrip_precision(self, axis_120bpm): + """Round-trip error ≤ half a tick duration.""" + original = np.linspace(0, 10, 500) # seconds + ticks = axis_120bpm.seconds_to_ticks(original) + recovered = axis_120bpm.ticks_to_seconds(ticks) + tick_duration_s = 60 / (120 * RESOLUTION) + assert np.all(np.abs(original - recovered) <= tick_duration_s / 2 + 1e-12) - # Edit track 2 (1-indexed) - edit_ustx_expression_curve( - sample_ustx_dict, 2, "dyn", - np.array([0, 480]), np.array([0.0, 50.0]) - ) - - # Verify curve was added to track 2 (index 1) - assert "curves" in sample_ustx_dict["voice_parts"][1] - assert len(sample_ustx_dict["voice_parts"][1]["curves"]) == 1 + # --- tempo change --- - # Track 1 should not have curves - assert "curves" not in sample_ustx_dict["voice_parts"][0] or \ - len(sample_ustx_dict["voice_parts"][0]["curves"]) == 0 - - -class TestIntegration: - """Integration tests combining multiple operations""" + def test_tempo_change_boundary(self): + """After a tempo change the ms position must reflect the new BPM.""" + tempos = [ + UTempo(position=0, bpm=120.0), + UTempo(position=1920, bpm=60.0), # 4 beats in at 120 BPM + ] + time_sigs = [UTimeSignature(bar_position=0, beat_per_bar=4, beat_unit=4)] + axis = TimeAxis.build(tempos, time_sigs) + + # First 1920 ticks at 120 BPM = 2000 ms + assert axis.tick_pos_to_ms(1920) == pytest.approx(2000.0) + # Next 480 ticks at 60 BPM (1000 ms/beat) = 1000 ms more + assert axis.tick_pos_to_ms(2400) == pytest.approx(3000.0) + + # --- shift_ticks_by_seconds --- + + def test_shift_ticks_by_seconds_positive(self, axis_120bpm): + ticks = np.array([0, 480, 960]) + shifted = axis_120bpm.shift_ticks_by_seconds(ticks, 0.5) + # 0.5 s = 480 ticks at 120 BPM + assert_array_equal(shifted, [480, 960, 1440]) + + def test_shift_ticks_by_seconds_zero(self, axis_120bpm): + ticks = np.array([0, 480, 960]) + shifted = axis_120bpm.shift_ticks_by_seconds(ticks, 0.0) + assert_array_equal(shifted, ticks) + + def test_shift_ticks_by_seconds_negative(self, axis_120bpm): + ticks = np.array([960, 1440]) + shifted = axis_120bpm.shift_ticks_by_seconds(ticks, -0.5) + assert_array_equal(shifted, [480, 960]) + + # --- build validation --- + + def test_build_requires_tempos(self): + with pytest.raises(ValueError, match="tempo"): + TimeAxis.build( + [], + [UTimeSignature(bar_position=0, beat_per_bar=4, beat_unit=4)], + ) - def test_full_workflow(self, sample_ustx_dict, temp_dir): - """Test complete workflow: load, edit, save, load again""" - ustx_path = temp_dir / "workflow.ustx" + def test_build_requires_time_signatures(self): + with pytest.raises(ValueError, match="time.signature"): + TimeAxis.build([UTempo(position=0, bpm=120)], []) - # Save initial file - save_ustx(sample_ustx_dict, str(ustx_path)) + def test_build_requires_first_time_sig_at_bar_0(self): + with pytest.raises(ValueError): + TimeAxis.build( + [UTempo(position=0, bpm=120)], + [UTimeSignature(bar_position=1, beat_per_bar=4, beat_unit=4)], + ) - # Load file - loaded = load_ustx(str(ustx_path)) - # Edit expression - edit_ustx_expression_curve( - loaded, 1, "dyn", - np.array([0, 480, 960]), - np.array([0.0, 50.0, 100.0]) +# =========================================================================== +# UstxEditor +# =========================================================================== + +class TestUstxEditor: + """Test UstxEditor context-manager and expression helpers.""" + + def test_context_manager_saves_on_clean_exit(self, temp_ustx_file): + with UstxEditor(str(temp_ustx_file)) as editor: + editor.project.voice_parts[0].name = "Edited" + # Reload and check + reloaded = load_ustx(str(temp_ustx_file)) + assert reloaded.voice_parts[0].name == "Edited" + + def test_context_manager_no_save_on_exception(self, temp_ustx_file): + original_name = load_ustx(str(temp_ustx_file)).voice_parts[0].name + with pytest.raises(RuntimeError): + with UstxEditor(str(temp_ustx_file)) as editor: + editor.project.voice_parts[0].name = "Should Not Save" + raise RuntimeError("deliberate error") + reloaded = load_ustx(str(temp_ustx_file)) + assert reloaded.voice_parts[0].name == original_name + + def test_add_expression_to_part(self, temp_ustx_file): + with UstxEditor(str(temp_ustx_file)) as editor: + part = editor.voice_parts[0] + editor.add_expression_to_part( + part, "dyn", + np.array([0, 480, 960]), + np.array([0.0, 50.0, 100.0]), + ) + reloaded = load_ustx(str(temp_ustx_file)) + curve = reloaded.voice_parts[0].get_curve("dyn") + assert curve is not None + assert curve.xs == [0, 480, 960] + + def test_add_expression_to_track_basic(self, temp_ustx_file): + """Absolute ticks within part window are written as relative ticks.""" + with UstxEditor(str(temp_ustx_file)) as editor: + # part starts at 0, duration 1920 + editor.add_expression_to_track( + 0, "dyn", + np.array([0, 480, 960]), + np.array([0.0, 50.0, 100.0]), + ) + reloaded = load_ustx(str(temp_ustx_file)) + curve = reloaded.voice_parts[0].get_curve("dyn") + assert curve is not None + assert curve.xs == [0, 480, 960] + + def test_add_expression_to_track_clips_to_part_window(self, temp_ustx_file): + """Ticks outside [part.position, part.position + part.duration) are dropped.""" + with UstxEditor(str(temp_ustx_file)) as editor: + # Part has position=0, duration=1920; tick 2400 is outside + editor.add_expression_to_track( + 0, "dyn", + np.array([0, 960, 2400]), + np.array([10.0, 20.0, 30.0]), + ) + reloaded = load_ustx(str(temp_ustx_file)) + curve = reloaded.voice_parts[0].get_curve("dyn") + assert 2400 not in curve.xs + + def test_add_expression_to_track_relative_ticks(self, temp_dir): + """Ticks stored in the curve must be relative to part.position.""" + content = ( + "tempos:\n - bpm: 120\n position: 0\n" + "time_signatures:\n - bar_position: 0\n beat_per_bar: 4\n beat_unit: 4\n" + "tracks:\n - track_name: T\n track_color: Blue\n singer: ''\n" + " phonemizer: ''\n mute: false\n solo: false\n volume: 0.0\n pan: 0.0\n" + "voice_parts:\n - name: P\n track_no: 0\n position: 480\n duration: 960\n" + " notes: []\n curves: []\n" ) + path = temp_dir / "offset.ustx" + path.write_text(content, encoding="utf-8-sig") + + with UstxEditor(str(path)) as editor: + # absolute ticks 480–1439 fall inside the part (offset 480) + editor.add_expression_to_track( + 0, "dyn", + np.array([480, 960, 1439]), + np.array([10.0, 20.0, 30.0]), + ) + reloaded = load_ustx(str(path)) + curve = reloaded.voice_parts[0].get_curve("dyn") + # Stored as relative: 480-480=0, 960-480=480, 1439-480=959 + assert curve.xs == [0, 480, 959] + + def test_add_expression_to_track_no_parts_raises(self, temp_ustx_file): + with UstxEditor(str(temp_ustx_file)) as editor: + with pytest.raises(ValueError, match="No voice parts"): + editor.add_expression_to_track( + 99, "dyn", + np.array([0]), np.array([0.0]), + ) + + def test_manual_save_and_close(self, temp_ustx_file): + editor = UstxEditor(str(temp_ustx_file)) + editor.project.voice_parts[0].name = "Manual" + editor.save() + editor.close() + reloaded = load_ustx(str(temp_ustx_file)) + assert reloaded.voice_parts[0].name == "Manual" + + def test_build_time_axis_returns_time_axis(self, temp_ustx_file): + with UstxEditor(str(temp_ustx_file)) as editor: + axis = editor.build_time_axis() + assert isinstance(axis, TimeAxis) + + def test_tracks_property(self, temp_ustx_file): + with UstxEditor(str(temp_ustx_file)) as editor: + assert editor.tracks is editor.project.tracks + + def test_voice_parts_property(self, temp_ustx_file): + with UstxEditor(str(temp_ustx_file)) as editor: + assert editor.voice_parts is editor.project.voice_parts + + +# =========================================================================== +# Integration +# =========================================================================== - # Save modified file - save_ustx(loaded, str(ustx_path)) - - # Load again and verify - final = load_ustx(str(ustx_path)) +class TestIntegration: + """End-to-end workflows.""" + + def test_full_workflow_via_editor(self, temp_ustx_file): + """Load → edit via UstxEditor → verify persisted curve.""" + with UstxEditor(str(temp_ustx_file)) as editor: + axis = editor.build_time_axis() + ticks = axis.seconds_to_ticks(np.array([0.0, 0.5, 1.0])) + editor.add_expression_to_track( + 0, "dyn", ticks, np.array([0.0, 50.0, 100.0]) + ) - assert "curves" in final["voice_parts"][0] - curves = final["voice_parts"][0]["curves"] - assert len(curves) == 1 - assert curves[0]["abbr"] == "dyn" - assert curves[0]["xs"] == [0, 480, 960] - assert curves[0]["ys"] == [0, 50, 100] + final = load_ustx(str(temp_ustx_file)) + curve = final.voice_parts[0].get_curve("dyn") + assert curve is not None + assert len(curve.xs) == 3 + + def test_multiple_expressions_persist(self, temp_ustx_file): + with UstxEditor(str(temp_ustx_file)) as editor: + ticks = np.array([0, 480, 960]) + for abbr in sorted(SUPPORTED_EXPRESSIONS): + editor.add_expression_to_part( + editor.voice_parts[0], abbr, ticks, np.array([1.0, 2.0, 3.0]) + ) + + final = load_ustx(str(temp_ustx_file)) + for abbr in SUPPORTED_EXPRESSIONS: + assert final.voice_parts[0].get_curve(abbr) is not None + + def test_time_axis_used_for_ticks(self, temp_ustx_file): + """Verify ticks produced by TimeAxis match expected values.""" + with UstxEditor(str(temp_ustx_file)) as editor: + axis = editor.build_time_axis() + ticks = axis.seconds_to_ticks(np.array([0.0, 0.5, 1.0, 1.5])) + # 120 BPM, 480 PPQN → 960 ticks/second + assert_array_equal(ticks, [0, 480, 960, 1440]) diff --git a/tests/test_wavtool.py b/tests/test_wavtool.py index 9d45951..ac99205 100644 --- a/tests/test_wavtool.py +++ b/tests/test_wavtool.py @@ -672,7 +672,6 @@ def test_tonal_wav_active_frames_are_nonnegative(self): def test_leading_silence_masked(self): """Frames before the first active frame should be NaN when mask_silence=True.""" - # Build: 0.5 s silence + 1.5 s tone sr = 22050 silence = np.zeros(int(0.5 * sr), dtype=np.float32) t = np.linspace(0, 1.5, int(1.5 * sr), endpoint=False) @@ -718,19 +717,12 @@ class TestExtractWavFrequency(unittest.TestCase): """Tests for extract_wav_frequency. Heavy ML backends (crepe, swift-f0) are mocked so tests stay fast and - dependency-free. A shared _mock_swift_f0 fixture provides realistic-looking - return values that mirror the SwiftF0 result object interface. + dependency-free. """ def setUp(self): self.wav = _make_wav(duration=2.0, sr=22050) self._n = 200 # number of fake time points - - # Pre-compute plain Python lists so they can be reused across helpers. - # Do NOT assign these to MagicMock attributes here — doing so replaces - # the auto-created Mock sub-attributes with real numpy arrays, which - # causes `.tolist.return_value` to fail with AttributeError because the - # builtin ndarray.tolist has no `return_value`. self._fake_times = list(np.linspace(0, 2.0, self._n)) self._fake_freqs = list(np.random.uniform(80, 300, self._n)) self._fake_confs = list(np.random.uniform(0.5, 1.0, self._n)) @@ -742,20 +734,11 @@ def tearDown(self): pass def _patch_swift(self): - """Return a context manager that patches SwiftF0 on its home module. - - SwiftF0 is imported inside the function body with - ``from swift_f0 import SwiftF0``, so patching ``utils.wavtool.SwiftF0`` - has no effect. Patching ``swift_f0.SwiftF0`` ensures the local import - picks up the replacement. - - The result object stays a plain MagicMock so that attribute access on - ``.timestamps``, ``.pitch_hz``, and ``.confidence`` returns further - Mocks whose ``.tolist.return_value`` we can control. - """ + """Patch SwiftF0 on its home module so the local import inside + extract_wav_frequency picks up the replacement.""" fake_result = MagicMock() fake_result.timestamps.tolist.return_value = self._fake_times - fake_result.pitch_hz.tolist.return_value = self._fake_freqs + fake_result.pitch_hz.tolist.return_value = self._fake_freqs fake_result.confidence.tolist.return_value = self._fake_confs mock_detector = MagicMock() @@ -771,20 +754,20 @@ def test_returns_tuple_of_three(self): self.assertIsInstance(result, tuple) self.assertEqual(len(result), 3) - def test_time_is_list(self): + def test_time_is_ndarray(self): with self._patch_swift(): time, _, _ = extract_wav_frequency(self.wav, backend="swift-f0", use_cache=False) - self.assertIsInstance(time, list) + self.assertIsInstance(time, np.ndarray) - def test_frequency_is_list(self): + def test_frequency_is_ndarray(self): with self._patch_swift(): _, freq, _ = extract_wav_frequency(self.wav, backend="swift-f0", use_cache=False) - self.assertIsInstance(freq, list) + self.assertIsInstance(freq, np.ndarray) - def test_confidence_is_list(self): + def test_confidence_is_ndarray(self): with self._patch_swift(): _, _, conf = extract_wav_frequency(self.wav, backend="swift-f0", use_cache=False) - self.assertIsInstance(conf, list) + self.assertIsInstance(conf, np.ndarray) def test_all_outputs_same_length(self): with self._patch_swift(): @@ -797,20 +780,20 @@ def test_output_length_matches_mock(self): time, _, _ = extract_wav_frequency(self.wav, backend="swift-f0", use_cache=False) self.assertEqual(len(time), self._n) - def test_time_values_are_floats(self): + def test_time_values_are_numeric(self): with self._patch_swift(): time, _, _ = extract_wav_frequency(self.wav, backend="swift-f0", use_cache=False) - self.assertTrue(all(isinstance(t, float) for t in time)) + self.assertTrue(np.issubdtype(time.dtype, np.floating)) - def test_frequency_values_are_floats(self): + def test_frequency_values_are_numeric(self): with self._patch_swift(): _, freq, _ = extract_wav_frequency(self.wav, backend="swift-f0", use_cache=False) - self.assertTrue(all(isinstance(f, float) for f in freq)) + self.assertTrue(np.issubdtype(freq.dtype, np.floating)) - def test_confidence_values_are_floats(self): + def test_confidence_values_are_numeric(self): with self._patch_swift(): _, _, conf = extract_wav_frequency(self.wav, backend="swift-f0", use_cache=False) - self.assertTrue(all(isinstance(c, float) for c in conf)) + self.assertTrue(np.issubdtype(conf.dtype, np.floating)) # --- backend validation --- @@ -819,21 +802,16 @@ def test_invalid_backend_raises(self): extract_wav_frequency(self.wav, backend="nonexistent", use_cache=False) def test_invalid_backend_message_contains_name(self): - with self.assertRaises(ValueError, msg="nonexistent") as ctx: + with self.assertRaises(ValueError) as ctx: extract_wav_frequency(self.wav, backend="nonexistent", use_cache=False) self.assertIn("nonexistent", str(ctx.exception)) def test_swift_f0_backend_accepted(self): with self._patch_swift(): - # must not raise extract_wav_frequency(self.wav, backend="swift-f0", use_cache=False) def test_crepe_backend_accepted(self): - """crepe backend path should be accepted (mocked to avoid TF dependency). - - ``import crepe`` is a top-level import inside the elif branch, so we - patch ``crepe.predict`` on the *crepe* module directly. - """ + """crepe backend path should be accepted (mocked to avoid TF dependency).""" fake_time = np.linspace(0, 2, self._n) fake_freq = np.random.uniform(80, 300, self._n) fake_conf = np.random.uniform(0.5, 1.0, self._n) @@ -867,33 +845,33 @@ def test_cache_file_written_when_use_cache_true(self): self.assertTrue(os.path.exists(cache_path)) def test_cache_read_skips_backend_call(self): - """If a valid cache file exists, the backend must not be invoked.""" + """If a valid cache file exists the backend must not be invoked.""" tmp_cache_dir = tempfile.mkdtemp() fake_hash = "deadbeef" cache_path = os.path.join(tmp_cache_dir, "pitd", f"{fake_hash}.swift-f0.csv") os.makedirs(os.path.dirname(cache_path), exist_ok=True) - # Write a minimal cache CSV with open(cache_path, "w", newline="") as f: writer = csv.writer(f) writer.writerow(["Time (s)", "Frequency (Hz)", "Confidence"]) writer.writerow([0.0, 220.0, 0.9]) writer.writerow([0.5, 440.0, 0.95]) - with self._patch_swift() as mock_cls, \ + with self._patch_swift() as mock_swift_cls, \ patch("utils.wavtool.CACHE_DIR", tmp_cache_dir), \ patch("utils.wavtool.calculate_file_hash", return_value=fake_hash): time, freq, conf = extract_wav_frequency(self.wav, backend="swift-f0", use_cache=True) - mock_cls.assert_not_called() + # The SwiftF0 class must never have been instantiated + mock_swift_cls.assert_not_called() - self.assertEqual(time, [0.0, 0.5]) - self.assertEqual(freq, [220.0, 440.0]) - self.assertEqual(conf, [0.9, 0.95]) + np.testing.assert_array_equal(time, [0.0, 0.5]) + np.testing.assert_array_equal(freq, [220.0, 440.0]) + np.testing.assert_array_equal(conf, [0.9, 0.95]) def test_cache_disabled_always_calls_backend(self): - with self._patch_swift() as mock_cls: + with self._patch_swift() as mock_swift_cls: extract_wav_frequency(self.wav, backend="swift-f0", use_cache=False) - mock_cls.assert_called_once() + mock_swift_cls.assert_called_once() def test_no_cache_written_when_use_cache_false(self): tmp_cache_dir = tempfile.mkdtemp() diff --git a/utils/seqtool.py b/utils/seqtool.py index 69df34b..8352c56 100644 --- a/utils/seqtool.py +++ b/utils/seqtool.py @@ -1,42 +1,65 @@ from itertools import accumulate +from typing import Callable import numpy as np -from fastdtw import fastdtw # type: ignore -from scipy.stats import zscore +from fastdtw import fastdtw # type: ignore from scipy.interpolate import interp1d from scipy.ndimage import gaussian_filter1d +from scipy.stats import zscore -def time_to_ticks(time, tempo, ppqn=480, unique=True): - """Convert time in seconds to MIDI ticks. +# --------------------------------------------------------------------------- +# Tick converter registry +# --------------------------------------------------------------------------- +# Defaults raise clearly if to_ticks=True is used before registration. +# Call set_tick_converters() once at startup to wire up tempo-map-aware ones, +# e.g.: +# axis = editor.build_time_axis() +# set_tick_converters(axis.seconds_to_ticks, axis.ticks_to_seconds) - Args: - time (float or array-like): Time values in seconds. - tempo (float): Tempo in beats per minute (BPM). - ppqn (int, optional): Pulses per quarter note (MIDI resolution). Defaults to 480. - unique (bool, optional): If ``True``, return sorted deduplicated integer - ticks. If ``False``, return rounded integer ticks preserving the original shape. +def _default_time_to_ticks(time: np.ndarray, unique: bool = True) -> np.ndarray: + raise RuntimeError( + "No tick converter registered. " + "Call set_tick_converters() before using to_ticks=True." + ) - Returns: - numpy.ndarray: Integer MIDI tick values, deduplicated and sorted if ``unique=True``. - """ - ticks = np.round((np.array(time) * tempo * ppqn) / 60).astype(int) - return np.unique(ticks) if unique else ticks +def _default_ticks_to_time(ticks: np.ndarray) -> np.ndarray: + raise RuntimeError( + "No tick converter registered. " + "Call set_tick_converters() before using to_ticks=True." + ) +_time_to_ticks_fn: Callable = _default_time_to_ticks +_ticks_to_time_fn: Callable = _default_ticks_to_time -def ticks_to_time(ticks, tempo, ppqn=480): - """Convert MIDI ticks to time in seconds. - Args: - ticks (int or array-like): MIDI tick values. - tempo (float): Tempo in beats per minute (BPM). - ppqn (int, optional): Pulses per quarter note. Defaults to 480. +def set_tick_converters( + time_to_ticks_fn: Callable[[np.ndarray], np.ndarray], + ticks_to_time_fn: Callable[[np.ndarray], np.ndarray], +) -> None: + """Register tempo-map-aware tick converters for this module. - Returns: - numpy.ndarray: Corresponding time values in seconds. + Must be called before any function that uses ``to_ticks=True``. + + Args: + time_to_ticks_fn: ``(times: ndarray) -> ticks: ndarray`` + ticks_to_time_fn: ``(ticks: ndarray) -> times: ndarray`` """ - return (np.array(ticks) * 60) / (tempo * ppqn) + global _time_to_ticks_fn, _ticks_to_time_fn + _time_to_ticks_fn = time_to_ticks_fn + _ticks_to_time_fn = ticks_to_time_fn + +def reset_tick_converters() -> None: + """Restore the default (error-raising) tick converters.""" + global _time_to_ticks_fn, _ticks_to_time_fn + _time_to_ticks_fn = _default_time_to_ticks + _ticks_to_time_fn = _default_ticks_to_time + + +# --------------------------------------------------------------------------- +# Sequence utilities +# --------------------------------------------------------------------------- def sequence_interval_intersection(seqs): """Find the intersection of multiple sequences. @@ -76,41 +99,41 @@ def sequence_interval_union(seqs): return np.unique(np.concatenate(seqs)).tolist() -def unify_sequence_time(seq_times, seq_vals, to_ticks=False, tempo=120, ppqn=480): +def unify_sequence_time(seq_times, seq_vals, to_ticks=False): """Unify multiple sequences to a common time base. - This function aligns multiple sequences to a common time base by interpolating values. + Aligns multiple sequences to a common time base by interpolating values. + When ``to_ticks=True``, uses the converters registered via + :func:`set_tick_converters`. Args: seq_times (list of array-like): List of time sequences. Shape: (n_sequences, n_time_points). - seq_vals (list of array-like): List of value sequences. Shape: (n_sequences, n_time_points). - to_ticks (bool, optional): Whether to convert time to MIDI ticks. Defaults to False. - tempo (float, optional): Tempo in beats per minute (BPM). Defaults to 120. - ppqn (int, optional): Pulses per quarter note (MIDI resolution). Defaults to 480. + seq_vals (list of array-like): List of value sequences. Shape: (n_sequences, n_time_points). + to_ticks (bool, optional): Convert unified time to MIDI ticks. Defaults to ``False``. Returns: - tuple: (unified_time, unified_seqs), where: - - unified_time (numpy.ndarray): Unified time points. Shape: (n_time_points). - - unified_seqs (tuple): Unified sequences. Shape: (n_sequences, n_time_points). + tuple: ``(unified_time, unified_seqs)`` where + + - **unified_time** (*ndarray*): Unified time points (seconds or ticks). + - **unified_seqs** (*tuple of ndarray*): Interpolated sequences. """ - unified_seq_time = np.array(sequence_interval_union(seq_times)) + unified_seq_time = np.asarray(sequence_interval_union(seq_times)) + if not to_ticks: unified_seq_time = np.unique(unified_seq_time) unified_seqs_val = [ - interp1d(st, sv, fill_value="extrapolate")(unified_seq_time) # type: ignore + interp1d(st, sv, fill_value="extrapolate")(unified_seq_time) # type: ignore for (st, sv) in zip(seq_times, seq_vals, strict=False) ] return unified_seq_time, tuple(unified_seqs_val) - else: - unified_seq_ticks = time_to_ticks(unified_seq_time, tempo, ppqn, unique=True) - - time_mapping = ticks_to_time(unified_seq_ticks, tempo, ppqn) - unified_seqs_val = [ - interp1d(st, sv, fill_value="extrapolate")(time_mapping) # type: ignore - for (st, sv) in zip(seq_times, seq_vals, strict=False) - ] - return unified_seq_ticks, tuple(unified_seqs_val) + unified_seq_ticks = _time_to_ticks_fn(unified_seq_time) + time_mapping = _ticks_to_time_fn(unified_seq_ticks) + unified_seqs_val = [ + interp1d(st, sv, fill_value="extrapolate")(time_mapping) # type: ignore + for (st, sv) in zip(seq_times, seq_vals, strict=False) + ] + return unified_seq_ticks, tuple(unified_seqs_val) def gaussian_filter1d_with_nan(seq, sigma, **kwargs): @@ -139,28 +162,26 @@ def gaussian_filter1d_with_nan(seq, sigma, **kwargs): def align_sequence_tick( - query_time, queries, reference_time, references, tempo=120, ppqn=480, align_radius=1 + query_time, queries, reference_time, references, align_radius=1 ): - """Align sequences to a common MIDI tick time base. + """Align sequences to a common MIDI tick time base using dynamic time warping. - This function aligns sequences to a common MIDI tick time base using dynamic time warping. + Requires tick converters to be registered via :func:`set_tick_converters`. Args: - query_time (numpy.ndarray): Time values for the query sequences. Shape: (n_time_points). - queries (tuple): Query sequences to align. Shape: (n_sequences, n_time_points). - reference_time (numpy.ndarray): Time values for the reference sequences. Shape: (n_time_points). - references (tuple): Reference sequences to align. Shape: (n_sequences, n_time_points). - tempo (float, optional): Tempo in beats per minute (BPM). Defaults to 120. - ppqn (int, optional): Pulses per quarter note (MIDI resolution). Defaults to 480. - align_radius (int, optional): Radius for dynamic time warping. Defaults to 1. + query_time (numpy.ndarray): Time values for the query sequences. + queries (tuple): Query sequences to align. + reference_time (numpy.ndarray): Time values for the reference sequences. + references (tuple): Reference sequences to align. + align_radius (int, optional): DTW radius. Defaults to 1. Returns: tuple: (unified_tick, aligned_queries, unified_references), where: - - unified_tick (numpy.ndarray): Unified MIDI tick time base. Shape: (n_time_points). - - aligned_queries (tuple): Aligned query sequences. Shape: (n_sequences, n_time_points). - - unified_references (tuple): Unified reference sequences. Shape: (n_sequences, n_time_points). + - unified_tick (numpy.ndarray): Unified MIDI tick time base. Shape: (n_time_points). + - aligned_queries (tuple): Aligned query sequences. Shape: (n_sequences, n_time_points). + - unified_references (tuple): Unified reference sequences. Shape: (n_sequences, n_time_points). """ - query_times = [query_time] * len(queries) + query_times = [query_time] * len(queries) reference_times = [reference_time] * len(references) # Unify time and sequences @@ -168,18 +189,16 @@ def align_sequence_tick( (*query_times, *reference_times), (*queries, *references), to_ticks=True, - tempo=tempo, - ppqn=ppqn, ) - unified_queries = list(seqs)[: len(queries)] - unified_references = list(seqs)[len(queries) :] + unified_queries = list(seqs)[: len(queries)] + unified_references = list(seqs)[len(queries):] # Align sequences using dynamic time warping qs_nonan = np.nan_to_num(zscore(unified_queries, axis=1, nan_policy="omit")) rs_nonan = np.nan_to_num(zscore(unified_references, axis=1, nan_policy="omit")) - distance, path = fastdtw( - list(map(tuple, zip(*qs_nonan, strict=False))), - list(map(tuple, zip(*rs_nonan, strict=False))), + _, path = fastdtw( + list(map(tuple, zip(*qs_nonan, strict=False))), + list(map(tuple, zip(*rs_nonan, strict=False))), radius=align_radius, ) @@ -188,8 +207,8 @@ def align_sequence_tick( aligned_queries = [] for q in unified_queries: aligned_tick = np.interp(path[:, 1], np.arange(len(unified_tick)), unified_tick) - aligned_seq = np.interp(path[:, 0], np.arange(len(q)), q) - interp_seq = interp1d(aligned_tick, aligned_seq, fill_value="extrapolate") # type: ignore + aligned_seq = np.interp(path[:, 0], np.arange(len(q)), q) + interp_seq = interp1d(aligned_tick, aligned_seq, fill_value="extrapolate") # type: ignore aligned_queries.append(interp_seq(unified_tick)) return unified_tick, tuple(aligned_queries), tuple(unified_references) @@ -200,7 +219,8 @@ def seq_dynamics_trends(seq, n_order=3): This function computes the gradients and cumulative sums of a sequence. Args: seq (numpy.ndarray): Input sequence. Shape: (n_time_points,). - n_order (int, optional): Order of the features to extract. Defaults to 3. + n_order (int, optional): Order of features to extract. Defaults to 3. + Returns: numpy.ndarray: Extracted features, including gradients and cumulative sums. Shape: (2 * n_order, n_time_points). """ @@ -219,6 +239,7 @@ def seq_rcr(seq): This function calculates the relative change rate of a sequence, which is useful for analyzing dynamics. Args: seq (numpy.ndarray): Input sequence. Shape: (n_time_points,). + Returns: numpy.ndarray: Relative change rate of the sequence. Shape: (n_time_points,). """ diff --git a/utils/ustx.py b/utils/ustx.py index 34df7d3..0139692 100644 --- a/utils/ustx.py +++ b/utils/ustx.py @@ -1,61 +1,718 @@ +""" +ustx.py — OpenUtau USTX file I/O and editing utilities. + +Data structures mirror the OpenUtau C# models (UProject, UTempo, +UTimeSignature, UVoicePart, UCurve) as found in: + OpenUtau.Core/Ustx/UProject.cs + OpenUtau.Core/Ustx/UTrack.cs + OpenUtau.Core/Ustx/UPart.cs + OpenUtau.Core/Ustx/UCurve.cs + OpenUtau.Core/Util/TimeAxis.cs + +TimeAxis provides tick ↔ millisecond conversion that respects all tempo and +time-signature changes, matching the BuildSegments / TickPosToMsPos logic in +OpenUtau.Core/Util/TimeAxis.cs. + +Notes on the format: + - ``resolution`` is always 480 ppqn (hardcoded in UProject.cs); it is + *not* stored in the YAML file. + - TimeAxis works in **milliseconds** internally (ms_per_tick = 60000 / (bpm * resolution)). + - YAML keys use snake_case (e.g. ``voice_parts``, ``track_no``, + ``beat_per_bar``, ``bar_position``, ``time_signatures``). + - Legacy top-level ``bpm`` / ``beat_per_bar`` / ``beat_unit`` fields exist for + files predating ustx v0.6 but are marked [Obsolete] in C#. +""" + +from __future__ import annotations + +import bisect +import logging +from typing import Optional +from dataclasses import dataclass, field + import oyaml import numpy as np +from filelock import FileLock from yamlcore import CoreLoader +log = logging.getLogger(__name__) -def load_ustx(ustx_path): - """Load a USTX (Vocal Synth format) file as a dictionary. +RESOLUTION = 480 # pulses per quarter note — hardcoded in UProject.cs +MS_PER_MIN = 60_000.0 # milliseconds per minute - Uses YAML parsing to extract the structure of a USTX file. +SUPPORTED_EXPRESSIONS = frozenset({"dyn", "pitd", "tenc"}) - Args: - ustx_path (str): Path to the USTX file. - Returns: - dict: Parsed USTX data. +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + +@dataclass +class UTempo: + """A tempo event. ``position`` is a tick offset from the project start.""" + position: int + bpm: float + + @classmethod + def from_dict(cls, d: dict) -> "UTempo": + return cls(position=int(d["position"]), bpm=float(d["bpm"])) + + def to_dict(self) -> dict: + return {"position": self.position, "bpm": self.bpm} + + +@dataclass +class UTimeSignature: + """ + A time-signature event. + + ``bar_position`` is a 0-based bar index. + """ + bar_position: int + beat_per_bar: int + beat_unit: int + + @classmethod + def from_dict(cls, d: dict) -> "UTimeSignature": + return cls( + bar_position=int(d["bar_position"]), + beat_per_bar=int(d["beat_per_bar"]), + beat_unit=int(d["beat_unit"]), + ) + + def to_dict(self) -> dict: + return { + "bar_position": self.bar_position, + "beat_per_bar": self.beat_per_bar, + "beat_unit": self.beat_unit, + } + + +@dataclass +class UCurve: + """Expression curve inside a voice part (xs = ticks, ys = integer values).""" + abbr: str + xs: list[int] = field(default_factory=list) + ys: list[int] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: dict) -> "UCurve": + return cls( + abbr=str(d["abbr"]), + xs=list(d.get("xs", [])), + ys=list(d.get("ys", [])), + ) + + def to_dict(self) -> dict: + return {"xs": self.xs, "ys": self.ys, "abbr": self.abbr} + + +@dataclass +class UTrack: + """ + A track (UTrack in C#). Holds singer / phonemizer / renderer metadata. + Does *not* store notes or curves — those live in UVoicePart. + + ``track_no`` is the 0-based index of this track in ``UProject.tracks``, + set after loading (mirrors ``TrackNo = project.tracks.IndexOf(this)``). """ - with open(ustx_path, "r", encoding="utf-8-sig") as u: - ustx_str = u.read() - # Use yamlcore.CoreLoader to support YAML1.2 - ustx_dict = oyaml.load(ustx_str, CoreLoader) - return ustx_dict + track_no: int = 0 # populated by UProject.from_dict + track_name: str = "New Track" + track_color: str = "Blue" + singer: str = "" + phonemizer: str = "" + mute: bool = False + solo: bool = False + volume: float = 0.0 + pan: float = 0.0 + _raw: dict = field(default_factory=dict, repr=False, compare=False) + + @classmethod + def from_dict(cls, d: dict, track_no: int) -> "UTrack": + return cls( + track_no=track_no, + track_name=str(d.get("track_name", "New Track")), + track_color=str(d.get("track_color", "Blue")), + singer=str(d.get("singer", "") or ""), + phonemizer=str(d.get("phonemizer", "") or ""), + mute=bool(d.get("mute", False)), + solo=bool(d.get("solo", False)), + volume=float(d.get("volume", 0.0)), + pan=float(d.get("pan", 0.0)), + _raw=d, + ) + + def to_dict(self) -> dict: + out = dict(self._raw) + out["track_name"] = self.track_name + out["track_color"] = self.track_color + out["singer"] = self.singer + out["phonemizer"] = self.phonemizer + out["mute"] = self.mute + out["solo"] = self.solo + out["volume"] = self.volume + out["pan"] = self.pan + return out + + +@dataclass +class UVoicePart: + """ + A voice part (UVoicePart in C#). + + ``track_no`` is 0-based. + ``position`` is the tick offset of the part start within the project. + """ + track_no: int + position: int + duration: int + name: str = "" + curves: list[UCurve] = field(default_factory=list) + # Preserves all unrecognised YAML keys for lossless round-trip + _raw: dict = field(default_factory=dict, repr=False, compare=False) + + @classmethod + def from_dict(cls, d: dict) -> "UVoicePart": + return cls( + track_no=int(d.get("track_no", 0)), + position=int(d.get("position", 0)), + duration=int(d.get("duration", 0)), + name=str(d.get("name", "")), + curves=[UCurve.from_dict(c) for c in d.get("curves", [])], + _raw=d, + ) + + def to_dict(self) -> dict: + out = dict(self._raw) + out["track_no"] = self.track_no + out["position"] = self.position + out["duration"] = self.duration + out["name"] = self.name + out["curves"] = [c.to_dict() for c in self.curves] + return out + # ------------------------------------------------------------------ + # Curve helpers + # ------------------------------------------------------------------ -def save_ustx(ustx_dict, ustx_path): - """Save a USTX dictionary to a file, preserving order. + def get_curve(self, abbr: str) -> Optional[UCurve]: + for c in self.curves: + if c.abbr == abbr: + return c + return None + + def get_or_create_curve(self, abbr: str) -> UCurve: + curve = self.get_curve(abbr) + if curve is None: + curve = UCurve(abbr=abbr) + self.curves.append(curve) + return curve + + def set_curve( + self, + abbr: str, + ticks: np.ndarray, + values: np.ndarray, + ) -> None: + """Overwrite the xs/ys of *abbr* from numpy arrays, skipping NaN frames. + + Args: + abbr: Expression abbreviation, e.g. ``"dyn"``. + ticks: 1-D integer array of tick positions. + values: 1-D float array of curve values; NaN entries are dropped. + """ + if abbr not in SUPPORTED_EXPRESSIONS: + raise ValueError( + f"Unsupported expression '{abbr}'. " + f"Supported: {sorted(SUPPORTED_EXPRESSIONS)}" + ) + mask = ~np.isnan(values) + curve = self.get_or_create_curve(abbr) + curve.xs = ticks[mask].astype(int).tolist() + curve.ys = np.round(values[mask]).astype(int).tolist() + + +@dataclass +class UProject: + """ + Top-level USTX project. + + ``resolution`` is always 480 (hardcoded in C#); it is not read from or + written to the YAML file. + + ``voice_parts`` is a flat list ordered as they appear under the YAML key + ``voiceParts``. + """ + tempos: list[UTempo] + time_signatures: list[UTimeSignature] + tracks: list[UTrack] + voice_parts: list[UVoicePart] + _raw: dict = field(default_factory=dict, repr=False, compare=False) + + resolution: int = field(default=RESOLUTION, init=False) + + @classmethod + def from_dict(cls, d: dict) -> "UProject": + tempos = [UTempo.from_dict(t) for t in d.get("tempos", [])] + if not tempos: + tempos = [UTempo(position=0, bpm=float(d.get("bpm", 120.0)))] + + time_sigs = [UTimeSignature.from_dict(ts) for ts in d.get("time_signatures", [])] + if not time_sigs: + time_sigs = [ + UTimeSignature( + bar_position=0, + beat_per_bar=int(d.get("beat_per_bar", 4)), + beat_unit=int(d.get("beat_unit", 4)), + ) + ] + + tracks = [UTrack.from_dict(t, i) for i, t in enumerate(d.get("tracks", []))] + voice_parts = [UVoicePart.from_dict(vp) for vp in d.get("voice_parts", [])] + + return cls( + tempos=tempos, + time_signatures=time_sigs, + tracks=tracks, + voice_parts=voice_parts, + _raw=d, + ) + + def to_dict(self) -> dict: + out = dict(self._raw) + out["tempos"] = [t.to_dict() for t in self.tempos] + out["time_signatures"] = [ts.to_dict() for ts in self.time_signatures] + out["tracks"] = [t.to_dict() for t in self.tracks] + out["voice_parts"] = [vp.to_dict() for vp in self.voice_parts] + return out + + # ------------------------------------------------------------------ + # Convenience accessors + # ------------------------------------------------------------------ + + def get_track(self, track_no: int) -> UTrack: + """Return the track at 0-based index *track_no*. + + Raises: + IndexError: if *track_no* is out of range. + """ + if track_no < 0 or track_no >= len(self.tracks): + raise IndexError( + f"track_no {track_no} is out of range " + f"(project has {len(self.tracks)} track(s))." + ) + return self.tracks[track_no] + + def get_parts_for_track(self, track_no: int) -> list[UVoicePart]: + """Return all voice parts whose ``track_no`` matches *track_no* (0-based). + + A track can own multiple parts (segments); this returns them all, + sorted by position. + """ + return sorted( + [vp for vp in self.voice_parts if vp.track_no == track_no], + key=lambda p: p.position, + ) + + def build_time_axis(self) -> "TimeAxis": + """Build and return a :class:`TimeAxis` for this project.""" + return TimeAxis.build(self.tempos, self.time_signatures) + + +# --------------------------------------------------------------------------- +# TimeAxis (mirrors OpenUtau.Core/Util/TimeAxis.cs → BuildSegments) +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class _TempoSegment: + tick_pos: int + tick_end: int # exclusive upper bound + bpm: float + ms_pos: float # absolute ms at segment start + ms_per_tick: float + ticks_per_ms: float + + @property + def ticks(self) -> int: + return self.tick_end - self.tick_pos + + @property + def ms_end(self) -> float: + return self.ms_pos + self.ticks * self.ms_per_tick + + +class TimeAxis: + """ + Piecewise tick ↔ millisecond converter that faithfully replicates the + ``BuildSegments`` / ``TickPosToMsPos`` / ``MsPosToTickPos`` logic from + ``OpenUtau.Core/Util/TimeAxis.cs``. + + The C# implementation merges time-signature segment boundaries with tempo + events before computing absolute millisecond offsets. We replicate that + merge here so segment boundaries are identical. + + Usage:: + + axis = project.build_time_axis() + ms = axis.ticks_to_ms(ticks_array) + tick = axis.ms_to_ticks(ms_array) + + ``seconds_to_ticks`` / ``ticks_to_seconds`` wrappers are also provided. + """ + + def __init__(self, segments: list[_TempoSegment]) -> None: + self._segs = segments # sorted by tick_pos + self._tick_starts = [s.tick_pos for s in segments] + self._ms_starts = [s.ms_pos for s in segments] + + # ------------------------------------------------------------------ + # Factory — replicates BuildSegments + # ------------------------------------------------------------------ + + @classmethod + def build( + cls, + tempos: list[UTempo], + time_signatures: list[UTimeSignature], + resolution: int = RESOLUTION, + ) -> "TimeAxis": + """Build a TimeAxis from project tempo and time-signature lists.""" + if not tempos: + raise ValueError("At least one tempo event is required.") + if not time_signatures: + raise ValueError("At least one time-signature event is required.") + + sorted_ts = sorted(time_signatures, key=lambda ts: ts.bar_position) + sorted_bpm = sorted(tempos, key=lambda t: t.position) + + # --- step 1: compute the tick position of each time-signature change --- + ts_tick: list[int] = [] + ticks_per_bar: list[int] = [] + for i, ts in enumerate(sorted_ts): + if i == 0: + if ts.bar_position != 0: + raise ValueError("First time signature must be at bar 0.") + ts_tick.append(0) + else: + prev_bar = sorted_ts[i - 1].bar_position + ts_tick.append( + ts_tick[-1] + + ticks_per_bar[-1] * (ts.bar_position - prev_bar) + ) + ticks_per_bar.append( + resolution * 4 * ts.beat_per_bar // ts.beat_unit + ) + + # --- step 2: merge time-sig boundary ticks with tempo-event ticks --- + # Replicates the C# loop that inserts / updates TempoSegments. + seg_ticks: list[int] = list(ts_tick) + seg_bpms: list[float] = [0.0] * len(ts_tick) + + for tempo in sorted_bpm: + p = tempo.position + idx = bisect.bisect_left(seg_ticks, p) + if idx < len(seg_ticks) and seg_ticks[idx] == p: + seg_bpms[idx] = tempo.bpm + elif idx == len(seg_ticks): + seg_ticks.append(p) + seg_bpms.append(tempo.bpm) + else: + seg_ticks.insert(idx, p) + seg_bpms.insert(idx, tempo.bpm) + + # Propagate BPM forward into time-sig boundary segments that have + # no explicit tempo event (they inherit the preceding tempo). + for i in range(1, len(seg_bpms)): + if seg_bpms[i] == 0.0: + seg_bpms[i] = seg_bpms[i - 1] + + # --- step 3: compute absolute ms_pos for each segment --- + n = len(seg_ticks) + ms_pos_arr = [0.0] * n + for i in range(1, n): + dt = seg_ticks[i] - seg_ticks[i - 1] + ms_per_tick_prev = MS_PER_MIN / (seg_bpms[i - 1] * resolution) + ms_pos_arr[i] = ms_pos_arr[i - 1] + dt * ms_per_tick_prev + + # --- step 4: build immutable _TempoSegment objects --- + segments: list[_TempoSegment] = [] + for i in range(n): + bpm = seg_bpms[i] + ms_per_tick = MS_PER_MIN / (bpm * resolution) + tick_end = seg_ticks[i + 1] if i + 1 < n else 2 ** 31 - 1 + segments.append( + _TempoSegment( + tick_pos=seg_ticks[i], + tick_end=tick_end, + bpm=bpm, + ms_pos=ms_pos_arr[i], + ms_per_tick=ms_per_tick, + ticks_per_ms=1.0 / ms_per_tick, + ) + ) + return cls(segments) + + # ------------------------------------------------------------------ + # Scalar converters (mirror TickPosToMsPos / MsPosToTickPos in C#) + # ------------------------------------------------------------------ + + def _seg_at_tick(self, tick: float) -> _TempoSegment: + idx = bisect.bisect_right(self._tick_starts, tick) - 1 + return self._segs[max(idx, 0)] + + def _seg_at_ms(self, ms: float) -> _TempoSegment: + idx = bisect.bisect_right(self._ms_starts, ms) - 1 + return self._segs[max(idx, 0)] + + def tick_pos_to_ms(self, tick: float) -> float: + """Convert a tick position to milliseconds (mirrors TickPosToMsPos).""" + seg = self._seg_at_tick(tick) + return seg.ms_pos + seg.ms_per_tick * (tick - seg.tick_pos) + + def ms_pos_to_tick(self, ms: float) -> float: + """Convert a ms position to (non-integer) ticks (mirrors MsPosToNonExactTickPos).""" + seg = self._seg_at_ms(ms) + return seg.tick_pos + (ms - seg.ms_pos) * seg.ticks_per_ms + + def ms_between_ticks(self, tick_start: float, tick_end: float) -> float: + """Duration in ms between two tick positions (mirrors MsBetweenTickPos).""" + return self.tick_pos_to_ms(tick_end) - self.tick_pos_to_ms(tick_start) + + # ------------------------------------------------------------------ + # Vectorised numpy API + # ------------------------------------------------------------------ + + def ticks_to_ms(self, ticks: np.ndarray | float) -> np.ndarray: + """Convert tick values to milliseconds (vectorised).""" + return np.vectorize(self.tick_pos_to_ms)(np.asarray(ticks, dtype=float)) + + def ms_to_ticks( + self, + ms: np.ndarray | float, + *, + unique: bool = False, + ) -> np.ndarray: + """Convert millisecond positions to integer ticks (vectorised). + + Args: + ms: Millisecond positions. + unique: Return sorted deduplicated ticks when ``True``. + """ + ticks = np.round( + np.vectorize(self.ms_pos_to_tick)(np.asarray(ms, dtype=float)) + ).astype(int) + return np.unique(ticks) if unique else ticks + + def ticks_to_seconds(self, ticks: np.ndarray | float) -> np.ndarray: + """Convenience wrapper: ticks → seconds.""" + return self.ticks_to_ms(ticks) / 1000.0 + + def seconds_to_ticks( + self, + times: np.ndarray | float, + *, + unique: bool = False, + ) -> np.ndarray: + """Convenience wrapper: seconds → integer ticks.""" + return self.ms_to_ticks(np.asarray(times, dtype=float) * 1000.0, unique=unique) + + def shift_ticks_by_seconds( + self, + ticks: np.ndarray, + offset_seconds: float, + ) -> np.ndarray: + """Shift tick positions by *offset_seconds* seconds. + + Unlike ``ticks + seconds_to_ticks(offset)``, this correctly handles + tempo changes: each tick is converted back to seconds, shifted, then + re-converted to ticks — so the shift is always measured in real time, + not in a fixed-tempo approximation. + + Args: + ticks: 1-D integer array of tick positions. + offset_seconds: Time shift in seconds (positive = delay). + + Returns: + Shifted integer tick positions. + """ + times = self.ticks_to_seconds(np.asarray(ticks, dtype=float)) + return self.seconds_to_ticks(times + offset_seconds) + + +# --------------------------------------------------------------------------- +# File I/O +# --------------------------------------------------------------------------- + +def load_ustx(ustx_path: str) -> UProject: + """Parse a USTX file and return a :class:`UProject`. + + Args: + ustx_path: Path to the ``.ustx`` file. + """ + with open(ustx_path, "r", encoding="utf-8-sig") as fh: + raw = oyaml.load(fh.read(), CoreLoader) + project = UProject.from_dict(raw) + log.debug( + "Loaded USTX from %s (%d voice part(s), %d tempo(s))", + ustx_path, len(project.voice_parts), len(project.tempos), + ) + return project + + +def save_ustx(project: UProject, ustx_path: str) -> None: + """Serialise *project* back to a USTX file, preserving key order. Args: - ustx_dict (dict): USTX data to save. - ustx_path (str): Path to save the USTX file. - """ - # Use oyaml to keep original order of USTX items - output_str = oyaml.dump(ustx_dict, Dumper=oyaml.Dumper) - with open(ustx_path, "w+", encoding="utf-8-sig") as o: - o.write(output_str) - - -def edit_ustx_expression_curve( - ustx_dict, ustx_track_number, expression, tick_seq, exp_seq -): - if expression in ["dyn", "pitd", "tenc"]: - track_idx = ustx_track_number - 1 # track index starts from 0 - track = ustx_dict["voice_parts"][track_idx] - if "curves" not in track.keys(): - track["curves"] = [] - - curves = track["curves"] - exp = None - for c in curves: - if c["abbr"] == expression: - exp = c - break - if exp is None: - curves.append({"xs": [], "ys": [], "abbr": expression}) - exp = curves[-1] - - mask = ~np.isnan(exp_seq) - exp["xs"] = tick_seq[mask].tolist() - exp["ys"] = np.round(exp_seq[mask]).astype(int).tolist() - - else: - raise ValueError(f"Unsupported expression type: {expression}") + project: The project to save. + ustx_path: Destination path. + """ + output = oyaml.dump(project.to_dict(), Dumper=oyaml.Dumper) + with open(ustx_path, "w+", encoding="utf-8-sig") as fh: + fh.write(output) + log.debug("Saved USTX to %s", ustx_path) + + +# --------------------------------------------------------------------------- +# Editor +# --------------------------------------------------------------------------- + +class UstxEditor: + """ + RAII wrapper that holds an exclusive file lock for the duration of an + editing session and exposes the parsed :class:`UProject`. + + Preferred usage — context manager (auto-saves on clean exit):: + + with UstxEditor("song.ustx") as editor: + axis = editor.build_time_axis() + ticks = axis.seconds_to_ticks(times_array) + # write relative-tick data to one part: + editor.add_expression_to_part(editor.voice_parts[0], "dyn", ticks, values) + # write absolute-tick data across every part on a track: + editor.add_expression_to_track(0, "pitd", abs_ticks, pitd_values) + + Manual usage:: + + editor = UstxEditor("song.ustx") + ... + editor.save() + editor.close() + """ + + def __init__(self, ustx_path: str) -> None: + self.ustx_path = ustx_path + self._lock = FileLock(ustx_path + ".lock", thread_local=False, is_singleton=True) + self._lock.acquire() + self.project: UProject = load_ustx(ustx_path) + + # ------------------------------------------------------------------ + # Project-level properties + # ------------------------------------------------------------------ + + @property + def tracks(self) -> list[UTrack]: + """All tracks in the project (0-based).""" + return self.project.tracks + + @property + def voice_parts(self) -> list[UVoicePart]: + """All voice parts in the project, in file order.""" + return self.project.voice_parts + + @property + def tempos(self) -> list[UTempo]: + """Tempo map of the project.""" + return self.project.tempos + + @property + def time_signatures(self) -> list[UTimeSignature]: + """Time-signature map of the project.""" + return self.project.time_signatures + + def get_track(self, track_no: int) -> UTrack: + """Return the track at 0-based index *track_no*.""" + return self.project.get_track(track_no) + + def get_parts_for_track(self, track_no: int) -> list[UVoicePart]: + """Return all voice parts for *track_no* (0-based), sorted by position.""" + return self.project.get_parts_for_track(track_no) + + def build_time_axis(self) -> TimeAxis: + """Build and return a :class:`TimeAxis` for the project's tempo map.""" + return self.project.build_time_axis() + + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + + def __enter__(self) -> "UstxEditor": + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + try: + if exc_type is None: + self.save() + finally: + self.close() + return False + + def save(self) -> None: + """Write the project back to disk.""" + save_ustx(self.project, self.ustx_path) + + def close(self) -> None: + """Release the file lock without saving.""" + self._lock.release() + + def add_expression_to_part( + self, + part: UVoicePart, + expression_name: str, + expression_ticks: np.ndarray, + expression_values: np.ndarray, + ) -> None: + """Overwrite an expression curve on a specific voice part. + + Args: + part: The :class:`UVoicePart` to edit. + expression_name: Curve abbreviation, e.g. ``"dyn"``. + expression_ticks: 1-D integer array of tick positions (relative to part start). + expression_values: 1-D float array of values (NaN entries are skipped). + """ + part.set_curve(expression_name, expression_ticks, expression_values) + + def add_expression_to_track( + self, + track_no: int, + expression_name: str, + expression_ticks: np.ndarray, + expression_values: np.ndarray, + ) -> None: + """Overwrite an expression curve across all voice parts on a track. + + Tick positions in *expression_ticks* are absolute project ticks. + Each part receives the slice of the curve that falls within its own + ``[position, position + duration)`` window, re-expressed as ticks + relative to the part start (matching how OpenUtau stores curves). + + Args: + track_no: 0-based track index (matches ``UVoicePart.track_no``). + expression_name: Curve abbreviation, e.g. ``"dyn"``. + expression_ticks: 1-D integer array of *absolute* project tick positions. + expression_values: 1-D float array of values (NaN entries are skipped). + """ + parts = self.project.get_parts_for_track(track_no) + if not parts: + raise ValueError(f"No voice parts found for track_no {track_no}.") + + ticks = np.asarray(expression_ticks, dtype=int) + values = np.asarray(expression_values, dtype=float) + + for part in parts: + part_start = part.position + part_end = part.position + part.duration + mask = (ticks >= part_start) & (ticks < part_end) + if not mask.any(): + continue + relative_ticks = ticks[mask] - part_start + part.set_curve(expression_name, relative_ticks, values[mask]) diff --git a/utils/wavtool.py b/utils/wavtool.py index 3f193ad..43e4f4c 100644 --- a/utils/wavtool.py +++ b/utils/wavtool.py @@ -67,9 +67,9 @@ def extract_wav_frequency(file_path, backend="swift-f0", use_cache=True): Returns: tuple: (time, frequency, confidence), where: - - time (list of float): Time points in seconds. Shape: (n_time_points). - - frequency (list of float): Detected pitch frequencies in Hz. Shape: (n_time_points). - - confidence (list of float): Confidence values for the detected pitches. Shape: (n_time_points). + - time (np.ndarray of float): Time points in seconds. Shape: (n_time_points). + - frequency (np.ndarray of float): Detected pitch frequencies in Hz. Shape: (n_time_points). + - confidence (np.ndarray of float): Confidence values for the detected pitches. Shape: (n_time_points). """ _SUPPORTED_BACKENDS = ("crepe", "swift-f0") if backend not in _SUPPORTED_BACKENDS: @@ -121,7 +121,7 @@ def extract_wav_frequency(file_path, backend="swift-f0", use_cache=True): writer.writerow([t, f, c]) print(_("F0 data saved to cache file: '{}'").format(cache_path)) - return time, frequency, confidence + return np.asarray(time), np.asarray(frequency), np.asarray(confidence) def extract_wav_rms(wav_path, mask_silence=True):