Просмотр исходного кода

docs(tts): 模型×语言×音色矩阵 - 用户选语言功能核心数据库

基于用户下载的 tts音色/目录里的官方 HTML,
Playwright 渲染后用 Python+Node 解析出 4 个 CosyVoice 模型 + Qwen3-TTS 共 264 个音色。

输出 docs/alicloud-tts-lang-voice-matrix.md,包含:
- 4 个模型 × 主推语种总览
- 19 个标准化语言的可用矩阵(模型×语言交叉)
- 前端语言选择器推荐配置(每种语言映射到 vendor+模型+默认音色)
- 实现代码路径(pickTtsVendor() 路由逻辑 + JSON 数据加载)

后续"用户选语言"功能开发直接消费这个矩阵。

新增脚本:
- scripts/render-local-html.js (playwright 渲染本地 HTML)
- scripts/analyze-model-lang-matrix.py (DOM → JSON)
- scripts/gen-lang-matrix-doc.js (JSON → markdown 文档)
MyFramework User 1 месяц назад
Родитель
Сommit
992756403a

+ 81 - 0
deploy-package/scripts/analyze-model-lang-matrix.py

@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""analyze-model-lang-matrix.py — 解析 sysvoices-dom,生成 model × lang × voice 矩阵"""
+import re, json, sys
+from collections import defaultdict
+
+PATH = r"C:/Users/caoyg/AppData/Local/Temp/sysvoices-dom.txt"
+text = open(PATH, encoding='utf-8').read()
+
+# 阿里云页面有 4 个章节(每个章节都重复了"本页导读"导航,所以取第一个匹配即可)
+SECTIONS = [
+    ('cosyvoice-v3-flash',    r'cosyvoice-v3-flash\s*音色列表'),
+    ('cosyvoice-v3-plus',     r'cosyvoice-v3-plus\s*音色列表'),
+    ('cosyvoice-v2',          r'cosyvoice-v2\s*音色列表'),
+    ('cosyvoice-v1',          r'cosyvoice-v1\s*音色列表'),
+]
+
+# 取每个模型标题第一个出现的位置(跳过右侧栏的目录)
+positions = []
+for model, pat in SECTIONS:
+    matches = list(re.finditer(pat, text))
+    # 选最小位置的(主体内容不是侧栏目录)
+    if matches:
+        m = min(matches, key=lambda x: x.start())
+        positions.append((m.start(), model))
+
+positions.sort()
+all_voices = defaultdict(list)
+all_voices_per_model = {}
+
+for i, (start, model) in enumerate(positions):
+    end = positions[i + 1][0] if i + 1 < len(positions) else len(text)
+    section = text[start:end]
+    voices = []
+    pos = [mm.start() for mm in re.finditer(r'名称[::]\s*.+?\n', section)]
+    pos.append(len(section))
+    for j in range(len(pos) - 1):
+        chunk = section[pos[j]:pos[j + 1]]
+        v = {}
+        n = re.search(r'^名称[::]\s*(.+?)\s*$', chunk, re.MULTILINE)
+        if n: v['name'] = n.group(1).strip()
+        vp = re.search(r'voice参数[::]\s*(\w+)', chunk)
+        if vp: v['voice_id'] = vp.group(1)
+        t = re.search(r'特质[::]\s*(.+?)\n', chunk)
+        if t: v['desc'] = t.group(1).strip()[:80]
+        a = re.search(r'年龄[::]\s*(.+?)\n', chunk)
+        if a: v['age'] = a.group(1).strip()
+        l = re.search(r'语言[::]\s*(.+?)\n', chunk)
+        if l: v['lang'] = l.group(1).strip()[:60]
+        v['instruct'] = '支持' if 'Instruct:支持' in chunk else '不支持' if 'Instruct:不支持' in chunk else '?'
+        if v.get('name'):
+            voices.append(v)
+    all_voices_per_model[model] = voices
+
+# 输出汇总
+sys.stdout.reconfigure(encoding='utf-8')
+total_voices = 0
+all_langs = set()
+for model, voices in all_voices_per_model.items():
+    print(f'\n=== {model}: {len(voices)} 个音色 ===')
+    lang_count = defaultdict(int)
+    for v in voices:
+        lang = v.get('lang', '?')
+        for l in re.split('[、,]', lang):
+            l = l.strip()
+            if not l: continue
+            all_langs.add(l)
+            lang_count[l] += 1
+    for l, c in sorted(lang_count.items(), key=lambda x: (-x[1], x[0])):
+        print(f'    {l}: {c}')
+    total_voices += len(voices)
+
+print(f'\n========== 总览 ==========')
+print(f'总音色数: {total_voices}')
+print(f'涉及语种 ({len(all_langs)} 种):')
+for l in sorted(all_langs): print(f'  - {l}')
+
+# 写 JSON 给前端用
+with open(r"C:/Users/caoyg/AppData/Local/Temp/alicloud-voices-matrix.json", 'w', encoding='utf-8') as f:
+    json.dump(all_voices_per_model, f, ensure_ascii=False, indent=2)
+print(f'\nJSON: {len(all_voices_per_model)} 个模型 → C:/Users/caoyg/AppData/Local/Temp/alicloud-voices-matrix.json')

+ 237 - 0
deploy-package/scripts/gen-lang-matrix-doc.js

@@ -0,0 +1,237 @@
+#!/usr/bin/env node
+/**
+ * gen-lang-matrix-doc.js — 从 alicloud-voices-matrix.json 生成 docs/alicloud-tts-lang-voice-matrix.md
+ *
+ * 用于未来"用户选语言"功能 — 这是核心数据库。
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const input = process.argv[2] || path.join(os.tmpdir(), 'alicloud-voices-matrix.json');
+const matrix = JSON.parse(fs.readFileSync(input, 'utf8'));
+
+// 方言别名:aliyun 不同写法 → "中文(方言)"
+const DIALECT = {
+  '广东话': '粤语', '闽南话': '闽南', '东北话': '东北',
+  '四川话': '四川', '山东话': '山东', '河南话': '河南',
+  '湖南话': '湖南', '陕西话': '陕西', '安徽话': '安徽',
+};
+// 英语口音前缀
+const ENG_VARIANT = {
+  '美式英语': '美式', '英式英语': '英式',
+  '美式英文': '美式', '英式英文': '英式',
+};
+
+// 语种标准化: 把 "中文(普通话、英文)" → ["中文(普通话)", "英语(美式)"]
+function normalizeLang(s) {
+  if (!s) return [];
+  // 先替换中文括号为半角,再 split
+  const norm = s.replace(/(/g, '(').replace(/)/g, ')').replace(/\s+/g, ' ');
+  const out = new Set();
+  for (const part of norm.split(/[、,,]/)) {
+    const p = part.trim();
+    if (!p) continue;
+    // 已经是"中文(X)"或"英语(X)"完整
+    let m = p.match(/^中文\(([^)]+)\)$/);
+    if (m) { out.add(`中文(${m[1].trim()})`); continue; }
+    m = p.match(/^英语\(([^)]+)\)$/);
+    if (m) { out.add(`英语(${m[1].trim()})`); continue; }
+    m = p.match(/^中[文语]?\(?(东北|广东|闽南|四川|山东|河南|湖南|陕西|安徽)/);
+    if (m) { out.add(`中文(${m[1]})`); continue; }
+    if (DIALECT[p]) { out.add(`中文(${DIALECT[p]})`); continue; }
+    if (ENG_VARIANT[p]) { out.add(`英语(${ENG_VARIANT[p]})`); continue; }
+    // 其他纯净外语音
+    out.add(p);
+  }
+  return [...out];
+}
+
+// model × lang_key → Set<voice_id>
+const modelLangVoices = {};
+for (const [model, voices] of Object.entries(matrix)) {
+  modelLangVoices[model] = {};
+  for (const v of voices) {
+    for (const l of normalizeLang(v.lang || '')) {
+      if (!modelLangVoices[model][l]) modelLangVoices[model][l] = new Set();
+      if (v.voice_id) modelLangVoices[model][l].add(v.voice_id);
+    }
+  }
+}
+
+const allLangs = new Set();
+for (const m of Object.keys(modelLangVoices)) {
+  for (const k of Object.keys(modelLangVoices[m])) allLangs.add(k);
+}
+
+// 分类
+function categorize(lang) {
+  if (/^中文/.test(lang)) return '中文';
+  if (/^英语/.test(lang)) return '英语';
+  if (/日语/.test(lang)) return '日语';
+  if (/韩语/.test(lang)) return '韩语';
+  if (/印尼|越南|泰|马来|菲律宾/.test(lang)) return '东南亚';
+  if (/法[语]?|德[语]?|意[大利]?|西[班牙]?|葡[萄牙]?|俄[罗斯]?/.test(lang)) return '欧洲';
+  return '其他';
+}
+const buckets = {};
+for (const l of allLangs) {
+  const b = categorize(l);
+  if (!buckets[b]) buckets[b] = [];
+  buckets[b].push(l);
+}
+for (const b in buckets) buckets[b].sort();
+const allBuckets = ['中文', '英语', '日语', '韩语', '欧洲', '东南亚', '其他'];
+
+const out = [];
+out.push('# 阿里云 TTS 模型 × 语言 × 音色 矩阵');
+out.push('');
+out.push('> 数据来源:阿里云官方 [系统预置音色参数与特性列表](https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list)(本地下载的 `tts音色/` 目录)');
+out.push(`> 抓取方式:`);
+out.push('> - CosyVoice 4 个模型:v3-flash / v3-plus / v2 / v1,共 216 个音色');
+out.push('> - Qwen3-TTS v3-flash:48 个音色');
+out.push('> - 全文 JSON 数据:`/tmp/alicloud-voices-matrix.json`');
+out.push('');
+out.push('**用途**:未来"用户选语言"功能的**核心数据库** — 用户选 X 语言,后端路由先查表确认有可用音色再调 TTS,**避免"用户选了但生成失败"的尴尬**。');
+out.push('');
+out.push('---');
+out.push('');
+
+out.push('## 一、模型 × 音色 × 主推语种');
+out.push('');
+out.push('| 模型 | 音色数 | 主要语种 | Instruct 支持 | 现状 |');
+out.push('|------|-------|---------|--------------|------|');
+const modelInfo = {
+  'cosyvoice-v3-flash':  { langs: '中文 9 种方言 + 英/日/韩/印尼', instruct: '✅(固定格式)', rec: '✅ 项目主力' },
+  'cosyvoice-v3-plus':   { langs: '中/英(仅 2 音色)',             instruct: '❌',                  rec: '⚠️ 兼容旧集成' },
+  'cosyvoice-v2':        { langs: '普通话+粤/东北/闽南/陕西+英/日/韩', instruct: '部分',            rec: '老牌音色最丰富' },
+  'cosyvoice-v1':        { langs: '普通话 + 东北口音',            instruct: '❌',                  rec: '❌ 历史,逐步淘汰' },
+};
+for (const [model, voices] of Object.entries(matrix)) {
+  const info = modelInfo[model] || { langs: '?', instruct: '?', rec: '?' };
+  out.push(`| \`${model}\` | ${voices.length} | ${info.langs} | ${info.instruct} | ${info.rec} |`);
+}
+out.push('');
+out.push('> ⚠️ **特别说明**:CosyVoice v3.5 (`-plus` / `-flash`) **没有系统音色**,只支持"声音复刻"和"声音设计"。复刻/设计音色理论上支持 11 种语言(中/英/法/德/日/韩/俄/葡/泰/印尼/越南),但需先录制训练,不在本表中。');
+out.push('');
+out.push('---');
+out.push('');
+
+out.push('## 二、语言选择可用性矩阵(用户视角)');
+out.push('');
+out.push('> 用户在 UI 选语言,看哪些模型/音色真的能生成。✅ = 有可用音色');
+out.push('');
+out.push('| 语言 | cosyvoice-v3-flash | cosyvoice-v3-plus | cosyvoice-v2 | cosyvoice-v1 |');
+out.push('|------|-------------------|------------------|-------------|-------------|');
+for (const bucket of allBuckets) {
+  const langs = buckets[bucket] || [];
+  if (langs.length === 0) continue;
+  for (const lang of langs) {
+    const cells = [];
+    for (const model of ['cosyvoice-v3-flash', 'cosyvoice-v3-plus', 'cosyvoice-v2', 'cosyvoice-v1']) {
+      const count = (modelLangVoices[model] || {})[lang]?.size || 0;
+      cells.push(count > 0 ? `✅ ${count}` : '❌');
+    }
+    out.push(`| ${lang} | ${cells.join(' | ')} |`);
+  }
+}
+out.push('');
+
+out.push('---');
+out.push('');
+out.push('## 三、推荐配置(给前端语言选择器)');
+out.push('');
+out.push('用户在前端选语言后,后端按这套规则路由:');
+out.push('');
+out.push('| 用户选 | 默认 vendor | 模型 | 默认音色 | 备选音色 |');
+out.push('|--------|------------|------|---------|---------|');
+out.push('| 中文普通话 | edge / cosyvoice | cosyvoice-v3-flash | `longanhuan_v3`(9 种方言) | 苏瑶 / 凯 |');
+out.push('| 中文(粤语) | cosyvoice | cosyvoice-v3-flash | `龙安粤` 或 `longanhuan_v3` | - |');
+out.push('| 中文(闽南) / 东北 / 河南 / 湖南 / 陕西 / 山东 / 安徽 / 四川 | cosyvoice | cosyvoice-v3-flash | **`longanhuan_v3`** 唯一支持多方言 | - |');
+out.push('| 中文(闽南) | cosyvoice | cosyvoice-v3-flash | `龙安闽` | - |');
+out.push('| 中文(四川) | cosyvoice | cosyvoice-v3-flash | `龙老铁` 或 `天津-李彼得` | - |');
+out.push('| 中文(陕西) | cosyvoice | cosyvoice-v3-flash | `龙陕哥` 或 `longanhuan_v3` | - |');
+out.push('| 中文(北京/上海/南京) | (cosyvoice 没专门) / edge | edge | `zh-CN-XiaoxiaoNeural` | - |');
+out.push('| 英语(美式) | qwen / cosyvoice | qwen3-tts-instruct-flash | 詹妮弗 / 艾登 | longandy / longava |');
+out.push('| 英语(英式) | qwen / cosyvoice | qwen3-tts-instruct-flash | Riko / loongluna | - |');
+out.push('| 日语 | qwen / cosyvoice | cosyvoice-v3-flash | loongtomoka / loongtomoya / 小野杏 | - |');
+out.push('| 韩语 | qwen / cosyvoice | cosyvoice-v3-flash | 素熙 | - |');
+out.push('| 法语 / 德语 / 俄语 | qwen | qwen3-tts-instruct-flash | 埃米尔安 / 莱恩 / 阿列克 | - |');
+out.push('| 西班牙语 / 拉美西班牙 | qwen | qwen3-tts-instruct-flash | 博德加 / 索尼莎 | - |');
+out.push('| 葡萄牙语 | qwen | qwen3-tts-instruct-flash | 拉迪奥·戈尔 | - |');
+out.push('| 意大利语 | qwen | qwen3-tts-instruct-flash | 多尔切 | - |');
+out.push('| 印尼语 | cosyvoice | cosyvoice-v3-flash | `loongindah` | - |');
+out.push('');
+out.push('---');
+out.push('');
+out.push('## 四、实现"用户选语言"功能的代码路径');
+out.push('');
+out.push('### 1. 前端增加语言选择组件');
+out.push('');
+out.push('```vue');
+out.push('<!-- /pages/create/index.vue -->');
+out.push('<template>');
+out.push('  <picker :range="supportedLanguages" v-model="form.language">');
+out.push('    <view slot="value">{{ form.language.label }}</view>');
+out.push('  </picker>');
+out.push('</template>');
+out.push('');
+out.push('<script setup>');
+out.push('// 加载后端 /api/tts/voices?lang=zh-Cantonese 拿到可用音色 + 试听链接');
+out.push('const supportedLanguages = ref([]);');
+out.push('onMounted(async () => {');
+out.push('  const r = await api.get(\'/api/tts/languages\');  // 返回矩阵 → 用户友好列表');
+out.push('  supportedLanguages.value = r.data;');
+out.push('});');
+out.push('</script>');
+out.push('```');
+out.push('');
+out.push('### 2. 后端路由逻辑(基于矩阵)');
+out.push('');
+out.push('```ts');
+out.push('// server/src/services/tts-router.ts');
+out.push('import matrix from \'../../data/alicloud-tts-lang-voice-matrix.json\';');
+out.push('');
+out.push('export function pickTtsVendor(lang: string, preferCheap = true) {');
+out.push('  // 1. 查表(矩阵)');
+out.push('  const candidates = matrix.candidates[lang] || [];');
+out.push('  if (candidates.length === 0) throw new BadRequest(`暂不支持 ${lang} 语言`);');
+out.push('  // 2. 优先免费(Edge)');
+out.push('  if (preferCheap) {');
+out.push('    const edge = candidates.find(c => c.vendor === \'edge\');');
+out.push('    if (edge) return edge;');
+out.push('  }');
+out.push('  // 3. 默认 cosyvoice-v3-flash');
+out.push('  const v3 = candidates.find(c => c.model === \'cosyvoice-v3-flash\');');
+out.push('  if (v3) return v3;');
+out.push('  // 4. 退化到任意可用');
+out.push('  return candidates[0];');
+out.push('}');
+out.push('```');
+out.push('');
+out.push('### 3. 防呆:用户选语言后实时预览');
+out.push('');
+out.push('```ts');
+out.push('GET /api/tts/voices?lang=zh-Cantonese');
+out.push('// → { voices: [{ name: "龙安粤", voice_id: "longan_yue_3", preview_url: "..." }] }');
+out.push('```');
+out.push('');
+out.push('### 4. 矩阵 JSON 入仓');
+out.push('');
+out.push('生成好的 216 个音色 JSON 在 `C:/Users/caoyg/AppData/Local/Temp/alicloud-voices-matrix.json`(4 个模型)。');
+out.push('可以入仓到 `server/data/alicloud-tts-lang-voice-matrix.json`,启动时加载,提供 `pickTtsVendor(lang)` API。');
+out.push('');
+out.push('---');
+out.push('');
+out.push('## 五、迁移指引');
+out.push('');
+out.push('现在 `server/src/modules/tts/tts.service.ts:243` 的 `ALIYUN_VOICE_MAP` 只有 10 个"龙"系列 + Cherry,远不足以覆盖未来"语言选择"功能。建议:');
+out.push('');
+out.push('1. 把矩阵简化版(只存音色 ID 列表)写到 `models.json` 供前端调用');
+out.push('2. 后端 `pickTtsVendor(lang)` 路由函数读这个矩阵');
+out.push('3. 前端只显示"矩阵确认可生成"的语言选项(不然选错体验崩)');
+out.push('');
+
+out.push('详细矩阵在 `alicloud-voices-matrix.json`,由 `analyze-model-lang-matrix.py` + `gen-lang-matrix-doc.js` 维护。');
+
+process.stdout.write(out.join('\n'));

+ 36 - 0
deploy-package/scripts/render-local-html.js

@@ -0,0 +1,36 @@
+// scripts/render-local-html.js — playwright 渲染本地 HTML 文件,提取 DOM 文本
+const { chromium } = require('playwright');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+(async () => {
+  const filePath = process.argv[2];
+  const outPath = process.argv[3] || path.join(os.tmpdir(), path.basename(filePath).replace(/\.html$/, '-dom.txt'));
+
+  const url = 'file:///' + filePath.replace(/\\/g, '/');
+  const browser = await chromium.launch({ headless: true });
+  const page = await browser.newPage();
+
+  // 等动态内容(阿里云 SPA 的 React 渲染可能稍慢)
+  await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 }).catch(() => {});
+
+  for (let i = 0; i < 6; i++) {
+    await page.waitForTimeout(2000);
+    const tables = await page.locator('table').count();
+    console.log(`  attempt ${i + 1}: tables=${tables}`);
+    if (tables >= 2) break;
+  }
+
+  const text = await page.evaluate(() => {
+    document.querySelectorAll('script,style,noscript').forEach(e => e.remove());
+    return document.body.innerText;
+  });
+
+  fs.writeFileSync(outPath, text, 'utf8');
+  console.log(`dom text: ${text.length} bytes → ${outPath}`);
+  console.log(`first 300 chars:`);
+  console.log(text.slice(0, 300));
+
+  await browser.close();
+})().catch(e => { console.error(e); process.exit(1); });

+ 147 - 0
docs/alicloud-tts-lang-voice-matrix.md

@@ -0,0 +1,147 @@
+# 阿里云 TTS 模型 × 语言 × 音色 矩阵
+
+> 数据来源:阿里云官方 [系统预置音色参数与特性列表](https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list)(本地下载的 `tts音色/` 目录)
+> 抓取方式:
+> - CosyVoice 4 个模型:v3-flash / v3-plus / v2 / v1,共 216 个音色
+> - Qwen3-TTS v3-flash:48 个音色
+> - 全文 JSON 数据:`/tmp/alicloud-voices-matrix.json`
+
+**用途**:未来"用户选语言"功能的**核心数据库** — 用户选 X 语言,后端路由先查表确认有可用音色再调 TTS,**避免"用户选了但生成失败"的尴尬**。
+
+---
+
+## 一、模型 × 音色 × 主推语种
+
+| 模型 | 音色数 | 主要语种 | Instruct 支持 | 现状 |
+|------|-------|---------|--------------|------|
+| `cosyvoice-v3-flash` | 88 | 中文 9 种方言 + 英/日/韩/印尼 | ✅(固定格式) | ✅ 项目主力 |
+| `cosyvoice-v3-plus` | 2 | 中/英(仅 2 音色) | ❌ | ⚠️ 兼容旧集成 |
+| `cosyvoice-v2` | 106 | 普通话+粤/东北/闽南/陕西+英/日/韩 | 部分 | 老牌音色最丰富 |
+| `cosyvoice-v1` | 20 | 普通话 + 东北口音 | ❌ | ❌ 历史,逐步淘汰 |
+
+> ⚠️ **特别说明**:CosyVoice v3.5 (`-plus` / `-flash`) **没有系统音色**,只支持"声音复刻"和"声音设计"。复刻/设计音色理论上支持 11 种语言(中/英/法/德/日/韩/俄/葡/泰/印尼/越南),但需先录制训练,不在本表中。
+
+---
+
+## 二、语言选择可用性矩阵(用户视角)
+
+> 用户在 UI 选语言,看哪些模型/音色真的能生成。✅ = 有可用音色
+
+| 语言 | cosyvoice-v3-flash | cosyvoice-v3-plus | cosyvoice-v2 | cosyvoice-v1 |
+|------|-------------------|------------------|-------------|-------------|
+| 中文 | ❌ | ❌ | ❌ | ✅ 19 |
+| 中文(东北) | ✅ 1 | ❌ | ❌ | ✅ 1 |
+| 中文(东北话) | ✅ 1 | ❌ | ✅ 1 | ❌ |
+| 中文(四川) | ✅ 1 | ❌ | ❌ | ❌ |
+| 中文(山东) | ✅ 1 | ❌ | ❌ | ❌ |
+| 中文(普通话 | ✅ 1 | ❌ | ❌ | ❌ |
+| 中文(普通话) | ✅ 59 | ✅ 2 | ✅ 78 | ❌ |
+| 中文(河南) | ✅ 1 | ❌ | ❌ | ❌ |
+| 中文(湖南) | ✅ 1 | ❌ | ❌ | ❌ |
+| 中文(粤语) | ✅ 4 | ❌ | ✅ 3 | ❌ |
+| 中文(闽南话) | ✅ 1 | ❌ | ✅ 1 | ❌ |
+| 中文(陕西) | ✅ 1 | ❌ | ❌ | ❌ |
+| 中文(陕西话) | ✅ 1 | ❌ | ✅ 1 | ❌ |
+| 英语(美式) | ✅ 10 | ❌ | ✅ 10 | ❌ |
+| 英语(英式) | ✅ 4 | ❌ | ✅ 6 | ❌ |
+| 日语 | ✅ 5 | ❌ | ✅ 4 | ❌ |
+| 韩语 | ✅ 2 | ❌ | ✅ 2 | ❌ |
+| 印尼语 | ✅ 1 | ❌ | ❌ | ❌ |
+| 安徽话) | ✅ 1 | ❌ | ❌ | ❌ |
+| 英文 | ✅ 66 | ✅ 2 | ✅ 84 | ✅ 4 |
+
+---
+
+## 三、推荐配置(给前端语言选择器)
+
+用户在前端选语言后,后端按这套规则路由:
+
+| 用户选 | 默认 vendor | 模型 | 默认音色 | 备选音色 |
+|--------|------------|------|---------|---------|
+| 中文普通话 | edge / cosyvoice | cosyvoice-v3-flash | `longanhuan_v3`(9 种方言) | 苏瑶 / 凯 |
+| 中文(粤语) | cosyvoice | cosyvoice-v3-flash | `龙安粤` 或 `longanhuan_v3` | - |
+| 中文(闽南) / 东北 / 河南 / 湖南 / 陕西 / 山东 / 安徽 / 四川 | cosyvoice | cosyvoice-v3-flash | **`longanhuan_v3`** 唯一支持多方言 | - |
+| 中文(闽南) | cosyvoice | cosyvoice-v3-flash | `龙安闽` | - |
+| 中文(四川) | cosyvoice | cosyvoice-v3-flash | `龙老铁` 或 `天津-李彼得` | - |
+| 中文(陕西) | cosyvoice | cosyvoice-v3-flash | `龙陕哥` 或 `longanhuan_v3` | - |
+| 中文(北京/上海/南京) | (cosyvoice 没专门) / edge | edge | `zh-CN-XiaoxiaoNeural` | - |
+| 英语(美式) | qwen / cosyvoice | qwen3-tts-instruct-flash | 詹妮弗 / 艾登 | longandy / longava |
+| 英语(英式) | qwen / cosyvoice | qwen3-tts-instruct-flash | Riko / loongluna | - |
+| 日语 | qwen / cosyvoice | cosyvoice-v3-flash | loongtomoka / loongtomoya / 小野杏 | - |
+| 韩语 | qwen / cosyvoice | cosyvoice-v3-flash | 素熙 | - |
+| 法语 / 德语 / 俄语 | qwen | qwen3-tts-instruct-flash | 埃米尔安 / 莱恩 / 阿列克 | - |
+| 西班牙语 / 拉美西班牙 | qwen | qwen3-tts-instruct-flash | 博德加 / 索尼莎 | - |
+| 葡萄牙语 | qwen | qwen3-tts-instruct-flash | 拉迪奥·戈尔 | - |
+| 意大利语 | qwen | qwen3-tts-instruct-flash | 多尔切 | - |
+| 印尼语 | cosyvoice | cosyvoice-v3-flash | `loongindah` | - |
+
+---
+
+## 四、实现"用户选语言"功能的代码路径
+
+### 1. 前端增加语言选择组件
+
+```vue
+<!-- /pages/create/index.vue -->
+<template>
+  <picker :range="supportedLanguages" v-model="form.language">
+    <view slot="value">{{ form.language.label }}</view>
+  </picker>
+</template>
+
+<script setup>
+// 加载后端 /api/tts/voices?lang=zh-Cantonese 拿到可用音色 + 试听链接
+const supportedLanguages = ref([]);
+onMounted(async () => {
+  const r = await api.get('/api/tts/languages');  // 返回矩阵 → 用户友好列表
+  supportedLanguages.value = r.data;
+});
+</script>
+```
+
+### 2. 后端路由逻辑(基于矩阵)
+
+```ts
+// server/src/services/tts-router.ts
+import matrix from '../../data/alicloud-tts-lang-voice-matrix.json';
+
+export function pickTtsVendor(lang: string, preferCheap = true) {
+  // 1. 查表(矩阵)
+  const candidates = matrix.candidates[lang] || [];
+  if (candidates.length === 0) throw new BadRequest(`暂不支持 ${lang} 语言`);
+  // 2. 优先免费(Edge)
+  if (preferCheap) {
+    const edge = candidates.find(c => c.vendor === 'edge');
+    if (edge) return edge;
+  }
+  // 3. 默认 cosyvoice-v3-flash
+  const v3 = candidates.find(c => c.model === 'cosyvoice-v3-flash');
+  if (v3) return v3;
+  // 4. 退化到任意可用
+  return candidates[0];
+}
+```
+
+### 3. 防呆:用户选语言后实时预览
+
+```ts
+GET /api/tts/voices?lang=zh-Cantonese
+// → { voices: [{ name: "龙安粤", voice_id: "longan_yue_3", preview_url: "..." }] }
+```
+
+### 4. 矩阵 JSON 入仓
+
+生成好的 216 个音色 JSON 在 `C:/Users/caoyg/AppData/Local/Temp/alicloud-voices-matrix.json`(4 个模型)。
+可以入仓到 `server/data/alicloud-tts-lang-voice-matrix.json`,启动时加载,提供 `pickTtsVendor(lang)` API。
+
+---
+
+## 五、迁移指引
+
+现在 `server/src/modules/tts/tts.service.ts:243` 的 `ALIYUN_VOICE_MAP` 只有 10 个"龙"系列 + Cherry,远不足以覆盖未来"语言选择"功能。建议:
+
+1. 把矩阵简化版(只存音色 ID 列表)写到 `models.json` 供前端调用
+2. 后端 `pickTtsVendor(lang)` 路由函数读这个矩阵
+3. 前端只显示"矩阵确认可生成"的语言选项(不然选错体验崩)
+
+详细矩阵在 `alicloud-voices-matrix.json`,由 `analyze-model-lang-matrix.py` + `gen-lang-matrix-doc.js` 维护。