@linto-ai/transcript-ui-plugin-live 0.9.23 → 0.9.24-unstable.202610151359

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linto-ai/transcript-ui-plugin-live",
3
- "version": "0.9.23",
3
+ "version": "0.9.24-unstable.202610151359",
4
4
  "description": "Live transcription plugin for @linto-ai/transcript-ui — ingests a real-time partial/final feed",
5
5
  "keywords": [
6
6
  "vue",
@@ -37,6 +37,6 @@
37
37
  "vue": "^3.5.0"
38
38
  },
39
39
  "dependencies": {
40
- "@linto-ai/transcript-ui-core": "0.9.23"
40
+ "@linto-ai/transcript-ui-core": "0.9.24-unstable.202610151359"
41
41
  }
42
42
  }
package/src/index.ts CHANGED
@@ -16,6 +16,11 @@ import type {
16
16
 
17
17
  const { isSameLanguage, speakText, stopTTS, unlockTTS, isTTSSupported, hasVoices } = utils
18
18
 
19
+ /** ASRs can go quiet for a few seconds between the last partial and the final
20
+ * while someone is still talking: long enough to bridge that gap, so the
21
+ * indicator doesn't blink in the middle of a speech. */
22
+ const DEFAULT_SILENCE_DELAY = 6000
23
+
19
24
  export type { LivePartialEvent, LiveFinalEvent, LiveTranslationEvent }
20
25
  export type { LivePluginApi }
21
26
 
@@ -53,8 +58,17 @@ function finalEventToTranslationTurn(
53
58
  }
54
59
  }
55
60
 
