Files
hermes-web-ui/tests/client/use-speech-tts.test.ts
2026-06-22 16:58:27 +08:00

510 lines
18 KiB
TypeScript

// @vitest-environment jsdom
import { readFileSync } from 'node:fs'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const createObjectURL = vi.fn()
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', {
createObjectURL,
revokeObjectURL,
})
const audioInstances: MockAudio[] = []
let objectUrlCounter = 0
class MockAudio {
src: string
onended: (() => void) | null = null
onerror: (() => void) | null = null
play = vi.fn().mockResolvedValue(undefined)
pause = vi.fn()
constructor(src = '') {
this.src = src
audioInstances.push(this)
}
}
vi.stubGlobal('Audio', MockAudio)
function flushPromises() {
return new Promise(resolve => setTimeout(resolve, 0))
}
function installSpeechSynthesisMock() {
Object.defineProperty(window, 'speechSynthesis', {
configurable: true,
value: {
speaking: false,
pending: false,
paused: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getVoices: vi.fn(() => []),
speak: vi.fn(),
cancel: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
},
})
}
describe('client TTS unified synthesize flow', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
localStorage.clear()
localStorage.setItem('hermes_server_url', 'https://hermes.example')
localStorage.setItem('hermes_api_key', 'secret-key')
objectUrlCounter = 0
createObjectURL.mockImplementation(() => `blob:mock-${++objectUrlCounter}`)
audioInstances.length = 0
installSpeechSynthesisMock()
})
it('synthesizeSpeech posts to the unified endpoint with auth, body, and signal', async () => {
localStorage.setItem('hermes_active_profile_name', 'research')
mockFetch.mockResolvedValue(new Response('audio-bytes', {
status: 200,
headers: {
'X-TTS-Engine': 'openai-engine',
'X-TTS-Provider': 'openai',
'Content-Type': 'audio/mpeg',
},
}))
const { synthesizeSpeech } = await import('../../packages/client/src/api/hermes/tts')
const controller = new AbortController()
const result = await synthesizeSpeech({
provider: 'openai',
text: 'Hello world',
options: { voice: 'alloy', model: 'tts-1' },
signal: controller.signal,
})
expect(mockFetch).toHaveBeenCalledOnce()
expect(mockFetch).toHaveBeenCalledWith(
'https://hermes.example/api/hermes/tts/synthesize',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer secret-key',
'X-Hermes-Profile': 'research',
},
body: JSON.stringify({
provider: 'openai',
text: 'Hello world',
options: { voice: 'alloy', model: 'tts-1' },
}),
signal: controller.signal,
},
)
expect(result.audio).toBeInstanceOf(Blob)
expect(result.audio.size).toBeGreaterThan(0)
expect(result.engine).toBe('openai-engine')
expect(result.provider).toBe('openai')
})
it('openaiPlay routes through the unified synthesize endpoint with provider=openai', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: {
'X-TTS-Engine': 'openai',
'X-TTS-Provider': 'openai',
},
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-openai', 'Hello from OpenAI', {
baseUrl: 'https://api.openai.com/v1',
apiKey: 'provider-key',
model: 'tts-1',
voice: 'alloy',
})
expect(mockFetch).toHaveBeenCalledOnce()
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe('https://hermes.example/api/hermes/tts/synthesize')
expect(url).not.toContain('/audio/speech')
expect(JSON.parse(options.body)).toEqual({
provider: 'openai',
text: 'Hello from OpenAI',
options: {
baseUrl: 'https://api.openai.com/v1',
apiKey: 'provider-key',
model: 'tts-1',
voice: 'alloy',
},
})
})
it('mimoPlay routes through the unified synthesize endpoint with provider=mimo', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['mimo-audio'], { type: 'audio/wav' }), {
status: 200,
headers: {
'X-TTS-Engine': 'mimo',
'X-TTS-Provider': 'mimo',
},
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.mimoPlay('msg-mimo', 'Hello from MiMo', {
baseUrl: 'https://mimo.example/v1',
apiKey: 'mimo-key',
authMode: 'api-key',
model: 'mimo-v2.5-tts',
voiceMode: 'voiceClone',
voice: 'verse',
voiceCloneDataUri: 'data:audio/wav;base64,ZmFrZQ==',
voiceCloneFormat: 'wav',
stylePrompt: 'warm and calm',
})
expect(mockFetch).toHaveBeenCalledOnce()
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe('https://hermes.example/api/hermes/tts/synthesize')
expect(url).not.toContain('/chat/completions')
expect(JSON.parse(options.body)).toEqual({
provider: 'mimo',
text: 'Hello from MiMo',
options: {
baseUrl: 'https://mimo.example/v1',
apiKey: 'mimo-key',
authMode: 'api-key',
model: 'mimo-v2.5-tts',
voiceMode: 'voiceClone',
voice: 'verse',
voiceCloneDataUri: 'data:audio/wav;base64,ZmFrZQ==',
voiceCloneFormat: 'wav',
stylePrompt: 'warm and calm',
},
})
})
it('openaiPlay respects an explicit edge provider even when other fields look OpenAI-compatible', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['edge-audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'edge' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-edge-explicit', 'Edge explicit', {
provider: 'edge',
baseUrl: 'https://api.openai.com/v1',
model: 'tts-1',
voice: 'zh-CN-XiaoxiaoNeural',
})
const [, options] = mockFetch.mock.calls[0]
expect(JSON.parse(options.body).provider).toBe('edge')
})
it('openaiPlay falls back to edge for the legacy /api/tts/proxy baseUrl', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['edge-audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'edge' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-edge-fallback', 'Edge fallback', {
baseUrl: '/api/tts/proxy',
voice: 'zh-CN-XiaoxiaoNeural',
})
const [, options] = mockFetch.mock.calls[0]
expect(JSON.parse(options.body).provider).toBe('edge')
})
it('openaiPlay falls back to custom for non-proxy endpoints without a model', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['custom-audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'custom' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-custom-fallback', 'Custom fallback', {
baseUrl: 'https://custom.example/v1',
apiKey: 'custom-key',
})
const [, options] = mockFetch.mock.calls[0]
expect(JSON.parse(options.body).provider).toBe('custom')
})
it('stop aborts a pending unified custom TTS request and clears custom state', async () => {
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' })
let capturedSignal: AbortSignal | undefined
mockFetch.mockImplementation((_url, options: RequestInit) => {
capturedSignal = options.signal as AbortSignal
return new Promise((_resolve, reject) => {
capturedSignal?.addEventListener('abort', () => reject(abortError), { once: true })
})
})
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
const pending = speech.openaiPlay('msg-stop', 'Stop me', {
baseUrl: 'https://custom.example/v1',
})
expect(speech.isCustomPlaying.value).toBe(true)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe('msg-stop')
speech.stop()
expect(capturedSignal?.aborted).toBe(true)
expect(speech.isCustomPlaying.value).toBe(false)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe(null)
await pending.catch(() => undefined)
})
it('stop(true) clears queued playback and stops the active TTS audio', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Engine': 'edge' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
speech.enqueue('msg-1', 'First queued response')
await flushPromises()
const [firstAudio] = audioInstances
speech.enqueue('msg-2', 'Second queued response')
speech.stop(true)
firstAudio.onended?.()
await flushPromises()
expect(firstAudio.pause).toHaveBeenCalledOnce()
expect(firstAudio.src).toBe('')
expect(audioInstances).toHaveLength(1)
expect(speech.currentMessageId.value).toBe(null)
expect(speech.isPlaying.value).toBe(false)
})
it('handles AbortError silently without console.error and clears custom state', async () => {
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' })
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetch.mockRejectedValue(abortError)
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await expect(speech.openaiPlay('msg-abort', 'Abort me', {
baseUrl: 'https://custom.example/v1',
})).resolves.toBeUndefined()
expect(consoleError).not.toHaveBeenCalled()
expect(speech.isCustomPlaying.value).toBe(false)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe(null)
})
it('openaiToggle handles request failures without an unhandled rejection', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetch.mockRejectedValue(new Error('upstream failed'))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
speech.openaiToggle('msg-toggle-fail', 'Fail through toggle', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
await flushPromises()
expect(consoleError).not.toHaveBeenCalled()
expect(speech.isCustomPlaying.value).toBe(false)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe(null)
})
it('handles custom audio resume play() rejection', async () => {
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'custom' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-resume', 'Audio to resume', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
const [audio] = audioInstances
speech.openaiToggle('msg-resume', 'Audio to resume', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
audio.play.mockRejectedValueOnce(new Error('play blocked'))
speech.openaiToggle('msg-resume', 'Audio to resume', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
await flushPromises()
expect(consoleWarn).toHaveBeenCalledWith('[useSpeech] Custom TTS audio resume failed:', expect.any(Error))
expect(speech.isCustomPaused.value).toBe(true)
})
it('pause() pauses active custom audio and preserves the current custom message', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'custom' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-custom-pause', 'Audio to pause', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
const [audio] = audioInstances
speech.pause()
expect(audio.pause).toHaveBeenCalledOnce()
expect(speech.isCustomPlaying.value).toBe(true)
expect(speech.isCustomPaused.value).toBe(true)
expect(speech.currentCustomMessageId.value).toBe('msg-custom-pause')
})
it('resume() resumes paused custom audio and clears the custom paused flag', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: { 'X-TTS-Provider': 'custom' },
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-custom-resume', 'Audio to resume globally', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
const [audio] = audioInstances
speech.pause()
audio.play.mockClear()
speech.resume()
await flushPromises()
expect(audio.play).toHaveBeenCalledOnce()
expect(speech.isCustomPlaying.value).toBe(true)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe('msg-custom-resume')
})
it('same-message toggle during pending custom synthesis aborts before audio playback starts', async () => {
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' })
let capturedSignal: AbortSignal | undefined
mockFetch.mockImplementation((_url, options: RequestInit) => {
capturedSignal = options.signal as AbortSignal
return new Promise((_resolve, reject) => {
capturedSignal?.addEventListener('abort', () => reject(abortError), { once: true })
})
})
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
speech.openaiToggle('msg-pending-toggle', 'Audio still synthesizing', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
expect(speech.isCustomPlaying.value).toBe(true)
speech.openaiToggle('msg-pending-toggle', 'Audio still synthesizing', {
provider: 'custom',
baseUrl: 'https://custom.example/v1',
})
await flushPromises()
expect(capturedSignal?.aborted).toBe(true)
expect(audioInstances).toHaveLength(0)
expect(speech.isCustomPlaying.value).toBe(false)
expect(speech.isCustomPaused.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe(null)
})
it('revokes custom audio object URLs when stop() stops custom playback', async () => {
mockFetch.mockResolvedValue(new Response(new Blob(['audio'], { type: 'audio/mpeg' }), {
status: 200,
headers: {
'X-TTS-Engine': 'openai',
'X-TTS-Provider': 'custom',
},
}))
const { useSpeech } = await import('../../packages/client/src/composables/useSpeech')
const speech = useSpeech()
await speech.openaiPlay('msg-audio', 'Audio to stop', {
baseUrl: 'https://custom.example/v1',
})
expect(createObjectURL).toHaveBeenCalledOnce()
expect(audioInstances).toHaveLength(1)
const [audio] = audioInstances
const createdUrl = createObjectURL.mock.results[0]?.value
speech.stop()
expect(audio.pause).toHaveBeenCalledOnce()
expect(audio.src).toBe('')
expect(revokeObjectURL).toHaveBeenCalledWith(createdUrl)
expect(speech.isCustomPlaying.value).toBe(false)
expect(speech.currentCustomMessageId.value).toBe(null)
})
})
describe('client TTS autoplay call sites', () => {
it('catches fire-and-forget custom TTS autoplay promises', () => {
const messageItem = readFileSync('packages/client/src/components/hermes/chat/MessageItem.vue', 'utf8')
const groupMessageItem = readFileSync('packages/client/src/components/hermes/group-chat/GroupMessageItem.vue', 'utf8')
expect(messageItem).toContain('function handleAutoplayTtsError')
expect(messageItem).toContain('void speech.openaiPlay')
expect(messageItem).toContain('void speech.mimoPlay')
expect(messageItem).toContain('.catch(handleAutoplayTtsError)')
expect(groupMessageItem).toContain('function handleAutoplayTtsError')
expect(groupMessageItem).toContain('void speech.openaiPlay')
expect(groupMessageItem).toContain('void speech.mimoPlay')
expect(groupMessageItem).toContain('.catch(handleAutoplayTtsError)')
})
it('does not require a local MiMo API key before using server-stored TTS credentials', () => {
const messageItem = readFileSync('packages/client/src/components/hermes/chat/MessageItem.vue', 'utf8')
const groupMessageItem = readFileSync('packages/client/src/components/hermes/group-chat/GroupMessageItem.vue', 'utf8')
expect(messageItem).not.toContain('MiMo TTS API Key 为空')
expect(groupMessageItem).not.toContain('if (!voiceSettings.mimoApiKey.value) return')
expect(messageItem).toContain('apiKey: apiKey || undefined')
expect(groupMessageItem).toContain('apiKey: apiKey || undefined')
})
})