TNOT commited on
Commit
2f01cc6
·
1 Parent(s): 86371bb

feat: 简单导出插件的头尾拓展和质量评估集成

Browse files
docs/流程文档_AI用.md CHANGED
@@ -142,6 +142,8 @@
142
  │ │ • Overlap: 交叉淡化区域 │ │
143
  │ │ 3. IPA 音素转换为拼音/罗马音别名 │ │
144
  │ │ 4. 生成 oto.ini 配置文件 │ │
 
 
145
  │ └─────────────────────────────────────────────────────────────────────┘ │
146
  │ │
147
  │ 输出: export/[音源名称]/simple_export/ │
@@ -201,9 +203,9 @@ MFA 支持两种运行模式:
201
 
202
  | 模块 | 文件 | 功能 |
203
  |------|------|------|
204
- | 插件基类 | `export_plugins/base.py` | 定义插件接口配置选项 |
205
  | 插件加载器 | `export_plugins/loader.py` | 扫描和加载插件 |
206
- | 简单导出 | `export_plugins/simple_export.py` | 按拼音分类导出单字音频 |
207
  | UTAU 导出 | `export_plugins/utau_oto_export.py` | 生成 UTAU 音源配置文件 (oto.ini) |
208
  | 质量评分 | `quality_scorer.py` | 音频质量多维度评估 |
209
 
@@ -215,6 +217,14 @@ MFA 支持两种运行模式:
215
  - `MULTI_SELECT`: 多选框
216
  - `FILE`/`FOLDER`: 文件/文件夹选择
217
 
 
 
 
 
 
 
 
 
218
  ### 5. 音源质量评分模块
219
 
220
  `src/quality_scorer.py` 提供多维度音频质量评估:
@@ -234,9 +244,15 @@ scores = scorer.score_from_file("audio.wav")
234
  # 返回: {"duration": 0.85, "f0": 0.91, "combined": 0.88}
235
  ```
236
 
237
- 导出插件基类已集成质量评分接口:
238
- - `get_quality_scorer()`: 获取分器实例
239
- - `score_audio_quality()`: 直接评估频文件
 
 
 
 
 
 
240
 
241
  ### 5. MFA 跨平台支持
242
 
 
142
  │ │ • Overlap: 交叉淡化区域 │ │
143
  │ │ 3. IPA 音素转换为拼音/罗马音别名 │ │
144
  │ │ 4. 生成 oto.ini 配置文件 │ │
145
+ │ │ 5. 生成 character.txt(支持自定义角色名) │ │
146
+ │ │ 6. 自动检测文件名编码兼容性,不合法时转拼音 │ │
147
  │ └─────────────────────────────────────────────────────────────────────┘ │
148
  │ │
149
  │ 输出: export/[音源名称]/simple_export/ │
 
203
 
204
  | 模块 | 文件 | 功能 |
205
  |------|------|------|
206
+ | 插件基类 | `export_plugins/base.py` | 定义插件接口配置选项和公共方法 |
207
  | 插件加载器 | `export_plugins/loader.py` | 扫描和加载插件 |
208
+ | 简单导出 | `export_plugins/simple_export.py` | 按拼音分类导出单字音频,支持质量评估 |
209
  | UTAU 导出 | `export_plugins/utau_oto_export.py` | 生成 UTAU 音源配置文件 (oto.ini) |
210
  | 质量评分 | `quality_scorer.py` | 音频质量多维度评估 |
211
 
 
217
  - `MULTI_SELECT`: 多选框
218
  - `FILE`/`FOLDER`: 文件/文件夹选择
219
 
220
+ 基类公共方法 (`ExportPlugin`):
221
+ - `load_language_from_meta()`: 从 meta.json 加载语言设置
222
+ - `parse_quality_metrics()`: 解析质量评估维度选项
223
+ - `apply_naming_rule()`: 应用命名规则生成文件名/别名
224
+ - `get_source_paths()`: 获取音源相关路径
225
+ - `get_export_dir()`: 获取导出目录路径
226
+ - `get_quality_scorer()`: 获取质量评分器实例
227
+
228
  ### 5. 音源质量评分模块
229
 
230
  `src/quality_scorer.py` 提供多维度音频质量评估:
 
244
  # 返回: {"duration": 0.85, "f0": 0.91, "combined": 0.88}
245
  ```
246
 
247
+ 导出插件质量评估选项:
248
+ - `duration`: 仅时长估(默认,最快)
249
+ - `duration+rms`: 时长 + 量稳定性
250
+ - `duration+f0`: 时长 + 音高稳定性
251
+ - `all`: 全部维度(耗时较长)
252
+
253
+ 已集成质量评估的插件:
254
+ - **简单单字导出**: 默认仅评估时长,可选启用 RMS/F0 评估
255
+ - **UTAU oto.ini 导出**: 默认评估时长+RMS,可选启用 F0 评估
256
 
257
  ### 5. MFA 跨平台支持
258
 
src/export_plugins/base.py CHANGED
@@ -146,6 +146,63 @@ class ExportPlugin(ABC):
146
  "textgrid_dir": os.path.join(source_dir, "textgrid")
