Date: 2026-04-26
Scope: src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue and its composables/services
Status: Approved for implementation planning
DialogueChatView.vue currently has three audio playback triggers:
useDialogueEngine.speakTTS() runs after every AI done event (greeting, sendStudentMessage, WebSocket stream, regenerateAiMessage), using the browser-native SpeechSynthesisUtterance API.togglePlay() in the view creates a separate SpeechSynthesisUtterance for the same message.togglePlay() plays the message's stored audioBlob via HTMLAudioElement.The three triggers do not coordinate. Two owners (engine + view) both call speechSynthesis.cancel(), the click-replay path does not know about ongoing auto-play, and starting a new recording does not interrupt currently-playing audio. As a result:
playingMessageId (view) is unaware of ttsUtterance (engine).We are also switching the AI voice from browser-native TTS to Azure Speech REST for higher-quality output. This is the right moment to refactor.
useAudioPlayer composable).useDialogueEngine from audio entirely — the engine becomes pure dialogue state.done is acceptable.en-US-AriaNeural for now.microsoft-cognitiveservices-speech-sdk package is not introduced.The player is created inside DialogueChatView.setup() via useAudioPlayer() and torn down on view unmount. Reasoning: today all three triggers live in this one view; a module singleton would add a global lifecycle hazard (ensure stop() on every navigation) without solving any current problem. If a future view needs playback, it will instantiate its own player; cross-view coordination is a separate, future problem.
The engine no longer touches speechSynthesis. Instead, the view runs a watch() on engine.messages and triggers player.play(...) when an AI message transitions to status === 'done'. Reasoning: "auto-play after a message completes" is a presentation concern. Keeping it in the view means engine has zero audio dependency, and any future toggle ("disable auto-play") is a view-only change.
The watcher uses a Set<string> of already-auto-played message IDs to avoid re-playing on unrelated re-renders.
Each AI message text → one Azure REST call → MP3 blob → cached in a Map<messageId, Blob> inside the player. Replays hit the cache (no second call). Cache lives for the player's lifetime; on unmount, all cached object URLs are revoked and the map is cleared. Student-recording blobs are not added to this cache — the message itself owns the blob.
The player exposes errorId: Ref<string | null>. The play button on the affected message renders an error variant (warning icon + "点击重试" text). Clicking it retries by calling player.play(id, source) again. Reasoning:
All failure paths (Azure network/5xx, audio.play() rejection from autoplay policy, decoder errors) collapse to the same UI: warning icon + "播放失败,点击重试". We do not differentiate causes; the user action is identical.
audioBlob to student messageThe beginStudentStream path (useDialogueEngine.ts) does not currently attach the recorded blob to the student message it pushes. As a result, the student's "replay recording" button is silent in WS mode. We fix this in the same change: handleFinishRecording will attach the blob to the in-flight student message via a new engine.attachStudentBlob(studentMsgId, blob) helper. Without this fix, student replay is broken in production (WS is the default path).
The play button has four visual states, derived from three player refs:
| State | Condition | Render |
|---|---|---|
| idle | none of the below | ▶ play icon |
| loading | player.loadingId.value === message.id |
spinner |
| playing | player.playingId.value === message.id |
⏸ pause icon |
| error | player.errorId.value === message.id |
⚠ warning icon, red border, "点击重试" |
loadingId is non-null between play() invocation and either the audio's onplaying event (success path) or the catch block (failure path). It is needed because the synthesis round-trip is observable (~500ms-2s) and the user must see something happen.
| File | Operation | Responsibility |
|---|---|---|
src/views/Editor/EnglishSpeaking/composables/useAudioPlayer.ts |
NEW | Sole audio owner. Single-channel rule, TTS cache, error surface. |
src/views/Editor/EnglishSpeaking/services/speechService.ts |
NEW | Azure Speech REST synthesis. Stateless synthesize(text, signal). |
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts |
EDIT | Remove speakTTS, cancelTTS, ttsUtterance. Add attachStudentBlob. |
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue |
EDIT | Use useAudioPlayer. Add auto-play watcher. Wire stop into recording start. Render new button states. |
.env.example |
NEW or APPEND | Document VITE_AZURE_SPEECH_KEY and VITE_AZURE_SPEECH_REGION. |
useAudioPlayer APIfunction useAudioPlayer(): {
playingId: Readonly<Ref<string | null>>
loadingId: Readonly<Ref<string | null>>
errorId: Readonly<Ref<string | null>>
play(id: string, source: PlaySource): Promise<void>
stop(): void
}
type PlaySource =
| { kind: 'tts'; text: string }
| { kind: 'blob'; blob: Blob }
Contract:
play(id, source) is the single playback entry point.
errorId (a fresh attempt — error is stale).stop() to interrupt the current playback.loadingId = id.kind: 'tts': hits cache or calls synthesize(text).Audio(URL.createObjectURL(blob)).audio.onplaying: loadingId = null; playingId = id.audio.onended: playingId = null (if still us). No error.audio.onerror (mid-play decoder failure), audio.play() rejection, synthesis throw: loadingId = null; playingId = null; errorId = id.stop() aborts pending synthesis (AbortController.abort()), pauses current HTMLAudioElement, clears playingId and loadingId. Does not clear errorId (errors are sticky until a new play() for that id, or the user navigates away).onUnmounted: stop(), revoke all cached URLs, clear cache map.The composable holds at most one of each:
currentAudio: HTMLAudioElement | nullsynthAbort: AbortController | nullplayingId valueplay() always begins by calling stop(), so by construction there can never be two active audio elements or two in-flight syntheses simultaneously. The view does not need to "remember to cancel" anything; the rule is impossible to violate from outside the composable.
speechService.tsconst KEY = import.meta.env.VITE_AZURE_SPEECH_KEY as string
const REGION = import.meta.env.VITE_AZURE_SPEECH_REGION as string
const VOICE = 'en-US-AriaNeural'
const FORMAT = 'audio-24khz-48kbitrate-mono-mp3'
export async function synthesize(text: string, signal?: AbortSignal): Promise<Blob> {
if (!KEY || !REGION) throw new Error('Azure Speech credentials not configured')
const ssml =
`<speak version='1.0' xml:lang='en-US'>` +
`<voice name='${VOICE}'>${escapeXml(text)}</voice>` +
`</speak>`
const res = await fetch(
`https://${REGION}.tts.speech.microsoft.com/cognitiveservices/v1`,
{
method: 'POST',
signal,
headers: {
'Ocp-Apim-Subscription-Key': KEY,
'Content-Type': 'application/ssml+xml',
'X-Microsoft-OutputFormat': FORMAT,
'User-Agent': 'PPT-EnglishSpeaking',
},
body: ssml,
},
)
if (!res.ok) throw new Error(`Azure TTS failed: ${res.status}`)
return res.blob()
}
function escapeXml(s: string): string {
return s.replace(/[<>&'"]/g, c => ({
'<': '<', '>': '>', '&': '&', "'": ''', '"': '"',
}[c]!))
}
The service is stateless — no token cache, no retry logic. Each call is a single fetch. The player owns the cache.
Auto-play watcher:
const player = useAudioPlayer()
const autoPlayedIds = new Set<string>()
watch(
() => engine.messages.value.map(m => `${m.id}:${m.status}`).join('|'),
() => {
for (const m of engine.messages.value) {
if (m.role === 'ai' && m.status === 'done' && m.content && !autoPlayedIds.has(m.id)) {
autoPlayedIds.add(m.id)
player.play(m.id, { kind: 'tts', text: m.content })
}
}
},
)
The autoPlayedIds set is scoped to the component instance, so it resets if the view is remounted. Re-generating an AI message creates a new id, so it correctly auto-plays again.
Click toggle:
function togglePlay(id: string) {
if (player.playingId.value === id || player.loadingId.value === id) {
player.stop()
return
}
const msg = engine.messages.value.find(m => m.id === id)
if (!msg) return
if (msg.role === 'student' && msg.audioBlob) {
player.play(id, { kind: 'blob', blob: msg.audioBlob })
} else if (msg.role === 'ai' && msg.content) {
player.play(id, { kind: 'tts', text: msg.content })
}
}
errorId === id falls through to the play() branch, naturally retrying.
Recording start interrupt:
async function handleStartRecording() {
if (!engine.canRecord.value || recorder.isRecording.value) return
player.stop() // ← new
// ...rest unchanged
}
handleRestart similarly calls player.stop() (replacing engine.cancelTTS()).
Template state derivation:
Each play button reads player.playingId.value / player.loadingId.value / player.errorId.value directly. No local playingMessageId ref.
Error UI (per voice-bar):
When player.errorId.value === message.id:
play-btn-error modifier class (red border, warning icon).<span class="play-error-hint">点击重试</span> replaces the duration label.Removed:
let ttsUtterance: SpeechSynthesisUtterance | null = null (line 16)function speakTTS(text) (lines 244-252)function cancelTTS() (lines 254-259)speakTTS(...) call sites (lines 56, 124, 201, 369)cancelTTS() call in onUnmounted (line 429)cancelTTS from the returned object (line 454)Added:
function attachStudentBlob(messageId: string, blob: Blob) {
const msg = messages.value.find(m => m.id === messageId)
if (msg && msg.role === 'student') msg.audioBlob = blob
}
Returned alongside other helpers.
async function handleFinishRecording() {
if (!recorder.isRecording.value) return
const ctl = streamCtl
streamCtl = null
try {
const blob = await recorder.stopRecording()
recorder.onChunk.value = null
if (ctl) {
engine.attachStudentBlob(ctl.studentMsgId, blob) // ← new
ctl.finish()
} else {
await engine.sendStudentMessage(blob)
}
} catch (err) {
console.error('Recording/send failed:', err)
}
}
let currentAudio: HTMLAudioElement | null = nulllet currentAudioUrl: string | null = nullfunction stopCurrentPlayback()togglePlay()playingMessageId refengine.cancelTTS() call in handleRestart1. WS / HTTP stream finishes → engine sets aiMsg.status = 'done'
2. View watcher detects new (id, 'done') → autoPlayedIds.add(id) → player.play(id, { kind: 'tts', text })
3. player.play → stop() → loadingId = id → speechService.synthesize() (≈800ms)
4. fetch resolves → blob → ttsCache.set(id, blob) → URL.createObjectURL(blob)
5. new Audio(url).play() → audio.onplaying fires → loadingId = null, playingId = id
6. audio finishes → onended → playingId = null
1. User clicks play button on AI message → togglePlay(id)
2. Not playing/loading → player.play(id, { kind: 'tts', text })
3. player.play → stop() → loadingId = id → ttsCache.get(id) hits → no fetch
4. URL.createObjectURL(blob) → new Audio.play() → onplaying → playingId = id
1. User clicks 开始录音 → handleStartRecording
2. player.stop() → synthAbort.abort() (if synthesizing) → audio.pause() (if playing)
→ loadingId = null, playingId = null
3. recorder.startRecording() proceeds
1. player.play(id, { kind: 'tts', text }) → loadingId = id
2. synthesize() → fetch rejects (network) or 5xx
3. catch: loadingId = null, errorId = id
4. View re-renders: play button shows ⚠ + "点击重试"
5. User clicks button → togglePlay → not playing/loading → player.play(id, ...) (errorId cleared, retry)
| Scenario | Handling |
|---|---|
| Synthesis network failure | errorId = id. Button shows retry. No global toast. |
| Azure 401 (bad key) / 5xx | Same as network failure. Logged to console for ops. |
audio.play() rejected (autoplay policy) |
Same as failure. User clicks → counts as user gesture → succeeds. |
| Synthesis in progress, recording starts | synthAbort.abort() + loadingId = null. No audio plays. No error. |
| Synthesis in progress, new AI message arrives | Old synth aborted. New play() starts. Old message NOT marked errored. |
| User replaying old msg, new AI message arrives | Old audio paused. New auto-play takes over (matches existing single-channel behavior). |
| User clicks same message twice | Second click hits playingId === id → stop(). Toggle. |
| User clicks errored message | Error cleared, retry begins. |
| View unmounts mid-synthesis | onUnmounted → stop() → abort. Cached URLs revoked. |
crypto.randomUUID() collision (theoretical) |
N/A — not handled. Astronomically unlikely. |
Empty text for TTS |
View guards (m.content truthy check in watcher and toggle). |
VITE_AZURE_SPEECH_KEY missing |
synthesize() throws immediately. Falls into normal failure path → error UI. Console error explains. |
There is no existing unit test infrastructure for this view. Verification is manual, captured as a checklist:
Single-channel correctness:
Toggle correctness:
Cache:
*.tts.speech.microsoft.com.Error UI:
VITE_AZURE_SPEECH_KEY → all AI plays fail with retry UI.Lifecycle:
WS audio attachment fix:
.env.local (developer machine, not committed):
VITE_AZURE_SPEECH_KEY=<your-key>
VITE_AZURE_SPEECH_REGION=eastus
.env.example (committed, no values):
VITE_AZURE_SPEECH_KEY=
VITE_AZURE_SPEECH_REGION=
VITE_AZURE_SPEECH_KEY is bundled into the JavaScript shipped to browsers. Anyone with DevTools can extract it. This is acceptable only for local development and internal demos.
Before shipping to external users, replace with the deferred design:
GET /api/speaking/azure-token returns { token, region } (10-min expiry).speechService.ts fetches the token before each batch of synthesis calls; caches it for ≤9 minutes; refreshes on 401.Authorization: Bearer <token> instead of Ocp-Apim-Subscription-Key.VITE_AZURE_SPEECH_KEY is removed from the frontend.This change is isolated to speechService.ts (player and view code stay identical).
These are deferred until product asks for them.