feat(tts): add provider voice selection and preview (#646)

This commit is contained in:
mateaix 2026-10-04 17:07:35 +08:00
parent 9f49637ac9
commit a504ae1687
9 changed files with 397 additions and 6 deletions

View File

@ -3,6 +3,7 @@ package vip.mate.tts;
import lombok.Data; import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@ -43,6 +44,24 @@ public class TtsController {
return ResponseEntity.ok(ttsService.listVoices()); return ResponseEntity.ok(ttsService.listVoices());
} }
@PostMapping("/preview")
public ResponseEntity<?> preview(@RequestBody PreviewRequest req) {
TtsResult result = ttsService.preview(req.getProvider(), req.getVoice(), req.getSpeed(), req.getText());
if (!result.isSuccess()) {
return ResponseEntity.badRequest().body(Map.of("msg", result.getErrorMessage()));
}
return ResponseEntity.ok().contentType(MediaType.parseMediaType(result.getContentType()))
.header("Cache-Control", "no-store").body(result.getAudioData());
}
@Data
public static class PreviewRequest {
private String provider;
private String voice;
private Double speed;
private String text;
}
@Data @Data
public static class SynthesizeRequest { public static class SynthesizeRequest {
private String conversationId; private String conversationId;

View File

@ -149,6 +149,30 @@ public class TtsService {
return voices; return voices;
} }
/** Preview unsaved settings without fallback or persisting a chat attachment. */
public TtsResult preview(String providerId, String voice, Double speed, String text) {
SystemSettingsDTO config = systemSettingService.getAllSettings();
TtsProvider provider = providerRegistry.getById(providerId == null ? "" : providerId);
if (provider == null || !provider.isAvailable(config)) {
return TtsResult.failure("所选 TTS 提供商未配置或不可用");
}
if (voice != null && !voice.isBlank() && !provider.availableVoices().contains(voice)) {
return TtsResult.failure("所选音色不属于该提供商");
}
if (speed != null && (!Double.isFinite(speed) || speed < 0.5 || speed > 2.0)) {
return TtsResult.failure("试听语速必须在 0.5 到 2.0 之间");
}
if (text == null || text.isBlank() || text.length() > 200) {
return TtsResult.failure("试听文本必须为 1 到 200 个字符");
}
// Empty selection previews the provider default, not the previously saved voice.
SystemSettingsDTO previewConfig = new SystemSettingsDTO();
org.springframework.beans.BeanUtils.copyProperties(config, previewConfig);
previewConfig.setTtsDefaultVoice("");
return provider.synthesize(TtsRequest.builder().text(text).voice(voice)
.speed(speed == null ? 1.0 : speed).format("mp3").build(), previewConfig);
}
// ==================== 内部逻辑 ==================== // ==================== 内部逻辑 ====================
private TtsResult synthesizeWithFallback(TtsRequest request, SystemSettingsDTO config) { private TtsResult synthesizeWithFallback(TtsRequest request, SystemSettingsDTO config) {
@ -169,7 +193,10 @@ public class TtsService {
if (Boolean.TRUE.equals(config.getTtsFallbackEnabled())) { if (Boolean.TRUE.equals(config.getTtsFallbackEnabled())) {
for (TtsProvider fb : providerRegistry.fallbackCandidates(config, primary.id())) { for (TtsProvider fb : providerRegistry.fallbackCandidates(config, primary.id())) {
log.info("[TTS] Trying fallback provider: {}", fb.id()); log.info("[TTS] Trying fallback provider: {}", fb.id());
result = fb.synthesize(request, config); // Voice and model IDs are scoped to the original provider.
TtsRequest fallbackRequest = TtsRequest.builder()
.text(request.getText()).speed(request.getSpeed()).format(request.getFormat()).build();
result = fb.synthesize(fallbackRequest, config);
if (result.isSuccess()) { if (result.isSuccess()) {
return result; return result;
} }

View File

@ -86,7 +86,8 @@ public class OpenAiTtsProvider implements TtsProvider {
String model = request.getModel() != null && !request.getModel().isBlank() String model = request.getModel() != null && !request.getModel().isBlank()
? request.getModel() : DEFAULT_MODEL; ? request.getModel() : DEFAULT_MODEL;
String voice = request.getVoice() != null && !request.getVoice().isBlank() String voice = request.getVoice() != null && !request.getVoice().isBlank()
? request.getVoice() : DEFAULT_VOICE; ? request.getVoice() : (availableVoices().contains(config.getTtsDefaultVoice() == null
? "" : config.getTtsDefaultVoice()) ? config.getTtsDefaultVoice() : DEFAULT_VOICE);
ObjectNode body = objectMapper.createObjectNode(); ObjectNode body = objectMapper.createObjectNode();
body.put("model", model); body.put("model", model);

View File

@ -0,0 +1,88 @@
package vip.mate.tts;
import org.junit.jupiter.api.Test;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.system.service.SystemSettingService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class TtsVoiceSelectionTest {
private final SystemSettingsDTO config = new SystemSettingsDTO();
private final SystemSettingService settings = mock(SystemSettingService.class);
private final TtsProvider edge = provider("edge-tts", "edge-voice", 100);
private final TtsProvider cloud = provider("openai", "alloy", 200);
private final ChatUploadLocationResolver uploads = mock(ChatUploadLocationResolver.class);
private final TtsService service = new TtsService(settings,
new TtsProviderRegistry(List.of(edge, cloud)), mock(ChatStreamTracker.class), uploads);
TtsVoiceSelectionTest() {
config.setTtsEnabled(true);
config.setTtsProvider("edge-tts");
config.setTtsFallbackEnabled(true);
when(settings.getAllSettings()).thenReturn(config);
}
private TtsProvider provider(String id, String voice, int order) {
var p = mock(TtsProvider.class);
when(p.id()).thenReturn(id);
when(p.autoDetectOrder()).thenReturn(order);
when(p.availableVoices()).thenReturn(List.of(voice));
when(p.defaultVoice()).thenReturn(voice);
when(p.isAvailable(any())).thenReturn(true);
return p;
}
@Test void previewUsesUnsavedSelectionWithoutWritingOrFallback() {
config.setTtsDefaultVoice("old-voice");
when(edge.synthesize(any(), any())).thenAnswer(inv -> {
TtsRequest request = inv.getArgument(0);
SystemSettingsDTO previewConfig = inv.getArgument(1);
assertEquals("edge-voice", request.getVoice());
assertEquals(1.4, request.getSpeed());
assertEquals("", previewConfig.getTtsDefaultVoice());
return TtsResult.failure("upstream failed");
});
assertFalse(service.preview("edge-tts", "edge-voice", 1.4, "Hello").isSuccess());
assertEquals("old-voice", config.getTtsDefaultVoice());
verify(cloud, never()).synthesize(any(), any());
verifyNoInteractions(uploads);
}
@Test void previewRejectsWrongVoiceUnavailableProviderAndInvalidInput() {
assertFalse(service.preview("edge-tts", "alloy", 1.0, "Hello").isSuccess());
assertFalse(service.preview("unknown", "", 1.0, "Hello").isSuccess());
assertFalse(service.preview("edge-tts", "", Double.NaN, "Hello").isSuccess());
assertFalse(service.preview("edge-tts", "", 1.0, "x".repeat(201)).isSuccess());
when(edge.isAvailable(any())).thenReturn(false);
assertFalse(service.preview("edge-tts", "", 1.0, "Hello").isSuccess());
verify(edge, never()).synthesize(any(), any());
}
@Test void previewDefaultDoesNotReuseSavedVoice() {
config.setTtsDefaultVoice("edge-voice");
when(edge.synthesize(any(), any())).thenAnswer(inv -> {
assertEquals("", ((TtsRequest) inv.getArgument(0)).getVoice());
assertEquals("", ((SystemSettingsDTO) inv.getArgument(1)).getTtsDefaultVoice());
return TtsResult.success(new byte[]{1}, "audio/mpeg", "mp3");
});
assertTrue(service.preview("edge-tts", "", 1.0, "Hello").isSuccess());
assertEquals("edge-voice", config.getTtsDefaultVoice());
}
@Test void fallbackDoesNotReceiveAnotherProvidersVoice() {
when(edge.synthesize(any(), any())).thenReturn(TtsResult.failure("offline"));
when(cloud.synthesize(any(), any())).thenAnswer(inv -> {
TtsRequest request = inv.getArgument(0);
assertNull(request.getVoice());
assertNull(request.getModel());
assertEquals("Hello", request.getText());
assertEquals(1.2, request.getSpeed());
return TtsResult.failure("test failure");
});
service.synthesize("test", "Hello", "edge-voice", 1.2, "mp3");
verify(cloud).synthesize(any(), any());
}
}

View File

@ -0,0 +1,39 @@
package vip.mate.tts.provider;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tts.TtsRequest;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class OpenAiTtsVoiceTest {
@Test void usesSavedVoiceAndAllowsExplicitOverride() throws Exception {
var models = mock(ModelProviderService.class, RETURNS_DEEP_STUBS);
when(models.getProviderConfig("openai").getApiKey()).thenReturn("test-key");
when(models.getProviderConfig("openai").getBaseUrl()).thenReturn("https://example.invalid");
var mapper = new ObjectMapper();
var provider = new OpenAiTtsProvider(models, mapper);
var config = new SystemSettingsDTO();
var http = mock(HttpRequest.class, RETURNS_SELF);
doReturn(http).when(http).header(anyString(), anyString());
var response = mock(HttpResponse.class);
when(http.execute()).thenReturn(response);
when(response.getStatus()).thenReturn(200);
when(response.bodyBytes()).thenReturn(new byte[]{1});
try (var factory = mockStatic(HttpRequest.class)) {
factory.when(() -> HttpRequest.post(anyString())).thenReturn(http);
config.setTtsDefaultVoice("nova");
assertTrue(provider.synthesize(TtsRequest.builder().text("Hi").build(), config).isSuccess());
verify(http).body(contains("\"voice\":\"nova\""));
provider.synthesize(TtsRequest.builder().text("Hi").voice("echo").build(), config);
verify(http).body(contains("\"voice\":\"echo\""));
config.setTtsDefaultVoice("zh-CN-XiaoxiaoNeural");
provider.synthesize(TtsRequest.builder().text("Hi").build(), config);
verify(http).body(contains("\"voice\":\"alloy\""));
}
}
}

View File

@ -1550,6 +1550,16 @@ export default {
model3dDesc: 'Configure AI 3D model generation with Tencent Hunyuan 3D (HY-3D-3.1 / HY-3D-3.0 / HY-3D-Express)', model3dDesc: 'Configure AI 3D model generation with Tencent Hunyuan 3D (HY-3D-3.1 / HY-3D-3.0 / HY-3D-Express)',
model3dProviderOptions: { auto: 'Auto Select', hunyuan: 'Tencent Hunyuan 3D' }, model3dProviderOptions: { auto: 'Auto Select', hunyuan: 'Tencent Hunyuan 3D' },
model3dProviderTags: { requiresKey: 'Requires SecretId:SecretKey' }, model3dProviderTags: { requiresKey: 'Requires SecretId:SecretKey' },
ttsVoiceLabel: 'Voice',
ttsVoiceHint: 'Voices depend on the provider. Select again after switching providers. Preview uses unsaved settings without provider fallback.',
ttsAutoVoiceProvider: 'Current automatic provider: {provider}',
ttsVoiceDefault: 'Use default voice',
ttsVoiceUnavailable: 'Configure this provider’s API key before previewing.',
ttsPreview: 'Preview',
ttsPreviewLoading: 'Generating…',
ttsPreviewStop: 'Stop preview',
ttsPreviewText: 'Hello, welcome to MateClaw. This is a preview of the selected voice.',
ttsPreviewFailed: 'Voice preview failed. Check your provider settings.',
ttsTitle: 'Text-to-Speech', ttsTitle: 'Text-to-Speech',
ttsDesc: 'Configure TTS with Edge TTS (free), OpenAI TTS, and DashScope CosyVoice', ttsDesc: 'Configure TTS with Edge TTS (free), OpenAI TTS, and DashScope CosyVoice',
ttsProviderOptions: { ttsProviderOptions: {

View File

@ -1395,6 +1395,16 @@ export default {
model3dDesc: '配置 AI 3D 模型生成,支持腾讯混元 3D(HY-3D-3.1 / HY-3D-3.0 / HY-3D-Express)', model3dDesc: '配置 AI 3D 模型生成,支持腾讯混元 3D(HY-3D-3.1 / HY-3D-3.0 / HY-3D-Express)',
model3dProviderOptions: { auto: '自动选择', hunyuan: '腾讯混元 3D' }, model3dProviderOptions: { auto: '自动选择', hunyuan: '腾讯混元 3D' },
model3dProviderTags: { requiresKey: '需配置 SecretId:SecretKey' }, model3dProviderTags: { requiresKey: '需配置 SecretId:SecretKey' },
ttsVoiceLabel: '音色',
ttsVoiceHint: '音色随提供商变化;切换提供商后重新选择。试听无需保存,也不会切换到其他提供商。',
ttsAutoVoiceProvider: '自动模式当前优先使用:{provider}',
ttsVoiceDefault: '使用默认音色',
ttsVoiceUnavailable: '请先配置该提供商的 API Key,再试听。',
ttsPreview: '试听',
ttsPreviewLoading: '正在合成…',
ttsPreviewStop: '停止试听',
ttsPreviewText: '你好,欢迎使用 MateClaw。这是当前音色的试听效果。',
ttsPreviewFailed: '音色试听失败,请检查提供商配置。',
ttsTitle: '语音合成', ttsTitle: '语音合成',
ttsDesc: '配置 TTS 语音合成,支持 Edge TTS(免费)、OpenAI TTS、DashScope CosyVoice', ttsDesc: '配置 TTS 语音合成,支持 Edge TTS(免费)、OpenAI TTS、DashScope CosyVoice',
ttsProviderOptions: { ttsProviderOptions: {

View File

@ -0,0 +1,94 @@
import { createApp, nextTick, type App } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import TtsSettings from '../index.vue'
import { http, settingsApi } from '@/api'
vi.mock('@/api', () => ({ http: { get: vi.fn(), post: vi.fn() }, settingsApi: { get: vi.fn(), update: vi.fn() } }))
let app: App
let host: HTMLDivElement
const saved = { ttsEnabled: true, ttsProvider: 'edge-tts', ttsDefaultVoice: 'zh-CN-YunxiNeural', ttsSpeed: 1.2 }
const pause = vi.fn()
const play = vi.fn().mockResolvedValue(undefined)
async function flush() { for (let i = 0; i < 8; i++) { await Promise.resolve(); await nextTick() } }
async function mount() {
host = document.createElement('div')
app = createApp(TtsSettings)
app.use(createI18n({ legacy: false, locale: 'en', missingWarn: false, fallbackWarn: false, messages: { en: {} } }))
app.mount(host)
await flush()
}
function voice() { return host.querySelector('[aria-label="settings.ttsVoiceLabel"]') as HTMLSelectElement }
function preview() { return host.querySelector('.voice-control button') as HTMLButtonElement }
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(settingsApi.get).mockResolvedValue({ data: { ...saved } } as any)
vi.mocked(http.get).mockResolvedValue([
{ provider: 'edge-tts', providerLabel: 'Edge', voice: 'zh-CN-YunxiNeural', available: true },
{ provider: 'edge-tts', providerLabel: 'Edge', voice: 'zh-CN-XiaoxiaoNeural', available: true },
{ provider: 'dashscope', providerLabel: 'DashScope', voice: 'longxiaochun_v2', available: false },
] as any)
vi.stubGlobal('Audio', class { pause = pause; play = play; onended = null; onerror = null })
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
})
afterEach(() => { app?.unmount(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('restores saved voice, filters options by provider and persists a new selection', async () => {
await mount()
expect(voice().value).toBe('zh-CN-YunxiNeural')
expect(voice().options.length).toBe(3)
voice().value = 'zh-CN-XiaoxiaoNeural'
voice().dispatchEvent(new Event('change'))
await flush()
;(host.querySelector('.save-bar .btn-primary') as HTMLButtonElement).click()
await flush()
expect(settingsApi.update).toHaveBeenCalledWith(expect.objectContaining({ ttsDefaultVoice: 'zh-CN-XiaoxiaoNeural' }))
})
it('clears incompatible voice when switching provider and disables preview without credentials', async () => {
await mount()
const provider = host.querySelector('select')!
provider.value = 'dashscope'
provider.dispatchEvent(new Event('change'))
await flush()
expect(voice().value).toBe('')
expect(voice().options.length).toBe(2)
expect(preview().disabled).toBe(true)
})
it('previews unsaved voice and speed and releases audio on unmount', async () => {
await mount()
vi.mocked(http.post).mockResolvedValue(new Blob(['audio']) as any)
voice().value = 'zh-CN-XiaoxiaoNeural'
voice().dispatchEvent(new Event('change'))
await flush()
preview().click()
await flush()
expect(http.post).toHaveBeenCalledWith('/tts/preview', expect.objectContaining({
provider: 'edge-tts', voice: 'zh-CN-XiaoxiaoNeural', speed: 1.2,
}), expect.objectContaining({ responseType: 'blob' }))
expect(settingsApi.update).not.toHaveBeenCalled()
expect(play).toHaveBeenCalled()
app.unmount()
expect(pause).toHaveBeenCalled()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test')
})
it('discards a preview response after the selected voice changes', async () => {
await mount()
let resolve!: (v: any) => void
vi.mocked(http.post).mockReturnValue(new Promise(r => { resolve = r }))
preview().click()
await flush()
voice().value = ''
voice().dispatchEvent(new Event('change'))
await flush()
resolve(new Blob(['audio']))
await flush()
expect(play).not.toHaveBeenCalled()
})
it('shows upstream preview errors returned as JSON blobs', async () => {
await mount()
vi.mocked(http.post).mockRejectedValue({ response: { data: new Blob(['{"msg":"Invalid key"}']) } })
preview().click()
await flush()
expect(host.querySelector('[role="alert"]')?.textContent).toBe('Invalid key')
})

View File

@ -27,7 +27,7 @@
<div class="setting-hint">{{ t('settings.hints.ttsProvider') }}</div> <div class="setting-hint">{{ t('settings.hints.ttsProvider') }}</div>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<select v-model="settings.ttsProvider" class="form-input" :disabled="!settings.ttsEnabled"> <select v-model="settings.ttsProvider" @change="onProviderChange" class="form-input" :disabled="!settings.ttsEnabled">
<option value="auto">{{ t('settings.ttsProviderOptions.auto') }}</option> <option value="auto">{{ t('settings.ttsProviderOptions.auto') }}</option>
<option value="edge-tts">Edge TTS ({{ t('settings.ttsProviderTags.free') }})</option> <option value="edge-tts">Edge TTS ({{ t('settings.ttsProviderTags.free') }})</option>
<option value="dashscope">DashScope (CosyVoice)</option> <option value="dashscope">DashScope (CosyVoice)</option>
@ -36,6 +36,29 @@
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.ttsVoiceLabel') }}</div>
<div class="setting-hint">{{ t('settings.ttsVoiceHint') }}</div>
<div v-if="settings.ttsProvider === 'auto' && activeProvider" class="setting-hint">
{{ t('settings.ttsAutoVoiceProvider', { provider: activeProviderLabel }) }}
</div>
</div>
<div class="setting-control voice-control">
<select v-model="settings.ttsDefaultVoice" :aria-label="t('settings.ttsVoiceLabel')"
class="form-input" :disabled="!settings.ttsEnabled || voicesLoading || !voiceOptions.length">
<option value="">{{ t('settings.ttsVoiceDefault') }}</option>
<option v-for="voice in voiceOptions" :key="voice.voice" :value="voice.voice">
{{ voiceLabel(voice.voice) }}
</option>
</select>
<button class="btn-secondary" :disabled="!settings.ttsEnabled || !canPreview || previewState === 'loading'"
@click="previewVoice">{{ t(previewState === 'loading' ? 'settings.ttsPreviewLoading' : previewState === 'playing' ? 'settings.ttsPreviewStop' : 'settings.ttsPreview') }}</button>
</div>
</div>
<p v-if="voiceError" class="voice-error" role="alert">{{ voiceError }}</p>
<p v-if="voiceOptions.length && !canPreview" class="setting-hint">{{ t('settings.ttsVoiceUnavailable') }}</p>
<!-- Fallback 开关 --> <!-- Fallback 开关 -->
<div class="setting-item"> <div class="setting-item">
<div class="setting-info"> <div class="setting-info">
@ -141,9 +164,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api' import { http, settingsApi } from '@/api'
const { t } = useI18n() const { t } = useI18n()
const savedTip = ref('') const savedTip = ref('')
@ -157,11 +180,89 @@ const settings = reactive({
ttsSpeed: 1.0, ttsSpeed: 1.0,
}) })
interface VoiceOption { voice: string; provider: string; providerLabel: string; available: boolean; isDefault: boolean }
const voices = ref<VoiceOption[]>([])
const voicesLoading = ref(true)
const voiceError = ref('')
const previewState = ref<'idle' | 'loading' | 'playing'>('idle')
const activeProvider = computed(() => settings.ttsProvider === 'auto'
? voices.value.find(v => v.available)?.provider : settings.ttsProvider)
const voiceOptions = computed(() => voices.value.filter(v => v.provider === activeProvider.value))
const activeProviderLabel = computed(() => voiceOptions.value[0]?.providerLabel || '')
const canPreview = computed(() => !voicesLoading.value && voiceOptions.value.some(v => v.available))
const edgeNames: Record<string, string> = {
longxiaochun_v2: '龙小淳', longxiaoxia_v2: '龙小夏', longshu_v2: '龙书',
longhua_v2: '龙华', longwan_v2: '龙婉', longcheng_v2: '龙橙',
'zh-CN-XiaoxiaoNeural': '晓晓', 'zh-CN-YunxiNeural': '云希',
'zh-CN-YunjianNeural': '云健', 'zh-CN-XiaoyiNeural': '晓伊', 'zh-CN-YunyangNeural': '云扬',
}
function voiceLabel(id: string) { return edgeNames[id] ? `${edgeNames[id]} · ${id}` : id }
let audio: HTMLAudioElement | null = null
let audioUrl: string | null = null
let previewVersion = 0
let previewAbort: AbortController | null = null
function stopPreview() {
++previewVersion
previewAbort?.abort()
previewAbort = null
if (audio) { audio.pause(); audio.onended = null; audio.onerror = null }
audio = null
if (audioUrl) URL.revokeObjectURL(audioUrl)
audioUrl = null
previewState.value = 'idle'
}
function onProviderChange() {
settings.ttsDefaultVoice = ''
voiceError.value = ''
stopPreview()
}
watch(() => [settings.ttsDefaultVoice, settings.ttsSpeed, settings.ttsEnabled], stopPreview)
onBeforeUnmount(stopPreview)
async function previewVoice() {
if (previewState.value === 'playing') { stopPreview(); return }
stopPreview()
const version = previewVersion
previewAbort = new AbortController()
previewState.value = 'loading'
voiceError.value = ''
try {
const blob = await http.post('/tts/preview', {
provider: activeProvider.value, voice: settings.ttsDefaultVoice,
speed: settings.ttsSpeed, text: t('settings.ttsPreviewText'),
}, { responseType: 'blob', timeout: 70000, signal: previewAbort.signal }) as unknown as Blob
if (version !== previewVersion) return
audioUrl = URL.createObjectURL(blob)
audio = new Audio(audioUrl)
audio.onended = stopPreview
audio.onerror = () => { stopPreview(); voiceError.value = t('settings.ttsPreviewFailed') }
await audio.play()
if (version === previewVersion) previewState.value = 'playing'
} catch (error: any) {
let message = error?.message || t('settings.ttsPreviewFailed')
if (error?.response?.data instanceof Blob) {
try { message = JSON.parse(await error.response.data.text()).msg || message } catch { /* Keep network error. */ }
}
if (version !== previewVersion) return
stopPreview()
voiceError.value = message
}
}
onMounted(async () => { onMounted(async () => {
await loadSettings() try {
await Promise.all([loadSettings(), loadVoices()])
} catch (error) {
voiceError.value = error instanceof Error ? error.message : t('settings.ttsPreviewFailed')
}
}) })
async function loadVoices() {
try { voices.value = await http.get('/tts/voices') as unknown as VoiceOption[] }
finally { voicesLoading.value = false }
}
async function loadSettings() { async function loadSettings() {
stopPreview()
const res: any = await settingsApi.get() const res: any = await settingsApi.get()
const data = res.data || {} const data = res.data || {}
settings.ttsEnabled = data.ttsEnabled ?? false settings.ttsEnabled = data.ttsEnabled ?? false
@ -217,6 +318,8 @@ function showSavedTip(message: string) {
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(20px); } .toggle-switch input:checked + .toggle-slider::before { transform: translateX(20px); }
.toggle-switch input:disabled + .toggle-slider { opacity: 0.5; cursor: not-allowed; } .toggle-switch input:disabled + .toggle-slider { opacity: 0.5; cursor: not-allowed; }
.voice-control { flex-direction: column; gap: 8px; align-items: stretch; }
.voice-error { color: var(--mc-danger, #c0392b); font-size: 13px; }
.speed-control { gap: 10px; } .speed-control { gap: 10px; }
.speed-slider { width: 140px; accent-color: var(--mc-primary); } .speed-slider { width: 140px; accent-color: var(--mc-primary); }
.speed-value { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); min-width: 36px; text-align: right; } .speed-value { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); min-width: 36px; text-align: right; }