147
  }
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  def get_quality_scorer(
150
  self,
151
  enabled_metrics: Optional[List[str]] = None,
 
146
  "textgrid_dir": os.path.join(source_dir, "textgrid")
147
  }
148
 
149
+ def load_language_from_meta(self, bank_dir: str, source_name: str) -> str:
150
+ """
151
+ 从 meta.json 加载语言设置
152
+
153
+ 参数:
154
+ bank_dir: bank 目录路径
155
+ source_name: 音源名称
156
+
157
+ 返回:
158
+ 语言代码,默认 "chinese"
159
+ """
160
+ import json
161
+ meta_path = os.path.join(bank_dir, source_name, "meta.json")
162
+ try:
163
+ if os.path.exists(meta_path):
164
+ with open(meta_path, 'r', encoding='utf-8') as f:
165
+ meta = json.load(f)
166
+ language = meta.get("language", "chinese")
167
+ self._log(f"语言设置: {language}")
168
+ return language
169
+ except Exception as e:
170
+ logger.warning(f"读取 meta.json 失败: {e}")
171
+ return "chinese"
172
+
173
+ def parse_quality_metrics(self, metrics_str: str) -> List[str]:
174
+ """
175
+ 解析质量评估维度选项
176
+
177
+ 参数:
178
+ metrics_str: 选项字符串,如 "duration", "duration+rms", "all"
179
+
180
+ 返回:
181
+ 启用的维度列表
182
+ """
183
+ if metrics_str == "all":
184
+ return ["duration", "rms", "f0"]
185
+ elif metrics_str == "duration+rms":
186
+ return ["duration", "rms"]
187
+ elif metrics_str == "duration+f0":
188
+ return ["duration", "f0"]
189
+ else:
190
+ return ["duration"]
191
+
192
+ def apply_naming_rule(self, rule: str, base_name: str, index: int) -> str:
193
+ """
194
+ 应用命名规则生成文件名/别名
195
+
196
+ 参数:
197
+ rule: 命名规则,支持 %p%(拼音)和 %n%(序号)
198
+ base_name: 基础名称(拼音/罗马音)
199
+ index: 序号
200
+
201
+ 返回:
202
+ 生成的名称
203
+ """
204
+ return rule.replace("%p%", base_name).replace("%n%", str(index))
205
+
206
  def get_quality_scorer(
207
  self,
208
  enabled_metrics: Optional[List[str]] = None,
src/export_plugins/simple_export.py CHANGED
@@ -21,17 +21,12 @@ class SimpleExportPlugin(ExportPlugin):
21
  """简单单字导出插件"""
22
 
23
  name = "简单单字导出"
24
- description = "从TextGrid提取分词片段,按拼音排序导出"
25
  version = "1.1.0"
26
  author = "内置"
27
 
28
  def get_options(self) -> List[PluginOption]:
29
  return [
30
- PluginOption(
31
- key="info",
32
- label="将每个汉字按拼音分类,选取最佳样本导出",
33
- option_type=OptionType.LABEL
34
- ),
35
  PluginOption(
36
  key="max_samples",
37
  label="每个拼音最大样本数",
@@ -39,14 +34,22 @@ class SimpleExportPlugin(ExportPlugin):
39
  default=10,
40
  min_value=1,
41
  max_value=1000,
42
- description="按时长排序,保留最的N个"
 
 
 
 
 
 
 
 
43
  ),
44
  PluginOption(
45
  key="extend_duration",
46
  label="头尾拓展(秒)",
47
  option_type=OptionType.TEXT,
48
  default="0",
49
- description="裁剪时头尾各拓展的时长,最大1.5秒。若一边到达边界,另一边继续拓展"
50
  ),
51
  PluginOption(
52
  key="naming_rule",
@@ -71,25 +74,6 @@ class SimpleExportPlugin(ExportPlugin):
71
  )
72
  ]
73
 
74
- def _load_language_from_meta(self, bank_dir: str, source_name: str) -> str:
75
- """从meta.json加载语言设置"""
76
- meta_path = os.path.join(bank_dir, source_name, "meta.json")
77
- try:
78
- if os.path.exists(meta_path):
79
- with open(meta_path, 'r', encoding='utf-8') as f:
80
- meta = json.load(f)
81
- language = meta.get("language", "chinese")
82
- self._log(f"从meta.json读取语言设置: {language}")
83
- return language
84
- except Exception as e:
85
- logger.warning(f"读取meta.json失败: {e}")
86
- return "chinese"
87
-
88
- def _apply_naming_rule(self, rule: str, pinyin: str, index: int) -> str:
89
- """应用命名规则生成文件名"""
90
- name = rule.replace("%p%", pinyin).replace("%n%", str(index))
91
- return name
92
-
93
  def _apply_extend(
94
  self,
95
  start_time: float,
@@ -101,45 +85,29 @@ class SimpleExportPlugin(ExportPlugin):
101
  应用头尾拓展
102
 
103
  头尾各拓展 extend_duration 秒,若一边到达边界则另一边继续拓展
104
-
105
- 参数:
106
- start_time: 原始开始时间
107
- end_time: 原始结束时间
108
- extend_duration: 单边拓展时长
109
- audio_duration: 音频总时长
110
-
111
- 返回:
112
- (实际开始时间, 实际结束时间)
113
  """
114
  if extend_duration <= 0:
115
  return start_time, end_time
116
 
117
- total_extend = extend_duration * 2 # 总拓展量
118
-
119
- # 先尝试头尾各拓展
120
- head_extend = extend_duration
121
- tail_extend = extend_duration
122
 
123
- # 检查头部是否到达
124
- if start_time - head_extend < 0:
125
- head_actual = start_time # 头部只能拓展到0
126
- head_remaining = head_extend - head_actual # 剩余量转给尾部
127
- tail_extend += head_remaining
128
- head_extend = head_actual
129
 
130
- # 检查尾部是否
131
- if end_time + tail_extend > audio_duration:
132
- tail_actual = audio_duration - end_time # 尾部只能拓展到边界
133
- tail_remaining = tail_extend - tail_actual # 剩余量转给头部
134
- # 头部再次尝试拓展(如果还有空间)
135
- additional_head = min(tail_remaining, start_time - (start_time - head_extend))
136
- head_extend = min(start_time, head_extend + tail_remaining)
137
- tail_extend = tail_actual
138
 
139
- actual_start = max(0, start_time - head_extend)
140
- actual_end = min(audio_duration, end_time + tail_extend)
 
 
 
 
 
141
 
142
- return actual_start, actual_end
143
 
144
  def export(
145
  self,
@@ -149,12 +117,16 @@ class SimpleExportPlugin(ExportPlugin):
149
  ) -> Tuple[bool, str]:
150
  """执行简单单字导出"""
151
  try:
152
- # 自动从meta.json获取语言设置
153
- language = self._load_language_from_meta(bank_dir, source_name)
154
  max_samples = int(options.get("max_samples", 10))
155
  naming_rule = options.get("naming_rule", "%p%_%n%")
156
  first_naming_rule = options.get("first_naming_rule", "")
157
  clean_temp = options.get("clean_temp", True)
 
 
 
 
158
 
159
  paths = self.get_source_paths(bank_dir, source_name)
160
  export_dir = self.get_export_dir(bank_dir, source_name, "simple_export")
@@ -164,7 +136,7 @@ class SimpleExportPlugin(ExportPlugin):
164
  segments_dir = os.path.join(temp_base, source_name)
165
 
166
  # 获取头尾拓展参数
167
- extend_duration = min(float(options.get("extend_duration", 0)), 1.5)
168
 
169
  # 步骤1: 提取分词片段
170
  self._log("【提取分词片段】")
@@ -181,13 +153,14 @@ class SimpleExportPlugin(ExportPlugin):
181
  return False, msg
182
 
183
  # 步骤2: 排序导出
184
- self._log("\n【排序导出】")
185
  success, msg = self._sort_and_export(
186
  segments_dir,
187
  export_dir,
188
  max_samples,
189
  naming_rule,
190
- first_naming_rule
 
191
  )
192
  if not success:
193
  return False, msg
@@ -517,11 +490,13 @@ class SimpleExportPlugin(ExportPlugin):
517
  export_dir: str,
518
  max_samples: int,
519
  naming_rule: str,
520
- first_naming_rule: str
 
521
  ) -> Tuple[bool, str]:
522
  """排序并导出"""
523
  try:
524
  import soundfile as sf
 
525
 
526
  os.makedirs(export_dir, exist_ok=True)
527
 
@@ -541,8 +516,15 @@ class SimpleExportPlugin(ExportPlugin):
541
 
542
  self._log(f"扫描到 {len(wav_files)} 个片段")
543
 
 
 
 
544
  # 按拼音分组
545
- stats: Dict[str, List[Tuple[str, float]]] = {}
 
 
 
 
546
  for path in wav_files:
547
  rel_path = os.path.relpath(path, segments_dir)
548
  parts = rel_path.split(os.sep)
@@ -550,24 +532,42 @@ class SimpleExportPlugin(ExportPlugin):
550
  pinyin = parts[0]
551
  if pinyin not in stats:
552
  stats[pinyin] = []
553
- info = sf.info(path)
554
- stats[pinyin].append((path, info.duration))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
 
556
  self._log(f"统计到 {len(stats)} 个拼音")
557
  self._log(f"命名规则: {naming_rule}")
558
  if first_naming_rule:
559
  self._log(f"首个样本规则: {first_naming_rule}")
560
 
561
- # 按时长排序并导出
562
  exported = 0
563
  for pinyin, files in stats.items():
564
- sorted_files = sorted(files, key=lambda x: -x[1])
565
- for idx, (src_path, _) in enumerate(sorted_files[:max_samples]):
566
- # 第0个样本使用特殊规则(如果设置了)
567
  if idx == 0 and first_naming_rule:
568
- filename = self._apply_naming_rule(first_naming_rule, pinyin, idx)
569
  else:
570
- filename = self._apply_naming_rule(naming_rule, pinyin, idx)
571
 
572
  dst_path = os.path.join(export_dir, f'{filename}.wav')
573
  shutil.copyfile(src_path, dst_path)
 
21
  """简单单字导出插件"""
22
 
23
  name = "简单单字导出"
24
+ description = "从TextGrid提取分词片段,按时长排序导出"
25
  version = "1.1.0"
26
  author = "内置"
27
 
28
  def get_options(self) -> List[PluginOption]:
29
  return [
 
 
 
 
 
30
  PluginOption(
31
  key="max_samples",
32
  label="每个拼音最大样本数",
 
34
  default=10,
35
  min_value=1,
36
  max_value=1000,
37
+ description="按质量评分排序,保留最的N个"
38
+ ),
39
+ PluginOption(
40
+ key="quality_metrics",
41
+ label="质量评估维度",
42
+ option_type=OptionType.COMBO,
43
+ default="duration",
44
+ choices=["duration", "duration+rms", "duration+f0", "all"],
45
+ description="duration=仅时长, +rms=音量稳定性, +f0=音高稳定性。选择 all 可能耗时较长"
46
  ),
47
  PluginOption(
48
  key="extend_duration",
49
  label="头尾拓展(秒)",
50
  option_type=OptionType.TEXT,
51
  default="0",
52
+ description="裁剪时头尾各拓展的时长,最大0.5秒。若一边到达边界,另一边继续拓展"
53
  ),
54
  PluginOption(
55
  key="naming_rule",
 
74
  )
75
  ]
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  def _apply_extend(
78
  self,
79
  start_time: float,
 
85
  应用头尾拓展
86
 
87
  头尾各拓展 extend_duration 秒,若一边到达边界则另一边继续拓展
 
 
 
 
 
 
 
 
 
88
  """
89
  if extend_duration <= 0:
90
  return start_time, end_time
91
 
92
+ total_extend = extend_duration * 2
 
 
 
 
93
 
94
+ # 先尝试两各拓展
95
+ new_start = max(0, start_time - extend_duration)
96
+ new_end = min(audio_duration, end_time + extend_duration)
 
 
 
97
 
98
+ # 计算实际拓展量,剩余量补偿另一
99
+ used = (start_time - new_start) + (new_end - end_time)
100
+ remaining = total_extend - used
 
 
 
 
 
101
 
102
+ if remaining > 0:
103
+ # 优先补偿到尾部,再补偿到头部
104
+ extra_end = min(remaining, audio_duration - new_end)
105
+ new_end += extra_end
106
+ remaining -= extra_end
107
+ if remaining > 0:
108
+ new_start = max(0, new_start - remaining)
109
 
110
+ return new_start, new_end
111
 
112
  def export(
113
  self,
 
117
  ) -> Tuple[bool, str]:
118
  """执行简单单字导出"""
119
  try:
120
+ # 使用基类方法获取语言设置
121
+ language = self.load_language_from_meta(bank_dir, source_name)
122
  max_samples = int(options.get("max_samples", 10))
123
  naming_rule = options.get("naming_rule", "%p%_%n%")
124
  first_naming_rule = options.get("first_naming_rule", "")
125
  clean_temp = options.get("clean_temp", True)
126
+ quality_metrics = options.get("quality_metrics", "duration")
127
+
128
+ # 使用基类方法解析质量评估维度
129
+ enabled_metrics = self.parse_quality_metrics(quality_metrics)
130
 
131
  paths = self.get_source_paths(bank_dir, source_name)
132
  export_dir = self.get_export_dir(bank_dir, source_name, "simple_export")
 
136
  segments_dir = os.path.join(temp_base, source_name)
137
 
138
  # 获取头尾拓展参数
139
+ extend_duration = min(float(options.get("extend_duration", 0)), 0.5)
140
 
141
  # 步骤1: 提取分词片段
142
  self._log("【提取分词片段】")
 
153
  return False, msg
154
 
155
  # 步骤2: 排序导出
156
+ self._log(f"\n【排序导出】评估维度: {enabled_metrics}")
157
  success, msg = self._sort_and_export(
158
  segments_dir,
159
  export_dir,
160
  max_samples,
161
  naming_rule,
162
+ first_naming_rule,
163
+ enabled_metrics
164
  )
165
  if not success:
166
  return False, msg
 
490
  export_dir: str,
491
  max_samples: int,
492
  naming_rule: str,
493
+ first_naming_rule: str,
494
+ enabled_metrics: List[str]
495
  ) -> Tuple[bool, str]:
496
  """排序并导出"""
497
  try:
498
  import soundfile as sf
499
+ from src.quality_scorer import QualityScorer, duration_score
500
 
501
  os.makedirs(export_dir, exist_ok=True)
502
 
 
516
 
517
  self._log(f"扫描到 {len(wav_files)} 个片段")
518
 
519
+ # 判断是否需要加载音频计算质量分数
520
+ need_audio_scoring = any(m in enabled_metrics for m in ["rms", "f0"])
521
+
522
  # 按拼音分组
523
+ stats: Dict[str, List[Tuple[str, float, float]]] = {} # pinyin -> [(path, duration, score)]
524
+
525
+ if need_audio_scoring:
526
+ scorer = QualityScorer(enabled_metrics=enabled_metrics)
527
+
528
  for path in wav_files:
529
  rel_path = os.path.relpath(path, segments_dir)
530
  parts = rel_path.split(os.sep)
 
532
  pinyin = parts[0]
533
  if pinyin not in stats:
534
  stats[pinyin] = []
535
+
536
+ try:
537
+ info = sf.info(path)
538
+ duration = info.duration
539
+
540
+ if need_audio_scoring:
541
+ # 加载音频计算质量分数
542
+ audio, sr = sf.read(path)
543
+ if len(audio.shape) > 1:
544
+ audio = audio.mean(axis=1)
545
+ scores = scorer.score(audio, sr, duration)
546
+ quality_score = scores.get("combined", 0.5)
547
+ else:
548
+ # 仅使用时长评分
549
+ quality_score = duration_score(duration)
550
+
551
+ stats[pinyin].append((path, duration, quality_score))
552
+ except Exception as e:
553
+ logger.warning(f"处理文件失败 {path}: {e}")
554
+ continue
555
 
556
  self._log(f"统计到 {len(stats)} 个拼音")
557
  self._log(f"命名规则: {naming_rule}")
558
  if first_naming_rule:
559
  self._log(f"首个样本规则: {first_naming_rule}")
560
 
561
+ # 按质量分数排序并导出
562
  exported = 0
563
  for pinyin, files in stats.items():
564
+ sorted_files = sorted(files, key=lambda x: -x[2]) # 按质量分数降序
565
+ for idx, (src_path, _, _) in enumerate(sorted_files[:max_samples]):
566
+ # 使用基类方法应用命名规则
567
  if idx == 0 and first_naming_rule:
568
+ filename = self.apply_naming_rule(first_naming_rule, pinyin, idx)
569
  else:
570
+ filename = self.apply_naming_rule(naming_rule, pinyin, idx)
571
 
572
  dst_path = os.path.join(export_dir, f'{filename}.wav')
573
  shutil.copyfile(src_path, dst_path)
src/export_plugins/utau_oto_export.py CHANGED
@@ -233,11 +233,6 @@ class UTAUOtoExportPlugin(ExportPlugin):
233
 
234
  def get_options(self) -> List[PluginOption]:
235
  return [
236
- PluginOption(
237
- key="info",
238
- label="从 TextGrid phones 层提取音素,生成 oto.ini(音频不裁剪)",
239
- option_type=OptionType.LABEL
240
- ),
241
  PluginOption(
242
  key="cross_language",
243
  label="跨语种导出",
@@ -309,11 +304,11 @@ class UTAUOtoExportPlugin(ExportPlugin):
309
  description="oto.ini 和 character.txt 编码(UTAU 标准为 Shift_JIS)"
310
  ),
311
  PluginOption(
312
- key="sanitize_filename",
313
- label="文件转拼音",
314
- option_type=OptionType.SWITCH,
315
- default=False,
316
- description="文文件转为拼音清理特殊字符,防止 UTAU 识别故障"
317
  ),
318
  ]
319
 
@@ -325,8 +320,8 @@ class UTAUOtoExportPlugin(ExportPlugin):
325
  ) -> Tuple[bool, str]:
