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
7 changes: 7 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ When using a DiffSinger virtual singer for covers, users often already have an O

A new USTX file with expression parameters added. The original project will not be modified.

> [!TIP]
> Starting from `v0.9.1`, if you prefer not to generate a new project file, you can **set the output path to be the same as the input project path**. In this case, the expression parameters will be written directly into the original project file.
>
> Under normal circumstances, the program will only update the specified expression parameters in the selected track. It will not affect other parameters or modify other tracks.
>
> ⚠️ **If you plan to use this feature, please make a backup in advance to prevent potential data loss in case of errors**.

## ✨ Features

* [x] Windows support
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@

一个携带表情参数的新 USTX 文件。原始工程不会被修改。

> [!TIP]
> 从 `v0.9.1` 开始,如果您不希望额外生成新的工程文件,可以**将输出路径设置为与输入工程路径一致**。这样,表情参数会直接写入原始工程文件中。
>
> 正常情况下,程序只会更新您所选音轨中的指定表情参数,不会影响其它参数,也不会修改其他音轨。
>
> ⚠️ **如果您需要使用此功能,请提前做好备份,以防程序出错**。

## ✨ 功能特性

* [x] Windows 支持
Expand Down
2 changes: 1 addition & 1 deletion expressions/dyn.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class DynLoader(ExpressionLoader):
expression_name = "dyn"
expression_info = _l("Dynamics (curve)")
args = SimpleNamespace(
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
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\n\n**NOTICE**: This may slightly cut into the beginning and ending of voiced segments. If the effect is too severe, consider disabling this option\n\n")), # 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
Expand Down
1 change: 0 additions & 1 deletion expressions/pitd.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ def get_expression(
utau_time, utau_pitch, utau_confidence, utau_features = get_wav_features(
wav_path=self.utau_path, confidence_threshold=confidence_utau, backend=backend
)
with StreamToLogger(self.logger, tee=True):
ref_time, ref_pitch, ref_confidence, ref_features = get_wav_features(
wav_path=self.ref_path, confidence_threshold=confidence_ref, backend=backend
)
Expand Down
2 changes: 1 addition & 1 deletion expressions/tenc.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class TencLoader(ExpressionLoader):
expression_name = "tenc"
expression_info = _l("Tension (curve)")
args = SimpleNamespace(
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
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\n\n**NOTICE**: This may slightly cut into the beginning and ending of voiced segments. If the effect is too severe, consider disabling this option\n\n")), # 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=6 , 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.0 , help=_l("**Scaling factor** applied to the expression curve. Values >1 amplify the expression, =1 keeps original intensity, <1 reduces it")), # noqa: E501
Expand Down
7 changes: 5 additions & 2 deletions expressive.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import os
import logging
import argparse
from shutil import copy
from pathlib import Path
from datetime import datetime
from contextlib import contextmanager
from shutil import copy, SameFileError
from os.path import splitext, basename

from utils.i18n import init_gettext, _
Expand Down Expand Up @@ -77,7 +77,10 @@ def process_expressions(
]
```
"""
copy(ustx_input, ustx_output)
try:
copy(ustx_input, ustx_output)
except SameFileError:
pass

for exp in expressions:
exp_type = exp["expression"]
Expand Down
2 changes: 1 addition & 1 deletion expressive_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ async def export_config(state=state):
if file and len(file) > 0:
try:
with open(file[0], "w+", encoding="utf-8-sig") as f: # type: ignore
json.dump(state, f, indent=4)
json.dump(state, f, indent=4, ensure_ascii=False)
ui.notify(_("Config exported successfully!"), type="positive")
except Exception as e:
ui.notify(_("Failed to export config") + f": {str(e)}", type="negative")
Expand Down
16 changes: 12 additions & 4 deletions locales/app.pot
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,11 @@ msgstr ""
#: expressions/dyn.py:28 expressions/tenc.py:28
msgid ""
"**Trim silence** from the leading and trailing edges of the audio before "
"extracting expression"
"extracting expression\n"
"\n"
"**NOTICE**: This may slightly cut into the beginning and ending of voiced"
" segments. If the effect is too severe, consider disabling this option\n"
"\n"
msgstr ""

#: expressions/dyn.py:29 expressions/pitd.py:41 expressions/tenc.py:29
Expand Down Expand Up @@ -474,15 +478,19 @@ msgid ""
"curve upward; negative values shift it downward"
msgstr ""

#: utils/ui.py:353
#: utils/ui.py:350
msgid "Failed to load audio"
msgstr ""

#: utils/ui.py:377
msgid "Play/Pause"
msgstr ""

#: utils/ui.py:354
#: utils/ui.py:378
msgid "Loop region"
msgstr ""

#: utils/ui.py:355
#: utils/ui.py:379
msgid "Zoom"
msgstr ""

Expand Down
22 changes: 17 additions & 5 deletions locales/en/LC_MESSAGES/app.po
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,18 @@ msgstr "Dynamics (curve)"
#: expressions/dyn.py:28 expressions/tenc.py:28
msgid ""
"**Trim silence** from the leading and trailing edges of the audio before "
"extracting expression"
"extracting expression\n"
"\n"
"**NOTICE**: This may slightly cut into the beginning and ending of voiced"
" segments. If the effect is too severe, consider disabling this option\n"
"\n"
msgstr ""
"**Trim silence** from the leading and trailing edges of the audio before "
"extracting expression"
"extracting expression\n"
"\n"
"**NOTICE**: This may slightly cut into the beginning and ending of voiced"
" segments. If the effect is too severe, consider disabling this option\n"
"\n"

#: expressions/dyn.py:29 expressions/pitd.py:41 expressions/tenc.py:29
msgid ""
Expand Down Expand Up @@ -517,15 +525,19 @@ msgstr ""
"**Bias** offset added to the expression curve. Positive values shift the "
"curve upward; negative values shift it downward"

#: utils/ui.py:353
#: utils/ui.py:350
msgid "Failed to load audio"
msgstr "Failed to load audio"

#: utils/ui.py:377
msgid "Play/Pause"
msgstr "Play/Pause"

#: utils/ui.py:354
#: utils/ui.py:378
msgid "Loop region"
msgstr "Loop region"

#: utils/ui.py:355
#: utils/ui.py:379
msgid "Zoom"
msgstr "Zoom"

Expand Down
22 changes: 17 additions & 5 deletions locales/zh_CN/LC_MESSAGES/app.po
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,16 @@ msgstr "动态曲线 Dynamics (curve)"
#: expressions/dyn.py:28 expressions/tenc.py:28
msgid ""
"**Trim silence** from the leading and trailing edges of the audio before "
"extracting expression"
msgstr "在提取表情特征前,**剪除**音频开头与结尾的**静音部分**"
"extracting expression\n"
"\n"
"**NOTICE**: This may slightly cut into the beginning and ending of voiced"
" segments. If the effect is too severe, consider disabling this option\n"
"\n"
msgstr ""
"在提取表情特征前,**剪除**音频开头与结尾的**静音部分**\n"
"\n"
"**注意**:该操作可能会轻微截断有声段的起始和结束部分;若影响较为明显,建议关闭此功能\n"
"\n"

#: expressions/dyn.py:29 expressions/pitd.py:41 expressions/tenc.py:29
msgid ""
Expand Down Expand Up @@ -487,15 +495,19 @@ msgid ""
"curve upward; negative values shift it downward"
msgstr "添加到表情曲线的**偏置**偏移量;正值使曲线上移,负值使曲线下移"

#: utils/ui.py:353
#: utils/ui.py:350
msgid "Failed to load audio"
msgstr "音频加载失败"

#: utils/ui.py:377
msgid "Play/Pause"
msgstr "播放/暂停"

#: utils/ui.py:354
#: utils/ui.py:378
msgid "Loop region"
msgstr "选区循环播放"

#: utils/ui.py:355
#: utils/ui.py:379
msgid "Zoom"
msgstr "缩放"

Expand Down
30 changes: 0 additions & 30 deletions tests/test_ustx.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
TimeAxis,
UstxEditor,
RESOLUTION,
SUPPORTED_EXPRESSIONS,
)


Expand Down Expand Up @@ -295,23 +294,6 @@ def test_set_curve_overwrites(self):
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
Expand Down Expand Up @@ -709,18 +691,6 @@ def test_full_workflow_via_editor(self, temp_ustx_file):
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:
Expand Down
6 changes: 3 additions & 3 deletions utils/seqtool.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,15 @@ def unify_sequence_time(seq_times, seq_vals, to_ticks=False):
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=np.nan, bounds_error=False)(unified_seq_time) # type: ignore
for (st, sv) in zip(seq_times, seq_vals, strict=False)
]
return unified_seq_time, tuple(unified_seqs_val)

unified_seq_ticks = np.unique(_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
interp1d(st, sv, fill_value=np.nan, bounds_error=False)(time_mapping) # type: ignore
for (st, sv) in zip(seq_times, seq_vals, strict=False)
]
return unified_seq_ticks, tuple(unified_seqs_val)
Expand Down Expand Up @@ -262,7 +262,7 @@ def align_sequence_tick(
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
interp_seq = interp1d(aligned_tick, aligned_seq, fill_value=np.nan, bounds_error=False) # type: ignore
aligned_queries.append(interp_seq(unified_tick))

return unified_tick, tuple(aligned_queries), tuple(unified_references)
Expand Down
40 changes: 40 additions & 0 deletions utils/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,29 @@ def _build(self) -> None:
waveform_div.classes("w-full rounded-lg overflow-hidden")
waveform_div.style(f"min-height:{self._height}px; background:var(--ws-bg);")

self._error = (
ui.label('')
.classes('w-full text-xs text-red-400 hidden')
)

self._loader = (
ui.linear_progress(show_value=False)
.props('instant-feedback rounded indeterminate')
.classes('w-full hidden')
.style('height:3px; margin:0; color:#c800c8;')
)

ui.on(f'{iid}-loading', lambda e: (
self._error.set_visibility(False),
self._loader.set_visibility(True),
))
ui.on(f'{iid}-ready', lambda e: self._loader.set_visibility(False))
ui.on(f'{iid}-error', lambda e: (
self._loader.set_visibility(False),
self._error.set_text(e.args.get('message') or _('Failed to load audio')),
self._error.set_visibility(True),
))

ws_opts: dict[str, Any] = {
"container": f"#{iid}_waveform",
"waveColor": self._wave_color,
Expand Down Expand Up @@ -371,6 +394,20 @@ def _build(self) -> None:

window['{iid}'] = {{ ws, regions, loop: {loop_init} }};

ws.on('loading', (percent) => {{
emitEvent('{iid}-loading', {{ percent: percent }});
}});

ws.on('ready', () => {{
emitEvent('{iid}-ready', {{}});
}});

ws.on('error', (err) => {{
// Filter out non-fatal errors
if (ws.getDuration() > 0) return;
emitEvent('{iid}-error', {{ message: err?.message || '' }});
}});

if ({show_controls_json}) {{
// Overlay controls: inject a style block + overlay div into the waveform container
const styleEl = document.createElement('style');
Expand Down Expand Up @@ -537,6 +574,9 @@ def serve_wav(wav_path: str) -> str:
Each unique directory is registered once under /wav/<hashed_dir>.
WaveSurfer then streams the file normally — no base64 overhead.
"""
if not wav_path or not os.path.exists(wav_path):
return ''

import hashlib
directory = os.path.dirname(os.path.abspath(wav_path))
dir_hash = hashlib.md5(directory.encode()).hexdigest()[:8]
Expand Down
18 changes: 10 additions & 8 deletions utils/ustx.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@
RESOLUTION = 480 # pulses per quarter note — hardcoded in UProject.cs
MS_PER_MIN = 60_000.0 # milliseconds per minute

SUPPORTED_EXPRESSIONS = frozenset({"dyn", "pitd", "tenc"})


# ---------------------------------------------------------------------------
# Data structures
Expand Down Expand Up @@ -221,11 +219,6 @@ def set_curve(
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()
Expand Down Expand Up @@ -566,7 +559,7 @@ def save_ustx(project: UProject, ustx_path: str) -> None:
project: The project to save.
ustx_path: Destination path.
"""
output = oyaml.dump(project.to_dict(), Dumper=oyaml.Dumper)
output = oyaml.dump(project.to_dict(), Dumper=oyaml.Dumper, allow_unicode=True)
with open(ustx_path, "w+", encoding="utf-8-sig") as fh:
fh.write(output)
log.debug("Saved USTX to %s", ustx_path)
Expand Down Expand Up @@ -705,6 +698,7 @@ def add_expression_to_track(
if not parts:
raise ValueError(f"No voice parts found for track_no {track_no}.")

curve_set = False
ticks = np.asarray(expression_ticks, dtype=int)
values = np.asarray(expression_values, dtype=float)

Expand All @@ -716,3 +710,11 @@ def add_expression_to_track(
continue
relative_ticks = ticks[mask] - part_start
part.set_curve(expression_name, relative_ticks, values[mask])
curve_set = True

if not curve_set:
log.warning(
"No expression points fit inside any part on track %d (0-based) for curve %s.",
track_no,
expression_name,
)
Loading