diff --git a/mateclaw-server/src/main/java/vip/mate/tts/TtsController.java b/mateclaw-server/src/main/java/vip/mate/tts/TtsController.java index f41c812e..695fe476 100644 --- a/mateclaw-server/src/main/java/vip/mate/tts/TtsController.java +++ b/mateclaw-server/src/main/java/vip/mate/tts/TtsController.java @@ -3,6 +3,7 @@ package vip.mate.tts; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -43,6 +44,24 @@ public class TtsController { 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 public static class SynthesizeRequest { private String conversationId; diff --git a/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java b/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java index 75a1d23e..d65a26d5 100644 --- a/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java +++ b/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java @@ -149,6 +149,30 @@ public class TtsService { 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) { @@ -169,7 +193,10 @@ public class TtsService { if (Boolean.TRUE.equals(config.getTtsFallbackEnabled())) { for (TtsProvider fb : providerRegistry.fallbackCandidates(config, primary.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()) { return result; } diff --git a/mateclaw-server/src/main/java/vip/mate/tts/provider/OpenAiTtsProvider.java b/mateclaw-server/src/main/java/vip/mate/tts/provider/OpenAiTtsProvider.java index fb6ead6a..ead473ac 100644 --- a/mateclaw-server/src/main/java/vip/mate/tts/provider/OpenAiTtsProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/tts/provider/OpenAiTtsProvider.java @@ -86,7 +86,8 @@ public class OpenAiTtsProvider implements TtsProvider { String model = request.getModel() != null && !request.getModel().isBlank() ? request.getModel() : DEFAULT_MODEL; 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(); body.put("model", model); diff --git a/mateclaw-server/src/test/java/vip/mate/tts/TtsVoiceSelectionTest.java b/mateclaw-server/src/test/java/vip/mate/tts/TtsVoiceSelectionTest.java new file mode 100644 index 00000000..405f224f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tts/TtsVoiceSelectionTest.java @@ -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()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tts/provider/OpenAiTtsVoiceTest.java b/mateclaw-server/src/test/java/vip/mate/tts/provider/OpenAiTtsVoiceTest.java new file mode 100644 index 00000000..7b655757 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tts/provider/OpenAiTtsVoiceTest.java @@ -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\"")); + } + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 09b362ea..1b232c30 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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)', model3dProviderOptions: { auto: 'Auto Select', hunyuan: 'Tencent Hunyuan 3D' }, 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', ttsDesc: 'Configure TTS with Edge TTS (free), OpenAI TTS, and DashScope CosyVoice', ttsProviderOptions: { diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 451193ec..c0334690 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1395,6 +1395,16 @@ export default { model3dDesc: '配置 AI 3D 模型生成,支持腾讯混元 3D(HY-3D-3.1 / HY-3D-3.0 / HY-3D-Express)', model3dProviderOptions: { auto: '自动选择', hunyuan: '腾讯混元 3D' }, model3dProviderTags: { requiresKey: '需配置 SecretId:SecretKey' }, + ttsVoiceLabel: '音色', + ttsVoiceHint: '音色随提供商变化;切换提供商后重新选择。试听无需保存,也不会切换到其他提供商。', + ttsAutoVoiceProvider: '自动模式当前优先使用:{provider}', + ttsVoiceDefault: '使用默认音色', + ttsVoiceUnavailable: '请先配置该提供商的 API Key,再试听。', + ttsPreview: '试听', + ttsPreviewLoading: '正在合成…', + ttsPreviewStop: '停止试听', + ttsPreviewText: '你好,欢迎使用 MateClaw。这是当前音色的试听效果。', + ttsPreviewFailed: '音色试听失败,请检查提供商配置。', ttsTitle: '语音合成', ttsDesc: '配置 TTS 语音合成,支持 Edge TTS(免费)、OpenAI TTS、DashScope CosyVoice', ttsProviderOptions: { diff --git a/mateclaw-ui/src/views/Settings/Tts/__tests__/voiceSelection.test.ts b/mateclaw-ui/src/views/Settings/Tts/__tests__/voiceSelection.test.ts new file mode 100644 index 00000000..b16ae7e5 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Tts/__tests__/voiceSelection.test.ts @@ -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') +}) diff --git a/mateclaw-ui/src/views/Settings/Tts/index.vue b/mateclaw-ui/src/views/Settings/Tts/index.vue index ce145ff5..74b5142a 100644 --- a/mateclaw-ui/src/views/Settings/Tts/index.vue +++ b/mateclaw-ui/src/views/Settings/Tts/index.vue @@ -27,7 +27,7 @@
{{ t('settings.hints.ttsProvider') }}
- @@ -36,6 +36,29 @@
+
+
+
{{ t('settings.ttsVoiceLabel') }}
+
{{ t('settings.ttsVoiceHint') }}
+
+ {{ t('settings.ttsAutoVoiceProvider', { provider: activeProviderLabel }) }} +
+
+
+ + +
+
+ +

{{ t('settings.ttsVoiceUnavailable') }}

+
@@ -141,9 +164,9 @@