326
  """执行 UTAU oto.ini 导出"""
327
  try:
328
- # 加载语言设置
329
- language = self._load_language_from_meta(bank_dir, source_name)
330
 
331
  # 获取选项
332
  max_samples = int(options.get("max_samples", 5))
@@ -336,11 +331,11 @@ class UTAUOtoExportPlugin(ExportPlugin):
336
  alias_style = options.get("alias_style", "romaji")
337
  overlap_ratio = float(options.get("overlap_ratio", 0.3))
338
  encoding = options.get("encoding", "utf-8")
339
- sanitize_filename = options.get("sanitize_filename", False)
340
  use_hiragana = (alias_style == "hiragana") and language in ('japanese', 'ja', 'jp')
341
 
342
- # 解析质量评估维度
343
- enabled_metrics = self._parse_quality_metrics(quality_metrics)
344
 
345
  paths = self.get_source_paths(bank_dir, source_name)
346
  export_dir = self.get_export_dir(bank_dir, source_name, "utau_oto")
@@ -370,12 +365,10 @@ class UTAUOtoExportPlugin(ExportPlugin):
370
  )
371
  self._log(f"筛选后保留 {len(filtered_entries)} 条配置,涉及 {len(used_wavs)} 个音频文件")
372
 
373
- # 步骤3: 复制音频文件(可选文件名转拼音)
374
  self._log("\n【复制音频文件】")