61
+ export interface LivePluginOptions {
62
+ /** Whether the voice-playback feature is offered at all. */
63
+ tts?: boolean
64
+ /** Milliseconds without a partial before speech counts as over. See
65
+ * LivePluginApi.silenceDelay — ASRs differ in how often they emit, so
66
+ * hosts tune it per backend. Defaults to 6s. */
67
+ silenceDelay?: number
68
+ }
69
+
56
70
  export function createLivePlugin(
57
- options: { tts?: boolean } = {},
71
+ options: LivePluginOptions = {},
58
72
  ): CorePlugin {
59
73
  // Deployment flag: whether the voice-playback feature is offered at all.
60
74
  const ttsAvailable = options.tts ?? false
@@ -64,6 +78,10 @@ export function createLivePlugin(
64
78
 
65
79
  install(core: Core) {
66
80
  const partial = shallowRef<string | null>(null)
81
+ const partialsVisible = ref(true)
82
+ const isSpeechActive = ref(false)
83
+ const silenceDelay = ref(options.silenceDelay ?? DEFAULT_SILENCE_DELAY)
84
+ let silenceTimeout: ReturnType<typeof setTimeout> | null = null
67
85
  const hasLiveUpdate = ref(false)
68
86
  // Voice playback of finalized turns (browser speech synthesis).
69
87
  const ttsEnabled = ref(false)
@@ -84,12 +102,49 @@ export function createLivePlugin(
84
102
 
85
103
  hasLiveUpdate.value = true
86
104
 
105
+ // Partials are the only sign that someone is talking, and nothing on
106
+ // the wire ever announces silence — it is only the absence of the next
107
+ // one. Each partial pushes the deadline back, so the state doesn't
108
+ // flicker off between two sentences.
109
+ function markSpeechActivity(): void {
110
+ isSpeechActive.value = true
111
+ if (silenceTimeout !== null) clearTimeout(silenceTimeout)
112
+ silenceTimeout = setTimeout(endSpeechActivity, silenceDelay.value)
113
+ }
114
+
115
+ function endSpeechActivity(): void {
116
+ if (silenceTimeout !== null) {
117
+ clearTimeout(silenceTimeout)
118
+ silenceTimeout = null
119
+ }
120
+ isSpeechActive.value = false
121
+ }
122
+
87
123
  function clearPartial(): void {
88
124
  // shallowRef detects the change on its own, no triggerRef needed
89
125
  partial.value = null
90
126
  lastOriginalPartialEvent = null
91
127
  }
92
128
 
129
+ // The single door every partial goes through — source, translation and
130
+ // cross alike — so one setting closes it for the transcription panel
131
+ // and the subtitle scroller at once. Ignored while they are hidden.
132
+ function setPartial(text: string): void {
133
+ if (!partialsVisible.value) return
134
+ partial.value = text
135
+ }
136
+
137
+ function showPartials(): void {
138
+ partialsVisible.value = true
139
+ }
140
+
141
+ function hidePartials(): void {
142
+ partialsVisible.value = false
143
+ // Drop what is already on screen now, rather than leaving the last
144
+ // provisional line up until a final turn happens to replace it.
145
+ immediateClearPartial()
146
+ }
147
+
93
148
  function isTranslationTrackFor(
94
149
  active: TranslationInfo,
95
150
  language: string,
@@ -103,6 +158,10 @@ export function createLivePlugin(
103
158
  const channel = core.activeChannel.value
104
159
  if (!channel) return
105
160
 
161
+ // Someone is talking on the channel being watched — true whichever
162
+ // track is displayed, and whether or not the text itself is shown.
163
+ markSpeechActivity()
164
+
106
165
  lastOriginalPartialEvent = event
107
166
 
108
167
  const activeTranslation = channel.activeTranslation.value
@@ -110,7 +169,7 @@ export function createLivePlugin(
110
169
  // Only the original (source-language) partial flows through here.
111
170
  // Translated partials arrive via onTranslation, one per translation.
112
171
  if (activeTranslation.isSource && event.text != null) {
113
- partial.value = event.text
172
+ setPartial(event.text)
114
173
  }
115
174
  }
116
175
 
@@ -247,6 +306,8 @@ export function createLivePlugin(
247
306
  const activeTranslation = channel.activeTranslation.value
248
307
 
249
308
  if (!_event.final) {
309
+ markSpeechActivity()
310
+
250
311
  if (activeTranslation.id === CROSS_TRANSLATION_ID) {
251
312
  if (
252
313
  _event.turnId === lastOriginalPartialEvent?.turnId &&
@@ -255,10 +316,10 @@ export function createLivePlugin(
255
316
  lastOriginalPartialEvent?.language,
256
317
  )
257
318
  ) {
258
- partial.value = _event.text
319
+ setPartial(_event.text)
259
320
  }
260
321
  } else if (isTranslationTrackFor(activeTranslation, _event.language)) {
261
- partial.value = _event.text
322
+ setPartial(_event.text)
262
323
  }
263
324
  return
264
325
  }
@@ -305,6 +366,11 @@ export function createLivePlugin(
305
366
 
306
367
  const api: LivePluginApi = {
307
368
  partial,
369
+ partialsVisible,
370
+ isSpeechActive,
371
+ silenceDelay,
372
+ showPartials,
373
+ hidePartials,
308
374
  hasLiveUpdate,
309
375
  ttsAvailable,
310
376
  ttsEnabled,
@@ -336,6 +402,7 @@ export function createLivePlugin(
336
402
 
337
403
  return () => {
338
404
  immediateClearPartial()
405
+ endSpeechActivity()
339
406
  stopTTS()
340
407
  if (ttsSupported) {
341
408
  window.speechSynthesis.removeEventListener(
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { createCore } from "@linto-ai/transcript-ui-core"
3
+ import type { Core } from "@linto-ai/transcript-ui-core"
4
+ import { createLivePlugin } from "../index"
5
+
6
+ /** One channel, a French source and an English translation track — enough
7
+ * for both partial paths (source, and translated/cross). */
8
+ function makeLiveCore(silenceDelay?: number): Core {
9
+ const core = createCore()
10
+ core.setDocument({
11
+ title: "live",
12
+ speakers: new Map(),
13
+ channels: [
14
+ {
15
+ id: "ch-1",
16
+ name: "channel 1",
17
+ duration: 0,
18
+ translations: [
19
+ { id: "tr-fr", languages: ["fr"], isSource: true, turns: [] },
20
+ { id: "tr-en", languages: ["en"], isSource: false, turns: [] },
21
+ ],
22
+ },
23
+ ],
24
+ })
25
+ core.use(createLivePlugin(silenceDelay ? { silenceDelay } : {}))
26
+ return core
27
+ }
28
+
29
+ function wait(ms: number): Promise<void> {
30
+ return new Promise((resolve) => setTimeout(resolve, ms))
31
+ }
32
+
33
+ function sendPartial(core: Core, text: string): void {
34
+ core.live!.onPartial({ turnId: "turn-1", text, language: "fr" }, "ch-1")
35
+ }
36
+
37
+ function sendTranslatedPartial(core: Core, text: string): void {
38
+ core.live!.onTranslation({
39
+ turnId: "turn-1",
40
+ language: "en",
41
+ sourceLanguage: "fr",
42
+ text,
43
+ final: false,
44
+ startTime: 0,
45
+ endTime: 1,
46
+ speakerId: null,
47
+ })
48
+ }
49
+
50
+ describe("createLivePlugin — partials visibility", () => {
51
+ it("shows partials by default", () => {
52
+ const core = makeLiveCore()
53
+
54
+ expect(core.live!.partialsVisible.value).toBe(true)
55
+
56
+ sendPartial(core, "bonjour le")
57
+ expect(core.live!.partial.value).toBe("bonjour le")
58
+ })
59
+
60
+ it("drops what is on screen and swallows the next ones once hidden", () => {
61
+ const core = makeLiveCore()
62
+ sendPartial(core, "bonjour le")
63
+
64
+ core.live!.hidePartials()
65
+ expect(core.live!.partialsVisible.value).toBe(false)
66
+ expect(core.live!.partial.value).toBeNull()
67
+
68
+ sendPartial(core, "bonjour le monde")
69
+ expect(core.live!.partial.value).toBeNull()
70
+ })
71
+
72
+ it("lets them through again when shown back", () => {
73
+ const core = makeLiveCore()
74
+ core.live!.hidePartials()
75
+ sendPartial(core, "swallowed")
76
+
77
+ core.live!.showPartials()
78
+ expect(core.live!.partialsVisible.value).toBe(true)
79
+
80
+ sendPartial(core, "bonjour")
81
+ expect(core.live!.partial.value).toBe("bonjour")
82
+ })
83
+
84
+ it("gates translated partials too, not just the source ones", () => {
85
+ const core = makeLiveCore()
86
+ core.activeChannel.value!.setActiveTranslation("tr-en")
87
+
88
+ sendTranslatedPartial(core, "hello the")
89
+ expect(core.live!.partial.value).toBe("hello the")
90
+
91
+ core.live!.hidePartials()
92
+ expect(core.live!.partial.value).toBeNull()
93
+
94
+ sendTranslatedPartial(core, "hello the world")
95
+ expect(core.live!.partial.value).toBeNull()
96
+ })
97
+
98
+ it("still records finalized turns while partials are hidden", () => {
99
+ const core = makeLiveCore()
100
+ core.live!.hidePartials()
101
+
102
+ core.live!.onFinal(
103
+ {
104
+ turnId: "turn-1",
105
+ speakerId: null,
106
+ text: "bonjour le monde",
107
+ words: [],
108
+ startTime: 0,
109
+ endTime: 2,
110
+ language: "fr",
111
+ },
112
+ "ch-1",
113
+ )
114
+
115
+ const turns = core.activeChannel.value!.sourceTranslation.turns.value
116
+ expect(turns.map((t) => t.text)).toEqual(["bonjour le monde"])
117
+ expect(core.live!.partial.value).toBeNull()
118
+ })
119
+ })
120
+
121
+ describe("createLivePlugin — speech activity", () => {
122
+ it("lights up on a partial and goes out after the silence delay", async () => {
123
+ const core = makeLiveCore(30)
124
+
125
+ expect(core.live!.isSpeechActive.value).toBe(false)
126
+
127
+ sendPartial(core, "bonjour")
128
+ expect(core.live!.isSpeechActive.value).toBe(true)
129
+
130
+ await wait(15)
131
+ // Still talking: each partial pushes the deadline back.
132
+ sendPartial(core, "bonjour le")
133
+ await wait(20)
134
+ expect(core.live!.isSpeechActive.value).toBe(true)
135
+
136
+ await wait(40)
137
+ expect(core.live!.isSpeechActive.value).toBe(false)
138
+ })
139
+
140
+ it("tracks speech even while the partial text is hidden", async () => {
141
+ const core = makeLiveCore(30)
142
+ core.live!.hidePartials()
143
+
144
+ sendPartial(core, "bonjour")
145
+ expect(core.live!.partial.value).toBeNull()
146
+ expect(core.live!.isSpeechActive.value).toBe(true)
147
+
148
+ await wait(50)
149
+ expect(core.live!.isSpeechActive.value).toBe(false)
150
+ })
151
+
152
+ it("counts translated partials as speech too", () => {
153
+ const core = makeLiveCore(30)
154
+ core.activeChannel.value!.setActiveTranslation("tr-en")
155
+
156
+ sendTranslatedPartial(core, "hello")
157
+ expect(core.live!.isSpeechActive.value).toBe(true)
158
+ })
159
+
160
+ it("defaults to a two-second delay, host-tunable at runtime", () => {
161
+ expect(makeLiveCore().live!.silenceDelay.value).toBe(6000)
162
+ expect(makeLiveCore(500).live!.silenceDelay.value).toBe(500)
163
+ })
164
+ })