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):