375
- if sanitize_filename:
376
- self._log("已启用文件名转拼音")
377
  copied, filename_map = self._copy_wav_files(
378
- used_wavs, paths["slices_dir"], export_dir, sanitize_filename
379
  )
380
  self._log(f"复制了 {copied} 个音频文件")
381
 
@@ -388,7 +381,9 @@ class UTAUOtoExportPlugin(ExportPlugin):
388
  # 步骤5: 写入 character.txt
389
  self._log("\n【生成 character.txt】")
390
  char_path = os.path.join(export_dir, "character.txt")
391
- self._write_character_txt(source_name, char_path, encoding)
 
 
392
  self._log(f"写入: {char_path}")
393
 
394
  # 统计别名数量
@@ -399,31 +394,6 @@ class UTAUOtoExportPlugin(ExportPlugin):
399
  logger.error(f"UTAU oto.ini 导出失败: {e}", exc_info=True)
400
  return False, str(e)
401
 
402
- def _parse_quality_metrics(self, metrics_str: str) -> List[str]:
403
- """解析质量评估维度选项"""
404
- if metrics_str == "all":
405
- return ["duration", "rms", "f0"]
406
- elif metrics_str == "duration+rms":
407
- return ["duration", "rms"]
408
- elif metrics_str == "duration+f0":
409
- return ["duration", "f0"]
410
- else:
411
- return ["duration"]
412
-
413
- def _load_language_from_meta(self, bank_dir: str, source_name: str) -> str:
414
- """从 meta.json 加载语言设置"""
415
- meta_path = os.path.join(bank_dir, source_name, "meta.json")
416
- try:
417
- if os.path.exists(meta_path):
418
- with open(meta_path, 'r', encoding='utf-8') as f:
419
- meta = json.load(f)
420
- language = meta.get("language", "chinese")
421
- self._log(f"语言设置: {language}")
422
- return language
423
- except Exception as e:
424
- logger.warning(f"读取 meta.json 失败: {e}")
425
- return "chinese"
426
-
427
  def _parse_textgrids(
428
  self,
429
  slices_dir: str,
@@ -689,11 +659,11 @@ class UTAUOtoExportPlugin(ExportPlugin):
689
 
690
  # 保留前 N 个,并应用命名规则
691
  for idx, entry in enumerate(sorted_group[:max_samples]):
692
- # 生成带编号的别
693
  if idx == 0 and first_naming_rule:
694
- final_alias = self._apply_naming_rule(first_naming_rule, base_alias, idx)
695
  else:
696
- final_alias = self._apply_naming_rule(naming_rule, base_alias, idx)
697
 
698
  entry["alias"] = final_alias
699
  filtered.append(entry)
@@ -747,16 +717,12 @@ class UTAUOtoExportPlugin(ExportPlugin):
747
 
748
  return entries
749
 
750
- def _apply_naming_rule(self, rule: str, base_alias: str, index: int) -> str:
751
- """应用命名规则生成别名"""
752
- return rule.replace("%p%", base_alias).replace("%n%", str(index))
753
-
754
  def _copy_wav_files(
755
  self,
756
  wav_files: set,
757
  slices_dir: str,
758
  export_dir: str,
759
- sanitize: bool = False
760
  ) -> Tuple[int, Dict[str, str]]:
761
  """
762
  复制音频文件到导出目录
@@ -765,7 +731,7 @@ class UTAUOtoExportPlugin(ExportPlugin):
765
  wav_files: 需要复制的文件名集合
766
  slices_dir: 源目录
767
  export_dir: 目标目录
768
- sanitize: 是否对文件名进行转拼音和清理
769
 
770
  返回:
771
  (复制数量, 文件名映射表 {原文件名: 新文件名})
@@ -773,25 +739,48 @@ class UTAUOtoExportPlugin(ExportPlugin):
773
  copied = 0
774
  filename_map: Dict[str, str] = {}
775
  used_names: set = set()
 
776
 
777
  for wav_name in wav_files:
778
  src = os.path.join(slices_dir, wav_name)
779
  if not os.path.exists(src):
780
  continue
781
 
782
- if sanitize:
783
- new_name = self._sanitize_filename(wav_name, used_names)
784
- used_names.add(new_name)
785
- else:
786
  new_name = wav_name
 
 
 
787
 
 
788
  filename_map[wav_name] = new_name
789
  dst = os.path.join(export_dir, new_name)
790
  shutil.copyfile(src, dst)
791
  copied += 1
792
 
 
 
 
793
  return copied, filename_map
794
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
795
  def _sanitize_filename(self, filename: str, used_names: set) -> str:
796
  """
797
  清理文件名:中文转拼音 + 特殊字符清理 + 防冲突
@@ -883,25 +872,31 @@ class UTAUOtoExportPlugin(ExportPlugin):
883
 
884
  def _write_character_txt(
885
  self,
886
- source_name: str,
887
  output_path: str,
888
  encoding: str
889
  ):
890
  """写入 character.txt 文件,用于 UTAU 识别音源名称
891
 
892
- 注意:当音源名称包含无法用指定编码表示的字符时,
 
 
 
 
 
893
  自动将名称转换为拼音/罗马音。
894
  """
895
- name_to_write = source_name
896
 
897
  # 检测是否能用指定编码
898
  try:
899
- source_name.encode(encoding)
900
  except UnicodeEncodeError:
901
  # 无法编码,转换为拼音
902
  from pypinyin import lazy_pinyin
903
- pinyin_name = ''.join(lazy_pinyin(source_name))
904
- logger.warning(f"音源名称 '{source_name}' 无法用 {encoding} 编码,已转换为拼音: {pinyin_name}")
 
905
  name_to_write = pinyin_name
906
 
907
  with open(output_path, 'w', encoding=encoding) as f:
 
233
 
234
  def get_options(self) -> List[PluginOption]:
235
  return [
 
 
 
 
 
236
  PluginOption(
237
  key="cross_language",
238
  label="跨语种导出",
 
304
  description="oto.ini 和 character.txt 编码(UTAU 标准为 Shift_JIS)"
305
  ),
306
  PluginOption(
307
+ key="character_name",
308
+ label="角色",
309
+ option_type=OptionType.TEXT,
310
+ default="",
311
+ description="character.txt 的角色名,留空则使用音源名称"
312
  ),
313
  ]
314
 
 
320
  ) -> Tuple[bool, str]:
