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: `
{{v.name.charAt(0)}}
{{v.name}}
{{v.scene}} {{v.language}}
{{t}}
没有匹配的音色,换个关键词试试
{{(t.name || '?').charAt(0)}}
{{t.name || t.objectId}}
我的复刻
ID: {{t.objectId}}
你还没有复刻音色,先去左侧"音色复刻"训练一个吧
{{previewError}}
当前选择: {{pendingSelection.name}} ({{pendingSelection.source === 'official' ? '音色库' : '我的音色'}}) 未选择
`, 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(); /** 用户点关闭/取消/遮罩时抛出 */ @Output() close = new EventEmitter(); 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(); 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(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; } } }