@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.
Files changed (101) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +46 -0
  3. package/package.json +46 -0
  4. package/src/adapters/apiAdapter.ts +44 -0
  5. package/src/adapters/mapApiTurns.ts +32 -0
  6. package/src/adapters/test/mapApiTurns.test.ts +73 -0
  7. package/src/adapters/whisperXAdapter.ts +73 -0
  8. package/src/components/ChannelSelector.vue +31 -0
  9. package/src/components/EditorErrorOverlay.vue +55 -0
  10. package/src/components/EditorLoadingOverlay.vue +52 -0
  11. package/src/components/Header.vue +138 -0
  12. package/src/components/Layout.vue +221 -0
  13. package/src/components/MicrophoneIllustration.vue +28 -0
  14. package/src/components/SelectionActionBar.vue +86 -0
  15. package/src/components/SidebarDrawer.vue +32 -0
  16. package/src/components/SpeakerLabel.vue +105 -0
  17. package/src/components/SpeakerSidebar.vue +455 -0
  18. package/src/components/TabBar.constants.ts +2 -0
  19. package/src/components/TabBar.vue +52 -0
  20. package/src/components/TranscriptUI.vue +68 -0
  21. package/src/components/TranscriptionEmpty.vue +39 -0
  22. package/src/components/TranscriptionPanel.vue +275 -0
  23. package/src/components/TranscriptionTurn.vue +407 -0
  24. package/src/components/TranslationSelector.vue +39 -0
  25. package/src/components/VerbatimPanel.vue +160 -0
  26. package/src/components/molecules/MergeDialog.vue +150 -0
  27. package/src/components/molecules/MergeTurnsButton.vue +57 -0
  28. package/src/components/molecules/SpeakerPopover.vue +146 -0
  29. package/src/composables/useEditorReady.ts +62 -0
  30. package/src/composables/useFollowPlayback.ts +124 -0
  31. package/src/composables/useIsMobile.ts +24 -0
  32. package/src/composables/useTurnSelection.ts +169 -0
  33. package/src/constants/speakers.ts +14 -0
  34. package/src/core/createCore.ts +162 -0
  35. package/src/core/helpers/addSpeaker.ts +11 -0
  36. package/src/core/helpers/countTurnsForSpeaker.ts +9 -0
  37. package/src/core/helpers/createSpeakerAndAssign.ts +16 -0
  38. package/src/core/helpers/ensureDocumentSpeakers.ts +26 -0
  39. package/src/core/helpers/ensureSpeaker.ts +12 -0
  40. package/src/core/helpers/ensureSpeakersFromTurns.ts +14 -0
  41. package/src/core/helpers/findTurnIndex.ts +5 -0
  42. package/src/core/helpers/index.ts +15 -0
  43. package/src/core/helpers/insertTurn.ts +5 -0
  44. package/src/core/helpers/mergeSpeakers.ts +29 -0
  45. package/src/core/helpers/patchTurn.ts +15 -0
  46. package/src/core/helpers/prependTurns.ts +5 -0
  47. package/src/core/helpers/removeTurn.ts +8 -0
  48. package/src/core/helpers/renameSpeaker.ts +12 -0
  49. package/src/core/helpers/speakerEquals.ts +6 -0
  50. package/src/core/helpers/switchTurnSpeaker.ts +17 -0
  51. package/src/core/helpers/test/countTurnsForSpeaker.test.ts +16 -0
  52. package/src/core/helpers/test/createSpeakerAndAssign.test.ts +22 -0
  53. package/src/core/helpers/test/makeTestCore.ts +40 -0
  54. package/src/core/helpers/test/mergeSpeakers.test.ts +24 -0
  55. package/src/core/helpers/test/renameSpeaker.test.ts +19 -0
  56. package/src/core/helpers/test/switchTurnSpeaker.test.ts +27 -0
  57. package/src/core/helpers/updateTurnWords.ts +22 -0
  58. package/src/core/index.ts +47 -0
  59. package/src/core/modules/eventBus.ts +43 -0
  60. package/src/core/stores/channelStore.ts +100 -0
  61. package/src/core/stores/crossTranslationStore.ts +141 -0
  62. package/src/core/stores/index.ts +7 -0
  63. package/src/core/stores/speakersStore.ts +53 -0
  64. package/src/core/stores/translationStore.ts +133 -0
  65. package/src/core/types.ts +602 -0
  66. package/src/core/useCore.ts +16 -0
  67. package/src/index.ts +88 -0
  68. package/src/types/api.ts +52 -0
  69. package/src/types/editor.ts +65 -0
  70. package/src/types/whisperx.ts +22 -0
  71. package/src/utils/color.ts +7 -0
  72. package/src/utils/computeCaretOffsetFromPoint.ts +56 -0
  73. package/src/utils/computeTurnPlainText.ts +9 -0
  74. package/src/utils/extractLangCode.ts +7 -0
  75. package/src/utils/findWordAtOffset.ts +16 -0
  76. package/src/utils/index.ts +19 -0
  77. package/src/utils/intl.ts +47 -0
  78. package/src/utils/isSameLanguage.ts +13 -0
  79. package/src/utils/test/computeTurnPlainText.test.ts +34 -0
  80. package/src/utils/test/findWordAtOffset.test.ts +25 -0
  81. package/src/utils/throttle.ts +23 -0
  82. package/src/utils/time.ts +95 -0
  83. package/src/utils/tokenize.ts +34 -0
  84. package/src/utils/tts.ts +49 -0
  85. package/src/utils/turnWords/carryWordTimes.ts +37 -0
  86. package/src/utils/turnWords/index.ts +6 -0
  87. package/src/utils/turnWords/layoutWords.ts +41 -0
  88. package/src/utils/turnWords/parseWordId.ts +13 -0
  89. package/src/utils/turnWords/test/carryWordTimes.test.ts +47 -0
  90. package/src/utils/turnWords/test/layoutWords.test.ts +54 -0
  91. package/src/utils/turnWords/test/parseWordId.test.ts +24 -0
  92. package/src/utils/turnWords/test/wordId.test.ts +9 -0
  93. package/src/utils/turnWords/test/wordsFromApi.test.ts +20 -0
  94. package/src/utils/turnWords/test/wordsFromText.test.ts +17 -0
  95. package/src/utils/turnWords/wordId.ts +9 -0
  96. package/src/utils/turnWords/wordsFromApi.ts +16 -0
  97. package/src/utils/turnWords/wordsFromText.ts +14 -0
  98. package/src/utils/validateDocument.ts +80 -0
  99. package/src/utils/waveform.ts +64 -0
  100. package/src/utils/wordRange.ts +67 -0
  101. package/src/utils/words.ts +45 -0
@@ -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,7 @@
1
+ /**
2
+ * Normalize a language tag to its base code, dropping any region/subtag.
3
+ * e.g. "fr-FR" → "fr", "en" → "en".
4
+ */
5
+ export function extractLangCode(language: string): string {
6
+ return language.split("-")[0]!
7
+ }
@@ -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
+ }
@@ -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
+ }