diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 1757f02..bd5332a 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -62,6 +62,7 @@ jobs: --splash assets/splash/big.png \ --collect-data crepe \ --collect-data nicegui \ + --collect-data swift_f0 \ --add-data "examples;examples/" \ --add-data "assets;assets/" \ --add-data "locales;locales/" \ diff --git a/README.en.md b/README.en.md index 2a2b8cd..109e3c9 100644 --- a/README.en.md +++ b/README.en.md @@ -27,33 +27,31 @@ The current version supports importing the following expression parameters: ## ✅ Supported Platforms * Windows / Linux -* OpenUtau Beta (supports DiffSinger) +* OpenUtau Beta (or other versions with DiffSinger support) * Python 3.10 \* -If your system has an NVIDIA GPU driver that supports [CUDA 11.x](https://docs.nvidia.com/deploy/cuda-compatibility/minor-version-compatibility.html) or later (i.e., driver version >= 450), this application will automatically enable GPU acceleration to improve processing speed. +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. -
- Click to expand platform details +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. -* \* This project uses [CREPE](https://github.com/marl/crepe) \*\* as the pitch extractor, which depends on TensorFlow. 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. -* \*\* In the future, it may switch to a PyTorch-based alternative like [PESTO](https://github.com/SonyCSLParis/pesto) to improve compatibility. - -
+> \* 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. ## 📌 Use Case -**Typical Need:** When using a DiffSinger virtual singer for covers, users often already have the pitch track with lyrics, but lack suitable emotional expression. This tool is designed to automatically generate and import those expression parameters. +### Need + +When using a DiffSinger virtual singer for covers, users often already have an OpenUtau project with lyrics and pitch track but without expression parameters. This tool extracts expression parameters from a reference vocal and imports them into the OpenUtau project. -**Required Inputs:** +### Inputs -* Virtual 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. -* Input project: original OpenUtau project file (`.ustx` format). -* Output project path: where the new processed project will be saved. +* **Virtual 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. +* **Input project**: Original OpenUtau project file (USTX format). +* **Output path**: Where the new processed project file will be saved. -**Output:** +### Output -* A new `.ustx` file with emotion parameters added. The original project will not be modified. +A new USTX file with expression parameters added. The original project will not be modified. ## ✨ Features @@ -73,8 +71,17 @@ If your system has an NVIDIA GPU driver that supports [CUDA 11.x](https://docs.n You can download pre-compiled executable files directly from the [Releases](https://github.com/NewComer00/expressive/releases) page: -- `Expressive-GUI--Windows-x64-CPU.exe`: GUI installer for Windows x64, CPU-only (no CUDA dependencies) -- `Expressive-GUI--Windows-x64-GPU.exe`: GUI installer for Windows x64 with NVIDIA GPU acceleration (requires driver >= 450, CUDA 11.x) +### `Expressive-GUI--Windows-x64-CPU.exe` + +GUI installer for Windows x64 architecture. + +CPU-only, no CUDA runtime libraries included. Small installation size, but slower when using the CREPE backend for pitch extraction. + +### `Expressive-GUI--Windows-x64-GPU.exe` + +GUI installer for Windows x64 architecture with GPU support. + +Includes CUDA runtime libraries. When used on a computer with an NVIDIA GPU (driver version >= 450), it significantly improves CREPE backend inference speed. ## 👨‍💻 Install from Source diff --git a/README.md b/README.md index c2e120d..5b98713 100644 --- a/README.md +++ b/README.md @@ -27,34 +27,31 @@ ## ✅ 支持平台 * Windows / Linux -* OpenUtau Beta(支持 DiffSinger) +* OpenUtau Beta(或支持 DiffSinger 的其他版本) * Python 3.10 \* -若您的系统中安装了 NVIDIA 显卡驱动,且驱动支持 [CUDA 11.x](https://docs.nvidia.com/deploy/cuda-compatibility/minor-version-compatibility.html)(即:驱动版本 >= 450),本应用会自动启用 GPU 加速以提升处理速度。 +本应用默认选择 [swift-f0](https://github.com/lars76/swift-f0)(基于 ONNX Runtime)作为音高提取后端,仅需 CPU 即可运行,可满足基础使用场景。 +也提供了经典的 [CREPE](https://github.com/marl/crepe)(依赖 TensorFlow)音高提取后端,适合更高要求的使用场景。若您的电脑配有 NVIDIA 显卡且支持 [CUDA 11.x](https://docs.nvidia.com/deploy/cuda-compatibility/minor-version-compatibility.html)(即显卡驱动版本 >= 450),使用 CREPE 后端时会自动启用 GPU 加速。 -
- 点击展开更多平台说明 - -* \* 本项目使用 [CREPE](https://github.com/marl/crepe) \*\* 作为音高提取器,依赖于 TensorFlow 框架。在 Windows 平台下,TensorFlow 2.10 是最后一个支持 GPU 加速的版本,Python 3.10 是它的 `.whl` 文件支持的最高 Python 版本。 -* \*\* 未来可能切换至基于 PyTorch 的 [PESTO](https://github.com/SonyCSLParis/pesto) 替代 CREPE,以提高兼容性。 - -
+> \* 在 Windows 平台下,TensorFlow 2.10 是最后一个支持 GPU 加速的版本,Python 3.10 是它的 `.whl` 文件支持的最高 Python 版本。 ## 📌 使用场景 -**典型需求**:在使用 DiffSinger 虚拟歌手翻唱时,已经完成了填好词的无参 OpenUtau 工程,但尚未添加表情参数。本应用可以从参考人声音频中提取表情参数,并导入至 OpenUtau 工程中。 +### 需求 + +在使用 DiffSinger 虚拟歌手翻唱时,已经完成了填好词的无参 OpenUtau 工程,但尚未添加表情参数。本应用可以从参考人声音频中提取表情参数,并导入至 OpenUtau 工程中。 -**所需输入:** +### 输入 -* 歌姬音声:由 OpenUtau 输出的无表情虚拟歌声音频(WAV 格式)。建议节奏 (`Tempo`) 和分段尽量与参考人声一致。 -* 参考人声:原始人声录音(WAV 格式),可使用 [UVR](https://github.com/Anjok07/ultimatevocalremovergui) 等工具去除伴奏与混响。 -* 输入工程:原始 OpenUtau 工程文件(USTX 格式)。 -* 输出工程路径:处理完成后新工程文件的保存位置。 +* **歌姬音声**:由 OpenUtau 输出的无表情虚拟歌声音频(WAV 格式)。建议节奏 (`Tempo`) 和分段尽量与参考人声一致。 +* **参考人声**:原始人声录音(WAV 格式),可使用 [UVR](https://github.com/Anjok07/ultimatevocalremovergui) 等工具去除伴奏与混响。 +* **输入工程**:原始 OpenUtau 工程文件(USTX 格式)。 +* **输出路径**:处理完成后新工程文件的保存位置。 -**输出结果:** +### 输出 -* 一个携带表情参数的新 USTX 文件,原始工程不会被修改。 +一个携带表情参数的新 USTX 文件。原始工程不会被修改。 ## ✨ 功能特性 @@ -74,8 +71,15 @@ 您可以直接在 [Releases](https://github.com/NewComer00/expressive/releases) 页面下载预编译的可执行文件: -- `Expressive-GUI--Windows-x64-CPU.exe`: 适用于 x64 架构 Windows 的图形用户界面安装包(仅 CPU,无 CUDA 依赖) -- `Expressive-GUI--Windows-x64-GPU.exe`: 适用于 x64 架构 Windows 的图形用户界面安装包(含 NVIDIA GPU 加速,需驱动版本 >= 450,支持 CUDA 11.x) +### `Expressive-GUI--Windows-x64-CPU.exe` +适用于 x64 架构 Windows 的图形用户界面安装包。 + +仅可使用 CPU,无 CUDA 运行时库。安装体积小,但选择 CREPE 后端提取音高时速度较慢。 + +### `Expressive-GUI--Windows-x64-GPU.exe` +带 GPU 支持的适用于 x64 架构 Windows 的图形用户界面安装包。 + +含 CUDA 运行时库。在配备 NVIDIA 显卡(驱动版本 >= 450)的电脑上使用时,会大幅提高 CREPE 后端的推理速度。 ## 👨‍💻 源码安装 diff --git a/build/auto-py-to-exe.json b/build/auto-py-to-exe.json index b1284d9..02328a5 100644 --- a/build/auto-py-to-exe.json +++ b/build/auto-py-to-exe.json @@ -73,6 +73,10 @@ "optionDest": "collect_data", "value": "nicegui" }, + { + "optionDest": "collect_data", + "value": "swift_f0" + }, { "optionDest": "datas", "value": "examples;examples/" @@ -102,4 +106,4 @@ "increaseRecursionLimit": false, "manualArguments": "" } -} \ No newline at end of file +} diff --git a/expressions/base.py b/expressions/base.py index b5cc35f..cf824d0 100644 --- a/expressions/base.py +++ b/expressions/base.py @@ -16,6 +16,7 @@ class Args: type: type default: Any | None help: str + choices: list | None = None class ExpressionLoader(): @@ -24,10 +25,10 @@ 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")), - utau_path = Args(name="utau_path" , type=str, default="", help=_l("Path to the UTAU audio file")), - 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")), + 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 ) @classmethod diff --git a/expressions/dyn.py b/expressions/dyn.py index 64cfd96..2a7a3e9 100644 --- a/expressions/dyn.py +++ b/expressions/dyn.py @@ -18,9 +18,9 @@ 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 algorithm; larger radius allows for more flexible alignment but increases computation time")), # noqa: E501 - smoothness = Args(name="smoothness" , type=int , default=2 , help=_l("Smoothness of the expression curve")), - scaler = Args(name="scaler" , type=float, default=1.5, help=_l("Scaling factor for the expression curve")), # 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( diff --git a/expressions/pitd.py b/expressions/pitd.py index fae6aeb..596c1a9 100644 --- a/expressions/pitd.py +++ b/expressions/pitd.py @@ -3,7 +3,6 @@ from pathlib import Path from types import SimpleNamespace -import crepe import librosa import numpy as np from scipy.io import wavfile @@ -12,7 +11,7 @@ from librosa import hz_to_midi from .base import Args, ExpressionLoader, register_expression -from utils.i18n import _, _l +from utils.i18n import _, _l, _lf from utils.seqtool import ( unify_sequence_time, align_sequence_tick, @@ -27,17 +26,25 @@ class PitdLoader(ExpressionLoader): expression_name = "pitd" expression_info = _l("Pitch Deviation (curve)") + backend_choices = { + "swift-f0": _l("fast, CPU-based (ONNX Runtime)"), + "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} args = SimpleNamespace( - confidence_utau = Args(name="confidence_utau", type=float, default=0.8 , help=_l("Confidence threshold for filtering uncertain pitch values in UTAU WAV")), # noqa: E501 - confidence_ref = Args(name="confidence_ref" , type=float, default=0.6 , help=_l("Confidence threshold for filtering uncertain pitch values in reference WAV")), # noqa: E501 - align_radius = Args(name="align_radius" , type=int , default=1 , help=_l("Radius for the FastDTW algorithm; larger radius allows for more flexible alignment but increases computation time")), # noqa: E501 - semitone_shift = Args(name="semitone_shift" , type=int , default=None, help=_l("Semitone shift between the UTAU and reference WAV; if the USTX WAV is an octave higher than the reference WAV, set to 12, otherwise -12; leave it empty to enable automatic shift estimation")), # noqa: E501 - smoothness = Args(name="smoothness" , type=int , default=2 , help=_l("Smoothness of the expression curve")), # noqa: E501 - scaler = Args(name="scaler" , type=float, default=2.0 , help=_l("Scaling factor for the expression curve")), # noqa: E501 + 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 + confidence_ref = Args(name="confidence_ref" , type=float, default=None, help=_lf("Minimum **confidence level** for keeping detected pitch values in the **reference** 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_ref_recommended.items()]))), # 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 + semitone_shift = Args(name="semitone_shift" , type=int , default=None, help=_l("**Semitone shift** between the UTAU and reference WAV. If the UTAU WAV is an octave higher than the reference WAV, set to 12; if lower, set to -12. Omit to enable automatic shift estimation")), # 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=2.0 , 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, + backend = args.backend .default, confidence_utau = args.confidence_utau.default, confidence_ref = args.confidence_ref .default, align_radius = args.align_radius .default, @@ -47,16 +54,22 @@ def get_expression( ): self.logger.info(_("Extracting expression...")) + # Resolve per-backend confidence defaults + if confidence_utau is None: + confidence_utau = self.__class__.confidence_utau_recommended[backend] + if confidence_ref is None: + confidence_ref = self.__class__.confidence_ref_recommended[backend] + # Extract pitch features from WAV files with StreamToLogger(self.logger, tee=True): utau_time, utau_pitch, utau_features = get_wav_features( - wav_path=self.utau_path, confidence_threshold=confidence_utau + 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 + wav_path=self.ref_path, confidence_threshold=confidence_ref, backend=backend ) # Align all sequences to a common MIDI tick time base @@ -127,13 +140,14 @@ def extract_wav_mfcc(wav_path, n_feat=6, n_mfcc=13): # TODO: Deal with different tempo or ppqn within the same USTX file -def get_wav_features(wav_path, confidence_threshold=0.8, confidence_filter_size=9): +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". confidence_threshold (float, optional): Confidence threshold for pitch detection. Defaults to 0.8. confidence_filter_size (int, optional): Size of the median filter for confidence. Defaults to 9. @@ -147,7 +161,7 @@ def get_wav_features(wav_path, confidence_threshold=0.8, confidence_filter_size= feature_vals = [] # List of feature sequences(list of lists) # Extract features from WAV file - time, frequency, confidence = extract_wav_frequency(wav_path, True) + time, frequency, confidence = extract_wav_frequency(wav_path, backend=backend) mask = ( medfilt(np.array(confidence), kernel_size=confidence_filter_size) < confidence_threshold @@ -224,15 +238,19 @@ def get_pitch_delta(query, reference, scaler=2.5): return scaler * (query - reference) -def extract_wav_frequency(file_path, use_cache=True): - """Extract pitch frequency from a WAV file using Crepe. +def extract_wav_frequency(file_path, backend="swift-f0", use_cache=True): + """Extract pitch frequency from a WAV file. - This function processes an audio file to extract pitch information using the - CREPE algorithm. It supports caching to improve performance when processing + This function processes an audio file to extract pitch information. + It supports caching to improve performance when processing the same file multiple times. Args: file_path (str): Path to the WAV file. + backend (str, optional): Pitch detection backend. One of "crepe" or "swift-f0". + "crepe" uses the CREPE model (requires TensorFlow, GPU-accelerated). + "swift-f0" uses SwiftF0 (faster CPU inference, requires swift-f0 package). + Defaults to "swift-f0". use_cache (bool, optional): Whether to use cached data if available. Defaults to True. Returns: @@ -241,6 +259,10 @@ def extract_wav_frequency(file_path, use_cache=True): - 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). """ + _SUPPORTED_BACKENDS = ("crepe", "swift-f0") + if backend not in _SUPPORTED_BACKENDS: + raise ValueError(f"Unknown backend '{backend}'. Choose from: {_SUPPORTED_BACKENDS}") + time = [] frequency = [] confidence = [] @@ -250,7 +272,7 @@ def extract_wav_frequency(file_path, use_cache=True): os.makedirs(cache_dir, exist_ok=True) wav_hash = calculate_file_hash(file_path) - cache_path = cache_dir / f"{wav_hash}.csv" + cache_path = cache_dir / f"{wav_hash}.{backend}.csv" if cache_path.is_file(): print(_("Loading F0 data from cache file: '{}'").format(cache_path)) with open(cache_path, "r", newline="") as file: @@ -263,8 +285,20 @@ def extract_wav_frequency(file_path, use_cache=True): # If cache is unavailable if not all([time, frequency, confidence]): - sr, audio = wavfile.read(file_path) - time, frequency, confidence, _unused = crepe.predict(audio, sr, viterbi=True) + # Extract pitch using the specified backend + if backend == "crepe": + import crepe + from utils.gpu import add_cuda_to_path + add_cuda_to_path(skip_missing=True) + sr, audio = wavfile.read(file_path) + time, frequency, confidence, _unused = crepe.predict(audio, sr, viterbi=True) + elif backend == "swift-f0": + from swift_f0 import SwiftF0 + detector = SwiftF0(confidence_threshold=0.0) + result = detector.detect_from_file(file_path) + time = result.timestamps.tolist() + frequency = result.pitch_hz.tolist() + confidence = result.confidence.tolist() # Save data to cache if use_cache: diff --git a/expressions/tenc.py b/expressions/tenc.py index b93bee2..5078223 100644 --- a/expressions/tenc.py +++ b/expressions/tenc.py @@ -18,10 +18,10 @@ class TencLoader(ExpressionLoader): expression_name = "tenc" expression_info = _l("Tension (curve)") args = SimpleNamespace( - align_radius = Args(name="align_radius", type=int , default=1 , help=_l("Radius for the FastDTW algorithm; larger radius allows for more flexible alignment but increases computation time")), # noqa: E501 - smoothness = Args(name="smoothness" , type=int , default=6 , help=_l("Smoothness of the expression curve")), - scaler = Args(name="scaler" , type=float, default=1.0, help=_l("Scaling factor for the expression curve")), # noqa: E501 - bias = Args(name="bias" , type=int , default=10 , help=_l("Bias for the expression curve")), + 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 + bias = Args(name="bias" , type=int , default=10 , help=_l("**Bias** offset added to the expression curve. Positive values shift the curve upward; negative values shift it downward")), # noqa: E501 ) def get_expression( diff --git a/expressive.py b/expressive.py index cc175a7..7ee1b3e 100644 --- a/expressive.py +++ b/expressive.py @@ -7,7 +7,7 @@ from os.path import splitext, basename from __version__ import VERSION -from utils.gpu import add_cuda_to_path +from utils.cli import ArgumentDefaultsWrappedTextRichHelpFormatter from expressions.base import getExpressionLoader, get_registered_expressions @@ -76,7 +76,6 @@ def process_expressions( loader.load_to_ustx(track_number) - @contextmanager def setup_loggers(): log_file = tempfile.NamedTemporaryFile( @@ -122,7 +121,7 @@ def setup_loggers(): def main(): parser = argparse.ArgumentParser( description="Migrate expressions from real singers to DiffSingers (CLI)", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, + formatter_class=ArgumentDefaultsWrappedTextRichHelpFormatter, ) # General arguments @@ -130,11 +129,11 @@ def main(): parser.add_argument("-u", "--utau_wav", type=general_args.utau_path.type, required=True, help=general_args.utau_path.help) # noqa: E501 parser.add_argument("-r", "--ref_wav", type=general_args.ref_path.type, required=True, help=general_args.ref_path.help) # noqa: E501 parser.add_argument("-i", "--ustx_input", type=general_args.ustx_path.type, required=True, help=general_args.ustx_path.help) # noqa: E501 - parser.add_argument("-o", "--ustx_output", type=str, required=True, help="Path to save the processed USTX file") # noqa: E501 + parser.add_argument("-o", "--ustx_output", type=str, required=True, help="Path to save the processed `.ustx` file") # noqa: E501 parser.add_argument("-t", "--track_number", type=general_args.track_number.type, required=True, help=general_args.track_number.help) # noqa: E501 parser.add_argument("-e", "--expression", type=str, action="append", required=True, choices=get_registered_expressions(), - help="Specify expressions to apply (e.g., --expression dyn --expression pitd)") + help="**Expression(s)** to apply. Repeat the flag for multiple expressions (e.g., `-e dyn -e pitd`)") parser.add_argument("--version", action="version", version=f"%(prog)s v{VERSION}") # Expression-specific arguments @@ -142,10 +141,12 @@ def main(): get_expression_args = lambda exp_name: getExpressionLoader(exp_name).get_args_dict() for exp_name in expression_names: - group = parser.add_argument_group(f"{exp_name.upper()} Expression") + exp_info = getExpressionLoader(exp_name).expression_info + group = parser.add_argument_group(f"[{exp_name.upper()}] {exp_info} Expression") for arg_name, arg in get_expression_args(exp_name).items(): group.add_argument(f"--{exp_name}.{arg_name}", - type=arg.type, default=arg.default, help=arg.help) + type=arg.type, default=arg.default, help=arg.help, + choices=arg.choices) # Parse arguments args = parser.parse_args() @@ -163,7 +164,6 @@ def main(): with setup_loggers() as (logger_app, _, _): logger_app.info("Starting Expressive CLI...") try: - add_cuda_to_path(skip_missing=True) process_expressions( args.utau_wav, args.ref_wav, args.ustx_input, args.ustx_output, args.track_number, expressions diff --git a/expressive_gui.py b/expressive_gui.py index 551c4eb..5e4ae64 100644 --- a/expressive_gui.py +++ b/expressive_gui.py @@ -17,11 +17,14 @@ NiceguiNativeDropArea, webview_active_window, ) +from utils.monkeypatch import ( + patch_runpy, + patch_tooltip_md, + patch_nicegui_json, +) from __version__ import VERSION -from utils.gpu import add_cuda_to_path -from utils.monkeypatch import patch_runpy +from utils.i18n import _, init_gettext from expressive import process_expressions -from utils.i18n import _, init_gettext, patch_nicegui_json from expressions.base import getExpressionLoader, get_registered_expressions @@ -393,7 +396,7 @@ def configure_logger(name: str, formatter: logging.Formatter): file_inputs["ustx_output"] = ( ui.input( label=_("Output USTX File"), - placeholder=_("Path to save processed USTX file"), + placeholder=_("Path to save the processed `.ustx` file"), validation={_("Input required"): lambda v: bool(v)}, ) .bind_value(state, "ustx_output") @@ -409,7 +412,7 @@ def configure_logger(name: str, formatter: logging.Formatter): ui.number(label=_("Track Number"), min=1, format="%d").bind_value( state, "track_number", forward=lambda v: general_args.track_number.type(v) if v is not None else None, - ).classes("w-full").tooltip(general_args.track_number.help) + ).classes("w-full").tooltip_md(general_args.track_number.help) # Expression selection with ui.card().classes("w-full"): @@ -434,17 +437,17 @@ def configure_logger(name: str, formatter: logging.Formatter): ui.number(label=_("Align Radius"), min=1, format="%d").bind_value( state["expressions"]["dyn"], "align_radius", forward=lambda v: dyn_args.align_radius.type(v) if v is not None else None, - ).tooltip(dyn_args.align_radius.help) + ).tooltip_md(dyn_args.align_radius.help) ui.number(label=_("Smoothness"), min=0, format="%d").bind_value( state["expressions"]["dyn"], "smoothness", forward=lambda v: dyn_args.smoothness.type(v) if v is not None else None, - ).tooltip(dyn_args.smoothness.help) + ).tooltip_md(dyn_args.smoothness.help) ui.number(label=_("Scaler"), min=0.0, step=0.1, format="%.1f").bind_value( state["expressions"]["dyn"], "scaler", forward=lambda v: dyn_args.scaler.type(v) if v is not None else None, - ).tooltip(dyn_args.scaler.help) + ).tooltip_md(dyn_args.scaler.help) # Pitd parameters pitd_args = getExpressionLoader("pitd").args @@ -455,37 +458,59 @@ def configure_logger(name: str, formatter: logging.Formatter): ui.label(pitd_info).classes("text-lg font-bold") with ui.grid(columns=3).classes("w-full"): - ui.number( - label=_("UTAU Confidence"), min=0.0, max=1.0, step=0.1, format="%.1f" + def on_backend_change(e): + nonlocal ui_confidence_utau, ui_confidence_ref + backend = e.value + # Update the confidence input placeholders based on the selected backend's recommended values + # TODO: _props is an internal API of NiceGUI, may need to be updated if NiceGUI changes its implementation + ui_confidence_utau._props.set_optional( + 'placeholder', + getExpressionLoader("pitd").confidence_utau_recommended[backend] + ) + ui_confidence_utau.update() + ui_confidence_ref._props.set_optional( + 'placeholder', + getExpressionLoader("pitd").confidence_ref_recommended[backend] + ) + ui_confidence_ref.update() + + ui_confidence_utau = ui.number( + label=_("UTAU Confidence"), min=0.0, max=1.0, step=0.01, format="%.2f" ).bind_value(state["expressions"]["pitd"], "confidence_utau", forward=lambda v: pitd_args.confidence_utau.type(v) if v is not None else None, - ).tooltip(pitd_args.confidence_utau.help) + ).tooltip_md(pitd_args.confidence_utau.help) - ui.number( - label=_("Reference Confidence"), min=0.0, max=1.0, step=0.1, format="%.1f" + ui_confidence_ref = ui.number( + label=_("Reference Confidence"), min=0.0, max=1.0, step=0.01, format="%.2f" ).bind_value(state["expressions"]["pitd"], "confidence_ref", forward=lambda v: pitd_args.confidence_ref.type(v) if v is not None else None, - ).tooltip(pitd_args.confidence_ref.help) + ).tooltip_md(pitd_args.confidence_ref.help) + + ui.select( + label=_("Backend"), options=pitd_args.backend.choices, + on_change=on_backend_change, + ).bind_value(state["expressions"]["pitd"], "backend").tooltip_md(pitd_args.backend.help) ui.number(label=_("Align Radius"), min=1, format="%d").bind_value( state["expressions"]["pitd"], "align_radius", forward=lambda v: pitd_args.align_radius.type(v) if v is not None else None, - ).tooltip(pitd_args.align_radius.help) + ).tooltip_md(pitd_args.align_radius.help) - ui.number(label=_("Semitone Shift"), step=1, format="%d").bind_value( + ui.number(label=_("Semitone Shift"), step=1, format="%d", + placeholder=_("Auto Estimation")).bind_value( state["expressions"]["pitd"], "semitone_shift", forward=lambda v: pitd_args.semitone_shift.type(v) if v is not None else None, - ).tooltip(pitd_args.semitone_shift.help) + ).tooltip_md(pitd_args.semitone_shift.help) ui.number(label=_("Smoothness"), min=0, format="%d").bind_value( state["expressions"]["pitd"], "smoothness", forward=lambda v: pitd_args.smoothness.type(v) if v is not None else None, - ).tooltip(pitd_args.smoothness.help) + ).tooltip_md(pitd_args.smoothness.help) ui.number(label=_("Scaler"), min=0.0, step=0.1, format="%.1f").bind_value( state["expressions"]["pitd"], "scaler", forward=lambda v: pitd_args.scaler.type(v) if v is not None else None, - ).tooltip(pitd_args.scaler.help) + ).tooltip_md(pitd_args.scaler.help) # Tenc parameters tenc_args = getExpressionLoader("tenc").args @@ -499,22 +524,22 @@ def configure_logger(name: str, formatter: logging.Formatter): ui.number(label=_("Align Radius"), min=1, format="%d").bind_value( state["expressions"]["tenc"], "align_radius", forward=lambda v: tenc_args.align_radius.type(v) if v is not None else None, - ).tooltip(tenc_args.align_radius.help) + ).tooltip_md(tenc_args.align_radius.help) ui.number(label=_("Smoothness"), min=0, format="%d").bind_value( state["expressions"]["tenc"], "smoothness", forward=lambda v: tenc_args.smoothness.type(v) if v is not None else None, - ).tooltip(tenc_args.smoothness.help) + ).tooltip_md(tenc_args.smoothness.help) ui.number(label=_("Scaler"), min=0.0, step=0.1, format="%.1f").bind_value( state["expressions"]["tenc"], "scaler", forward=lambda v: tenc_args.scaler.type(v) if v is not None else None, - ).tooltip(tenc_args.scaler.help) + ).tooltip_md(tenc_args.scaler.help) ui.number(label=_("Bias"), format="%d").bind_value( state["expressions"]["tenc"], "bias", forward=lambda v: tenc_args.bias.type(v) if v is not None else None, - ).tooltip(tenc_args.bias.help) + ).tooltip_md(tenc_args.bias.help) # Add the config buttons above the Process button with ui.row().classes("w-full justify-between"): @@ -586,10 +611,9 @@ def main(): args, unknown = parser.parse_known_args() init_gettext(args.lang, os.path.join(os.path.dirname(__file__), 'locales'), "app") - add_cuda_to_path(skip_missing=True) - # Patch NiceGUI's JSON serializer to handle LazyString patch_nicegui_json() + patch_tooltip_md() try: # Deal with different running mode of this nicegui app diff --git a/locales/app.pot b/locales/app.pot index d5d14c3..697bbb1 100644 --- a/locales/app.pot +++ b/locales/app.pot @@ -1,221 +1,270 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. +# Translations template for expressive. +# Copyright (C) 2025-2026 NewComer00 +# This file is distributed under the same license as the expressive project. +# NewComer00, 2025. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-05-06 21:48+0800\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" +"Project-Id-Version: expressive\n" +"Report-Msgid-Bugs-To: https://github.com/NewComer00/expressive/issues\n" +"POT-Creation-Date: 2026-03-01 19:30+0800\n" +"Last-Translator: NewComer00\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=CHARSET\n" +"Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" -#: expressions/base.py:27 -msgid "Path to the reference audio file" +#: expressive_gui.py:154 +msgid "Config exported successfully!" msgstr "" -#: expressions/base.py:28 -msgid "Path to the UTAU audio file" +#: expressive_gui.py:156 +msgid "Failed to export config" msgstr "" -#: expressions/base.py:29 -msgid "Path to the USTX project file to be processed" +#: expressive_gui.py:169 +msgid "Config imported successfully!" msgstr "" -#: expressions/base.py:30 -msgid "Track number to apply expressions" +#: expressive_gui.py:172 +msgid "Failed to import config" msgstr "" -#: expressions/base.py:50 -msgid "Initialization complete." +#: expressive_gui.py:207 expressive_gui.py:208 +msgid "Processing completed successfully!" msgstr "" -#: expressions/base.py:67 -msgid "Expression written to USTX file: '{}'" +#: expressive_gui.py:210 expressive_gui.py:211 +msgid "Error during processing" msgstr "" -#: expressions/base.py:69 -msgid "Expression result is empty. Skipping USTX update." +#: expressive_gui.py:273 +msgid "Please fill all required file paths" msgstr "" -#: expressions/dyn.py:19 -msgid "Dynamics (curve)" +#: expressive_gui.py:282 +msgid "Please select at least one expression to apply" msgstr "" -#: expressions/dyn.py:21 expressions/pitd.py:33 expressions/tenc.py:21 -msgid "" -"Radius for the FastDTW algorithm; larger radius allows for more flexible " -"alignment but increases computation time" +#: expressive_gui.py:337 +msgid "File Paths" msgstr "" -#: expressions/dyn.py:22 expressions/pitd.py:35 expressions/tenc.py:22 -msgid "Smoothness of the expression curve" +#: expressive_gui.py:353 +msgid "Reference WAV File" msgstr "" -#: expressions/dyn.py:23 expressions/pitd.py:36 expressions/tenc.py:23 -msgid "Scaling factor for the expression curve" +#: expressive_gui.py:355 expressive_gui.py:370 expressive_gui.py:385 +#: expressive_gui.py:400 +msgid "Input required" msgstr "" -#: expressions/dyn.py:32 expressions/pitd.py:48 expressions/tenc.py:34 -msgid "Extracting expression..." +#: expressive_gui.py:368 +msgid "UTAU WAV File" msgstr "" -#: expressions/dyn.py:56 expressions/pitd.py:92 expressions/tenc.py:58 -msgid "Expression extraction complete." +#: expressive_gui.py:383 +msgid "Input USTX File" msgstr "" -#: expressions/pitd.py:29 -msgid "Pitch Deviation (curve)" +#: expressive_gui.py:398 +msgid "Output USTX File" msgstr "" -#: expressions/pitd.py:31 -msgid "Confidence threshold for filtering uncertain pitch values in UTAU WAV" +#: expressive_gui.py:399 +msgid "Path to save the processed `.ustx` file" msgstr "" -#: expressions/pitd.py:32 -msgid "" -"Confidence threshold for filtering uncertain pitch values in reference WAV" +#: expressive_gui.py:412 +msgid "Track Number" msgstr "" -#: expressions/pitd.py:34 -msgid "" -"Semitone shift between the UTAU and reference WAV; if the USTX WAV is an " -"octave higher than the reference WAV, set to 12, otherwise -12; leave it " -"empty to enable automatic shift estimation" +#: expressive_gui.py:419 +msgid "Expression Selection" msgstr "" -#: expressions/pitd.py:200 -msgid "Estimated Semitone-shift: {}" +#: expressive_gui.py:437 expressive_gui.py:485 expressive_gui.py:514 +msgid "Align Radius" msgstr "" -#: expressions/pitd.py:255 -msgid "Loading F0 data from cache file: '{}'" +#: expressive_gui.py:442 expressive_gui.py:495 expressive_gui.py:519 +msgid "Smoothness" msgstr "" -#: expressions/pitd.py:276 -msgid "F0 data saved to cache file: '{}'" +#: expressive_gui.py:447 expressive_gui.py:500 expressive_gui.py:524 +msgid "Scaler" msgstr "" -#: expressions/tenc.py:19 -msgid "Tension (curve)" +#: expressive_gui.py:469 +msgid "Backend" msgstr "" -#: expressions/tenc.py:24 -msgid "Bias for the expression curve" +#: expressive_gui.py:474 +msgid "UTAU Confidence" msgstr "" -#: expressive-gui.py:128 -msgid "Config exported successfully!" +#: expressive_gui.py:480 +msgid "Reference Confidence" msgstr "" -#: expressive-gui.py:130 -msgid "Failed to export config" +#: expressive_gui.py:490 +msgid "Semitone Shift" msgstr "" -#: expressive-gui.py:143 -msgid "Config imported successfully!" +#: expressive_gui.py:500 +msgid "Auto Estimation" msgstr "" -#: expressive-gui.py:146 -msgid "Failed to import config" +#: expressive_gui.py:529 +msgid "Bias" msgstr "" -#: expressive-gui.py:181 expressive-gui.py:182 -msgid "Processing completed successfully!" +#: expressive_gui.py:537 +msgid "Import Config" msgstr "" -#: expressive-gui.py:184 expressive-gui.py:185 -msgid "Error during processing" +#: expressive_gui.py:543 +msgid "Export Config" msgstr "" -#: expressive-gui.py:217 -msgid "Please fill all required file paths" +#: expressive_gui.py:554 +msgid "Process" msgstr "" -#: expressive-gui.py:226 -msgid "Please select at least one expression to apply" +#: expressions/base.py:28 +msgid "Path to the **reference** audio file" msgstr "" -#: expressive-gui.py:262 -msgid "File Paths" +#: expressions/base.py:29 +msgid "Path to the **UTAU** audio file" msgstr "" -#: expressive-gui.py:267 -msgid "Reference WAV File" +#: expressions/base.py:30 +msgid "Path to the `.ustx` project file to be processed" msgstr "" -#: expressive-gui.py:269 expressive-gui.py:284 expressive-gui.py:299 -#: expressive-gui.py:314 -msgid "Input required" +#: expressions/base.py:31 +msgid "**Track number** to apply expressions to (1-based index)" msgstr "" -#: expressive-gui.py:282 -msgid "UTAU WAV File" +#: expressions/base.py:51 +msgid "Initialization complete." msgstr "" -#: expressive-gui.py:297 -msgid "Input USTX File" +#: expressions/base.py:68 +#, python-brace-format +msgid "Expression written to USTX file: '{}'" msgstr "" -#: expressive-gui.py:312 -msgid "Output USTX File" +#: expressions/base.py:70 +msgid "Expression result is empty. Skipping USTX update." msgstr "" -#: expressive-gui.py:313 -msgid "Path to save processed USTX file" +#: expressions/dyn.py:19 +msgid "Dynamics (curve)" msgstr "" -#: expressive-gui.py:326 -msgid "Track Number" +#: expressions/dyn.py:21 expressions/pitd.py:39 expressions/tenc.py:21 +msgid "" +"**Radius** for the FastDTW alignment algorithm; larger values allow more " +"flexible alignment but increase computation time" msgstr "" -#: expressive-gui.py:333 -msgid "Expression Selection" +#: expressions/dyn.py:22 expressions/pitd.py:41 expressions/tenc.py:22 +msgid "" +"Controls the **smoothness** of the expression curve using Gaussian " +"filtering. Higher values produce smoother curves but may lose fine detail" msgstr "" -#: expressive-gui.py:351 expressive-gui.py:387 expressive-gui.py:416 -msgid "Align Radius" +#: expressions/dyn.py:23 expressions/pitd.py:42 expressions/tenc.py:23 +msgid "" +"**Scaling factor** applied to the expression curve. Values >1 amplify the" +" expression, =1 keeps original intensity, <1 reduces it" msgstr "" -#: expressive-gui.py:356 expressive-gui.py:397 expressive-gui.py:421 -msgid "Smoothness" +#: expressions/dyn.py:32 expressions/pitd.py:55 expressions/tenc.py:34 +msgid "Extracting expression..." msgstr "" -#: expressive-gui.py:361 expressive-gui.py:402 expressive-gui.py:426 -msgid "Scaler" +#: expressions/dyn.py:56 expressions/pitd.py:105 expressions/tenc.py:58 +msgid "Expression extraction complete." msgstr "" -#: expressive-gui.py:376 -msgid "UTAU Confidence" +#: expressions/pitd.py:28 +msgid "Pitch Deviation (curve)" msgstr "" -#: expressive-gui.py:382 -msgid "Reference Confidence" +#: expressions/pitd.py:30 +msgid "fast, CPU-based (ONNX Runtime)" msgstr "" -#: expressive-gui.py:392 -msgid "Semitone Shift" +#: expressions/pitd.py:31 +msgid "classic but slow, CPU & NVIDIA GPU (TensorFlow)" msgstr "" -#: expressive-gui.py:431 -msgid "Bias" +#: expressions/pitd.py:36 +#, python-format +msgid "" +"**F0 detection backend** for extracting pitch from WAV files. Available " +"options:\n" +"\n" +"%s\n" +"\n" msgstr "" -#: expressive-gui.py:439 -msgid "Import Config" +#: expressions/pitd.py:37 +#, python-format +msgid "" +"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" msgstr "" -#: expressive-gui.py:445 -msgid "Export Config" +#: expressions/pitd.py:38 +#, python-format +msgid "" +"Minimum **confidence level** for keeping detected pitch values in the " +"**reference** 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" msgstr "" -#: expressive-gui.py:456 -msgid "Process" +#: expressions/pitd.py:40 +msgid "" +"**Semitone shift** between the UTAU and reference WAV. If the UTAU WAV is" +" an octave higher than the reference WAV, set to 12; if lower, set to " +"-12. Omit to enable automatic shift estimation" msgstr "" + +#: expressions/pitd.py:214 +#, python-brace-format +msgid "Estimated Semitone-shift: {}" +msgstr "" + +#: expressions/pitd.py:277 +#, python-brace-format +msgid "Loading F0 data from cache file: '{}'" +msgstr "" + +#: expressions/pitd.py:310 +#, python-brace-format +msgid "F0 data saved to cache file: '{}'" +msgstr "" + +#: expressions/tenc.py:19 +msgid "Tension (curve)" +msgstr "" + +#: expressions/tenc.py:24 +msgid "" +"**Bias** offset added to the expression curve. Positive values shift the " +"curve upward; negative values shift it downward" +msgstr "" + diff --git a/locales/en/LC_MESSAGES/app.po b/locales/en/LC_MESSAGES/app.po index 38d6060..8f0579d 100644 --- a/locales/en/LC_MESSAGES/app.po +++ b/locales/en/LC_MESSAGES/app.po @@ -1,228 +1,301 @@ -# English translations for Expressive package. -# Copyright (C) 2025 NewComer00 -# This file is distributed under the same license as the PitchLoader package. -# NewComer00, 2025. +# English translations for expressive. +# Copyright (C) 2025-2026 NewComer00 +# This file is distributed under the same license as the expressive project. +# NewComer00, 2025. # msgid "" msgstr "" -"Project-Id-Version: Expressive v0.2.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-05-06 21:48+0800\n" -"PO-Revision-Date: 2025-03-18 16:23+0800\n" -"Last-Translator: NewComer00\n" -"Language-Team: English\n" +"Project-Id-Version: expressive\n" +"Report-Msgid-Bugs-To: https://github.com/NewComer00/expressive/issues\n" +"POT-Creation-Date: 2026-03-01 19:30+0800\n" +"PO-Revision-Date: 2026-03-01 20:21+0800\n" +"Last-Translator: NewComer00\n" "Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" +"Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" - -#: expressions/base.py:27 -msgid "Path to the reference audio file" -msgstr "Path to the reference audio file" - -#: expressions/base.py:28 -msgid "Path to the UTAU audio file" -msgstr "Path to the UTAU audio file" - -#: expressions/base.py:29 -msgid "Path to the USTX project file to be processed" -msgstr "Path to the USTX project file to be processed" - -#: expressions/base.py:30 -msgid "Track number to apply expressions" -msgstr "Track number to apply expressions" - -#: expressions/base.py:50 -msgid "Initialization complete." -msgstr "Initialization complete." - -#: expressions/base.py:67 -msgid "Expression written to USTX file: '{}'" -msgstr "Expression written to USTX file: '{}'" - -#: expressions/base.py:69 -msgid "Expression result is empty. Skipping USTX update." -msgstr "Expression result is empty. Skipping USTX update." - -#: expressions/dyn.py:19 -msgid "Dynamics (curve)" -msgstr "Dynamics (curve)" - -#: expressions/dyn.py:21 expressions/pitd.py:33 expressions/tenc.py:21 -msgid "" -"Radius for the FastDTW algorithm; larger radius allows for more flexible " -"alignment but increases computation time" -msgstr "" -"Radius for the FastDTW algorithm; larger radius allows for more flexible " -"alignment but increases computation time" - -#: expressions/dyn.py:22 expressions/pitd.py:35 expressions/tenc.py:22 -msgid "Smoothness of the expression curve" -msgstr "Smoothness of the expression curve" - -#: expressions/dyn.py:23 expressions/pitd.py:36 expressions/tenc.py:23 -msgid "Scaling factor for the expression curve" -msgstr "Scaling factor for the expression curve" - -#: expressions/dyn.py:32 expressions/pitd.py:48 expressions/tenc.py:34 -msgid "Extracting expression..." -msgstr "Extracting expression..." - -#: expressions/dyn.py:56 expressions/pitd.py:92 expressions/tenc.py:58 -#, fuzzy -msgid "Expression extraction complete." -msgstr "Expression extraction complete." - -#: expressions/pitd.py:29 -msgid "Pitch Deviation (curve)" -msgstr "Pitch Deviation (curve)" - -#: expressions/pitd.py:31 -msgid "Confidence threshold for filtering uncertain pitch values in UTAU WAV" -msgstr "Confidence threshold for filtering uncertain pitch values in UTAU WAV" - -#: expressions/pitd.py:32 -msgid "" -"Confidence threshold for filtering uncertain pitch values in reference WAV" -msgstr "" -"Confidence threshold for filtering uncertain pitch values in reference WAV" - -#: expressions/pitd.py:34 -msgid "" -"Semitone shift between the UTAU and reference WAV; if the USTX WAV is an " -"octave higher than the reference WAV, set to 12, otherwise -12; leave it " -"empty to enable automatic shift estimation" -msgstr "" -"Semitone shift between the UTAU and reference WAV; if the USTX WAV is an " -"octave higher than the reference WAV, set to 12, otherwise -12; leave it " -"empty to enable automatic shift estimation" - -#: expressions/pitd.py:200 -msgid "Estimated Semitone-shift: {}" -msgstr "Estimated Semitone-shift: {}" - -#: expressions/pitd.py:255 -msgid "Loading F0 data from cache file: '{}'" -msgstr "Loading F0 data from cache file: '{}'" - -#: expressions/pitd.py:276 -msgid "F0 data saved to cache file: '{}'" -msgstr "F0 data saved to cache file: '{}'" - -#: expressions/tenc.py:19 -msgid "Tension (curve)" -msgstr "Tension (curve)" - -#: expressions/tenc.py:24 -msgid "Bias for the expression curve" -msgstr "Bias for the expression curve" +"Generated-By: Babel 2.18.0\n" -#: expressive-gui.py:128 +#: expressive_gui.py:154 msgid "Config exported successfully!" msgstr "Config exported successfully!" -#: expressive-gui.py:130 +#: expressive_gui.py:156 msgid "Failed to export config" msgstr "Failed to export config" -#: expressive-gui.py:143 +#: expressive_gui.py:169 msgid "Config imported successfully!" msgstr "Config imported successfully!" -#: expressive-gui.py:146 +#: expressive_gui.py:172 msgid "Failed to import config" msgstr "Failed to import config" -#: expressive-gui.py:181 expressive-gui.py:182 +#: expressive_gui.py:207 expressive_gui.py:208 msgid "Processing completed successfully!" msgstr "Processing completed successfully!" -#: expressive-gui.py:184 expressive-gui.py:185 +#: expressive_gui.py:210 expressive_gui.py:211 msgid "Error during processing" msgstr "Error during processing" -#: expressive-gui.py:217 +#: expressive_gui.py:273 msgid "Please fill all required file paths" msgstr "Please fill all required file paths" -#: expressive-gui.py:226 +#: expressive_gui.py:282 msgid "Please select at least one expression to apply" msgstr "Please select at least one expression to apply" -#: expressive-gui.py:262 +#: expressive_gui.py:337 msgid "File Paths" msgstr "File Paths" -#: expressive-gui.py:267 +#: expressive_gui.py:353 msgid "Reference WAV File" msgstr "Reference WAV File" -#: expressive-gui.py:269 expressive-gui.py:284 expressive-gui.py:299 -#: expressive-gui.py:314 +#: expressive_gui.py:355 expressive_gui.py:370 expressive_gui.py:385 +#: expressive_gui.py:400 msgid "Input required" msgstr "Input required" -#: expressive-gui.py:282 +#: expressive_gui.py:368 msgid "UTAU WAV File" msgstr "UTAU WAV File" -#: expressive-gui.py:297 +#: expressive_gui.py:383 msgid "Input USTX File" msgstr "Input USTX File" -#: expressive-gui.py:312 +#: expressive_gui.py:398 msgid "Output USTX File" msgstr "Output USTX File" -#: expressive-gui.py:313 -msgid "Path to save processed USTX file" -msgstr "Path to save processed USTX file" +#: expressive_gui.py:399 +msgid "Path to save the processed `.ustx` file" +msgstr "Path to save the processed `.ustx` file" -#: expressive-gui.py:326 +#: expressive_gui.py:412 msgid "Track Number" msgstr "Track Number" -#: expressive-gui.py:333 +#: expressive_gui.py:419 msgid "Expression Selection" msgstr "Expression Selection" -#: expressive-gui.py:351 expressive-gui.py:387 expressive-gui.py:416 +#: expressive_gui.py:437 expressive_gui.py:485 expressive_gui.py:514 msgid "Align Radius" msgstr "Align Radius" -#: expressive-gui.py:356 expressive-gui.py:397 expressive-gui.py:421 +#: expressive_gui.py:442 expressive_gui.py:495 expressive_gui.py:519 msgid "Smoothness" msgstr "Smoothness" -#: expressive-gui.py:361 expressive-gui.py:402 expressive-gui.py:426 +#: expressive_gui.py:447 expressive_gui.py:500 expressive_gui.py:524 msgid "Scaler" msgstr "Scaler" -#: expressive-gui.py:376 +#: expressive_gui.py:469 +msgid "Backend" +msgstr "Backend" + +#: expressive_gui.py:474 msgid "UTAU Confidence" msgstr "UTAU Confidence" -#: expressive-gui.py:382 +#: expressive_gui.py:480 msgid "Reference Confidence" msgstr "Reference Confidence" -#: expressive-gui.py:392 +#: expressive_gui.py:490 msgid "Semitone Shift" msgstr "Semitone Shift" -#: expressive-gui.py:431 +#: expressive_gui.py:500 +msgid "Auto Estimation" +msgstr "Auto Estimation" + +#: expressive_gui.py:529 msgid "Bias" msgstr "Bias" -#: expressive-gui.py:439 +#: expressive_gui.py:537 msgid "Import Config" msgstr "Import Config" -#: expressive-gui.py:445 +#: expressive_gui.py:543 msgid "Export Config" msgstr "Export Config" -#: expressive-gui.py:456 +#: expressive_gui.py:554 msgid "Process" msgstr "Process" + +#: expressions/base.py:28 +msgid "Path to the **reference** audio file" +msgstr "Path to the **reference** audio file" + +#: expressions/base.py:29 +msgid "Path to the **UTAU** audio file" +msgstr "Path to the **UTAU** audio file" + +#: expressions/base.py:30 +msgid "Path to the `.ustx` project file to be processed" +msgstr "Path to the `.ustx` project file to be processed" + +#: expressions/base.py:31 +msgid "**Track number** to apply expressions to (1-based index)" +msgstr "**Track number** to apply expressions to (1-based index)" + +#: expressions/base.py:51 +msgid "Initialization complete." +msgstr "Initialization complete." + +#: expressions/base.py:68 +#, python-brace-format +msgid "Expression written to USTX file: '{}'" +msgstr "Expression written to USTX file: '{}'" + +#: expressions/base.py:70 +msgid "Expression result is empty. Skipping USTX update." +msgstr "Expression result is empty. Skipping USTX update." + +#: expressions/dyn.py:19 +msgid "Dynamics (curve)" +msgstr "Dynamics (curve)" + +#: expressions/dyn.py:21 expressions/pitd.py:39 expressions/tenc.py:21 +msgid "" +"**Radius** for the FastDTW alignment algorithm; larger values allow more " +"flexible alignment but increase computation time" +msgstr "" +"**Radius** for the FastDTW alignment algorithm; larger values allow more " +"flexible alignment but increase computation time" + +#: expressions/dyn.py:22 expressions/pitd.py:41 expressions/tenc.py:22 +msgid "" +"Controls the **smoothness** of the expression curve using Gaussian " +"filtering. Higher values produce smoother curves but may lose fine detail" +msgstr "" +"Controls the **smoothness** of the expression curve using Gaussian " +"filtering. Higher values produce smoother curves but may lose fine detail" + +#: expressions/dyn.py:23 expressions/pitd.py:42 expressions/tenc.py:23 +msgid "" +"**Scaling factor** applied to the expression curve. Values >1 amplify the" +" expression, =1 keeps original intensity, <1 reduces it" +msgstr "" +"**Scaling factor** applied to the expression curve. Values >1 amplify the" +" expression, =1 keeps original intensity, <1 reduces it" + +#: expressions/dyn.py:32 expressions/pitd.py:55 expressions/tenc.py:34 +msgid "Extracting expression..." +msgstr "Extracting expression..." + +#: expressions/dyn.py:56 expressions/pitd.py:105 expressions/tenc.py:58 +msgid "Expression extraction complete." +msgstr "Expression extraction complete." + +#: expressions/pitd.py:28 +msgid "Pitch Deviation (curve)" +msgstr "Pitch Deviation (curve)" + +#: expressions/pitd.py:30 +msgid "fast, CPU-based (ONNX Runtime)" +msgstr "fast, CPU-based (ONNX Runtime)" + +#: expressions/pitd.py:31 +msgid "classic but slow, CPU & NVIDIA GPU (TensorFlow)" +msgstr "classic but slow, CPU & NVIDIA GPU (TensorFlow)" + +#: expressions/pitd.py:36 +#, python-format +msgid "" +"**F0 detection backend** for extracting pitch from WAV files. Available " +"options:\n" +"\n" +"%s\n" +"\n" +msgstr "" +"**F0 detection backend** for extracting pitch from WAV files. Available " +"options:\n" +"\n" +"%s\n" +"\n" + +#: expressions/pitd.py:37 +#, python-format +msgid "" +"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" +msgstr "" +"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" + +#: expressions/pitd.py:38 +#, python-format +msgid "" +"Minimum **confidence level** for keeping detected pitch values in the " +"**reference** 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" +msgstr "" +"Minimum **confidence level** for keeping detected pitch values in the " +"**reference** 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" + +#: expressions/pitd.py:40 +msgid "" +"**Semitone shift** between the UTAU and reference WAV. If the UTAU WAV is" +" an octave higher than the reference WAV, set to 12; if lower, set to " +"-12. Omit to enable automatic shift estimation" +msgstr "" +"**Semitone shift** between the UTAU and reference WAV. If the UTAU WAV is" +" an octave higher than the reference WAV, set to 12; if lower, set to " +"-12. Omit to enable automatic shift estimation" + +#: expressions/pitd.py:214 +#, python-brace-format +msgid "Estimated Semitone-shift: {}" +msgstr "Estimated Semitone-shift: {}" + +#: expressions/pitd.py:277 +#, python-brace-format +msgid "Loading F0 data from cache file: '{}'" +msgstr "Loading F0 data from cache file: '{}'" + +#: expressions/pitd.py:310 +#, python-brace-format +msgid "F0 data saved to cache file: '{}'" +msgstr "F0 data saved to cache file: '{}'" + +#: expressions/tenc.py:19 +msgid "Tension (curve)" +msgstr "Tension (curve)" + +#: expressions/tenc.py:24 +msgid "" +"**Bias** offset added to the expression curve. Positive values shift the " +"curve upward; negative values shift it downward" +msgstr "" +"**Bias** offset added to the expression curve. Positive values shift the " +"curve upward; negative values shift it downward" diff --git a/locales/generate.py b/locales/generate.py new file mode 100644 index 0000000..f91fa45 --- /dev/null +++ b/locales/generate.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +Equivalent of the bash i18n script, using pybabel (Babel) instead of +xgettext / msginit / msgmerge / msgfmt. + +Usage: + python3 locales/generate.py + +Requirements: + pip install Babel +""" +import argparse +import re +import subprocess +from pathlib import Path + +LOCALE_RE = re.compile(r'^[a-z]{2}(_[A-Z]{2})?$') +APP_NAME = "app" + + +def run(cmd: list, **kwargs) -> None: + print("+", " ".join(str(c) for c in cmd)) + subprocess.run(cmd, check=True, **kwargs) + + +def extract_pot(root: Path, cfg_file: Path, pot_file: Path) -> None: + """Extract translatable strings into a .pot template.""" + run([ + "pybabel", "extract", + "--mapping", cfg_file, + "--output", pot_file, + "--strip-comments", + "--project", "expressive", + ".", + ], cwd=root) + + +def init_po(pot_file: Path, locales_dir: Path, locale: str, po_file: Path) -> None: + """Create a new .po catalogue for a locale (first time).""" + run([ + "pybabel", "init", + "--input-file", pot_file, + "--output-dir", locales_dir, + "--locale", locale, + "--output-file", po_file, + ]) + + +def update_po(pot_file: Path, locales_dir: Path, locale: str, po_file: Path) -> None: + """Merge new/changed strings into an existing .po file.""" + run([ + "pybabel", "update", + "--input-file", pot_file, + "--output-dir", locales_dir, + "--locale", locale, + "--output-file", po_file, + ]) + + +def compile_po(po_file: Path, mo_file: Path) -> None: + """Compile .po → .mo.""" + run([ + "pybabel", "compile", + "--use-fuzzy", + "--input-file", po_file, + "--output-file", mo_file, + ]) + + +def iter_locales(locales_dir: Path): + for path in sorted(locales_dir.iterdir()): + if path.is_dir() and LOCALE_RE.match(path.name): + yield path + + +def process_locale( + locale_path: Path, + locales_dir: Path, + pot_file: Path, + mo_only: bool, +) -> None: + locale = locale_path.name + lc_dir = locale_path / "LC_MESSAGES" + lc_dir.mkdir(parents=True, exist_ok=True) + po_file = lc_dir / f"{APP_NAME}.po" + mo_file = lc_dir / f"{APP_NAME}.mo" + + if not mo_only: + if po_file.exists(): + update_po(pot_file, locales_dir, locale, po_file) + else: + init_po(pot_file, locales_dir, locale, po_file) + + compile_po(po_file, mo_file) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate localization files") + parser.add_argument( + "--skip-pot", + action="store_true", + help="Skip regenerating the .pot template file", + ) + parser.add_argument( + "--mo-only", + action="store_true", + help="Skip .pot extraction and .po update; only compile .po → .mo", + ) + args = parser.parse_args() + + root = Path(__file__).resolve().parent.parent + locales_dir = root / "locales" + pot_file = locales_dir / f"{APP_NAME}.pot" + + locales_dir.mkdir(parents=True, exist_ok=True) + + if not args.skip_pot and not args.mo_only: + extract_pot(root, root / "pyproject.toml", pot_file) + + for locale_path in iter_locales(locales_dir): + process_locale(locale_path, locales_dir, pot_file, args.mo_only) + + +if __name__ == "__main__": + main() diff --git a/locales/generate.sh b/locales/generate.sh deleted file mode 100644 index c397af4..0000000 --- a/locales/generate.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -set -euxo pipefail - -ROOT=$(dirname $(dirname "$(realpath "$0")")) -cd "$ROOT" - -# Generate .pot file -xgettext --language=Python --keyword=_ --from-code=UTF-8 --output=locales/app.pot \ - $(git ls-files '*.py' 2>/dev/null) - -for locale in $(ls locales | grep -E '^[a-z]{2}(_[A-Z]{2})?$'); do - # Generate .po files for each locale - mkdir -p "locales/$locale/LC_MESSAGES" - if [ ! -f "locales/$locale/LC_MESSAGES/app.po" ]; then - msginit --no-translator --locale="$locale" --input=locales/app.pot \ - --output-file="locales/$locale/LC_MESSAGES/app.po" - fi - msgmerge --update "locales/$locale/LC_MESSAGES/app.po" \ - locales/app.pot - - # Compile .po files to .mo files - msgfmt --output-file="locales/$locale/LC_MESSAGES/app.mo" \ - "locales/$locale/LC_MESSAGES/app.po" -done diff --git a/locales/zh_CN/LC_MESSAGES/app.po b/locales/zh_CN/LC_MESSAGES/app.po index 99819e5..264efcc 100644 --- a/locales/zh_CN/LC_MESSAGES/app.po +++ b/locales/zh_CN/LC_MESSAGES/app.po @@ -1,223 +1,287 @@ -# Simplified Chinese translations for Expressive package. -# Copyright (C) 2025 NewComer00 -# This file is distributed under the same license as the PitchLoader package. -# NewComer00, 2025. +# Simplified Chinese translations for expressive. +# Copyright (C) 2025-2026 NewComer00 +# This file is distributed under the same license as the expressive project. +# NewComer00, 2025. # msgid "" msgstr "" -"Project-Id-Version: Expressive v0.2.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-05-06 21:48+0800\n" -"PO-Revision-Date: 2025-03-18 16:23+0800\n" -"Last-Translator: NewComer00\n" -"Language-Team: Chinese (Simplified)\n" +"Project-Id-Version: expressive\n" +"Report-Msgid-Bugs-To: https://github.com/NewComer00/expressive/issues\n" +"POT-Creation-Date: 2026-03-01 19:30+0800\n" +"PO-Revision-Date: 2026-03-01 20:21+0800\n" +"Last-Translator: NewComer00\n" "Language: zh_CN\n" +"Language-Team: Chinese (Simplified)\n" +"Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" +"Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=1; plural=0;\n" - -#: expressions/base.py:27 -msgid "Path to the reference audio file" -msgstr "参考音频文件路径" - -#: expressions/base.py:28 -msgid "Path to the UTAU audio file" -msgstr "歌姬音频文件路径" - -#: expressions/base.py:29 -msgid "Path to the USTX project file to be processed" -msgstr "要处理的 USTX 项目文件路径" - -#: expressions/base.py:30 -msgid "Track number to apply expressions" -msgstr "要导入表情参数的音轨编号" - -#: expressions/base.py:50 -msgid "Initialization complete." -msgstr "初始化完成。" - -#: expressions/base.py:67 -msgid "Expression written to USTX file: '{}'" -msgstr "表情参数已写入 USTX 文件:'{}'" - -#: expressions/base.py:69 -msgid "Expression result is empty. Skipping USTX update." -msgstr "表情参数结果为空,USTX 文件将不会更新。" - -#: expressions/dyn.py:19 -msgid "Dynamics (curve)" -msgstr "动态曲线 Dynamics (curve)" - -#: expressions/dyn.py:21 expressions/pitd.py:33 expressions/tenc.py:21 -msgid "" -"Radius for the FastDTW algorithm; larger radius allows for more flexible " -"alignment but increases computation time" -msgstr "时间序列的对齐半径;值越小对齐越严格,反之越灵活,但计算时间也越长" - -#: expressions/dyn.py:22 expressions/pitd.py:35 expressions/tenc.py:22 -msgid "Smoothness of the expression curve" -msgstr "表情曲线的平滑度;值越大越平滑" - -#: expressions/dyn.py:23 expressions/pitd.py:36 expressions/tenc.py:23 -msgid "Scaling factor for the expression curve" -msgstr "表情曲线的缩放因子;值越大,曲线的变化幅度越大" - -#: expressions/dyn.py:32 expressions/pitd.py:48 expressions/tenc.py:34 -msgid "Extracting expression..." -msgstr "正在提取表情参数..." - -#: expressions/dyn.py:56 expressions/pitd.py:92 expressions/tenc.py:58 -msgid "Expression extraction complete." -msgstr "表情参数提取完成。" - -#: expressions/pitd.py:29 -msgid "Pitch Deviation (curve)" -msgstr "音高偏差曲线 Pitch Deviation (curve)" - -#: expressions/pitd.py:31 -msgid "Confidence threshold for filtering uncertain pitch values in UTAU WAV" -msgstr "提取歌姬音频的音高时,只有大于置信度阈值的音高会被采纳" - -#: expressions/pitd.py:32 -msgid "" -"Confidence threshold for filtering uncertain pitch values in reference WAV" -msgstr "提取参考音频的音高时,只有大于置信度阈值的音高会被采纳" - -#: expressions/pitd.py:34 -msgid "" -"Semitone shift between the UTAU and reference WAV; if the USTX WAV is an " -"octave higher than the reference WAV, set to 12, otherwise -12; leave it " -"empty to enable automatic shift estimation" -msgstr "" -"歌姬音频与参考音频之间的半音偏移;若歌姬比参考高一个八度则设为 12,低一个八度" -"则设为 -12;若音高一致则设为 0;此处留空则启用自动估算" - -#: expressions/pitd.py:200 -msgid "Estimated Semitone-shift: {}" -msgstr "估计的半音偏移:{}" +"Generated-By: Babel 2.18.0\n" -#: expressions/pitd.py:255 -msgid "Loading F0 data from cache file: '{}'" -msgstr "正在从缓存文件加载 F0 数据:'{}'" - -#: expressions/pitd.py:276 -msgid "F0 data saved to cache file: '{}'" -msgstr "F0 数据已保存到缓存文件:'{}'" - -#: expressions/tenc.py:19 -msgid "Tension (curve)" -msgstr "张力曲线 Tension (curve)" - -#: expressions/tenc.py:24 -msgid "Bias for the expression curve" -msgstr "表情曲线的偏置;值越大,曲线的起始点越高" - -#: expressive-gui.py:128 +#: expressive_gui.py:154 msgid "Config exported successfully!" msgstr "配置导出成功!" -#: expressive-gui.py:130 +#: expressive_gui.py:156 msgid "Failed to export config" msgstr "配置导出失败" -#: expressive-gui.py:143 +#: expressive_gui.py:169 msgid "Config imported successfully!" msgstr "配置导入成功!" -#: expressive-gui.py:146 +#: expressive_gui.py:172 msgid "Failed to import config" msgstr "配置导入失败" -#: expressive-gui.py:181 expressive-gui.py:182 +#: expressive_gui.py:207 expressive_gui.py:208 msgid "Processing completed successfully!" msgstr "处理完成!" -#: expressive-gui.py:184 expressive-gui.py:185 +#: expressive_gui.py:210 expressive_gui.py:211 msgid "Error during processing" msgstr "处理时发生错误" -#: expressive-gui.py:217 +#: expressive_gui.py:273 msgid "Please fill all required file paths" msgstr "请填写所有必填文件路径" -#: expressive-gui.py:226 +#: expressive_gui.py:282 msgid "Please select at least one expression to apply" msgstr "请至少选择一个表情" -#: expressive-gui.py:262 +#: expressive_gui.py:337 msgid "File Paths" msgstr "文件路径" -#: expressive-gui.py:267 +#: expressive_gui.py:353 msgid "Reference WAV File" msgstr "参考音频文件(WAV)" -#: expressive-gui.py:269 expressive-gui.py:284 expressive-gui.py:299 -#: expressive-gui.py:314 +#: expressive_gui.py:355 expressive_gui.py:370 expressive_gui.py:385 +#: expressive_gui.py:400 msgid "Input required" msgstr "需要填写内容" -#: expressive-gui.py:282 +#: expressive_gui.py:368 msgid "UTAU WAV File" msgstr "歌姬音频文件(WAV)" -#: expressive-gui.py:297 +#: expressive_gui.py:383 msgid "Input USTX File" msgstr "输入 OpenUtau 工程文件(USTX)" -#: expressive-gui.py:312 +#: expressive_gui.py:398 msgid "Output USTX File" msgstr "输出 OpenUtau 工程文件(USTX)" -#: expressive-gui.py:313 -msgid "Path to save processed USTX file" +#: expressive_gui.py:399 +msgid "Path to save the processed `.ustx` file" msgstr "用于保存处理后 USTX 文件的路径" -#: expressive-gui.py:326 +#: expressive_gui.py:412 msgid "Track Number" msgstr "轨道编号" -#: expressive-gui.py:333 +#: expressive_gui.py:419 msgid "Expression Selection" msgstr "表情参数" -#: expressive-gui.py:351 expressive-gui.py:387 expressive-gui.py:416 +#: expressive_gui.py:437 expressive_gui.py:485 expressive_gui.py:514 msgid "Align Radius" msgstr "对齐半径" -#: expressive-gui.py:356 expressive-gui.py:397 expressive-gui.py:421 +#: expressive_gui.py:442 expressive_gui.py:495 expressive_gui.py:519 msgid "Smoothness" msgstr "平滑度" -#: expressive-gui.py:361 expressive-gui.py:402 expressive-gui.py:426 +#: expressive_gui.py:447 expressive_gui.py:500 expressive_gui.py:524 msgid "Scaler" msgstr "缩放因子" -#: expressive-gui.py:376 +#: expressive_gui.py:469 +msgid "Backend" +msgstr "后端" + +#: expressive_gui.py:474 msgid "UTAU Confidence" msgstr "歌姬音频置信度" -#: expressive-gui.py:382 +#: expressive_gui.py:480 msgid "Reference Confidence" msgstr "参考音频置信度" -#: expressive-gui.py:392 +#: expressive_gui.py:490 msgid "Semitone Shift" msgstr "半音偏移" -#: expressive-gui.py:431 +#: expressive_gui.py:500 +msgid "Auto Estimation" +msgstr "自动估算" + +#: expressive_gui.py:529 msgid "Bias" msgstr "偏置" -#: expressive-gui.py:439 +#: expressive_gui.py:537 msgid "Import Config" msgstr "导入配置" -#: expressive-gui.py:445 +#: expressive_gui.py:543 msgid "Export Config" msgstr "导出配置" -#: expressive-gui.py:456 +#: expressive_gui.py:554 msgid "Process" msgstr "开始处理" + +#: expressions/base.py:28 +msgid "Path to the **reference** audio file" +msgstr "参考音频文件路径" + +#: expressions/base.py:29 +msgid "Path to the **UTAU** audio file" +msgstr "歌姬音频文件路径" + +#: expressions/base.py:30 +msgid "Path to the `.ustx` project file to be processed" +msgstr "要处理的 USTX 项目文件路径" + +#: expressions/base.py:31 +msgid "**Track number** to apply expressions to (1-based index)" +msgstr "要导入表情参数的音轨编号(从 1 开始)" + +#: expressions/base.py:51 +msgid "Initialization complete." +msgstr "初始化完成。" + +#: expressions/base.py:68 +#, python-brace-format +msgid "Expression written to USTX file: '{}'" +msgstr "表情参数已写入 USTX 文件:'{}'" + +#: expressions/base.py:70 +msgid "Expression result is empty. Skipping USTX update." +msgstr "表情参数结果为空,USTX 文件将不会更新。" + +#: expressions/dyn.py:19 +msgid "Dynamics (curve)" +msgstr "动态曲线 Dynamics (curve)" + +#: expressions/dyn.py:21 expressions/pitd.py:39 expressions/tenc.py:21 +msgid "" +"**Radius** for the FastDTW alignment algorithm; larger values allow more " +"flexible alignment but increase computation time" +msgstr "FastDTW 对齐算法的**半径**;值越大对齐越灵活,但计算时间也越长" + +#: expressions/dyn.py:22 expressions/pitd.py:41 expressions/tenc.py:22 +msgid "" +"Controls the **smoothness** of the expression curve using Gaussian " +"filtering. Higher values produce smoother curves but may lose fine detail" +msgstr "通过高斯滤波控制表情曲线的**平滑度**;值越大曲线越平滑,但可能丢失细节" + +#: expressions/dyn.py:23 expressions/pitd.py:42 expressions/tenc.py:23 +msgid "" +"**Scaling factor** applied to the expression curve. Values >1 amplify the" +" expression, =1 keeps original intensity, <1 reduces it" +msgstr "应用于表情曲线的**缩放因子**;大于 1 则放大,等于 1 则保持原强度,小于 1 则缩小" + +#: expressions/dyn.py:32 expressions/pitd.py:55 expressions/tenc.py:34 +msgid "Extracting expression..." +msgstr "正在提取表情参数..." + +#: expressions/dyn.py:56 expressions/pitd.py:105 expressions/tenc.py:58 +msgid "Expression extraction complete." +msgstr "表情参数提取完成。" + +#: expressions/pitd.py:28 +msgid "Pitch Deviation (curve)" +msgstr "音高偏差曲线 Pitch Deviation (curve)" + +#: expressions/pitd.py:30 +msgid "fast, CPU-based (ONNX Runtime)" +msgstr "快速,基于 CPU(ONNX Runtime)" + +#: expressions/pitd.py:31 +msgid "classic but slow, CPU & NVIDIA GPU (TensorFlow)" +msgstr "经典但较慢,支持 CPU 和 NVIDIA GPU(TensorFlow)" + +#: expressions/pitd.py:36 +#, python-format +msgid "" +"**F0 detection backend** for extracting pitch from WAV files. Available " +"options:\n" +"\n" +"%s\n" +"\n" +msgstr "" +"从 WAV 文件提取音高所用的 **F0 检测后端**,可选项如下:\n" +"\n" +"%s\n" +"\n" + +#: expressions/pitd.py:37 +#, python-format +msgid "" +"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" +msgstr "" +"提取**歌姬**音频音高时保留检测值所需的最低**置信度**;值越低保留帧越多,但可能引入误差。" +"忽略该参数则使用所选后端的推荐值:\n" +"\n" +"%s\n" +"\n" + +#: expressions/pitd.py:38 +#, python-format +msgid "" +"Minimum **confidence level** for keeping detected pitch values in the " +"**reference** 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" +msgstr "" +"提取**参考**音频音高时保留检测值所需的最低**置信度**;值越低保留帧越多,但可能引入误差。" +"忽略该参数则使用所选后端的推荐值:\n" +"\n" +"%s\n" +"\n" + +#: expressions/pitd.py:40 +msgid "" +"**Semitone shift** between the UTAU and reference WAV. If the UTAU WAV is" +" an octave higher than the reference WAV, set to 12; if lower, set to " +"-12. Omit to enable automatic shift estimation" +msgstr "" +"歌姬音频与参考音频之间的**半音偏移**;若歌姬比参考高一个八度则设为 12,低一个八度则设为 -12;忽略该参数则启用自动估算" + +#: expressions/pitd.py:214 +#, python-brace-format +msgid "Estimated Semitone-shift: {}" +msgstr "估计的半音偏移:{}" + +#: expressions/pitd.py:277 +#, python-brace-format +msgid "Loading F0 data from cache file: '{}'" +msgstr "正在从缓存文件加载 F0 数据:'{}'" + +#: expressions/pitd.py:310 +#, python-brace-format +msgid "F0 data saved to cache file: '{}'" +msgstr "F0 数据已保存到缓存文件:'{}'" + +#: expressions/tenc.py:19 +msgid "Tension (curve)" +msgstr "张力曲线 Tension (curve)" + +#: expressions/tenc.py:24 +msgid "" +"**Bias** offset added to the expression curve. Positive values shift the " +"curve upward; negative values shift it downward" +msgstr "添加到表情曲线的**偏置**偏移量;正值使曲线上移,负值使曲线下移" diff --git a/pyproject.toml b/pyproject.toml index 15808fb..e5d137f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "numpy<2", "tensorflow==2.10", "crepe @ git+https://github.com/NewComer00/crepe.git@master", + "swift-f0", "oyaml", "fastdtw", "scipy", @@ -21,6 +22,8 @@ dependencies = [ "matplotlib", "scikit-learn", "lazy-string", + "rich-argparse", + "rich", ] [project.optional-dependencies] @@ -39,6 +42,7 @@ gui = [ "pywebview>=6.0", "pywin32; platform_system=='Windows'", "pywinstyles; platform_system=='Windows'", + "markdown", ] dev = [ "pyinstaller==6.11.1", @@ -72,6 +76,17 @@ include = [ [tool.hatch.metadata] allow-direct-references = true +[tool.babel] +[[tool.babel.mappings]] +method = "ignore" +pattern = ["tests/**.py", "dist/**.py", "build/**.py"] + +[[tool.babel.mappings]] +method = "python" +pattern = "**.py" +encoding = "utf-8" +keywords = ["_", "_l", "_lf"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..1baca47 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,177 @@ +"""Tests for CLI utilities.""" + +import argparse + +from rich.text import Text + +from utils.cli import ( + ArgumentDefaultsWrappedTextRichHelpFormatter, + WrappedTextRichHelpFormatter, +) + + +class TestWrappedTextRichHelpFormatter: + """Tests for WrappedTextRichHelpFormatter.""" + + def test_highlights_includes_bold_markdown(self): + """Test that highlights includes pattern for **bold** markdown.""" + assert r"\*\*(?P[^*\n]+)\*\*" in WrappedTextRichHelpFormatter.highlights + + def test_highlights_inherits_from_rich_help_formatter(self): + """Test that highlights extends RichHelpFormatter's defaults.""" + from rich_argparse import RichHelpFormatter + # Our highlights should be longer than the base class + assert len(WrappedTextRichHelpFormatter.highlights) > len(RichHelpFormatter.highlights) + + def test_rich_split_lines_wraps_long_text(self): + """Test that _rich_split_lines properly wraps long lines.""" + formatter = WrappedTextRichHelpFormatter(prog="test") + text = Text("This is a very long line that should be wrapped into multiple lines when rendered") + lines = formatter._rich_split_lines(text, width=40) + # Should produce multiple lines due to wrapping + assert len(lines) > 1 + + def test_rich_split_lines_preserves_short_lines(self): + """Test that short lines are not unnecessarily split.""" + formatter = WrappedTextRichHelpFormatter(prog="test") + text = Text("Short line") + lines = formatter._rich_split_lines(text, width=80) + assert len(lines) == 1 + + def test_rich_fill_text_adds_newline(self): + """Test that _rich_fill_text appends a newline.""" + formatter = WrappedTextRichHelpFormatter(prog="test") + text = Text("Test paragraph") + result = formatter._rich_fill_text(text, width=80, indent=Text()) + assert result.plain.endswith("\n") + + def test_rich_fill_text_with_indent(self): + """Test that _rich_fill_text properly indents wrapped lines.""" + formatter = WrappedTextRichHelpFormatter(prog="test") + text = Text("This is a very long line that should be wrapped with proper indentation") + indent = Text(" ") # 4 spaces + result = formatter._rich_fill_text(text, width=40, indent=indent) + # Check that wrapped lines are indented + lines = result.plain.strip().split("\n") + for line in lines[1:]: # Skip first line + assert line.startswith(" ") + + +class TestArgumentDefaultsWrappedTextRichHelpFormatter: + """Tests for ArgumentDefaultsWrappedTextRichHelpFormatter.""" + + def test_inherits_from_argument_defaults_help_formatter(self): + """Test that the class inherits from ArgumentDefaultsHelpFormatter.""" + assert issubclass( + ArgumentDefaultsWrappedTextRichHelpFormatter, argparse.ArgumentDefaultsHelpFormatter + ) + + def test_inherits_from_wrapped_text_rich_help_formatter(self): + """Test that the class inherits from WrappedTextRichHelpFormatter.""" + assert issubclass( + ArgumentDefaultsWrappedTextRichHelpFormatter, WrappedTextRichHelpFormatter + ) + + def test_adds_default_values_to_help(self): + """Test that default values are added to help text.""" + parser = argparse.ArgumentParser( + formatter_class=ArgumentDefaultsWrappedTextRichHelpFormatter + ) + parser.add_argument("--test", default="default_value", help="Test argument") + + # Capture help output + help_text = parser.format_help() + + # Should contain the default value + assert "default_value" in help_text + + def test_handles_long_help_text_with_wrapping(self): + """Test that long help text is properly wrapped.""" + parser = argparse.ArgumentParser( + formatter_class=ArgumentDefaultsWrappedTextRichHelpFormatter + ) + long_help = "This is a very long help text that should be wrapped " * 5 + parser.add_argument("--test", default="default", help=long_help) + + # Should not raise an exception + help_text = parser.format_help() + assert "default" in help_text + + def test_handles_multiple_arguments(self): + """Test formatting with multiple arguments.""" + parser = argparse.ArgumentParser( + formatter_class=ArgumentDefaultsWrappedTextRichHelpFormatter + ) + parser.add_argument("--arg1", default="val1", help="First argument") + parser.add_argument("--arg2", default="val2", help="Second argument") + parser.add_argument("--arg3", type=int, default=42, help="Third argument") + + help_text = parser.format_help() + + assert "val1" in help_text + assert "val2" in help_text + assert "42" in help_text + + def test_preserves_bold_markdown_in_help(self): + """Test that **bold** markdown syntax is preserved in help text.""" + parser = argparse.ArgumentParser( + formatter_class=ArgumentDefaultsWrappedTextRichHelpFormatter + ) + parser.add_argument("--test", help="This is **bold** text") + + help_text = parser.format_help() + assert "**bold**" in help_text + + +class TestIntegrationWithArgumentParser: + """Integration tests with argparse.""" + + def test_full_help_output(self): + """Test complete help output formatting.""" + parser = argparse.ArgumentParser( + prog="test_prog", + description="Test **description** with bold text", + formatter_class=ArgumentDefaultsWrappedTextRichHelpFormatter + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Enable verbose output" + ) + parser.add_argument( + "--output", + default="output.txt", + help="Output file path" + ) + + help_text = parser.format_help() + + # Check structure + assert "test_prog" in help_text + assert "Test **description**" in help_text + assert "output.txt" in help_text + + def test_subparser_compatibility(self): + """Test that formatter works with subparsers.""" + parser = argparse.ArgumentParser( + formatter_class=ArgumentDefaultsWrappedTextRichHelpFormatter + ) + subparsers = parser.add_subparsers() + sub = subparsers.add_parser("subcommand") + sub.add_argument("--sub-arg", default="sub-default", help="Subcommand argument") + + # Should not raise + help_text = parser.format_help() + assert "subcommand" in help_text + + def test_positional_arguments(self): + """Test formatting with positional arguments.""" + parser = argparse.ArgumentParser( + formatter_class=ArgumentDefaultsWrappedTextRichHelpFormatter + ) + parser.add_argument("input", help="Input file") + parser.add_argument("--output", default="out.txt", help="Output file") + + help_text = parser.format_help() + assert "Input file" in help_text + assert "out.txt" in help_text diff --git a/tests/test_gpu.py b/tests/test_gpu.py index d18b916..a5077d1 100644 --- a/tests/test_gpu.py +++ b/tests/test_gpu.py @@ -26,6 +26,13 @@ def make_mock_pkg(path_base: str): return m +@pytest.fixture(autouse=True) +def reset_cuda_state(): + """Reset the _cuda_added flag before each test.""" + with patch("utils.gpu._cuda_added", False): + yield + + @pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") def test_add_cuda_to_path_windows(): """Test adding CUDA to PATH on Windows using importlib import mock.""" diff --git a/tests/test_i18n.py b/tests/test_i18n.py index d33ceb3..4565392 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -19,10 +19,11 @@ LazyStringEncoder, _, _l, + _lf, init_gettext, json_dumps, - patch_nicegui_json, ) +from utils.monkeypatch import patch_nicegui_json # --------------------------------------------------------------------------- @@ -220,6 +221,159 @@ def test_hot_swap(self): assert _("Save") == "Guardar" +# --------------------------------------------------------------------------- +# _lf (lazy formatted translation) +# --------------------------------------------------------------------------- + +class TestLazyFormattedTranslation: + def test_returns_lazy_string(self): + ls = _lf("Hello, {name}!", name=lambda: "World") + assert isinstance(ls, LazyString) + + def test_str_format_before_init(self): + ls = _lf("Hello, {name}!", name=lambda: "World") + assert str(ls) == "Hello, World!" + + def test_str_format_after_init(self, fr_translator): + ls = _lf("Hello, {name}!", name=lambda: "World") + assert str(ls) == "Bonjour, World!" + + def test_percent_formatting_before_init(self): + ls = _lf("Hello, %s!", lambda: "World") + assert str(ls) == "Hello, World!" + + def test_percent_formatting_after_init(self, fr_translator): + ls = _lf("Hello, %s!", lambda: "World") + assert str(ls) == "Bonjour, World!" + + def test_lazy_format_args(self, fr_translator): + ls = _lf("Hello, {name}!", name=lambda: "Alice") + assert str(ls) == "Bonjour, Alice!" + + def test_lazy_percent_args(self, fr_translator): + ls = _lf("Hello, %s!", lambda: "Alice") + assert str(ls) == "Bonjour, Alice!" + + def test_resolves_after_init(self): + ls = _lf("Hello, {name}!", name=lambda: "World") + _install_translator({"Hello, {name}!": "Bonjour, {name}!"}) + assert str(ls) == "Bonjour, World!" + + def test_reflects_locale_hot_swap(self): + ls = _lf("Hello, {name}!", name=lambda: "World") + _install_translator({"Hello, {name}!": "Bonjour, {name}!"}) + assert str(ls) == "Bonjour, World!" + _install_translator({"Hello, {name}!": "Hola, {name}!"}) + assert str(ls) == "Hola, World!" + + def test_multiple_kwargs(self, fr_translator): + # Add the multi-placeholder translation to the fixture mapping + import utils.i18n + with utils.i18n._lock: + utils.i18n._current_gettext = lambda msg: { + "Hello, {name}, you are {age}!": "Bonjour, {name}, vous avez {age} ans!", + "Hello": "Bonjour", + "Save": "Enregistrer", + "Cancel": "Annuler", + "Hello, %s!": "Bonjour, %s!", + "Hello, {name}!": "Bonjour, {name}!", + "item": "élément", + "Test message": "Message de test", + "Nested": "Imbriqué", + "Item1": "Élément1", + "Item2": "Élément2", + }.get(msg, msg) + ls = _lf("Hello, {name}, you are {age}!", name=lambda: "Alice", age=lambda: "25") + assert str(ls) == "Bonjour, Alice, vous avez 25 ans!" + + def test_non_callable_kwarg_int(self): + """Test _lf with non-callable integer argument.""" + ls = _lf("Error: {error_code}", error_code=404) + assert str(ls) == "Error: 404" + + def test_non_callable_kwarg_int_translated(self, fr_translator): + """Test _lf with non-callable integer argument gets translated.""" + import utils.i18n + with utils.i18n._lock: + utils.i18n._current_gettext = lambda msg: { + "Error: {error_code}": "Erreur: {error_code}", + }.get(msg, msg) + ls = _lf("Error: {error_code}", error_code=404) + assert str(ls) == "Erreur: 404" + + def test_non_callable_percent_arg(self): + """Test _lf with non-callable positional argument.""" + ls = _lf("Warning: %s", "Low battery") + assert str(ls) == "Warning: Low battery" + + def test_non_callable_percent_arg_translated(self, fr_translator): + """Test _lf with non-callable positional argument gets translated.""" + import utils.i18n + with utils.i18n._lock: + utils.i18n._current_gettext = lambda msg: { + "Warning: %s": "Avertissement: %s", + }.get(msg, msg) + ls = _lf("Warning: %s", "Low battery") + assert str(ls) == "Avertissement: Low battery" + + def test_lazy_callable_arg_in_percent(self, fr_translator): + """Test _lf with callable (lambda) argument for percent formatting.""" + import utils.i18n + with utils.i18n._lock: + utils.i18n._current_gettext = lambda msg: { + "Warning: %s": "Avertissement: %s", + }.get(msg, msg) + ls = _lf("Warning: %s", lambda: "Low battery") + assert str(ls) == "Avertissement: Low battery" + + def test_mixed_callable_and_non_callable(self, fr_translator): + """Test _lf with both callable and non-callable arguments.""" + import utils.i18n + with utils.i18n._lock: + utils.i18n._current_gettext = lambda msg: { + "Item {name} costs ${price}": "Article {name} coûte ${price}", + }.get(msg, msg) + ls = _lf("Item {name} costs ${price}", name=lambda: "Widget", price=9.99) + assert str(ls) == "Article Widget coûte $9.99" + + def test_complex_lazy_content(self): + """Test _lf with complex lazy-evaluated content (like PitdLoader example).""" + backend_choices = { + "swift-f0": "fast, CPU-based (ONNX Runtime)", + "crepe": "classic but slow, CPU & NVIDIA GPU (TensorFlow)", + } + ls = _lf( + "**F0 detection backend** ...options:\n\n%s\n\n", + lambda: "\n".join([f"- `{k}`: {v}" for k, v in backend_choices.items()]) + ) + expected_content = "\n".join([ + "- `swift-f0`: fast, CPU-based (ONNX Runtime)", + "- `crepe`: classic but slow, CPU & NVIDIA GPU (TensorFlow)", + ]) + assert str(ls) == f"**F0 detection backend** ...options:\n\n{expected_content}\n\n" + + def test_complex_lazy_content_translated(self, fr_translator): + """Test _lf with complex lazy-evaluated content gets translated.""" + import utils.i18n + backend_choices = { + "swift-f0": "fast, CPU-based (ONNX Runtime)", + "crepe": "classic but slow, CPU & NVIDIA GPU (TensorFlow)", + } + with utils.i18n._lock: + utils.i18n._current_gettext = lambda msg: { + "**F0 detection backend** ...options:\n\n%s\n\n": "**Backend F0** ...options:\n\n%s\n\n", + }.get(msg, msg) + ls = _lf( + "**F0 detection backend** ...options:\n\n%s\n\n", + lambda: "\n".join([f"- `{k}`: {v}" for k, v in backend_choices.items()]) + ) + expected_content = "\n".join([ + "- `swift-f0`: fast, CPU-based (ONNX Runtime)", + "- `crepe`: classic but slow, CPU & NVIDIA GPU (TensorFlow)", + ]) + assert str(ls) == f"**Backend F0** ...options:\n\n{expected_content}\n\n" + + # --------------------------------------------------------------------------- # JSON serialisation # --------------------------------------------------------------------------- diff --git a/utils/cli.py b/utils/cli.py new file mode 100644 index 0000000..cfc3560 --- /dev/null +++ b/utils/cli.py @@ -0,0 +1,28 @@ +from typing import ClassVar +from argparse import ArgumentDefaultsHelpFormatter + +from rich.text import Text +from rich.containers import Lines +from rich_argparse import RichHelpFormatter + + +class WrappedTextRichHelpFormatter(RichHelpFormatter): + """RichHelpFormatter that wraps long lines in help text while preserving rich formatting. + Cited from https://github.com/hamdanal/rich-argparse/issues/78#issuecomment-1627395697 + """ + highlights: ClassVar[list[str]] = RichHelpFormatter.highlights + [r"\*\*(?P[^*\n]+)\*\*"] + + def _rich_split_lines(self, text: Text, width: int) -> Lines: + lines = Lines() + for line in text.split(): + lines.extend(line.wrap(self.console, width)) + return lines + + def _rich_fill_text(self, text: Text, width: int, indent: Text) -> Text: + lines = self._rich_split_lines(text, width) + return Text("\n").join(indent + line for line in lines) + "\n" + + +class ArgumentDefaultsWrappedTextRichHelpFormatter(ArgumentDefaultsHelpFormatter, WrappedTextRichHelpFormatter): + """Combines ArgumentDefaultsHelpFormatter with WrappedTextRichHelpFormatter.""" + pass diff --git a/utils/gpu.py b/utils/gpu.py index dbec4c9..694929e 100644 --- a/utils/gpu.py +++ b/utils/gpu.py @@ -5,6 +5,7 @@ logger = logging.getLogger(__name__) +_cuda_added = False CUDA_PACKAGES = [ "nvidia.cuda_nvcc", "nvidia.cuda_runtime", "nvidia.cudnn", "nvidia.cublas", @@ -14,6 +15,10 @@ def add_cuda_to_path(skip_missing: bool = False): """Add CUDA to library searching path.""" + global _cuda_added + if _cuda_added: + return + packages = CUDA_PACKAGES missing = [] @@ -38,3 +43,5 @@ def add_cuda_to_path(skip_missing: bool = False): "If you are running the CPU-only version, you can safely ignore this message.", ", ".join(missing), ) + + _cuda_added = True diff --git a/utils/i18n.py b/utils/i18n.py index 8459a9e..47039d1 100644 --- a/utils/i18n.py +++ b/utils/i18n.py @@ -130,6 +130,38 @@ class MyModel: return LazyString(_, msg) +def _lf(msg: str, *args, **kwargs) -> LazyString: + """Like :func:`_l`, but supports formatting arguments. + + Args: + msg: The source-language string (translation key), with optional format + placeholders, e.g. ``"Hello, {name}!"``. + *args: Positional arguments for old-style ``%`` formatting. If provided, + the string is formatted using ``msg % args[0]``. + **kwargs: Keyword arguments for new-style ``str.format`` formatting. If + provided, the string is formatted using ``msg.format(**kwargs)``. + If both *args* and *kwargs* are provided, *args* takes precedence. + + Returns: + A :class:`~lazy_string.LazyString` proxy that formats the translated string + on first use. + + Example:: + ERROR_LABEL = _lf("Error: {error_code}", error_code=404) + WARNING_LABEL = _lf("Warning: %s", "Low battery") + # Formatter arguments can also be lazily evaluated: + help = _lf( + "**F0 detection backend** ...options:\n\n%s\n\n", + lambda: "\n".join([f"- `{k}`: {v}" for k, v in PitdLoader.backend_choices.items()]) + ) + """ + return LazyString( + lambda: _(msg) % (args[0]() if callable(args[0]) else args[0]) + if args else + _(msg).format(**{k: (v() if callable(v) else v) for k, v in kwargs.items()}) + ) + + class LazyStringEncoder(json.JSONEncoder): """JSON encoder that transparently handles :class:`~lazy_string.LazyString`. @@ -171,36 +203,3 @@ def json_dumps(obj, **kwargs) -> str: print(json_dumps(data)) """ return json.dumps(obj, cls=LazyStringEncoder, **kwargs) - - -def patch_nicegui_json() -> None: - """Patch NiceGUI's orjson converter to resolve ``LazyString`` during serialization. - - Monkey-patches ``_orjson_converter`` in ``nicegui.json.orjson_wrapper`` so - that ``LazyString`` objects are automatically converted to plain strings - when NiceGUI serializes UI state. - - Call this function **once** during application initialization, before - creating any NiceGUI UI elements:: - - from utils.i18n import init_gettext, patch_nicegui_json - - init_gettext("en", "locales", "app") - patch_nicegui_json() - - If NiceGUI is not installed this function is a no-op. - """ - try: - from nicegui.json import orjson_wrapper - - original_converter = orjson_wrapper._orjson_converter - - def patched_converter(obj): - if isinstance(obj, LazyString): - return str(obj) - return original_converter(obj) - - orjson_wrapper._orjson_converter = patched_converter - - except ImportError: - pass diff --git a/utils/monkeypatch.py b/utils/monkeypatch.py index a0113f7..8d685c5 100644 --- a/utils/monkeypatch.py +++ b/utils/monkeypatch.py @@ -5,6 +5,10 @@ from contextlib import ContextDecorator import runpy +from nicegui import ui +from lazy_string import LazyString + +from utils.ui import tooltip_md def ensure_same_signature( @@ -100,6 +104,49 @@ def _run_path_frozen(path_name, init_globals=None, run_name=None): return globals_dict +def patch_nicegui_json() -> None: + """Patch NiceGUI's orjson converter to resolve ``LazyString`` during serialization. + + Monkey-patches ``_orjson_converter`` in ``nicegui.json.orjson_wrapper`` so + that ``LazyString`` objects are automatically converted to plain strings + when NiceGUI serializes UI state. + + Call this function **once** during application initialization, before + creating any NiceGUI UI elements:: + + from utils.i18n import init_gettext, patch_nicegui_json + + init_gettext("en", "locales", "app") + patch_nicegui_json() + + If NiceGUI is not installed this function is a no-op. + """ + try: + from nicegui.json import orjson_wrapper + + original_converter = orjson_wrapper._orjson_converter + + def patched_converter(obj): + if isinstance(obj, LazyString): + return str(obj) + return original_converter(obj) + + orjson_wrapper._orjson_converter = patched_converter + + except ImportError: + pass + + +def patch_tooltip_md() -> None: + """Patch tooltip_md() onto all NiceGUI elements as a chainable method. + + Call this once at startup. After patching, any ui.element supports: + ui.select(...).bind_value(...).tooltip_md("**help text**") + """ + if not hasattr(ui.element, "tooltip_md"): + ui.element.tooltip_md = tooltip_md + + if __name__ == "__main__": print("Calling run_path with patch in simulated non-frozen mode (context manager):") sys.frozen = False diff --git a/utils/ui.py b/utils/ui.py index 16b1e5d..ac9025a 100644 --- a/utils/ui.py +++ b/utils/ui.py @@ -1,5 +1,11 @@ import os import ctypes +from typing import Callable, Optional + +import webview +from nicegui import ui, app +from markdown import markdown +from webview.dom import DOMEventHandler if os.name == "nt": @@ -60,12 +66,6 @@ class FLASHWINFO(ctypes.Structure): flash_window(hwnd, count, timeout) -import webview -from nicegui import ui, app -from typing import Callable, Optional -from webview.dom import DOMEventHandler - - class JS_API: """Allows JavaScript to call Python-side binding methods.""" def __init__(self) -> None: @@ -151,3 +151,11 @@ def _inject_bind_script(self) -> None: }}); ''') + + +def tooltip_md(element: ui.element, text: str) -> ui.element: + """Add a markdown-rendered tooltip to a NiceGUI element. Chainable like .tooltip().""" + with element: + with ui.tooltip(): + ui.html(markdown(str(text))) + return element