diff --git a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.test.ts b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.test.ts index 4ebf6b090..0e4b32fd0 100644 --- a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.test.ts +++ b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.test.ts @@ -48,4 +48,34 @@ describe('useCaptionItems', () => { vi.useRealTimers() } }) + + // ROOT CAUSE: + // + // Streaming providers send a complete volatile sentence on each update. + // The caption overlay appended every correction as a separate item. + it('replaces volatile speaker captions without accumulating corrections', () => { + vi.useFakeTimers() + + try { + const captions = useCaptionItems({ ttlMs: 1000 }) + + captions.add({ operation: 'replace', type: 'caption-speaker', text: '今天天气很号' }) + vi.advanceTimersByTime(500) + captions.add({ operation: 'replace', type: 'caption-speaker', text: '今天天气很好' }) + + expect(captions.items.value).toHaveLength(1) + expect(captions.items.value[0]?.text).toBe('今天天气很好') + + vi.advanceTimersByTime(500) + + expect(captions.items.value).toHaveLength(1) + + vi.advanceTimersByTime(500) + + expect(captions.items.value).toEqual([]) + } + finally { + vi.useRealTimers() + } + }) }) diff --git a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts index f19b3a278..8533b0216 100644 --- a/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts +++ b/apps/stage-tamagotchi/src/renderer/composables/useCaptionItems.ts @@ -1,8 +1,6 @@ -import { readonly, shallowRef } from 'vue' +import type { CaptionChannelEvent } from '@proj-airi/stage-shared' -export type CaptionChannelEvent - = | { type: 'caption-speaker', text: string } - | { type: 'caption-assistant', text: string } +import { readonly, shallowRef } from 'vue' export interface CaptionItem { /** Stable render key and timer owner for one broadcast caption event. */ @@ -68,21 +66,54 @@ export function useCaptionItems(options: UseCaptionItemsOptions = {}) { items.value = items.value.filter(item => item.type !== type) } + function scheduleExpiry(item: CaptionItem) { + expiryTimers.set(item.id, setTimeout(() => { + remove(item.id) + }, ttlMs)) + } + + function replace(event: CaptionChannelEvent) { + const matchedItems = items.value.filter(item => item.type === event.type) + const currentItem = matchedItems.at(-1) + if (!currentItem) { + const item: CaptionItem = { + id: nextId++, + type: event.type, + text: event.text, + } + items.value = [...items.value, item] + scheduleExpiry(item) + return + } + + for (const item of matchedItems) + clearTimer(item.id) + + const replacement = { ...currentItem, text: event.text } + items.value = items.value + .filter(item => item.type !== event.type || item.id === currentItem.id) + .map(item => item.id === currentItem.id ? replacement : item) + scheduleExpiry(replacement) + } + function add(event: CaptionChannelEvent) { if (!event.text.trim()) { clearType(event.type) return } + if (event.operation === 'replace') { + replace(event) + return + } + const item: CaptionItem = { id: nextId++, type: event.type, text: event.text, } items.value = [...items.value, item] - expiryTimers.set(item.id, setTimeout(() => { - remove(item.id) - }, ttlMs)) + scheduleExpiry(item) } function dispose() { diff --git a/apps/stage-tamagotchi/src/renderer/pages/caption.vue b/apps/stage-tamagotchi/src/renderer/pages/caption.vue index 7fcec6c0f..c20fe8465 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/caption.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/caption.vue @@ -1,4 +1,6 @@