mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 22:16:58 +08:00
feat(tts): add provider voice selection and preview (#646)
This commit is contained in:
parent
9f49637ac9
commit
a504ae1687
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: {
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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')
|
||||||
|
})
|
||||||
@ -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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user