321
  """执行 UTAU oto.ini 导出"""
322
  try:
323
+ # 使用基类方法加载语言设置
324
+ language = self.load_language_from_meta(bank_dir, source_name)
325
 
326
  # 获取选项
327
  max_samples = int(options.get("max_samples", 5))
 
331
  alias_style = options.get("alias_style", "romaji")
332
  overlap_ratio = float(options.get("overlap_ratio", 0.3))
333
  encoding = options.get("encoding", "utf-8")
334
+ character_name = options.get("character_name", "").strip()
335
  use_hiragana = (alias_style == "hiragana") and language in ('japanese', 'ja', 'jp')
336
 
337
+ # 使用基类方法解析质量评估维度
338
+ enabled_metrics = self.parse_quality_metrics(quality_metrics)
339
 
340
  paths = self.get_source_paths(bank_dir, source_name)
341
  export_dir = self.get_export_dir(bank_dir, source_name, "utau_oto")
 
365
  )
366
  self._log(f"筛选后保留 {len(filtered_entries)} 条配置,涉及 {len(used_wavs)} 个音频文件")
367
 
368
+ # 步骤3: 复制音频文件(自动检测文件名是否需要转拼音)
369
  self._log("\n【复制音频文件】")
 
 
370
  copied, filename_map = self._copy_wav_files(
371
+ used_wavs, paths["slices_dir"], export_dir, encoding
372
  )
