Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/packaging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ jobs:
--add-data "examples;examples/" \
--add-data "assets;assets/" \
--add-data "locales;locales/" \
--add-data "static;static/" \
--add-data "README.md;." \
--add-data "LICENSE;." \
--additional-hooks-dir build/hooks \
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ build/*
!build/auto-py-to-exe.json
!build/hooks/
!build/hatch_build.py
static/vendor/*
!static/vendor/.gitkeep
*.pyc
*PitchLoader Output*.ustx
*output*.ustx
Expand Down
4 changes: 1 addition & 3 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ The current version supports importing the following expression parameters:
* `Pitch Deviation (curve)`
* `Tension (curve)`

<p align="center">
<img src="https://github.com/user-attachments/assets/cd4c3f0f-4ac2-4d59-910d-4dec2d786b4f" width="100%" />
</p>
https://github.com/user-attachments/assets/4b5b7c15-947a-4f54-b80e-a14a9eefc86b

> - *OpenUtau version used 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)*
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@
* `Pitch Deviation (curve)`
* `Tension (curve)`

<p align="center">
<img src="https://github.com/user-attachments/assets/cd4c3f0f-4ac2-4d59-910d-4dec2d786b4f" width="100%" />
</p>
https://github.com/user-attachments/assets/4b5b7c15-947a-4f54-b80e-a14a9eefc86b

> - *OpenUtau 版本来自 [keirokeer/OpenUtau-DiffSinger-Lunai](https://github.com/keirokeer/OpenUtau-DiffSinger-Lunai)*
> - *歌手模型来自 [yousa-ling-official-production/yousa-ling-diffsinger-v1](https://github.com/yousa-ling-official-production/yousa-ling-diffsinger-v1)*
Expand Down
4 changes: 4 additions & 0 deletions build/auto-py-to-exe.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@
"optionDest": "datas",
"value": "locales;locales/"
},
{
"optionDest": "datas",
"value": "static;static/"
},
{
"optionDest": "datas",
"value": "README.md;./"
Expand Down
24 changes: 20 additions & 4 deletions build/hatch_build.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
"""Hatch build hook: compile gettext .po -> .mo before wheel packaging."""
"""Hatch build hook: compile gettext .po -> .mo and download vendored static deps."""

from __future__ import annotations

import glob
import os
import urllib.request

from babel.messages.mofile import write_mo
from babel.messages.pofile import read_po
from hatchling.builders.hooks.plugin.interface import BuildHookInterface


class CustomBuildHook(BuildHookInterface):
PLUGIN_NAME = "custom"

def initialize(self, version: str, build_data: dict) -> None:
self._compile_locales(build_data)
self._vendor_static(build_data)

def _compile_locales(self, build_data: dict) -> None:
locales_dir = os.path.join(self.root, "locales")
for po_file in glob.glob(
os.path.join(locales_dir, "**", "*.po"), recursive=True
Expand All @@ -23,7 +26,20 @@ def initialize(self, version: str, build_data: dict) -> None:
catalog = read_po(f)
with open(mo_file, "wb") as f:
write_mo(f, catalog)
# artifacts bypasses .gitignore so the compiled .mo is included in the wheel
build_data["artifacts"].append(
os.path.relpath(mo_file, self.root)
)

def _vendor_static(self, build_data: dict) -> None:
vendor_dir = os.path.join(self.root, "static", "vendor")
os.makedirs(vendor_dir, exist_ok=True)

for name, url in self.config.get("vendor-static-deps", []):
dest = os.path.join(vendor_dir, name)
os.makedirs(os.path.dirname(dest), exist_ok=True)
if not os.path.exists(dest):
print(f"Downloading {name} from {url}")
urllib.request.urlretrieve(url, dest)
build_data["artifacts"].append(
os.path.relpath(dest, self.root)
)
36 changes: 29 additions & 7 deletions expressions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import numpy as np

from utils.i18n import _, _l
from utils.wavtool import ClampedWav, sec2timestamp
from utils.ustx import load_ustx, save_ustx, edit_ustx_expression_curve


Expand All @@ -25,17 +26,23 @@ class ExpressionLoader():
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
ustx_path = Args(name="ustx_path" , type=str, default="", help=_l("Path to the `.ustx` project file to be processed")), # noqa: E501
track_number = Args(name="track_number", type=int, default=1 , help=_l("**Track number** to apply expressions to (1-based index)")), # noqa: E501
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
ustx_path = Args(name="ustx_path" , type=str, default="" , help=_l("Path to the `.ustx` project file to be processed")), # noqa: E501
track_number = Args(name="track_number", type=int, default=1 , help=_l("**Track number** to apply expressions to (1-based index)")), # noqa: E501
ref_start = Args(name="ref_start" , type=str, default=None, help=_l("**Start time** of the **reference** audio (format `M:S`, e.g. `0:10.01`). Omit to specify the beginning")), # noqa: E501
ref_end = Args(name="ref_end" , type=str, default=None, help=_l("**End time** of the **reference** audio (format `M:S`, e.g. `0:10.01`). Omit to specify the ending")), # noqa: E501
utau_start = Args(name="utau_start" , type=str, default=None, help=_l("**Start time** of the **UTAU** audio (format `M:S`, e.g. `0:10.01`). Omit to specify the beginning")), # noqa: E501
utau_end = Args(name="utau_end" , type=str, default=None, help=_l("**End time** of the **UTAU** audio (format `M:S`, e.g. `0:10.01`). Omit to specify the ending")), # noqa: E501
)

@classmethod
def get_args_dict(cls) -> dict[str, Args]:
return cls.args.__dict__

def __init__(self, ref_path: str, utau_path: str, ustx_path: str):
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):
ExpressionLoader._id_counter += 1
self.id = ExpressionLoader._id_counter
self.logger = logging.getLogger(f"{ExpressionLoader.__name__}.{self.expression_name}.{self.id}")
Expand All @@ -44,8 +51,23 @@ def __init__(self, ref_path: str, utau_path: str, ustx_path: str):

self.expression_tick: list | np.ndarray = []
self.expression_val: list | np.ndarray = []
self.ref_path = ref_path
self.utau_path = utau_path

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)
self.logger.info(_("ref [{} → {}] {:.3f}s").format(
sec2timestamp(self.ref_offset),
sec2timestamp(self.ref_offset + self.ref_duration),
self.ref_duration))

self._clamped_utau = ClampedWav(utau_path, utau_start, utau_end, logger=self.logger)
self.utau_path, self.utau_offset, self.utau_duration = (
self._clamped_utau.path, self._clamped_utau.offset_sec, self._clamped_utau.duration_sec)
self.logger.info(_("utau [{} → {}] {:.3f}s").format(
sec2timestamp(self.utau_offset),
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"]
self.logger.info(_("Initialization complete."))
Expand Down
41 changes: 22 additions & 19 deletions expressions/dyn.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
from types import SimpleNamespace

import librosa
import numpy as np
from scipy.stats import zscore

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,
seq_dynamics_trends,
)
from utils.wavtool import extract_wav_rms


@register_expression
class DynLoader(ExpressionLoader):
expression_name = "dyn"
expression_info = _l("Dynamics (curve)")
args = SimpleNamespace(
align_radius = Args(name="align_radius", type=int , default=1 , help=_l("**Radius** for the FastDTW alignment algorithm; larger values allow more flexible alignment but increase computation time")), # noqa: E501
smoothness = Args(name="smoothness" , type=int , default=2 , help=_l("Controls the **smoothness** of the expression curve using Gaussian filtering. Higher values produce smoother curves but may lose fine detail")), # noqa: E501
scaler = Args(name="scaler" , type=float, default=1.5, help=_l("**Scaling factor** applied to the expression curve. Values >1 amplify the expression, =1 keeps original intensity, <1 reduces it")), # noqa: E501
trim_silence = Args(name="trim_silence", type=bool , default=True, help=_l("**Trim silence** from the leading and trailing edges of the audio before extracting expression")), # noqa: E501
align_radius = Args(name="align_radius", type=int , default=1 , help=_l("**Radius** for the FastDTW alignment algorithm; larger values allow more flexible alignment but increase computation time")), # noqa: E501
smoothness = Args(name="smoothness" , type=int , default=2 , help=_l("Controls the **smoothness** of the expression curve using Gaussian filtering. Higher values produce smoother curves but may lose fine detail")), # noqa: E501
scaler = Args(name="scaler" , type=float, default=1.5 , help=_l("**Scaling factor** applied to the expression curve. Values >1 amplify the expression, =1 keeps original intensity, <1 reduces it")), # noqa: E501
)

def get_expression(
self,
trim_silence = args.trim_silence.default,
align_radius = args.align_radius.default,
smoothness = args.smoothness .default,
scaler = args.scaler .default,
Expand All @@ -33,15 +37,15 @@ def get_expression(

# Extract rms features from WAV files
utau_time, utau_rms, utau_features = get_wav_features(
wav_path=self.utau_path,
wav_path=self.utau_path, mask_silence=trim_silence
)
ref_time, ref_rms, ref_features = get_wav_features(
wav_path=self.ref_path,
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
dyn_tick, (time_aligned_ref_rms, *_unused), *_unused = align_sequence_tick(
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,
Expand All @@ -50,27 +54,26 @@ def get_expression(
align_radius=align_radius,
)

# Mask positions where utau is silent (NaN)
time_aligned_ref_rms[np.isnan(time_unified_utau_rms)] = np.nan

dyn_val = get_experssion_dynamics(time_aligned_ref_rms, smoothness, scaler)

self.expression_tick, self.expression_val = dyn_tick, dyn_val
# 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.logger.info(_("Expression extraction complete."))
return self.expression_tick, self.expression_val


def extract_wav_rms(wav_path):
sr = librosa.get_samplerate(wav_path)
y, _ = librosa.load(wav_path, sr=sr)
rms = librosa.feature.rms(y=y)[0]
rms_time = librosa.times_like(rms, sr=sr)
return rms_time, rms


def get_wav_features(wav_path):
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)

# Extract RMS feature
rms_time, rms = extract_wav_rms(wav_path)
rms_time, rms = extract_wav_rms(wav_path, mask_silence=mask_silence)
feature_times += [rms_time]
feature_vals += [rms]

Expand All @@ -89,7 +92,7 @@ def get_wav_features(wav_path):
def get_experssion_dynamics(time_aligned_rms, smoothness=2, scaler=1.0):
base_scaler = 10.0
smoothed_dyn = gaussian_filter1d_with_nan(
base_scaler * zscore(time_aligned_rms),
base_scaler * zscore(time_aligned_rms, nan_policy='omit'),
sigma=smoothness,
)
return scaler * smoothed_dyn
Loading
Loading