瀏覽代碼

feat(语音合成): 新增音色选择弹窗组件 (音色库 + 我的音色)

Yi Jiarui 4 月之前
父節點
當前提交
23d83de5f9
共有 5 個文件被更改,包括 710 次插入49 次删除
  1. 18 0
      .gitignore
  2. 51 0
      src/app/app.css
  3. 35 31
      src/app/app.html
  4. 74 18
      src/app/app.ts
  5. 532 0
      src/app/components/voice-picker/voice-picker.component.ts

+ 18 - 0
.gitignore

@@ -45,3 +45,21 @@ __screenshots__/
 # System files
 .DS_Store
 Thumbs.db
+
+# Windsurf IDE (本地配置/工作流,不上传)
+.windsurf/
+
+# 后端运行时数据(视频、任务、历史等本地产物)
+/data/
+!/data/.gitkeep
+
+# 项目内部文档/参考资料(本地保留,不入库)
+/docs/
+
+# 本地测试/调试脚本
+testAudioLocal.js
+deploy.ps1
+
+# 日志
+*.log
+

+ 51 - 0
src/app/app.css

@@ -4951,6 +4951,57 @@ select.dh-input option { background: #161a2e; color: var(--dh-text); }
 }
 .vs-preview-btn:hover { background: rgba(168, 85, 247, 0.2); color: #fff; border-color: rgba(168, 85, 247, 0.5); }
 
+/* 声音模型选择触发按钮(替代旧的 select 下拉) */
+.vs-voice-picker-trigger {
+  display: flex; align-items: center; gap: 12px;
+  width: 100%; padding: 10px 14px;
+  background: rgba(255,255,255,0.04);
+  border: 1px solid var(--dh-border-2);
+  border-radius: 12px;
+  cursor: pointer;
+  transition: background-color .15s ease, border-color .15s ease, transform .15s ease;
+  text-align: left;
+}
+.vs-voice-picker-trigger:hover:not(:disabled) {
+  background: rgba(168,85,247,0.1);
+  border-color: rgba(168,85,247,0.5);
+}
+.vs-voice-picker-trigger:disabled { opacity: .55; cursor: not-allowed; }
+.vs-voice-picker-trigger.is-empty { border-style: dashed; }
+
+.vs-voice-picker-avatar {
+  flex-shrink: 0; width: 40px; height: 40px; border-radius: 50%;
+  display: flex; align-items: center; justify-content: center;
+  font-weight: 700; font-size: 16px; color: #fff;
+  background: linear-gradient(135deg, #f472b6, #ec4899);
+}
+.vs-voice-picker-avatar--mine { background: linear-gradient(135deg, #34d399, #10b981); }
+.vs-voice-picker-avatar--empty {
+  background: rgba(255,255,255,0.06);
+  color: var(--dh-text-3);
+  font-size: 22px; font-weight: 400;
+  border: 1px dashed var(--dh-border-2);
+}
+
+.vs-voice-picker-info {
+  flex: 1; min-width: 0;
+  display: flex; flex-direction: column; gap: 2px;
+}
+.vs-voice-picker-name {
+  font-size: 14px; font-weight: 600; color: var(--dh-text-1);
+  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.vs-voice-picker-src {
+  font-size: 12px; color: var(--dh-text-3);
+}
+.vs-voice-picker-chevron {
+  flex-shrink: 0; font-size: 20px; color: var(--dh-text-3);
+  transition: transform .15s ease;
+}
+.vs-voice-picker-trigger:hover:not(:disabled) .vs-voice-picker-chevron {
+  transform: translateX(2px); color: var(--dh-text-1);
+}
+
 .vs-result {
   margin-top: 4px; padding: 14px 16px; border-radius: 12px;
   background: rgba(52, 211, 153, 0.08);

+ 35 - 31
src/app/app.html

@@ -2447,38 +2447,42 @@
 
               <label class="dh-field__label"><span>选择声音模型</span></label>
 
-              <div class="vs-select-row">
-
-                <select class="dh-input" [(ngModel)]="vsSynthForm.timbreId" [disabled]="vsSynthLoading">
-
-                  <option value="">请选择音色...</option>
-
-                  <option *ngFor="let t of vsClonedTimbreList" [value]="t.objectId">{{t.name || t.objectId}}</option>
-
-                </select>
-
-                <button type="button" class="vs-preview-btn" title="试听(选择音色后可用)">▶</button>
-
-              </div>
-
-              <div class="dh-chips" *ngIf="vsClonedTimbreList.length > 0">
-
-                <button type="button" class="dh-chip" *ngFor="let t of vsClonedTimbreList"
-
-                        [class.is-active]="vsSynthForm.timbreId === t.objectId"
-
-                        (click)="vsSynthForm.timbreId = t.objectId"
-
-                        [disabled]="vsSynthLoading">
-
-                  {{t.name}}
-
-                </button>
-
-              </div>
+              <button type="button" class="vs-voice-picker-trigger"
+                      (click)="openVoicePicker()"
+                      [disabled]="vsSynthLoading"
+                      [class.is-empty]="!currentVoiceSelection">
+                <ng-container *ngIf="currentVoiceSelection; else emptyVoiceTpl">
+                  <span class="vs-voice-picker-avatar"
+                        [class.vs-voice-picker-avatar--mine]="currentVoiceSelection.source === 'cloned'">
+                    {{currentVoiceSelection.name.charAt(0)}}
+                  </span>
+                  <span class="vs-voice-picker-info">
+                    <span class="vs-voice-picker-name">{{currentVoiceSelection.name}}</span>
+                    <span class="vs-voice-picker-src">
+                      {{currentVoiceSelection.source === 'official' ? '音色库' : '我的音色'}}
+                    </span>
+                  </span>
+                </ng-container>
+                <ng-template #emptyVoiceTpl>
+                  <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
+                  <span class="vs-voice-picker-info">
+                    <span class="vs-voice-picker-name">请选择声音模型</span>
+                    <span class="vs-voice-picker-src">点击打开音色库 / 我的音色</span>
+                  </span>
+                </ng-template>
+                <span class="vs-voice-picker-chevron">›</span>
+              </button>
 
             </div>
 
+            <app-voice-picker
+              [open]="voicePickerOpen"
+              [clonedList]="vsClonedTimbreList"
+              [currentSelection]="currentVoiceSelection"
+              (select)="onVoicePicked($event)"
+              (close)="closeVoicePicker()">
+            </app-voice-picker>
+
 
 
             <div class="dh-field">
@@ -2597,9 +2601,9 @@
 
             <p class="vs-hint" style="text-align:center"
 
-               *ngIf="!vsSynthLoading && (!vsSynthForm.timbreId || !vsSynthForm.text)">
+               *ngIf="!vsSynthLoading && (!currentVoiceSelection || !vsSynthForm.text)">
 
-              {{ !vsSynthForm.timbreId ? '请选择声音模型' : '请输入要合成的文本' }}
+              {{ !currentVoiceSelection ? '请选择声音模型' : '请输入要合成的文本' }}
 
             </p>
 

+ 74 - 18
src/app/app.ts

@@ -50,6 +50,7 @@ import { RecentTaskPillComponent, RecentTaskView } from './components/recent-tas
 
 import { AppAssistantComponent } from './components/app-assistant/app-assistant.component';
 import { AssistantSuggestion } from './services/assistant.service';
+import { VoicePickerComponent, VoiceSelection } from './components/voice-picker/voice-picker.component';
 
 
 
@@ -667,7 +668,9 @@ interface MonitorAuthor {
 
     RecentTaskPillComponent,
 
-    AppAssistantComponent
+    AppAssistantComponent,
+
+    VoicePickerComponent
 
   ],
 
@@ -887,10 +890,57 @@ export class App {
 
     speechRate: 0,
 
-    loudnessRate: 0
+    loudnessRate: 0,
+
+    /** 当前选中音色来源:'cloned' = 用户复刻;'official' = 官方音色库 */
+    voiceSource: 'cloned' as 'cloned' | 'official',
+
+    /** 仅 voiceSource='official' 时使用,对应火山 voice_type */
+    voiceType: '',
+
+    /** 当前选中音色的显示名(UI 用) */
+    voiceName: ''
 
   };
 
+  /** 音色选择弹窗开关 */
+  voicePickerOpen: boolean = false;
+
+  /** 传给弹窗用于回显高亮的当前选择 */
+  get currentVoiceSelection(): VoiceSelection | null {
+    const f = this.vsSynthForm;
+    if (f.voiceSource === 'official' && f.voiceType) {
+      return { source: 'official', name: f.voiceName, id: f.voiceType, voiceType: f.voiceType };
+    }
+    if (f.voiceSource === 'cloned' && f.timbreId) {
+      return { source: 'cloned', name: f.voiceName || f.timbreId, id: f.timbreId, timbreId: f.timbreId };
+    }
+    return null;
+  }
+
+  openVoicePicker(): void {
+    this.voicePickerOpen = true;
+  }
+
+  closeVoicePicker(): void {
+    this.voicePickerOpen = false;
+  }
+
+  onVoicePicked(sel: VoiceSelection): void {
+    this.vsSynthForm.voiceSource = sel.source;
+    this.vsSynthForm.voiceName = sel.name;
+    if (sel.source === 'official') {
+      this.vsSynthForm.voiceType = sel.voiceType || sel.id;
+      this.vsSynthForm.timbreId = '';
+    } else {
+      this.vsSynthForm.timbreId = sel.timbreId || sel.id;
+      this.vsSynthForm.voiceType = '';
+    }
+    this.vsSynthError = '';
+    this.voicePickerOpen = false;
+    this.refreshView();
+  }
+
   vsSynthLoading: boolean = false;
 
   vsSynthError: string = '';
@@ -12569,11 +12619,14 @@ ${taskOne}
 
 
 
-    const { timbreId, text, isStream, format, sampleRate, speechRate, loudnessRate } = this.vsSynthForm;
+    const { timbreId, voiceType, voiceSource, text, format, sampleRate, speechRate, loudnessRate } = this.vsSynthForm;
+
+    const usingOfficial = voiceSource === 'official';
+    const voiceId = usingOfficial ? (voiceType || '').trim() : (timbreId || '').trim();
 
-    if (!timbreId.trim()) {
+    if (!voiceId) {
 
-      this.vsSynthError = '请输入音色 ID(timbreId),可先通过音色复刻获取';
+      this.vsSynthError = '请先选择声音模型';
 
       this.refreshView();
 
@@ -12605,29 +12658,27 @@ ${taskOne}
 
 
 
-    this.http.post<any>('https://server.fmode.cn/api/volcengine/tts/unidirectional', {
-
+    const body: any = {
       token: 'Bearer r:f0333969e312a40e4703e8fe4ed1c600',
-
       text: text.trim(),
-
-      timbreId: timbreId.trim(),
-
       isStream: false,
-
       audio_params: {
-
         format: format || 'mp3',
-
         sample_rate: sampleRate || 24000,
-
         speech_rate: speechRate || 0,
-
         loudness_rate: loudnessRate || 0
-
       }
+    };
+    if (usingOfficial) {
+      // _uranus_bigtts 后缀对应豆包语音合成模型 2.0,需要 seed-tts-2.0 资源;
+      // 其它后缀回退到 seed-tts-1.0,可按需扩展。
+      body.volcengine_voice_type = voiceId;
+      body.x_api_resource_id = voiceId.includes('_uranus_bigtts') ? 'seed-tts-2.0' : 'seed-tts-1.0';
+    } else {
+      body.timbreId = voiceId;
+    }
 
-    }).subscribe({
+    this.http.post<any>('https://server.fmode.cn/api/volcengine/tts/unidirectional', body).subscribe({
 
       next: (res) => {
 
@@ -12675,6 +12726,11 @@ ${taskOne}
 
     this.vsSynthForm.timbreId = objectId;
 
+    this.vsSynthForm.voiceSource = 'cloned';
+    this.vsSynthForm.voiceType = '';
+    const matched = this.vsClonedTimbreList.find(t => t.objectId === objectId);
+    this.vsSynthForm.voiceName = matched?.name || objectId;
+
     this.vsTtsMode = 'synthesize';
 
     this.vsSynthError = '';

+ 532 - 0
src/app/components/voice-picker/voice-picker.component.ts

@@ -0,0 +1,532 @@
+import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { HttpClient } from '@angular/common/http';
+
+/** 官方音色库条目 */
+export interface OfficialVoice {
+  voiceType: string;        // 火山 voice_type(如 zh_male_sunwukong_uranus_bigtts)
+  name: string;             // 显示名
+  scene: string;            // 场景分组
+  language: string;         // 语种
+  tags?: string[];          // 特殊标签(抖音同款、剪映同款 等)
+}
+
+/** 已复刻音色条目(与 app.ts 中 vsClonedTimbreList 类型一致) */
+export interface ClonedTimbre {
+  objectId: string;
+  name: string;
+  speakerId: string;
+}
+
+/** 选择结果,向外抛 */
+export interface VoiceSelection {
+  source: 'official' | 'cloned';
+  name: string;
+  /** source=official 时为 voiceType;source=cloned 时为 objectId */
+  id: string;
+  /** 仅 official 时有值 */
+  voiceType?: string;
+  /** 仅 cloned 时有值 */
+  timbreId?: string;
+  speakerId?: string;
+}
+
+/** 精选官方音色库(首批 25 个,覆盖 6 个场景) */
+export const OFFICIAL_VOICE_LIBRARY: OfficialVoice[] = [
+  // 通用场景
+  { voiceType: 'zh_female_vv_uranus_bigtts',           name: 'Vivi 2.0',         scene: '通用',     language: '中文/日文/印尼/西语' },
+  { voiceType: 'zh_female_sophie_uranus_bigtts',       name: '魅力苏菲 2.0',     scene: '通用',     language: '中文' },
+  { voiceType: 'zh_female_qingxinnvsheng_uranus_bigtts', name: '清新女声 2.0',   scene: '通用',     language: '中文' },
+  { voiceType: 'zh_male_m191_uranus_bigtts',           name: '云舟 2.0',         scene: '通用',     language: '中文' },
+  { voiceType: 'zh_male_taocheng_uranus_bigtts',       name: '小天 2.0',         scene: '通用',     language: '中文' },
+  { voiceType: 'zh_female_xiaohe_uranus_bigtts',       name: '小何 2.0',         scene: '通用',     language: '中文' },
+
+  // 角色扮演
+  { voiceType: 'zh_female_cancan_uranus_bigtts',       name: '知性灿灿 2.0',     scene: '角色扮演', language: '中文' },
+  { voiceType: 'zh_female_sajiaoxuemei_uranus_bigtts', name: '撒娇学妹 2.0',     scene: '角色扮演', language: '中文' },
+  { voiceType: 'zh_male_silang_uranus_bigtts',         name: '四郎 2.0',         scene: '角色扮演', language: '中文', tags: ['抖音同款','剪映同款'] },
+  { voiceType: 'zh_male_qingcang_uranus_bigtts',       name: '擎苍 2.0',         scene: '角色扮演', language: '中文', tags: ['番茄同款','抖音同款'] },
+  { voiceType: 'zh_male_xionger_uranus_bigtts',        name: '熊二 2.0',         scene: '角色扮演', language: '中文', tags: ['剪映同款'] },
+
+  // 有声阅读
+  { voiceType: 'zh_male_baqiqingshu_uranus_bigtts',    name: '霸气青叔 2.0',     scene: '有声阅读', language: '中文', tags: ['番茄同款'] },
+  { voiceType: 'zh_male_xuanyijieshuo_uranus_bigtts',  name: '悬疑解说 2.0',     scene: '有声阅读', language: '中文', tags: ['抖音同款'] },
+  { voiceType: 'zh_female_xiaoxue_uranus_bigtts',      name: '儿童绘本 2.0',     scene: '有声阅读', language: '中文' },
+  { voiceType: 'zh_female_shaoergushi_uranus_bigtts',  name: '少儿故事 2.0',     scene: '有声阅读', language: '中文' },
+
+  // 视频配音
+  { voiceType: 'zh_male_sunwukong_uranus_bigtts',      name: '猴哥 2.0',         scene: '视频配音', language: '中文' },
+  { voiceType: 'zh_female_peiqi_uranus_bigtts',        name: '佩奇猪 2.0',       scene: '视频配音', language: '中文', tags: ['抖音同款','剪映同款'] },
+  { voiceType: 'zh_male_dayi_uranus_bigtts',           name: '大壹 2.0',         scene: '视频配音', language: '中文' },
+  { voiceType: 'zh_male_ruyayichen_uranus_bigtts',     name: '儒雅逸辰 2.0',     scene: '视频配音', language: '中文' },
+  { voiceType: 'zh_male_cixingjieshuonan_uranus_bigtts', name: '磁性解说 Morgan 2.0', scene: '视频配音', language: '中文', tags: ['抖音同款'] },
+
+  // 客服场景
+  { voiceType: 'zh_female_kefunvsheng_uranus_bigtts',  name: '暖阳女声 2.0',     scene: '客服',     language: '中文' },
+  { voiceType: 'zh_female_yingyujiaoxue_uranus_bigtts', name: 'Tina老师 2.0',    scene: '客服',     language: '中文/英语' },
+
+  // 多语种
+  { voiceType: 'en_male_tim_uranus_bigtts',            name: 'Tim',              scene: '多语种',   language: '美式英语' },
+  { voiceType: 'en_female_dacey_uranus_bigtts',        name: 'Dacey',            scene: '多语种',   language: '美式英语' },
+  { voiceType: 'en_female_stokie_uranus_bigtts',       name: 'Stokie',           scene: '多语种',   language: '美式英语' },
+];
+
+const VOICE_LIBRARY_SCENES = ['全部', '通用', '角色扮演', '有声阅读', '视频配音', '客服', '多语种'];
+
+/** 用于试听的固定文本 */
+const PREVIEW_TEXT = '青山依旧在,几度夕阳红。';
+
+/** TTS 代理(与 vsStartSynthesize 保持一致) */
+const TTS_API_URL = 'https://server.fmode.cn/api/volcengine/tts/unidirectional';
+const TTS_TOKEN = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
+
+@Component({
+  selector: 'app-voice-picker',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  template: `
+    <div class="vp-mask" *ngIf="open" (click)="onMaskClick($event)">
+      <div class="vp-modal" (click)="$event.stopPropagation()">
+
+        <!-- 头部:Tabs -->
+        <header class="vp-header">
+          <div class="vp-tabs">
+            <button type="button" class="vp-tab"
+                    [class.is-active]="activeTab === 'library'"
+                    (click)="activeTab = 'library'">音色库</button>
+            <button type="button" class="vp-tab"
+                    [class.is-active]="activeTab === 'mine'"
+                    (click)="activeTab = 'mine'">
+              我的音色
+              <span class="vp-tab-badge" *ngIf="clonedList?.length">{{clonedList.length}}</span>
+            </button>
+          </div>
+          <button type="button" class="vp-close" (click)="emitClose()" title="关闭">✕</button>
+        </header>
+
+        <!-- 场景筛选 + 搜索(仅音色库 Tab) -->
+        <div class="vp-toolbar" *ngIf="activeTab === 'library'">
+          <div class="vp-scenes">
+            <button type="button"
+                    *ngFor="let s of scenes"
+                    class="vp-scene-chip"
+                    [class.is-active]="sceneFilter === s"
+                    (click)="sceneFilter = s">{{s}}</button>
+          </div>
+          <input class="vp-search" type="text" placeholder="搜索音色名称/语种..."
+                 [(ngModel)]="searchKeyword">
+        </div>
+
+        <!-- 内容区 -->
+        <div class="vp-body">
+
+          <!-- 音色库 -->
+          <div *ngIf="activeTab === 'library'">
+            <div class="vp-grid" *ngIf="filteredLibrary.length > 0">
+              <article class="vp-card"
+                       *ngFor="let v of filteredLibrary"
+                       [class.is-selected]="isSelected('official', v.voiceType)"
+                       (click)="onPick('official', v)">
+                <div class="vp-card-head">
+                  <div class="vp-avatar" [class.vp-avatar--male]="isMale(v.voiceType)">
+                    {{v.name.charAt(0)}}
+                  </div>
+                  <button type="button" class="vp-play"
+                          [class.is-loading]="previewLoadingKey === 'official:' + v.voiceType"
+                          (click)="$event.stopPropagation(); preview('official', v.voiceType, v.name)"
+                          title="试听">
+                    <span *ngIf="previewLoadingKey !== 'official:' + v.voiceType">▶</span>
+                    <span *ngIf="previewLoadingKey === 'official:' + v.voiceType" class="vp-spin">⟳</span>
+                  </button>
+                </div>
+                <div class="vp-card-name" [title]="v.name">{{v.name}}</div>
+                <div class="vp-card-meta">
+                  <span class="vp-tag vp-tag--scene">{{v.scene}}</span>
+                  <span class="vp-tag vp-tag--lang">{{v.language}}</span>
+                </div>
+                <div class="vp-card-tags" *ngIf="v.tags?.length">
+                  <span class="vp-tag vp-tag--special" *ngFor="let t of v.tags">{{t}}</span>
+                </div>
+              </article>
+            </div>
+            <div class="vp-empty" *ngIf="filteredLibrary.length === 0">
+              没有匹配的音色,换个关键词试试
+            </div>
+          </div>
+
+          <!-- 我的音色 -->
+          <div *ngIf="activeTab === 'mine'">
+            <div class="vp-grid" *ngIf="clonedList && clonedList.length > 0">
+              <article class="vp-card"
+                       *ngFor="let t of clonedList"
+                       [class.is-selected]="isSelected('cloned', t.objectId)"
+                       (click)="onPickCloned(t)">
+                <div class="vp-card-head">
+                  <div class="vp-avatar vp-avatar--mine">{{(t.name || '?').charAt(0)}}</div>
+                  <button type="button" class="vp-play"
+                          [class.is-loading]="previewLoadingKey === 'cloned:' + t.objectId"
+                          (click)="$event.stopPropagation(); preview('cloned', t.objectId, t.name)"
+                          title="试听">
+                    <span *ngIf="previewLoadingKey !== 'cloned:' + t.objectId">▶</span>
+                    <span *ngIf="previewLoadingKey === 'cloned:' + t.objectId" class="vp-spin">⟳</span>
+                  </button>
+                </div>
+                <div class="vp-card-name" [title]="t.name || t.objectId">{{t.name || t.objectId}}</div>
+                <div class="vp-card-meta">
+                  <span class="vp-tag vp-tag--mine">我的复刻</span>
+                </div>
+                <div class="vp-card-id" [title]="t.objectId">ID: {{t.objectId}}</div>
+              </article>
+            </div>
+            <div class="vp-empty" *ngIf="!clonedList || clonedList.length === 0">
+              你还没有复刻音色,先去左侧"音色复刻"训练一个吧
+            </div>
+          </div>
+
+          <!-- 试听报错提示 -->
+          <div class="vp-error" *ngIf="previewError">{{previewError}}</div>
+        </div>
+
+        <!-- 底部:当前选中 + 操作 -->
+        <footer class="vp-footer">
+          <div class="vp-selected">
+            <span class="vp-selected-label">当前选择:</span>
+            <span class="vp-selected-name" *ngIf="pendingSelection">
+              {{pendingSelection.name}}
+              <small class="vp-selected-src">
+                ({{pendingSelection.source === 'official' ? '音色库' : '我的音色'}})
+              </small>
+            </span>
+            <span class="vp-selected-empty" *ngIf="!pendingSelection">未选择</span>
+          </div>
+          <div class="vp-actions">
+            <button type="button" class="vp-btn vp-btn--ghost" (click)="emitClose()">取消</button>
+            <button type="button" class="vp-btn vp-btn--primary"
+                    [disabled]="!pendingSelection"
+                    (click)="confirm()">使用此音色</button>
+          </div>
+        </footer>
+
+        <!-- 隐藏 audio 元素用于播放 -->
+        <audio #previewAudio style="display:none" preload="auto"></audio>
+      </div>
+    </div>
+  `,
+  styles: [`
+    :host { --vp-primary: #6366f1; --vp-primary-hover: #4f46e5; }
+    .vp-mask {
+      position: fixed; inset: 0; z-index: 9999;
+      background: rgba(15, 23, 42, 0.55);
+      display: flex; align-items: center; justify-content: center;
+      padding: 24px; backdrop-filter: blur(4px);
+      animation: vp-fade-in 160ms ease-out;
+    }
+    @keyframes vp-fade-in { from { opacity: 0 } to { opacity: 1 } }
+    .vp-modal {
+      width: min(960px, 100%); max-height: 88vh;
+      background: #fff; border-radius: 16px;
+      box-shadow: 0 24px 60px rgba(15,23,42,0.25);
+      display: flex; flex-direction: column; overflow: hidden;
+      animation: vp-pop-in 200ms cubic-bezier(.2,.9,.3,1.2);
+    }
+    @keyframes vp-pop-in { from { transform: scale(.96); opacity: 0 } to { transform: scale(1); opacity: 1 } }
+
+    .vp-header {
+      display: flex; align-items: center; justify-content: space-between;
+      padding: 14px 20px; border-bottom: 1px solid #e5e7eb;
+    }
+    .vp-tabs { display: flex; gap: 4px; }
+    .vp-tab {
+      background: transparent; border: none; padding: 8px 16px;
+      font-size: 15px; font-weight: 600; color: #64748b;
+      border-radius: 8px; cursor: pointer; transition: all .15s;
+      display: inline-flex; align-items: center; gap: 6px;
+    }
+    .vp-tab:hover { background: #f1f5f9; color: #334155; }
+    .vp-tab.is-active { background: #eef2ff; color: var(--vp-primary); }
+    .vp-tab-badge {
+      background: var(--vp-primary); color: #fff; font-size: 11px;
+      padding: 1px 7px; border-radius: 10px; line-height: 1.4;
+    }
+    .vp-close {
+      background: transparent; border: none; font-size: 18px;
+      width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
+      color: #64748b; transition: all .15s;
+    }
+    .vp-close:hover { background: #f1f5f9; color: #ef4444; }
+
+    .vp-toolbar {
+      display: flex; align-items: center; gap: 12px;
+      padding: 12px 20px; border-bottom: 1px solid #f1f5f9;
+      background: #fafbfc;
+    }
+    .vp-scenes { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }
+    .vp-scene-chip {
+      background: #fff; border: 1px solid #e2e8f0; color: #64748b;
+      padding: 5px 12px; border-radius: 16px; font-size: 13px;
+      cursor: pointer; transition: all .15s;
+    }
+    .vp-scene-chip:hover { border-color: var(--vp-primary); color: var(--vp-primary); }
+    .vp-scene-chip.is-active {
+      background: var(--vp-primary); border-color: var(--vp-primary); color: #fff;
+    }
+    .vp-search {
+      width: 200px; padding: 6px 12px; border: 1px solid #e2e8f0;
+      border-radius: 16px; font-size: 13px; outline: none;
+    }
+    .vp-search:focus { border-color: var(--vp-primary); }
+
+    .vp-body { flex: 1; overflow-y: auto; padding: 16px 20px; min-height: 320px; }
+    .vp-grid {
+      display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+      gap: 12px;
+    }
+    .vp-card {
+      background: #fff; border: 2px solid #e5e7eb; border-radius: 12px;
+      padding: 12px; cursor: pointer; transition: all .15s;
+      display: flex; flex-direction: column; gap: 8px;
+    }
+    .vp-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 12px rgba(99,102,241,.1); }
+    .vp-card.is-selected {
+      border-color: var(--vp-primary);
+      background: linear-gradient(135deg, #eef2ff 0%, #fff 100%);
+      box-shadow: 0 0 0 3px rgba(99,102,241,.15);
+    }
+    .vp-card-head { display: flex; justify-content: space-between; align-items: center; }
+    .vp-avatar {
+      width: 36px; height: 36px; border-radius: 50%;
+      background: linear-gradient(135deg, #f472b6, #ec4899);
+      color: #fff; display: flex; align-items: center; justify-content: center;
+      font-weight: 700; font-size: 16px;
+    }
+    .vp-avatar--male { background: linear-gradient(135deg, #60a5fa, #3b82f6); }
+    .vp-avatar--mine { background: linear-gradient(135deg, #34d399, #10b981); }
+    .vp-play {
+      background: #f3f4f6; border: none; border-radius: 50%;
+      width: 32px; height: 32px; cursor: pointer; font-size: 13px;
+      color: var(--vp-primary); transition: all .15s;
+      display: flex; align-items: center; justify-content: center;
+    }
+    .vp-play:hover { background: var(--vp-primary); color: #fff; }
+    .vp-play.is-loading { background: #e0e7ff; }
+    .vp-spin { display: inline-block; animation: vp-spin 1s linear infinite; }
+    @keyframes vp-spin { to { transform: rotate(360deg); } }
+
+    .vp-card-name {
+      font-weight: 600; font-size: 14px; color: #1e293b;
+      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+    }
+    .vp-card-meta { display: flex; gap: 4px; flex-wrap: wrap; }
+    .vp-card-tags { display: flex; gap: 4px; flex-wrap: wrap; }
+    .vp-card-id {
+      font-size: 11px; color: #94a3b8; overflow: hidden;
+      text-overflow: ellipsis; white-space: nowrap;
+    }
+    .vp-tag {
+      font-size: 11px; padding: 2px 8px; border-radius: 10px; line-height: 1.4;
+    }
+    .vp-tag--scene { background: #ede9fe; color: #7c3aed; }
+    .vp-tag--lang  { background: #dbeafe; color: #1d4ed8; }
+    .vp-tag--special { background: #fef3c7; color: #b45309; }
+    .vp-tag--mine  { background: #d1fae5; color: #047857; }
+
+    .vp-empty {
+      text-align: center; color: #94a3b8; padding: 60px 20px; font-size: 14px;
+    }
+    .vp-error {
+      margin-top: 12px; padding: 8px 12px; background: #fef2f2; color: #b91c1c;
+      border-radius: 8px; font-size: 13px;
+    }
+
+    .vp-footer {
+      display: flex; align-items: center; justify-content: space-between;
+      padding: 14px 20px; border-top: 1px solid #e5e7eb; background: #fafbfc;
+      gap: 12px;
+    }
+    .vp-selected { font-size: 13px; color: #475569; overflow: hidden;
+                   text-overflow: ellipsis; white-space: nowrap; }
+    .vp-selected-label { color: #94a3b8; }
+    .vp-selected-name { color: #1e293b; font-weight: 600; }
+    .vp-selected-src { color: #94a3b8; font-weight: 400; font-size: 12px; }
+    .vp-selected-empty { color: #94a3b8; }
+
+    .vp-actions { display: flex; gap: 8px; }
+    .vp-btn {
+      padding: 8px 18px; border-radius: 8px; font-size: 14px; font-weight: 600;
+      cursor: pointer; border: none; transition: all .15s;
+    }
+    .vp-btn--ghost { background: #fff; border: 1px solid #e2e8f0; color: #64748b; }
+    .vp-btn--ghost:hover { border-color: #cbd5e1; color: #334155; }
+    .vp-btn--primary { background: var(--vp-primary); color: #fff; }
+    .vp-btn--primary:hover:not(:disabled) { background: var(--vp-primary-hover); }
+    .vp-btn--primary:disabled { background: #cbd5e1; cursor: not-allowed; }
+  `]
+})
+export class VoicePickerComponent implements OnChanges {
+  /** 是否打开 */
+  @Input() open: boolean = false;
+
+  /** 用户的已复刻音色列表(外部传入) */
+  @Input() clonedList: ClonedTimbre[] = [];
+
+  /** 当前已选(用于回显高亮),可选 */
+  @Input() currentSelection: VoiceSelection | null = null;
+
+  /** 用户点"使用此音色"或双击时抛出 */
+  @Output() select = new EventEmitter<VoiceSelection>();
+
+  /** 用户点关闭/取消/遮罩时抛出 */
+  @Output() close = new EventEmitter<void>();
+
+  readonly officialLibrary = OFFICIAL_VOICE_LIBRARY;
+  readonly scenes = VOICE_LIBRARY_SCENES;
+
+  activeTab: 'library' | 'mine' = 'library';
+  sceneFilter: string = '全部';
+  searchKeyword: string = '';
+
+  /** 暂存选择(在用户点"使用此音色"前不真正提交) */
+  pendingSelection: VoiceSelection | null = null;
+
+  /** 试听音频缓存:key = source:id,value = audio url(dataURL 或 http url) */
+  private previewCache = new Map<string, string>();
+  previewLoadingKey: string = '';
+  previewError: string = '';
+  private currentAudio: HTMLAudioElement | null = null;
+
+  constructor(private http: HttpClient) {}
+
+  ngOnChanges(changes: SimpleChanges): void {
+    // 弹窗每次打开时根据已选项回显,并默认聚焦相应 Tab
+    if (changes['open'] && this.open) {
+      this.pendingSelection = this.currentSelection ? { ...this.currentSelection } : null;
+      this.previewError = '';
+      if (this.pendingSelection?.source === 'cloned') {
+        this.activeTab = 'mine';
+      } else {
+        this.activeTab = 'library';
+      }
+    }
+    // 关闭时停掉正在播放的试听
+    if (changes['open'] && !this.open) {
+      this.stopCurrentAudio();
+    }
+  }
+
+  get filteredLibrary(): OfficialVoice[] {
+    const kw = this.searchKeyword.trim().toLowerCase();
+    return this.officialLibrary.filter(v => {
+      if (this.sceneFilter !== '全部' && v.scene !== this.sceneFilter) return false;
+      if (kw) {
+        const hay = `${v.name} ${v.language} ${v.voiceType}`.toLowerCase();
+        if (!hay.includes(kw)) return false;
+      }
+      return true;
+    });
+  }
+
+  isSelected(source: 'official' | 'cloned', id: string): boolean {
+    return this.pendingSelection?.source === source && this.pendingSelection?.id === id;
+  }
+
+  isMale(voiceType: string): boolean {
+    return /(_male_)/.test(voiceType);
+  }
+
+  onPick(source: 'official', v: OfficialVoice): void {
+    this.pendingSelection = {
+      source: 'official',
+      name: v.name,
+      id: v.voiceType,
+      voiceType: v.voiceType
+    };
+  }
+
+  onPickCloned(t: ClonedTimbre): void {
+    this.pendingSelection = {
+      source: 'cloned',
+      name: t.name || t.objectId,
+      id: t.objectId,
+      timbreId: t.objectId,
+      speakerId: t.speakerId
+    };
+  }
+
+  confirm(): void {
+    if (!this.pendingSelection) return;
+    this.stopCurrentAudio();
+    this.select.emit(this.pendingSelection);
+  }
+
+  emitClose(): void {
+    this.stopCurrentAudio();
+    this.close.emit();
+  }
+
+  onMaskClick(_e: MouseEvent): void {
+    this.emitClose();
+  }
+
+  /** 试听:内存缓存命中直接播;否则调 TTS 合成 */
+  preview(source: 'official' | 'cloned', id: string, name: string): void {
+    const key = `${source}:${id}`;
+    this.previewError = '';
+
+    const cached = this.previewCache.get(key);
+    if (cached) {
+      this.playAudio(cached);
+      return;
+    }
+
+    if (this.previewLoadingKey) return; // 已在合成另一条,避免并发
+    this.previewLoadingKey = key;
+
+    const body: any = {
+      token: TTS_TOKEN,
+      text: PREVIEW_TEXT,
+      isStream: false,
+      audio_params: { format: 'mp3', sample_rate: 24000 }
+    };
+    if (source === 'official') {
+      // _uranus_bigtts 后缀是豆包语音合成模型 2.0,必须使用 seed-tts-2.0 资源。
+      // 其它后缀后续可扩展(如 1.0 模型可能需 seed-tts-1.0)。
+      body.volcengine_voice_type = id;
+      body.x_api_resource_id = id.includes('_uranus_bigtts') ? 'seed-tts-2.0' : 'seed-tts-1.0';
+    } else {
+      body.timbreId = id;
+    }
+
+    this.http.post<any>(TTS_API_URL, body).subscribe({
+      next: (res) => {
+        this.previewLoadingKey = '';
+        if (res?.code === 200 && res?.data?.audioUrl) {
+          this.previewCache.set(key, res.data.audioUrl);
+          this.playAudio(res.data.audioUrl);
+        } else {
+          this.previewError = `试听 "${name}" 失败:${res?.error?.message || res?.message || '未知错误'}`;
+        }
+      },
+      error: (err) => {
+        this.previewLoadingKey = '';
+        this.previewError = `试听 "${name}" 请求失败:${err?.error?.message || err?.message || '网络错误'}`;
+      }
+    });
+  }
+
+  private playAudio(url: string): void {
+    this.stopCurrentAudio();
+    const audio = new Audio(url);
+    audio.play().catch(e => {
+      this.previewError = `音频播放失败:${e?.message || e}`;
+    });
+    this.currentAudio = audio;
+  }
+
+  private stopCurrentAudio(): void {
+    if (this.currentAudio) {
+      try { this.currentAudio.pause(); } catch {}
+      this.currentAudio = null;
+    }
+  }
+}