voice-picker.component.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { FormsModule } from '@angular/forms';
  4. import { HttpClient } from '@angular/common/http';
  5. /** 官方音色库条目 */
  6. export interface OfficialVoice {
  7. voiceType: string; // 火山 voice_type(如 zh_male_sunwukong_uranus_bigtts)
  8. name: string; // 显示名
  9. scene: string; // 场景分组
  10. language: string; // 语种
  11. tags?: string[]; // 特殊标签(抖音同款、剪映同款 等)
  12. }
  13. /** 已复刻音色条目(与 app.ts 中 vsClonedTimbreList 类型一致) */
  14. export interface ClonedTimbre {
  15. objectId: string;
  16. name: string;
  17. speakerId: string;
  18. }
  19. /** 选择结果,向外抛 */
  20. export interface VoiceSelection {
  21. source: 'official' | 'cloned';
  22. name: string;
  23. /** source=official 时为 voiceType;source=cloned 时为 objectId */
  24. id: string;
  25. /** 仅 official 时有值 */
  26. voiceType?: string;
  27. /** 仅 cloned 时有值 */
  28. timbreId?: string;
  29. speakerId?: string;
  30. }
  31. /** 精选官方音色库(首批 25 个,覆盖 6 个场景) */
  32. export const OFFICIAL_VOICE_LIBRARY: OfficialVoice[] = [
  33. // 通用场景
  34. { voiceType: 'zh_female_vv_uranus_bigtts', name: 'Vivi 2.0', scene: '通用', language: '中文/日文/印尼/西语' },
  35. { voiceType: 'zh_female_sophie_uranus_bigtts', name: '魅力苏菲 2.0', scene: '通用', language: '中文' },
  36. { voiceType: 'zh_female_qingxinnvsheng_uranus_bigtts', name: '清新女声 2.0', scene: '通用', language: '中文' },
  37. { voiceType: 'zh_male_m191_uranus_bigtts', name: '云舟 2.0', scene: '通用', language: '中文' },
  38. { voiceType: 'zh_male_taocheng_uranus_bigtts', name: '小天 2.0', scene: '通用', language: '中文' },
  39. { voiceType: 'zh_female_xiaohe_uranus_bigtts', name: '小何 2.0', scene: '通用', language: '中文' },
  40. // 角色扮演
  41. { voiceType: 'zh_female_cancan_uranus_bigtts', name: '知性灿灿 2.0', scene: '角色扮演', language: '中文' },
  42. { voiceType: 'zh_female_sajiaoxuemei_uranus_bigtts', name: '撒娇学妹 2.0', scene: '角色扮演', language: '中文' },
  43. { voiceType: 'zh_male_silang_uranus_bigtts', name: '四郎 2.0', scene: '角色扮演', language: '中文', tags: ['抖音同款','剪映同款'] },
  44. { voiceType: 'zh_male_qingcang_uranus_bigtts', name: '擎苍 2.0', scene: '角色扮演', language: '中文', tags: ['番茄同款','抖音同款'] },
  45. { voiceType: 'zh_male_xionger_uranus_bigtts', name: '熊二 2.0', scene: '角色扮演', language: '中文', tags: ['剪映同款'] },
  46. // 有声阅读
  47. { voiceType: 'zh_male_baqiqingshu_uranus_bigtts', name: '霸气青叔 2.0', scene: '有声阅读', language: '中文', tags: ['番茄同款'] },
  48. { voiceType: 'zh_male_xuanyijieshuo_uranus_bigtts', name: '悬疑解说 2.0', scene: '有声阅读', language: '中文', tags: ['抖音同款'] },
  49. { voiceType: 'zh_female_xiaoxue_uranus_bigtts', name: '儿童绘本 2.0', scene: '有声阅读', language: '中文' },
  50. { voiceType: 'zh_female_shaoergushi_uranus_bigtts', name: '少儿故事 2.0', scene: '有声阅读', language: '中文' },
  51. // 视频配音
  52. { voiceType: 'zh_male_sunwukong_uranus_bigtts', name: '猴哥 2.0', scene: '视频配音', language: '中文' },
  53. { voiceType: 'zh_female_peiqi_uranus_bigtts', name: '佩奇猪 2.0', scene: '视频配音', language: '中文', tags: ['抖音同款','剪映同款'] },
  54. { voiceType: 'zh_male_dayi_uranus_bigtts', name: '大壹 2.0', scene: '视频配音', language: '中文' },
  55. { voiceType: 'zh_male_ruyayichen_uranus_bigtts', name: '儒雅逸辰 2.0', scene: '视频配音', language: '中文' },
  56. { voiceType: 'zh_male_cixingjieshuonan_uranus_bigtts', name: '磁性解说 Morgan 2.0', scene: '视频配音', language: '中文', tags: ['抖音同款'] },
  57. // 客服场景
  58. { voiceType: 'zh_female_kefunvsheng_uranus_bigtts', name: '暖阳女声 2.0', scene: '客服', language: '中文' },
  59. { voiceType: 'zh_female_yingyujiaoxue_uranus_bigtts', name: 'Tina老师 2.0', scene: '客服', language: '中文/英语' },
  60. // 多语种
  61. { voiceType: 'en_male_tim_uranus_bigtts', name: 'Tim', scene: '多语种', language: '美式英语' },
  62. { voiceType: 'en_female_dacey_uranus_bigtts', name: 'Dacey', scene: '多语种', language: '美式英语' },
  63. { voiceType: 'en_female_stokie_uranus_bigtts', name: 'Stokie', scene: '多语种', language: '美式英语' },
  64. ];
  65. const VOICE_LIBRARY_SCENES = ['全部', '通用', '角色扮演', '有声阅读', '视频配音', '客服', '多语种'];
  66. /** 用于试听的固定文本 */
  67. const PREVIEW_TEXT = '青山依旧在,几度夕阳红。';
  68. /** TTS 代理(与 vsStartSynthesize 保持一致) */
  69. const TTS_API_URL = 'https://server.fmode.cn/api/volcengine/tts/unidirectional';
  70. const TTS_TOKEN = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
  71. @Component({
  72. selector: 'app-voice-picker',
  73. standalone: true,
  74. imports: [CommonModule, FormsModule],
  75. template: `
  76. <div class="vp-mask" *ngIf="open" (click)="onMaskClick($event)">
  77. <div class="vp-modal" (click)="$event.stopPropagation()">
  78. <!-- 头部:Tabs -->
  79. <header class="vp-header">
  80. <div class="vp-tabs">
  81. <button type="button" class="vp-tab"
  82. [class.is-active]="activeTab === 'library'"
  83. (click)="activeTab = 'library'">音色库</button>
  84. <button type="button" class="vp-tab"
  85. [class.is-active]="activeTab === 'mine'"
  86. (click)="activeTab = 'mine'">
  87. 我的音色
  88. <span class="vp-tab-badge" *ngIf="clonedList?.length">{{clonedList.length}}</span>
  89. </button>
  90. </div>
  91. <button type="button" class="vp-close" (click)="emitClose()" title="关闭">✕</button>
  92. </header>
  93. <!-- 场景筛选 + 搜索(仅音色库 Tab) -->
  94. <div class="vp-toolbar" *ngIf="activeTab === 'library'">
  95. <div class="vp-scenes">
  96. <button type="button"
  97. *ngFor="let s of scenes"
  98. class="vp-scene-chip"
  99. [class.is-active]="sceneFilter === s"
  100. (click)="sceneFilter = s">{{s}}</button>
  101. </div>
  102. <input class="vp-search" type="text" placeholder="搜索音色名称/语种..."
  103. [(ngModel)]="searchKeyword">
  104. </div>
  105. <!-- 内容区 -->
  106. <div class="vp-body">
  107. <!-- 音色库 -->
  108. <div *ngIf="activeTab === 'library'">
  109. <div class="vp-grid" *ngIf="filteredLibrary.length > 0">
  110. <article class="vp-card"
  111. *ngFor="let v of filteredLibrary"
  112. [class.is-selected]="isSelected('official', v.voiceType)"
  113. (click)="onPick('official', v)">
  114. <div class="vp-card-head">
  115. <div class="vp-avatar" [class.vp-avatar--male]="isMale(v.voiceType)">
  116. {{v.name.charAt(0)}}
  117. </div>
  118. <button type="button" class="vp-play"
  119. [class.is-loading]="previewLoadingKey === 'official:' + v.voiceType"
  120. (click)="$event.stopPropagation(); preview('official', v.voiceType, v.name)"
  121. title="试听">
  122. <span *ngIf="previewLoadingKey !== 'official:' + v.voiceType">▶</span>
  123. <span *ngIf="previewLoadingKey === 'official:' + v.voiceType" class="vp-spin">⟳</span>
  124. </button>
  125. </div>
  126. <div class="vp-card-name" [title]="v.name">{{v.name}}</div>
  127. <div class="vp-card-meta">
  128. <span class="vp-tag vp-tag--scene">{{v.scene}}</span>
  129. <span class="vp-tag vp-tag--lang">{{v.language}}</span>
  130. </div>
  131. <div class="vp-card-tags" *ngIf="v.tags?.length">
  132. <span class="vp-tag vp-tag--special" *ngFor="let t of v.tags">{{t}}</span>
  133. </div>
  134. </article>
  135. </div>
  136. <div class="vp-empty" *ngIf="filteredLibrary.length === 0">
  137. 没有匹配的音色,换个关键词试试
  138. </div>
  139. </div>
  140. <!-- 我的音色 -->
  141. <div *ngIf="activeTab === 'mine'">
  142. <div class="vp-grid" *ngIf="clonedList && clonedList.length > 0">
  143. <article class="vp-card"
  144. *ngFor="let t of clonedList"
  145. [class.is-selected]="isSelected('cloned', t.objectId)"
  146. (click)="onPickCloned(t)">
  147. <div class="vp-card-head">
  148. <div class="vp-avatar vp-avatar--mine">{{(t.name || '?').charAt(0)}}</div>
  149. <button type="button" class="vp-play"
  150. [class.is-loading]="previewLoadingKey === 'cloned:' + t.objectId"
  151. (click)="$event.stopPropagation(); preview('cloned', t.objectId, t.name)"
  152. title="试听">
  153. <span *ngIf="previewLoadingKey !== 'cloned:' + t.objectId">▶</span>
  154. <span *ngIf="previewLoadingKey === 'cloned:' + t.objectId" class="vp-spin">⟳</span>
  155. </button>
  156. </div>
  157. <div class="vp-card-name" [title]="t.name || t.objectId">{{t.name || t.objectId}}</div>
  158. <div class="vp-card-meta">
  159. <span class="vp-tag vp-tag--mine">我的复刻</span>
  160. </div>
  161. <div class="vp-card-id" [title]="t.objectId">ID: {{t.objectId}}</div>
  162. </article>
  163. </div>
  164. <div class="vp-empty" *ngIf="!clonedList || clonedList.length === 0">
  165. 你还没有复刻音色,先去左侧"音色复刻"训练一个吧
  166. </div>
  167. </div>
  168. <!-- 试听报错提示 -->
  169. <div class="vp-error" *ngIf="previewError">{{previewError}}</div>
  170. </div>
  171. <!-- 底部:当前选中 + 操作 -->
  172. <footer class="vp-footer">
  173. <div class="vp-selected">
  174. <span class="vp-selected-label">当前选择:</span>
  175. <span class="vp-selected-name" *ngIf="pendingSelection">
  176. {{pendingSelection.name}}
  177. <small class="vp-selected-src">
  178. ({{pendingSelection.source === 'official' ? '音色库' : '我的音色'}})
  179. </small>
  180. </span>
  181. <span class="vp-selected-empty" *ngIf="!pendingSelection">未选择</span>
  182. </div>
  183. <div class="vp-actions">
  184. <button type="button" class="vp-btn vp-btn--ghost" (click)="emitClose()">取消</button>
  185. <button type="button" class="vp-btn vp-btn--primary"
  186. [disabled]="!pendingSelection"
  187. (click)="confirm()">使用此音色</button>
  188. </div>
  189. </footer>
  190. <!-- 隐藏 audio 元素用于播放 -->
  191. <audio #previewAudio style="display:none" preload="auto"></audio>
  192. </div>
  193. </div>
  194. `,
  195. styles: [`
  196. :host { --vp-primary: #6366f1; --vp-primary-hover: #4f46e5; }
  197. .vp-mask {
  198. position: fixed; inset: 0; z-index: 9999;
  199. background: rgba(15, 23, 42, 0.55);
  200. display: flex; align-items: center; justify-content: center;
  201. padding: 24px; backdrop-filter: blur(4px);
  202. animation: vp-fade-in 160ms ease-out;
  203. }
  204. @keyframes vp-fade-in { from { opacity: 0 } to { opacity: 1 } }
  205. .vp-modal {
  206. width: min(960px, 100%); max-height: 88vh;
  207. background: #fff; border-radius: 16px;
  208. box-shadow: 0 24px 60px rgba(15,23,42,0.25);
  209. display: flex; flex-direction: column; overflow: hidden;
  210. animation: vp-pop-in 200ms cubic-bezier(.2,.9,.3,1.2);
  211. }
  212. @keyframes vp-pop-in { from { transform: scale(.96); opacity: 0 } to { transform: scale(1); opacity: 1 } }
  213. .vp-header {
  214. display: flex; align-items: center; justify-content: space-between;
  215. padding: 14px 20px; border-bottom: 1px solid #e5e7eb;
  216. }
  217. .vp-tabs { display: flex; gap: 4px; }
  218. .vp-tab {
  219. background: transparent; border: none; padding: 8px 16px;
  220. font-size: 15px; font-weight: 600; color: #64748b;
  221. border-radius: 8px; cursor: pointer; transition: all .15s;
  222. display: inline-flex; align-items: center; gap: 6px;
  223. }
  224. .vp-tab:hover { background: #f1f5f9; color: #334155; }
  225. .vp-tab.is-active { background: #eef2ff; color: var(--vp-primary); }
  226. .vp-tab-badge {
  227. background: var(--vp-primary); color: #fff; font-size: 11px;
  228. padding: 1px 7px; border-radius: 10px; line-height: 1.4;
  229. }
  230. .vp-close {
  231. background: transparent; border: none; font-size: 18px;
  232. width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
  233. color: #64748b; transition: all .15s;
  234. }
  235. .vp-close:hover { background: #f1f5f9; color: #ef4444; }
  236. .vp-toolbar {
  237. display: flex; align-items: center; gap: 12px;
  238. padding: 12px 20px; border-bottom: 1px solid #f1f5f9;
  239. background: #fafbfc;
  240. }
  241. .vp-scenes { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }
  242. .vp-scene-chip {
  243. background: #fff; border: 1px solid #e2e8f0; color: #64748b;
  244. padding: 5px 12px; border-radius: 16px; font-size: 13px;
  245. cursor: pointer; transition: all .15s;
  246. }
  247. .vp-scene-chip:hover { border-color: var(--vp-primary); color: var(--vp-primary); }
  248. .vp-scene-chip.is-active {
  249. background: var(--vp-primary); border-color: var(--vp-primary); color: #fff;
  250. }
  251. .vp-search {
  252. width: 200px; padding: 6px 12px; border: 1px solid #e2e8f0;
  253. border-radius: 16px; font-size: 13px; outline: none;
  254. }
  255. .vp-search:focus { border-color: var(--vp-primary); }
  256. .vp-body { flex: 1; overflow-y: auto; padding: 16px 20px; min-height: 320px; }
  257. .vp-grid {
  258. display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  259. gap: 12px;
  260. }
  261. .vp-card {
  262. background: #fff; border: 2px solid #e5e7eb; border-radius: 12px;
  263. padding: 12px; cursor: pointer; transition: all .15s;
  264. display: flex; flex-direction: column; gap: 8px;
  265. }
  266. .vp-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 12px rgba(99,102,241,.1); }
  267. .vp-card.is-selected {
  268. border-color: var(--vp-primary);
  269. background: linear-gradient(135deg, #eef2ff 0%, #fff 100%);
  270. box-shadow: 0 0 0 3px rgba(99,102,241,.15);
  271. }
  272. .vp-card-head { display: flex; justify-content: space-between; align-items: center; }
  273. .vp-avatar {
  274. width: 36px; height: 36px; border-radius: 50%;
  275. background: linear-gradient(135deg, #f472b6, #ec4899);
  276. color: #fff; display: flex; align-items: center; justify-content: center;
  277. font-weight: 700; font-size: 16px;
  278. }
  279. .vp-avatar--male { background: linear-gradient(135deg, #60a5fa, #3b82f6); }
  280. .vp-avatar--mine { background: linear-gradient(135deg, #34d399, #10b981); }
  281. .vp-play {
  282. background: #f3f4f6; border: none; border-radius: 50%;
  283. width: 32px; height: 32px; cursor: pointer; font-size: 13px;
  284. color: var(--vp-primary); transition: all .15s;
  285. display: flex; align-items: center; justify-content: center;
  286. }
  287. .vp-play:hover { background: var(--vp-primary); color: #fff; }
  288. .vp-play.is-loading { background: #e0e7ff; }
  289. .vp-spin { display: inline-block; animation: vp-spin 1s linear infinite; }
  290. @keyframes vp-spin { to { transform: rotate(360deg); } }
  291. .vp-card-name {
  292. font-weight: 600; font-size: 14px; color: #1e293b;
  293. overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  294. }
  295. .vp-card-meta { display: flex; gap: 4px; flex-wrap: wrap; }
  296. .vp-card-tags { display: flex; gap: 4px; flex-wrap: wrap; }
  297. .vp-card-id {
  298. font-size: 11px; color: #94a3b8; overflow: hidden;
  299. text-overflow: ellipsis; white-space: nowrap;
  300. }
  301. .vp-tag {
  302. font-size: 11px; padding: 2px 8px; border-radius: 10px; line-height: 1.4;
  303. }
  304. .vp-tag--scene { background: #ede9fe; color: #7c3aed; }
  305. .vp-tag--lang { background: #dbeafe; color: #1d4ed8; }
  306. .vp-tag--special { background: #fef3c7; color: #b45309; }
  307. .vp-tag--mine { background: #d1fae5; color: #047857; }
  308. .vp-empty {
  309. text-align: center; color: #94a3b8; padding: 60px 20px; font-size: 14px;
  310. }
  311. .vp-error {
  312. margin-top: 12px; padding: 8px 12px; background: #fef2f2; color: #b91c1c;
  313. border-radius: 8px; font-size: 13px;
  314. }
  315. .vp-footer {
  316. display: flex; align-items: center; justify-content: space-between;
  317. padding: 14px 20px; border-top: 1px solid #e5e7eb; background: #fafbfc;
  318. gap: 12px;
  319. }
  320. .vp-selected { font-size: 13px; color: #475569; overflow: hidden;
  321. text-overflow: ellipsis; white-space: nowrap; }
  322. .vp-selected-label { color: #94a3b8; }
  323. .vp-selected-name { color: #1e293b; font-weight: 600; }
  324. .vp-selected-src { color: #94a3b8; font-weight: 400; font-size: 12px; }
  325. .vp-selected-empty { color: #94a3b8; }
  326. .vp-actions { display: flex; gap: 8px; }
  327. .vp-btn {
  328. padding: 8px 18px; border-radius: 8px; font-size: 14px; font-weight: 600;
  329. cursor: pointer; border: none; transition: all .15s;
  330. }
  331. .vp-btn--ghost { background: #fff; border: 1px solid #e2e8f0; color: #64748b; }
  332. .vp-btn--ghost:hover { border-color: #cbd5e1; color: #334155; }
  333. .vp-btn--primary { background: var(--vp-primary); color: #fff; }
  334. .vp-btn--primary:hover:not(:disabled) { background: var(--vp-primary-hover); }
  335. .vp-btn--primary:disabled { background: #cbd5e1; cursor: not-allowed; }
  336. `]
  337. })
  338. export class VoicePickerComponent implements OnChanges {
  339. /** 是否打开 */
  340. @Input() open: boolean = false;
  341. /** 用户的已复刻音色列表(外部传入) */
  342. @Input() clonedList: ClonedTimbre[] = [];
  343. /** 当前已选(用于回显高亮),可选 */
  344. @Input() currentSelection: VoiceSelection | null = null;
  345. /** 用户点"使用此音色"或双击时抛出 */
  346. @Output() select = new EventEmitter<VoiceSelection>();
  347. /** 用户点关闭/取消/遮罩时抛出 */
  348. @Output() close = new EventEmitter<void>();
  349. readonly officialLibrary = OFFICIAL_VOICE_LIBRARY;
  350. readonly scenes = VOICE_LIBRARY_SCENES;
  351. activeTab: 'library' | 'mine' = 'library';
  352. sceneFilter: string = '全部';
  353. searchKeyword: string = '';
  354. /** 暂存选择(在用户点"使用此音色"前不真正提交) */
  355. pendingSelection: VoiceSelection | null = null;
  356. /** 试听音频缓存:key = source:id,value = audio url(dataURL 或 http url) */
  357. private previewCache = new Map<string, string>();
  358. previewLoadingKey: string = '';
  359. previewError: string = '';
  360. private currentAudio: HTMLAudioElement | null = null;
  361. constructor(private http: HttpClient) {}
  362. ngOnChanges(changes: SimpleChanges): void {
  363. // 弹窗每次打开时根据已选项回显,并默认聚焦相应 Tab
  364. if (changes['open'] && this.open) {
  365. this.pendingSelection = this.currentSelection ? { ...this.currentSelection } : null;
  366. this.previewError = '';
  367. if (this.pendingSelection?.source === 'cloned') {
  368. this.activeTab = 'mine';
  369. } else {
  370. this.activeTab = 'library';
  371. }
  372. }
  373. // 关闭时停掉正在播放的试听
  374. if (changes['open'] && !this.open) {
  375. this.stopCurrentAudio();
  376. }
  377. }
  378. get filteredLibrary(): OfficialVoice[] {
  379. const kw = this.searchKeyword.trim().toLowerCase();
  380. return this.officialLibrary.filter(v => {
  381. if (this.sceneFilter !== '全部' && v.scene !== this.sceneFilter) return false;
  382. if (kw) {
  383. const hay = `${v.name} ${v.language} ${v.voiceType}`.toLowerCase();
  384. if (!hay.includes(kw)) return false;
  385. }
  386. return true;
  387. });
  388. }
  389. isSelected(source: 'official' | 'cloned', id: string): boolean {
  390. return this.pendingSelection?.source === source && this.pendingSelection?.id === id;
  391. }
  392. isMale(voiceType: string): boolean {
  393. return /(_male_)/.test(voiceType);
  394. }
  395. onPick(source: 'official', v: OfficialVoice): void {
  396. this.pendingSelection = {
  397. source: 'official',
  398. name: v.name,
  399. id: v.voiceType,
  400. voiceType: v.voiceType
  401. };
  402. }
  403. onPickCloned(t: ClonedTimbre): void {
  404. this.pendingSelection = {
  405. source: 'cloned',
  406. name: t.name || t.objectId,
  407. id: t.objectId,
  408. timbreId: t.objectId,
  409. speakerId: t.speakerId
  410. };
  411. }
  412. confirm(): void {
  413. if (!this.pendingSelection) return;
  414. this.stopCurrentAudio();
  415. this.select.emit(this.pendingSelection);
  416. }
  417. emitClose(): void {
  418. this.stopCurrentAudio();
  419. this.close.emit();
  420. }
  421. onMaskClick(_e: MouseEvent): void {
  422. this.emitClose();
  423. }
  424. /** 试听:内存缓存命中直接播;否则调 TTS 合成 */
  425. preview(source: 'official' | 'cloned', id: string, name: string): void {
  426. const key = `${source}:${id}`;
  427. this.previewError = '';
  428. const cached = this.previewCache.get(key);
  429. if (cached) {
  430. this.playAudio(cached);
  431. return;
  432. }
  433. if (this.previewLoadingKey) return; // 已在合成另一条,避免并发
  434. this.previewLoadingKey = key;
  435. const body: any = {
  436. token: TTS_TOKEN,
  437. text: PREVIEW_TEXT,
  438. isStream: false,
  439. audio_params: { format: 'mp3', sample_rate: 24000 }
  440. };
  441. if (source === 'official') {
  442. // _uranus_bigtts 后缀是豆包语音合成模型 2.0,必须使用 seed-tts-2.0 资源。
  443. // 其它后缀后续可扩展(如 1.0 模型可能需 seed-tts-1.0)。
  444. body.volcengine_voice_type = id;
  445. body.x_api_resource_id = id.includes('_uranus_bigtts') ? 'seed-tts-2.0' : 'seed-tts-1.0';
  446. } else {
  447. body.timbreId = id;
  448. }
  449. this.http.post<any>(TTS_API_URL, body).subscribe({
  450. next: (res) => {
  451. this.previewLoadingKey = '';
  452. if (res?.code === 200 && res?.data?.audioUrl) {
  453. this.previewCache.set(key, res.data.audioUrl);
  454. this.playAudio(res.data.audioUrl);
  455. } else {
  456. this.previewError = `试听 "${name}" 失败:${res?.error?.message || res?.message || '未知错误'}`;
  457. }
  458. },
  459. error: (err) => {
  460. this.previewLoadingKey = '';
  461. this.previewError = `试听 "${name}" 请求失败:${err?.error?.message || err?.message || '网络错误'}`;
  462. }
  463. });
  464. }
  465. private playAudio(url: string): void {
  466. this.stopCurrentAudio();
  467. const audio = new Audio(url);
  468. audio.play().catch(e => {
  469. this.previewError = `音频播放失败:${e?.message || e}`;
  470. });
  471. this.currentAudio = audio;
  472. }
  473. private stopCurrentAudio(): void {
  474. if (this.currentAudio) {
  475. try { this.currentAudio.pause(); } catch {}
  476. this.currentAudio = null;
  477. }
  478. }
  479. }