373
  self._log(f"复制了 {copied} 个音频文件")
374
 
 
381
  # 步骤5: 写入 character.txt
382
  self._log("\n【生成 character.txt】")
383
  char_path = os.path.join(export_dir, "character.txt")
384
+ # 使用自定义角色名,留空则使用音源名称
385
+ final_character_name = character_name if character_name else source_name
386
+ self._write_character_txt(final_character_name, char_path, encoding)
387
  self._log(f"写入: {char_path}")
388
 
389
  # 统计别名数量
 
394
  logger.error(f"UTAU oto.ini 导出失败: {e}", exc_info=True)
395
  return False, str(e)
396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  def _parse_textgrids(
398
  self,
399
  slices_dir: str,
 
659
 
660
  # 保留前 N 个,并应用命名规则
661
  for idx, entry in enumerate(sorted_group[:max_samples]):
662
+ # 使用基类方法应用命规则
663
  if idx == 0 and first_naming_rule:
664
+ final_alias = self.apply_naming_rule(first_naming_rule, base_alias, idx)
665
  else:
666
+ final_alias = self.apply_naming_rule(naming_rule, base_alias, idx)
667
 
668
  entry["alias"] = final_alias
669
  filtered.append(entry)
 
717
 
718
  return entries
719
 
 
 
 
 
720
  def _copy_wav_files(
721
  self,
722
  wav_files: set,
723
  slices_dir: str,
724
  export_dir: str,
725
+ encoding: str = "shift_jis"
726
  ) -> Tuple[int, Dict[str, str]]:
727
  """
728
  复制音频文件到导出目录
 
731
  wav_files: 需要复制的文件名集合
732
  slices_dir: 源目录
733
  export_dir: 目标目录
734
+ encoding: 目标编码,用于检测文件名是否合法
735
 
736
  返回:
737
  (复制数量, 文件名映射表 {原文件名: 新文件名})
 
739
  copied = 0
740
  filename_map: Dict[str, str] = {}
741
  used_names: set = set()
742
+ sanitized_count = 0
743
 
744
  for wav_name in wav_files:
745
  src = os.path.join(slices_dir, wav_name)
746
  if not os.path.exists(src):
747
  continue
748
 
749
+ # 检测文件名是否能用指定编码表示
750
+ if self._is_filename_valid(wav_name, encoding):
 
 
751
  new_name = wav_name
752
+ else:
753
+ new_name = self._sanitize_filename(wav_name, used_names)
754
+ sanitized_count += 1
755
 
756
+ used_names.add(new_name)
757
  filename_map[wav_name] = new_name
758
  dst = os.path.join(export_dir, new_name)
759
  shutil.copyfile(src, dst)
760
  copied += 1
761
 
762
+ if sanitized_count > 0:
763
+ self._log(f"已将 {sanitized_count} 个文件名转换为拼音(原文件名无法用 {encoding} 编码)")
764
+
765
  return copied, filename_map
766
 
767
+ def _is_filename_valid(self, filename: str, encoding: str) -> bool:
768
+ """
769
+ 检测文件名是否合法(能否用指定编码表示)
770
+
771
+ 参数:
772
+ filename: 文件名
773
+ encoding: 目标编码
774
+
775
+ 返回:
776
+ True 表示文件名合法,False 表示需要转换
777
+ """
778
+ try:
779
+ filename.encode(encoding)
780
+ return True
781
+ except UnicodeEncodeError:
782
+ return False
783
+
784
  def _sanitize_filename(self, filename: str, used_names: set) -> str:
785
  """
786
  清理文件名:中文转拼音 + 特殊字符清理 + 防冲突
 
872
 
873
  def _write_character_txt(
874
  self,
875
+ character_name: str,
876
  output_path: str,
877
  encoding: str
878
  ):
879
  """写入 character.txt 文件,用于 UTAU 识别音源名称
880
 
881
+ 参数:
882
+ character_name: 角色名称(可以是用户自定义的名称或音源名称)
883
+ output_path: 输出路径
884
+ encoding: 文件编码
885
+
886
+ 注意:当角色名称包含无法用指定编码表示的字符时,
887
  自动将名称转换为拼音/罗马音。
888
  """
889
+ name_to_write = character_name
890
 
891
  # 检测是否能用指定编码
892
  try:
893
+ character_name.encode(encoding)
894
  except UnicodeEncodeError:
895
  # 无法编码,转换为拼音
896
  from pypinyin import lazy_pinyin
897
+ pinyin_name = ''.join(lazy_pinyin(character_name))
898
+ logger.warning(f"角色名称 '{character_name}' 无法用 {encoding} 编码,已转换为拼音: {pinyin_name}")
899
+ self._log(f"角色名称 '{character_name}' 无法用 {encoding} 编码,已转换为拼音: {pinyin_name}")
900
  name_to_write = pinyin_name
901
 
902
  with open(output_path, 'w', encoding=encoding) as f:
src/gui_cloud.py CHANGED
@@ -469,8 +469,14 @@ def process_export_voicebank(
469
  log("\n" + "=" * 50)
470
  log("【打包结果】")
471
 
472
- # 根据插件类型确定导出目录
473
- export_subdir = "utau_oto" if "UTAU" in plugin_name else "simple_export"
 
 
 
 
 
 
474
  export_dir = os.path.join(workspace, "export", source_name, export_subdir)
475
 
476
  # 如果导出目录不存在,尝试其他位置
@@ -482,16 +488,21 @@ def process_export_voicebank(
482
  # 再尝试另一个子目录
483
  if not os.path.exists(export_dir):
484
  other_subdir = "simple_export" if export_subdir == "utau_oto" else "utau_oto"
 
485
  export_dir = os.path.join(workspace, "export", source_name, other_subdir)
486
  if not os.path.exists(export_dir):
487
  alt_export = os.path.join(os.path.dirname(bank_dir), "export", source_name, other_subdir)
488
  if os.path.exists(alt_export):
489
  export_dir = alt_export
 
 
 
490
 
491
  if not os.path.exists(export_dir):
492
  return "❌ 未找到导出结果", "\n".join(logs), None
493
 
494
- zip_name = f"{source_name}_导出结果"
 
495
  result_zip = create_zip(export_dir, zip_name)
496
 
497
  if result_zip:
 
469
  log("\n" + "=" * 50)
470
  log("【打包结果】")
471
 
472
+ # 根据插件类型确定导出目录和导出标识
473
+ if "UTAU" in plugin_name:
474
+ export_subdir = "utau_oto"
475
+ export_id = "utau_oto_export"
476
+ else:
477
+ export_subdir = "simple_export"
478
+ export_id = "simple_export"
479
+
480
  export_dir = os.path.join(workspace, "export", source_name, export_subdir)
481
 
482
  # 如果导出目录不存在,尝试其他位置
 
488
  # 再尝试另一个子目录
489
  if not os.path.exists(export_dir):
490
  other_subdir = "simple_export" if export_subdir == "utau_oto" else "utau_oto"
491
+ other_id = "simple_export" if export_id == "utau_oto_export" else "utau_oto_export"
492
  export_dir = os.path.join(workspace, "export", source_name, other_subdir)
493
  if not os.path.exists(export_dir):
494
  alt_export = os.path.join(os.path.dirname(bank_dir), "export", source_name, other_subdir)
495
  if os.path.exists(alt_export):
496
  export_dir = alt_export
497
+ export_id = other_id
498
+ else:
499
+ export_id = other_id
500
 
501
  if not os.path.exists(export_dir):
502
  return "❌ 未找到导出结果", "\n".join(logs), None
503
 
504
+ # 命名格式: [音源名称]_[插件标识]
505
+ zip_name = f"{source_name}_{export_id}"
506
  result_zip = create_zip(export_dir, zip_name)
507
 
508
  if result_zip: