diff --git a/README.en.md b/README.en.md index 1e8d5f2..cde3196 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.md b/README.md index 9103852..29f1178 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,13 @@ 一个携带表情参数的新 USTX 文件。原始工程不会被修改。 +> [!TIP] +> 从 `v0.9.1` 开始,如果您不希望额外生成新的工程文件,可以**将输出路径设置为与输入工程路径一致**。这样,表情参数会直接写入原始工程文件中。 +> +> 正常情况下,程序只会更新您所选音轨中的指定表情参数,不会影响其它参数,也不会修改其他音轨。 +> +> ⚠️ **如果您需要使用此功能,请提前做好备份,以防程序出错**。 + ## ✨ 功能特性 * [x] Windows 支持 diff --git a/expressions/dyn.py b/expressions/dyn.py index 3df9cd8..8eb6ad9 100644 --- a/expressions/dyn.py +++ b/expressions/dyn.py @@ -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 diff --git a/expressions/pitd.py b/expressions/pitd.py index 5ae40dd..81ef51a 100644 --- a/expressions/pitd.py +++ b/expressions/pitd.py @@ -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 ) diff --git a/expressions/tenc.py b/expressions/tenc.py index 9078a49..d9a0ac9 100644 --- a/expressions/tenc.py +++ b/expressions/tenc.py @@ -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 diff --git a/expressive.py b/expressive.py index 2da1846..1baa320 100644 --- a/expressive.py +++ b/expressive.py @@ -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, _ @@ -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"] diff --git a/expressive_gui.py b/expressive_gui.py index 3d8a392..e635413 100644 --- a/expressive_gui.py +++ b/expressive_gui.py @@ -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") diff --git a/locales/app.pot b/locales/app.pot index 12d076e..253509e 100644 --- a/locales/app.pot +++ b/locales/app.pot @@ -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 @@ -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 "" diff --git a/locales/en/LC_MESSAGES/app.po b/locales/en/LC_MESSAGES/app.po index 2621c73..c268610 100644 --- a/locales/en/LC_MESSAGES/app.po +++ b/locales/en/LC_MESSAGES/app.po @@ -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 "" @@ -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" diff --git a/locales/zh_CN/LC_MESSAGES/app.po b/locales/zh_CN/LC_MESSAGES/app.po index d9ba0be..be89681 100644 --- a/locales/zh_CN/LC_MESSAGES/app.po +++ b/locales/zh_CN/LC_MESSAGES/app.po @@ -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 "" @@ -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 "缩放" diff --git a/tests/test_ustx.py b/tests/test_ustx.py index c38d44d..70c561c 100644 --- a/tests/test_ustx.py +++ b/tests/test_ustx.py @@ -25,7 +25,6 @@ TimeAxis, UstxEditor, RESOLUTION, - SUPPORTED_EXPRESSIONS, ) @@ -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 @@ -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: diff --git a/utils/seqtool.py b/utils/seqtool.py index 3c975a5..5ca4e64 100644 --- a/utils/seqtool.py +++ b/utils/seqtool.py @@ -122,7 +122,7 @@ 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) @@ -130,7 +130,7 @@ def unify_sequence_time(seq_times, seq_vals, to_ticks=False): 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) @@ -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) diff --git a/utils/ui.py b/utils/ui.py index 9858a6e..9ab71dc 100644 --- a/utils/ui.py +++ b/utils/ui.py @@ -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, @@ -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'); @@ -537,6 +574,9 @@ def serve_wav(wav_path: str) -> str: Each unique directory is registered once under /wav/. 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] diff --git a/utils/ustx.py b/utils/ustx.py index 0139692..a555c9a 100644 --- a/utils/ustx.py +++ b/utils/ustx.py @@ -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 @@ -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() @@ -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) @@ -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) @@ -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, + )