@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 +2 -2
- package/src/index.ts +71 -4
- package/src/test/createLivePlugin.test.ts +164 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linto-ai/transcript-ui-plugin-live",
|
|
3
|
-
"version": "0.9.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
319
|
+
setPartial(_event.text)
|
|
259
320
|
}
|
|
260
321
|
} else if (isTranslationTrackFor(activeTranslation, _event.language)) {
|
|
261
|
-
|
|
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
|
+
})
|