@linto-ai/transcript-ui-core 0.9.0
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/LICENSE +661 -0
- package/README.md +46 -0
- package/package.json +46 -0
- package/src/adapters/apiAdapter.ts +44 -0
- package/src/adapters/mapApiTurns.ts +32 -0
- package/src/adapters/test/mapApiTurns.test.ts +73 -0
- package/src/adapters/whisperXAdapter.ts +73 -0
- package/src/components/ChannelSelector.vue +31 -0
- package/src/components/EditorErrorOverlay.vue +55 -0
- package/src/components/EditorLoadingOverlay.vue +52 -0
- package/src/components/Header.vue +138 -0
- package/src/components/Layout.vue +221 -0
- package/src/components/MicrophoneIllustration.vue +28 -0
- package/src/components/SelectionActionBar.vue +86 -0
- package/src/components/SidebarDrawer.vue +32 -0
- package/src/components/SpeakerLabel.vue +105 -0
- package/src/components/SpeakerSidebar.vue +455 -0
- package/src/components/TabBar.constants.ts +2 -0
- package/src/components/TabBar.vue +52 -0
- package/src/components/TranscriptUI.vue +68 -0
- package/src/components/TranscriptionEmpty.vue +39 -0
- package/src/components/TranscriptionPanel.vue +275 -0
- package/src/components/TranscriptionTurn.vue +407 -0
- package/src/components/TranslationSelector.vue +39 -0
- package/src/components/VerbatimPanel.vue +160 -0
- package/src/components/molecules/MergeDialog.vue +150 -0
- package/src/components/molecules/MergeTurnsButton.vue +57 -0
- package/src/components/molecules/SpeakerPopover.vue +146 -0
- package/src/composables/useEditorReady.ts +62 -0
- package/src/composables/useFollowPlayback.ts +124 -0
- package/src/composables/useIsMobile.ts +24 -0
- package/src/composables/useTurnSelection.ts +169 -0
- package/src/constants/speakers.ts +14 -0
- package/src/core/createCore.ts +162 -0
- package/src/core/helpers/addSpeaker.ts +11 -0
- package/src/core/helpers/countTurnsForSpeaker.ts +9 -0
- package/src/core/helpers/createSpeakerAndAssign.ts +16 -0
- package/src/core/helpers/ensureDocumentSpeakers.ts +26 -0
- package/src/core/helpers/ensureSpeaker.ts +12 -0
- package/src/core/helpers/ensureSpeakersFromTurns.ts +14 -0
- package/src/core/helpers/findTurnIndex.ts +5 -0
- package/src/core/helpers/index.ts +15 -0
- package/src/core/helpers/insertTurn.ts +5 -0
- package/src/core/helpers/mergeSpeakers.ts +29 -0
- package/src/core/helpers/patchTurn.ts +15 -0
- package/src/core/helpers/prependTurns.ts +5 -0
- package/src/core/helpers/removeTurn.ts +8 -0
- package/src/core/helpers/renameSpeaker.ts +12 -0
- package/src/core/helpers/speakerEquals.ts +6 -0
- package/src/core/helpers/switchTurnSpeaker.ts +17 -0
- package/src/core/helpers/test/countTurnsForSpeaker.test.ts +16 -0
- package/src/core/helpers/test/createSpeakerAndAssign.test.ts +22 -0
- package/src/core/helpers/test/makeTestCore.ts +40 -0
- package/src/core/helpers/test/mergeSpeakers.test.ts +24 -0
- package/src/core/helpers/test/renameSpeaker.test.ts +19 -0
- package/src/core/helpers/test/switchTurnSpeaker.test.ts +27 -0
- package/src/core/helpers/updateTurnWords.ts +22 -0
- package/src/core/index.ts +47 -0
- package/src/core/modules/eventBus.ts +43 -0
- package/src/core/stores/channelStore.ts +100 -0
- package/src/core/stores/crossTranslationStore.ts +141 -0
- package/src/core/stores/index.ts +7 -0
- package/src/core/stores/speakersStore.ts +53 -0
- package/src/core/stores/translationStore.ts +133 -0
- package/src/core/types.ts +602 -0
- package/src/core/useCore.ts +16 -0
- package/src/index.ts +88 -0
- package/src/types/api.ts +52 -0
- package/src/types/editor.ts +65 -0
- package/src/types/whisperx.ts +22 -0
- package/src/utils/color.ts +7 -0
- package/src/utils/computeCaretOffsetFromPoint.ts +56 -0
- package/src/utils/computeTurnPlainText.ts +9 -0
- package/src/utils/extractLangCode.ts +7 -0
- package/src/utils/findWordAtOffset.ts +16 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/intl.ts +47 -0
- package/src/utils/isSameLanguage.ts +13 -0
- package/src/utils/test/computeTurnPlainText.test.ts +34 -0
- package/src/utils/test/findWordAtOffset.test.ts +25 -0
- package/src/utils/throttle.ts +23 -0
- package/src/utils/time.ts +95 -0
- package/src/utils/tokenize.ts +34 -0
- package/src/utils/tts.ts +49 -0
- package/src/utils/turnWords/carryWordTimes.ts +37 -0
- package/src/utils/turnWords/index.ts +6 -0
- package/src/utils/turnWords/layoutWords.ts +41 -0
- package/src/utils/turnWords/parseWordId.ts +13 -0
- package/src/utils/turnWords/test/carryWordTimes.test.ts +47 -0
- package/src/utils/turnWords/test/layoutWords.test.ts +54 -0
- package/src/utils/turnWords/test/parseWordId.test.ts +24 -0
- package/src/utils/turnWords/test/wordId.test.ts +9 -0
- package/src/utils/turnWords/test/wordsFromApi.test.ts +20 -0
- package/src/utils/turnWords/test/wordsFromText.test.ts +17 -0
- package/src/utils/turnWords/wordId.ts +9 -0
- package/src/utils/turnWords/wordsFromApi.ts +16 -0
- package/src/utils/turnWords/wordsFromText.ts +14 -0
- package/src/utils/validateDocument.ts +80 -0
- package/src/utils/waveform.ts +64 -0
- package/src/utils/wordRange.ts +67 -0
- package/src/utils/words.ts +45 -0
package/src/types/api.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Types mirroring the backend API JSON format */
|
|
2
|
+
|
|
3
|
+
export interface ApiWord {
|
|
4
|
+
/** Legacy identity — still present in Mongo/REST payloads for other
|
|
5
|
+
* consumers, ignored by the editor (words align by token index). */
|
|
6
|
+
wid?: string
|
|
7
|
+
stime?: number
|
|
8
|
+
etime?: number
|
|
9
|
+
word: string
|
|
10
|
+
confidence?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ApiTurn {
|
|
14
|
+
speaker_id: string
|
|
15
|
+
turn_id: string
|
|
16
|
+
segment: string
|
|
17
|
+
raw_segment: string
|
|
18
|
+
words: ApiWord[]
|
|
19
|
+
stime?: number
|
|
20
|
+
etime?: number
|
|
21
|
+
language: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ApiSpeaker {
|
|
25
|
+
speaker_id: string
|
|
26
|
+
speaker_name: string
|
|
27
|
+
stime: number
|
|
28
|
+
etime: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApiAudioMetadata {
|
|
32
|
+
filename: string
|
|
33
|
+
duration: number
|
|
34
|
+
mimetype: string
|
|
35
|
+
filepath: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ApiMetadata {
|
|
39
|
+
transcription: {
|
|
40
|
+
lang: string
|
|
41
|
+
confidence: number
|
|
42
|
+
}
|
|
43
|
+
audio: ApiAudioMetadata
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ApiDocument {
|
|
47
|
+
name: string
|
|
48
|
+
description: string
|
|
49
|
+
speakers: ApiSpeaker[]
|
|
50
|
+
text: ApiTurn[]
|
|
51
|
+
metadata: ApiMetadata
|
|
52
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Internal editor types — backend-agnostic */
|
|
2
|
+
|
|
3
|
+
export interface Word {
|
|
4
|
+
/** Derived, positional: `${turnId}#${index}`. Recomputed on every text
|
|
5
|
+
* change — an opaque key for consumers, never persisted. */
|
|
6
|
+
id: string
|
|
7
|
+
text: string
|
|
8
|
+
/** Offsets into the turn's plain text (UTF-16 code units), derived locally
|
|
9
|
+
* by tokenization — the karaoke/click/follow anchor. */
|
|
10
|
+
charStart?: number
|
|
11
|
+
charEnd?: number
|
|
12
|
+
startTime?: number
|
|
13
|
+
endTime?: number
|
|
14
|
+
confidence?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Turn {
|
|
18
|
+
id: string
|
|
19
|
+
speakerId: string | null
|
|
20
|
+
text: string | null // non-null when words is empty (live text-only), null otherwise
|
|
21
|
+
words: Word[] // non-empty for word-level detail (ASR), empty when text is the source
|
|
22
|
+
startTime?: number
|
|
23
|
+
endTime?: number
|
|
24
|
+
startDate?: number // Unix timestamp in seconds — wall-clock fallback when startTime is absent
|
|
25
|
+
endDate?: number
|
|
26
|
+
language: string
|
|
27
|
+
/** Original language of the turn (the side being translated from); live-only. */
|
|
28
|
+
sourceLanguage?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Speaker {
|
|
32
|
+
id: string
|
|
33
|
+
name: string
|
|
34
|
+
color: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AudioSource {
|
|
38
|
+
src: string
|
|
39
|
+
filename?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Translation {
|
|
43
|
+
id: string
|
|
44
|
+
languages: string[] // ["fr", "en"] for source, ["es"] for auto-translation
|
|
45
|
+
isSource: boolean
|
|
46
|
+
audio?: AudioSource
|
|
47
|
+
turns: Turn[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Channel {
|
|
51
|
+
id: string
|
|
52
|
+
name: string
|
|
53
|
+
description?: string
|
|
54
|
+
duration: number
|
|
55
|
+
translations: Translation[] // at least 1 (the source)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface EditorDocument {
|
|
59
|
+
title: string
|
|
60
|
+
description?: string
|
|
61
|
+
/** ISO date string or Unix timestamp (seconds) — date the recording took place */
|
|
62
|
+
date?: string | number
|
|
63
|
+
speakers: Map<string, Speaker>
|
|
64
|
+
channels: Channel[]
|
|
65
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Types mirroring the WhisperX JSON output format */
|
|
2
|
+
|
|
3
|
+
export interface WhisperXWord {
|
|
4
|
+
word: string
|
|
5
|
+
start: number
|
|
6
|
+
end: number
|
|
7
|
+
score: number
|
|
8
|
+
speaker?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WhisperXSegment {
|
|
12
|
+
start: number
|
|
13
|
+
end: number
|
|
14
|
+
text: string
|
|
15
|
+
speaker?: string
|
|
16
|
+
words: WhisperXWord[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WhisperXDocument {
|
|
20
|
+
segments: WhisperXSegment[]
|
|
21
|
+
language?: string
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function hexToRgba(hex: string, alpha: number): string {
|
|
2
|
+
const cleaned = hex.replace('#', '')
|
|
3
|
+
const r = parseInt(cleaned.substring(0, 2), 16)
|
|
4
|
+
const g = parseInt(cleaned.substring(2, 4), 16)
|
|
5
|
+
const b = parseInt(cleaned.substring(4, 6), 16)
|
|
6
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
7
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { computeTextOffsetInContainer } from "@linto-ai/transcript-ui-ui"
|
|
2
|
+
|
|
3
|
+
interface CaretPoint {
|
|
4
|
+
node: Node
|
|
5
|
+
offset: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Shadow roots enclosing `node`, innermost first. caretPositionFromPoint
|
|
9
|
+
// only resolves inside a shadow root when that root is listed in its
|
|
10
|
+
// shadowRoots option — Chrome clamps to the host otherwise (Firefox pierces
|
|
11
|
+
// the boundary even without it).
|
|
12
|
+
function collectShadowRoots(node: Node): ShadowRoot[] {
|
|
13
|
+
const roots: ShadowRoot[] = []
|
|
14
|
+
let root = node.getRootNode()
|
|
15
|
+
while (root instanceof ShadowRoot) {
|
|
16
|
+
roots.push(root)
|
|
17
|
+
root = root.host.getRootNode()
|
|
18
|
+
}
|
|
19
|
+
return roots
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Firefox/Chrome expose caretPositionFromPoint; Safari only the older
|
|
23
|
+
// caretRangeFromPoint (which clamps to the shadow host — callers get null
|
|
24
|
+
// and fall back). Both resolve a viewport point to a DOM position.
|
|
25
|
+
function resolveCaretPoint(
|
|
26
|
+
container: HTMLElement,
|
|
27
|
+
x: number,
|
|
28
|
+
y: number,
|
|
29
|
+
): CaretPoint | null {
|
|
30
|
+
const doc = container.ownerDocument
|
|
31
|
+
if (typeof doc.caretPositionFromPoint === "function") {
|
|
32
|
+
const position = doc.caretPositionFromPoint(x, y, {
|
|
33
|
+
shadowRoots: collectShadowRoots(container),
|
|
34
|
+
})
|
|
35
|
+
if (!position) return null
|
|
36
|
+
return { node: position.offsetNode, offset: position.offset }
|
|
37
|
+
}
|
|
38
|
+
const range = doc.caretRangeFromPoint?.(x, y)
|
|
39
|
+
if (!range) return null
|
|
40
|
+
return { node: range.startContainer, offset: range.startOffset }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Character offset in `container`'s plain text for a clicked viewport point,
|
|
45
|
+
* or null when the point doesn't resolve inside the container (caller picks
|
|
46
|
+
* its fallback — usually end of text).
|
|
47
|
+
*/
|
|
48
|
+
export function computeCaretOffsetFromPoint(
|
|
49
|
+
container: HTMLElement,
|
|
50
|
+
x: number,
|
|
51
|
+
y: number,
|
|
52
|
+
): number | null {
|
|
53
|
+
const point = resolveCaretPoint(container, x, y)
|
|
54
|
+
if (!point || !container.contains(point.node)) return null
|
|
55
|
+
return computeTextOffsetInContainer(container, point.node, point.offset)
|
|
56
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Turn } from "../types/editor"
|
|
2
|
+
|
|
3
|
+
/** A turn's editable plain text: the words joined by single spaces (the
|
|
4
|
+
* normalized whitespace contract shared with the server), or the raw text
|
|
5
|
+
* for words-less turns. */
|
|
6
|
+
export function computeTurnPlainText(turn: Turn): string {
|
|
7
|
+
if (turn.words.length > 0) return turn.words.map((w) => w.text).join(" ")
|
|
8
|
+
return turn.text ?? ""
|
|
9
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Word } from "../types/editor"
|
|
2
|
+
|
|
3
|
+
/** The word whose [charStart, charEnd) range contains the offset, if any —
|
|
4
|
+
* words without offsets (defensive) never match. */
|
|
5
|
+
export function findWordAtOffset(
|
|
6
|
+
words: Word[],
|
|
7
|
+
offset: number,
|
|
8
|
+
): Word | undefined {
|
|
9
|
+
return words.find(
|
|
10
|
+
(w) =>
|
|
11
|
+
w.charStart != null &&
|
|
12
|
+
w.charEnd != null &&
|
|
13
|
+
w.charStart <= offset &&
|
|
14
|
+
offset < w.charEnd,
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { hexToRgba } from "./color"
|
|
2
|
+
export { extractLangCode } from "./extractLangCode"
|
|
3
|
+
export { isSameLanguage } from "./isSameLanguage"
|
|
4
|
+
export { getLanguageDisplayName, buildTranslationItems } from "./intl"
|
|
5
|
+
export { throttle } from "./throttle"
|
|
6
|
+
export {
|
|
7
|
+
formatTime,
|
|
8
|
+
formatShortDateTime,
|
|
9
|
+
formatLongDate,
|
|
10
|
+
formatDurationMinutes,
|
|
11
|
+
formatRelativeFromNow,
|
|
12
|
+
} from "./time"
|
|
13
|
+
export { validateEditorDocument, DocumentValidationError } from "./validateDocument"
|
|
14
|
+
export { renderWaveform, normalizePeaks } from "./waveform"
|
|
15
|
+
export { findActiveWord, hasWordTimestamps, firstWordStart, lastWordEnd } from "./words"
|
|
16
|
+
export { speakText, stopTTS, unlockTTS, isTTSSupported, hasVoices } from "./tts"
|
|
17
|
+
export { computeTurnPlainText } from "./computeTurnPlainText"
|
|
18
|
+
export { wordsFromText, wordsFromApi, carryWordTimes, layoutWords, parseWordId, wordId } from "./turnWords"
|
|
19
|
+
export type { TimedText } from "./turnWords"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { TranslationInfo } from "../core/types"
|
|
2
|
+
|
|
3
|
+
export function getLanguageDisplayName(
|
|
4
|
+
code: string,
|
|
5
|
+
locale: string,
|
|
6
|
+
wildcardLabel = "*",
|
|
7
|
+
stripRegion = true,
|
|
8
|
+
): string {
|
|
9
|
+
// Some backend turns carry no language at all — an empty name hides the chip.
|
|
10
|
+
if (!code) return ""
|
|
11
|
+
if (code === "*") return wildcardLabel
|
|
12
|
+
const lookup = stripRegion ? (code.split("-")[0] ?? code) : code
|
|
13
|
+
try {
|
|
14
|
+
const display = new Intl.DisplayNames([locale], { type: "language" })
|
|
15
|
+
return display.of(lookup) ?? display.of(code.split("-")[0] ?? code) ?? code
|
|
16
|
+
} catch {
|
|
17
|
+
return code
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildTranslationItems(
|
|
22
|
+
translations: TranslationInfo[],
|
|
23
|
+
locale: string,
|
|
24
|
+
originalLabel: string,
|
|
25
|
+
wildcardLabel = "*",
|
|
26
|
+
bilingualLabel = "",
|
|
27
|
+
): { value: string; label: string }[] {
|
|
28
|
+
const sorted = [...translations].sort(
|
|
29
|
+
(a, b) => Number(b.isSource) - Number(a.isSource),
|
|
30
|
+
)
|
|
31
|
+
return sorted.map((tr) => {
|
|
32
|
+
// The virtual cross translation is the only non-source track with >1 language.
|
|
33
|
+
const isBilingual = !tr.isSource && tr.languages.length > 1
|
|
34
|
+
return {
|
|
35
|
+
value: tr.id,
|
|
36
|
+
label: tr.isSource
|
|
37
|
+
? originalLabel
|
|
38
|
+
: isBilingual && bilingualLabel
|
|
39
|
+
? bilingualLabel
|
|
40
|
+
: tr.languages
|
|
41
|
+
.map((code) =>
|
|
42
|
+
getLanguageDisplayName(code, locale, wildcardLabel, false),
|
|
43
|
+
)
|
|
44
|
+
.join(", "),
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { extractLangCode } from "./extractLangCode"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compare two language tags ignoring region/subtag — "fr-FR" matches "fr".
|
|
5
|
+
* Returns false when either tag is missing.
|
|
6
|
+
*/
|
|
7
|
+
export function isSameLanguage(
|
|
8
|
+
a: string | undefined,
|
|
9
|
+
b: string | undefined,
|
|
10
|
+
): boolean {
|
|
11
|
+
if (a == null || b == null) return false
|
|
12
|
+
return extractLangCode(a) === extractLangCode(b)
|
|
13
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { computeTurnPlainText } from "../computeTurnPlainText"
|
|
3
|
+
import type { Turn } from "../../types/editor"
|
|
4
|
+
|
|
5
|
+
function makeTurn(partial: Partial<Turn>): Turn {
|
|
6
|
+
return {
|
|
7
|
+
id: "turn-1",
|
|
8
|
+
speakerId: null,
|
|
9
|
+
text: null,
|
|
10
|
+
words: [],
|
|
11
|
+
language: "fr",
|
|
12
|
+
...partial,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("computeTurnPlainText", () => {
|
|
17
|
+
it("joins words with single spaces", () => {
|
|
18
|
+
const turn = makeTurn({
|
|
19
|
+
words: [
|
|
20
|
+
{ id: "turn-1#0", text: "Bonjour" },
|
|
21
|
+
{ id: "turn-1#1", text: "tout" },
|
|
22
|
+
{ id: "turn-1#2", text: "le" },
|
|
23
|
+
],
|
|
24
|
+
})
|
|
25
|
+
expect(computeTurnPlainText(turn)).toBe("Bonjour tout le")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("falls back to the raw text for words-less turns", () => {
|
|
29
|
+
expect(computeTurnPlainText(makeTurn({ text: "texte brut" }))).toBe(
|
|
30
|
+
"texte brut",
|
|
31
|
+
)
|
|
32
|
+
expect(computeTurnPlainText(makeTurn({}))).toBe("")
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { findWordAtOffset } from "../findWordAtOffset"
|
|
3
|
+
import { wordsFromText } from "../turnWords"
|
|
4
|
+
|
|
5
|
+
// "Bonjour tout" — Bonjour[0,7[ tout[8,12[
|
|
6
|
+
const WORDS = wordsFromText("turn-1", "Bonjour tout")
|
|
7
|
+
|
|
8
|
+
describe("findWordAtOffset", () => {
|
|
9
|
+
it("resolves the word containing the offset", () => {
|
|
10
|
+
expect(findWordAtOffset(WORDS, 0)?.text).toBe("Bonjour")
|
|
11
|
+
expect(findWordAtOffset(WORDS, 6)?.text).toBe("Bonjour")
|
|
12
|
+
expect(findWordAtOffset(WORDS, 8)?.text).toBe("tout")
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("returns nothing in a gap or past the end", () => {
|
|
16
|
+
expect(findWordAtOffset(WORDS, 7)).toBeUndefined()
|
|
17
|
+
expect(findWordAtOffset(WORDS, 12)).toBeUndefined()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("skips words without offsets", () => {
|
|
21
|
+
expect(
|
|
22
|
+
findWordAtOffset([{ id: "t#0", text: "x" }], 0),
|
|
23
|
+
).toBeUndefined()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function throttle(fn: (...args: unknown[]) => void, delay = 250) {
|
|
2
|
+
let isThrottled = false
|
|
3
|
+
let pendingArgs: unknown[] | null = null
|
|
4
|
+
|
|
5
|
+
return (...args: unknown[]) => {
|
|
6
|
+
if (isThrottled) {
|
|
7
|
+
pendingArgs = args
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
isThrottled = true
|
|
11
|
+
fn(...args)
|
|
12
|
+
setTimeout(() => {
|
|
13
|
+
isThrottled = false
|
|
14
|
+
if (pendingArgs !== null) {
|
|
15
|
+
const saved = pendingArgs
|
|
16
|
+
pendingArgs = null
|
|
17
|
+
fn(...saved)
|
|
18
|
+
}
|
|
19
|
+
}, delay)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { throttle }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export function formatTime(seconds: number): string {
|
|
2
|
+
const totalSeconds = Math.floor(seconds)
|
|
3
|
+
const hours = Math.floor(totalSeconds / 3600)
|
|
4
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
5
|
+
const secs = totalSeconds % 60
|
|
6
|
+
const mm = String(minutes).padStart(2, "0")
|
|
7
|
+
const ss = String(secs).padStart(2, "0")
|
|
8
|
+
if (hours > 0) {
|
|
9
|
+
return `${hours}:${mm}:${ss}`
|
|
10
|
+
}
|
|
11
|
+
return `${mm}:${ss}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatShortDateTime(
|
|
15
|
+
unixSeconds: number,
|
|
16
|
+
locale: string,
|
|
17
|
+
): string {
|
|
18
|
+
return new Intl.DateTimeFormat(locale, {
|
|
19
|
+
//day: "numeric",
|
|
20
|
+
//month: "numeric",
|
|
21
|
+
hour: "2-digit",
|
|
22
|
+
minute: "2-digit",
|
|
23
|
+
}).format(new Date(unixSeconds * 1000))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toDate(value: string | number): Date | null {
|
|
27
|
+
if (typeof value === "number") {
|
|
28
|
+
const ms = value < 1e12 ? value * 1000 : value
|
|
29
|
+
const d = new Date(ms)
|
|
30
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
31
|
+
}
|
|
32
|
+
const d = new Date(value)
|
|
33
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatLongDate(
|
|
37
|
+
value: string | number,
|
|
38
|
+
locale: string,
|
|
39
|
+
): string {
|
|
40
|
+
const d = toDate(value)
|
|
41
|
+
if (!d) return ""
|
|
42
|
+
return new Intl.DateTimeFormat(locale, {
|
|
43
|
+
day: "numeric",
|
|
44
|
+
month: "long",
|
|
45
|
+
year: "numeric",
|
|
46
|
+
}).format(d)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatDurationMinutes(
|
|
50
|
+
seconds: number,
|
|
51
|
+
locale: string,
|
|
52
|
+
): string {
|
|
53
|
+
const minutes = Math.max(0, Math.round(seconds / 60))
|
|
54
|
+
if (minutes < 60) {
|
|
55
|
+
return new Intl.NumberFormat(locale, {
|
|
56
|
+
style: "unit",
|
|
57
|
+
unit: "minute",
|
|
58
|
+
unitDisplay: "narrow",
|
|
59
|
+
}).format(minutes)
|
|
60
|
+
}
|
|
61
|
+
const hours = Math.floor(minutes / 60)
|
|
62
|
+
const remaining = minutes % 60
|
|
63
|
+
const h = new Intl.NumberFormat(locale, {
|
|
64
|
+
style: "unit",
|
|
65
|
+
unit: "hour",
|
|
66
|
+
unitDisplay: "narrow",
|
|
67
|
+
}).format(hours)
|
|
68
|
+
if (remaining === 0) return h
|
|
69
|
+
const m = new Intl.NumberFormat(locale, {
|
|
70
|
+
style: "unit",
|
|
71
|
+
unit: "minute",
|
|
72
|
+
unitDisplay: "narrow",
|
|
73
|
+
}).format(remaining)
|
|
74
|
+
return `${h} ${m}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatRelativeFromNow(
|
|
78
|
+
value: string | number,
|
|
79
|
+
locale: string,
|
|
80
|
+
): string {
|
|
81
|
+
const d = toDate(value)
|
|
82
|
+
if (!d) return ""
|
|
83
|
+
const diffSec = Math.round((Date.now() - d.getTime()) / 1000)
|
|
84
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
|
|
85
|
+
if (Math.abs(diffSec) < 60) {
|
|
86
|
+
return rtf.format(0, "minute")
|
|
87
|
+
}
|
|
88
|
+
if (Math.abs(diffSec) < 3600) {
|
|
89
|
+
return rtf.format(-Math.round(diffSec / 60), "minute")
|
|
90
|
+
}
|
|
91
|
+
if (Math.abs(diffSec) < 86400) {
|
|
92
|
+
return rtf.format(-Math.round(diffSec / 3600), "hour")
|
|
93
|
+
}
|
|
94
|
+
return rtf.format(-Math.round(diffSec / 86400), "day")
|
|
95
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE tokenization contract, shared by contract with the backend
|
|
3
|
+
* (studio-api/components/EditorHandler/words/tokenize.js): a word token is a
|
|
4
|
+
* maximal run of non-whitespace characters. Client and server MUST tokenize
|
|
5
|
+
* identically — word/timestamp payloads are aligned by token index, with no
|
|
6
|
+
* wid and no character offsets on the wire.
|
|
7
|
+
*
|
|
8
|
+
* Offsets are expressed in UTF-16 code units (plain JS string indices),
|
|
9
|
+
* relative to the turn's own text. They never leave the process: each side
|
|
10
|
+
* derives its own from its own copy of the text.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Token {
|
|
14
|
+
text: string
|
|
15
|
+
charStart: number
|
|
16
|
+
charEnd: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TOKEN_RE = /\S+/g
|
|
20
|
+
|
|
21
|
+
export function tokenize(text: string): Token[] {
|
|
22
|
+
const tokens: Token[] = []
|
|
23
|
+
if (!text) return tokens
|
|
24
|
+
TOKEN_RE.lastIndex = 0
|
|
25
|
+
let m: RegExpExecArray | null
|
|
26
|
+
while ((m = TOKEN_RE.exec(text)) !== null) {
|
|
27
|
+
tokens.push({
|
|
28
|
+
text: m[0],
|
|
29
|
+
charStart: m.index,
|
|
30
|
+
charEnd: m.index + m[0].length,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
return tokens
|
|
34
|
+
}
|
package/src/utils/tts.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Browser speech synthesis (Web Speech API) helpers for reading turns aloud.
|
|
2
|
+
|
|
3
|
+
const TTS_SUPPORTED =
|
|
4
|
+
typeof window !== "undefined" && "speechSynthesis" in window
|
|
5
|
+
|
|
6
|
+
export function isTTSSupported(): boolean {
|
|
7
|
+
return TTS_SUPPORTED
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// At least one voice installed (the list loads async — also watch "voiceschanged").
|
|
11
|
+
export function hasVoices(): boolean {
|
|
12
|
+
return TTS_SUPPORTED && window.speechSynthesis.getVoices().length > 0
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Voice for the language (exact tag, else base code); null → use the default.
|
|
16
|
+
function findVoice(lang: string): SpeechSynthesisVoice | null {
|
|
17
|
+
if (!TTS_SUPPORTED || !lang || lang === "*") return null
|
|
18
|
+
const norm = lang.toLowerCase()
|
|
19
|
+
const base = norm.split("-")[0]
|
|
20
|
+
const voices = window.speechSynthesis.getVoices()
|
|
21
|
+
const exact = voices.find((v) => v.lang.toLowerCase() === norm)
|
|
22
|
+
if (exact) return exact
|
|
23
|
+
return voices.find((v) => v.lang.toLowerCase().split("-")[0] === base) ?? null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Speaks text in its language; calls queue and play in order. Falls back to the
|
|
27
|
+
// browser's default voice when no voice matches the language.
|
|
28
|
+
export function speakText(text: string, lang?: string | null): void {
|
|
29
|
+
if (!isTTSSupported()) return
|
|
30
|
+
const clean = text.trim()
|
|
31
|
+
if (!clean) return
|
|
32
|
+
const utterance = new SpeechSynthesisUtterance(clean)
|
|
33
|
+
const voice = lang ? findVoice(lang) : null
|
|
34
|
+
if (voice) {
|
|
35
|
+
utterance.voice = voice
|
|
36
|
+
utterance.lang = voice.lang
|
|
37
|
+
}
|
|
38
|
+
window.speechSynthesis.speak(utterance)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Silent utterance within a user gesture, to unlock later event-driven playback.
|
|
42
|
+
export function unlockTTS(): void {
|
|
43
|
+
if (!isTTSSupported()) return
|
|
44
|
+
window.speechSynthesis.speak(new SpeechSynthesisUtterance(" "))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function stopTTS(): void {
|
|
48
|
+
if (isTTSSupported()) window.speechSynthesis.cancel()
|
|
49
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Word } from "../../types/editor"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Carry timestamps from the previous word list onto freshly derived tokens by
|
|
5
|
+
* anchoring on the common prefix and suffix (token text equality). The edited
|
|
6
|
+
* middle stays untimed until the server broadcasts recomputed timings (it
|
|
7
|
+
* re-flushes after every edit, so the gap lasts about a debounce). Cheap,
|
|
8
|
+
* deterministic, and wrong only transiently — by design.
|
|
9
|
+
*/
|
|
10
|
+
export function carryWordTimes(next: Word[], prev: Word[]): Word[] {
|
|
11
|
+
const max = Math.min(next.length, prev.length)
|
|
12
|
+
let prefix = 0
|
|
13
|
+
while (prefix < max && next[prefix]!.text === prev[prefix]!.text) prefix++
|
|
14
|
+
let suffix = 0
|
|
15
|
+
while (
|
|
16
|
+
suffix < max - prefix &&
|
|
17
|
+
next[next.length - 1 - suffix]!.text === prev[prev.length - 1 - suffix]!.text
|
|
18
|
+
) {
|
|
19
|
+
suffix++
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return next.map((w, i) => {
|
|
23
|
+
const from =
|
|
24
|
+
i < prefix
|
|
25
|
+
? prev[i]
|
|
26
|
+
: i >= next.length - suffix
|
|
27
|
+
? prev[prev.length - (next.length - i)]
|
|
28
|
+
: undefined
|
|
29
|
+
if (!from) return w
|
|
30
|
+
return {
|
|
31
|
+
...w,
|
|
32
|
+
...(from.startTime !== undefined && { startTime: from.startTime }),
|
|
33
|
+
...(from.endTime !== undefined && { endTime: from.endTime }),
|
|
34
|
+
...(from.confidence !== undefined && { confidence: from.confidence }),
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { wordId } from "./wordId"
|
|
2
|
+
export { parseWordId } from "./parseWordId"
|
|
3
|
+
export { wordsFromText } from "./wordsFromText"
|
|
4
|
+
export { layoutWords, type TimedText } from "./layoutWords"
|
|
5
|
+
export { wordsFromApi } from "./wordsFromApi"
|
|
6
|
+
export { carryWordTimes } from "./carryWordTimes"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { wordId } from "./wordId"
|
|
2
|
+
import type { Word } from "../../types/editor"
|
|
3
|
+
|
|
4
|
+
export interface TimedText {
|
|
5
|
+
text: string
|
|
6
|
+
startTime?: number
|
|
7
|
+
endTime?: number
|
|
8
|
+
confidence?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Lay timed source words out as store Words matching the doc text EXACTLY.
|
|
13
|
+
* The seed (client and server turnsToDoc alike) joins tokens with single
|
|
14
|
+
* spaces and collapses all whitespace — so a stored word carrying irregular
|
|
15
|
+
* whitespace (NBSP, internal space: "l'enfant ?") is SPLIT into its tokens
|
|
16
|
+
* here, each keeping the source word's timing. Without this, every offset
|
|
17
|
+
* after such a word would be shifted from the rendered text at load.
|
|
18
|
+
* Empty/whitespace-only source words (silence placeholders) yield nothing.
|
|
19
|
+
*/
|
|
20
|
+
export function layoutWords(turnId: string, source: TimedText[]): Word[] {
|
|
21
|
+
const out: Word[] = []
|
|
22
|
+
let cursor = 0
|
|
23
|
+
for (const src of source) {
|
|
24
|
+
for (const part of (src.text ?? "").split(/\s+/)) {
|
|
25
|
+
if (!part) continue
|
|
26
|
+
const charStart = cursor
|
|
27
|
+
const charEnd = charStart + part.length
|
|
28
|
+
cursor = charEnd + 1
|
|
29
|
+
out.push({
|
|
30
|
+
id: wordId(turnId, out.length),
|
|
31
|
+
text: part,
|
|
32
|
+
charStart,
|
|
33
|
+
charEnd,
|
|
34
|
+
...(src.startTime !== undefined && { startTime: src.startTime }),
|
|
35
|
+
...(src.endTime !== undefined && { endTime: src.endTime }),
|
|
36
|
+
...(src.confidence !== undefined && { confidence: src.confidence }),
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|