@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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Inverse of wordId: split a `turnId#index` key back into its parts. */
|
|
2
|
+
export function parseWordId(
|
|
3
|
+
id: string,
|
|
4
|
+
): { turnId: string; index: number } | null {
|
|
5
|
+
const sep = id.lastIndexOf("#")
|
|
6
|
+
if (sep <= 0) return null
|
|
7
|
+
const raw = id.slice(sep + 1)
|
|
8
|
+
// Number("") is 0 — an empty index must not parse as word 0.
|
|
9
|
+
if (raw === "") return null
|
|
10
|
+
const index = Number(raw)
|
|
11
|
+
if (!Number.isInteger(index) || index < 0) return null
|
|
12
|
+
return { turnId: id.slice(0, sep), index }
|
|
13
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { carryWordTimes } from "../carryWordTimes"
|
|
3
|
+
import { wordsFromText } from "../wordsFromText"
|
|
4
|
+
import { layoutWords } from "../layoutWords"
|
|
5
|
+
|
|
6
|
+
const prev = layoutWords("turn-1", [
|
|
7
|
+
{ text: "Bonjour", startTime: 0, endTime: 0.8 },
|
|
8
|
+
{ text: "tout", startTime: 0.9, endTime: 1.1 },
|
|
9
|
+
{ text: "le", startTime: 1.2, endTime: 1.3 },
|
|
10
|
+
{ text: "monde", startTime: 1.4, endTime: 1.8 },
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
describe("carryWordTimes", () => {
|
|
14
|
+
it("keeps every timing when the text is unchanged", () => {
|
|
15
|
+
const next = carryWordTimes(
|
|
16
|
+
wordsFromText("turn-1", "Bonjour tout le monde"),
|
|
17
|
+
prev,
|
|
18
|
+
)
|
|
19
|
+
expect(next.map((w) => w.startTime)).toEqual([0, 0.9, 1.2, 1.4])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("keeps prefix/suffix timings and leaves the edited middle untimed", () => {
|
|
23
|
+
const next = carryWordTimes(
|
|
24
|
+
wordsFromText("turn-1", "Bonjour tous le monde"),
|
|
25
|
+
prev,
|
|
26
|
+
)
|
|
27
|
+
expect(next[0]?.startTime).toBe(0)
|
|
28
|
+
expect(next[1]?.startTime).toBeUndefined()
|
|
29
|
+
expect(next[2]?.startTime).toBe(1.2)
|
|
30
|
+
expect(next[3]?.startTime).toBe(1.4)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("anchors the suffix by position from the end on insertion", () => {
|
|
34
|
+
const next = carryWordTimes(
|
|
35
|
+
wordsFromText("turn-1", "Bonjour vraiment tout le monde"),
|
|
36
|
+
prev,
|
|
37
|
+
)
|
|
38
|
+
expect(next.map((w) => w.startTime)).toEqual([0, undefined, 0.9, 1.2, 1.4])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("carries what survives a deletion", () => {
|
|
42
|
+
const next = carryWordTimes(wordsFromText("turn-1", "Bonjour le monde"), prev)
|
|
43
|
+
expect(next[0]?.startTime).toBe(0)
|
|
44
|
+
expect(next[1]?.startTime).toBe(1.2)
|
|
45
|
+
expect(next[2]?.startTime).toBe(1.4)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { layoutWords } from "../layoutWords"
|
|
3
|
+
|
|
4
|
+
describe("layoutWords", () => {
|
|
5
|
+
it("lays timed words out with single-space offsets", () => {
|
|
6
|
+
const words = layoutWords("turn-1", [
|
|
7
|
+
{ text: "Bonjour", startTime: 0, endTime: 0.8 },
|
|
8
|
+
{ text: "tout", startTime: 0.9, endTime: 1.1 },
|
|
9
|
+
])
|
|
10
|
+
expect(words).toEqual([
|
|
11
|
+
{
|
|
12
|
+
id: "turn-1#0",
|
|
13
|
+
text: "Bonjour",
|
|
14
|
+
charStart: 0,
|
|
15
|
+
charEnd: 7,
|
|
16
|
+
startTime: 0,
|
|
17
|
+
endTime: 0.8,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "turn-1#1",
|
|
21
|
+
text: "tout",
|
|
22
|
+
charStart: 8,
|
|
23
|
+
charEnd: 12,
|
|
24
|
+
startTime: 0.9,
|
|
25
|
+
endTime: 1.1,
|
|
26
|
+
},
|
|
27
|
+
])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("splits a source word with internal whitespace, each part keeping its timing", () => {
|
|
31
|
+
const words = layoutWords("turn-1", [
|
|
32
|
+
{ text: "l'enfant ?", startTime: 2, endTime: 3 },
|
|
33
|
+
])
|
|
34
|
+
expect(words.map((w) => w.text)).toEqual(["l'enfant", "?"])
|
|
35
|
+
expect(words[0]).toMatchObject({ charStart: 0, charEnd: 8, startTime: 2 })
|
|
36
|
+
expect(words[1]).toMatchObject({ charStart: 9, charEnd: 10, endTime: 3 })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("skips silence placeholders (empty/whitespace-only words)", () => {
|
|
40
|
+
const words = layoutWords("turn-1", [
|
|
41
|
+
{ text: "", startTime: 0, endTime: 1 },
|
|
42
|
+
{ text: " " },
|
|
43
|
+
{ text: "mot", startTime: 1, endTime: 2 },
|
|
44
|
+
])
|
|
45
|
+
expect(words.map((w) => w.text)).toEqual(["mot"])
|
|
46
|
+
expect(words[0]?.charStart).toBe(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("omits absent timing fields instead of writing undefined", () => {
|
|
50
|
+
const [word] = layoutWords("turn-1", [{ text: "mot" }])
|
|
51
|
+
expect(word && "startTime" in word).toBe(false)
|
|
52
|
+
expect(word && "endTime" in word).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { parseWordId } from "../parseWordId"
|
|
3
|
+
import { wordId } from "../wordId"
|
|
4
|
+
|
|
5
|
+
describe("parseWordId", () => {
|
|
6
|
+
it("round-trips wordId", () => {
|
|
7
|
+
expect(parseWordId(wordId("turn-1", 4))).toEqual({
|
|
8
|
+
turnId: "turn-1",
|
|
9
|
+
index: 4,
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("splits on the LAST separator (turn ids may contain '#')", () => {
|
|
14
|
+
expect(parseWordId("a#b#7")).toEqual({ turnId: "a#b", index: 7 })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("rejects malformed ids", () => {
|
|
18
|
+
expect(parseWordId("no-separator")).toBeNull()
|
|
19
|
+
expect(parseWordId("#3")).toBeNull()
|
|
20
|
+
expect(parseWordId("turn-1#")).toBeNull()
|
|
21
|
+
expect(parseWordId("turn-1#-2")).toBeNull()
|
|
22
|
+
expect(parseWordId("turn-1#1.5")).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { wordId } from "../wordId"
|
|
3
|
+
|
|
4
|
+
describe("wordId", () => {
|
|
5
|
+
it("derives the positional key", () => {
|
|
6
|
+
expect(wordId("turn-1", 0)).toBe("turn-1#0")
|
|
7
|
+
expect(wordId("turn-1", 12)).toBe("turn-1#12")
|
|
8
|
+
})
|
|
9
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { wordsFromApi } from "../wordsFromApi"
|
|
3
|
+
|
|
4
|
+
describe("wordsFromApi", () => {
|
|
5
|
+
it("maps API fields (word/stime/etime/confidence) onto store words", () => {
|
|
6
|
+
const words = wordsFromApi("turn-1", [
|
|
7
|
+
{ wid: "w1", word: "Bonjour", stime: 0, etime: 0.8, confidence: 0.9 },
|
|
8
|
+
{ wid: "w2", word: "tout" },
|
|
9
|
+
])
|
|
10
|
+
expect(words[0]).toMatchObject({
|
|
11
|
+
id: "turn-1#0",
|
|
12
|
+
text: "Bonjour",
|
|
13
|
+
startTime: 0,
|
|
14
|
+
endTime: 0.8,
|
|
15
|
+
confidence: 0.9,
|
|
16
|
+
})
|
|
17
|
+
expect(words[1]).toMatchObject({ id: "turn-1#1", text: "tout" })
|
|
18
|
+
expect(words[1] && "startTime" in words[1]).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { wordsFromText } from "../wordsFromText"
|
|
3
|
+
|
|
4
|
+
describe("wordsFromText", () => {
|
|
5
|
+
it("derives positional words with offsets from plain text", () => {
|
|
6
|
+
expect(wordsFromText("turn-1", "Bonjour tout le")).toEqual([
|
|
7
|
+
{ id: "turn-1#0", text: "Bonjour", charStart: 0, charEnd: 7 },
|
|
8
|
+
{ id: "turn-1#1", text: "tout", charStart: 8, charEnd: 12 },
|
|
9
|
+
{ id: "turn-1#2", text: "le", charStart: 13, charEnd: 15 },
|
|
10
|
+
])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("returns no words for empty or whitespace-only text", () => {
|
|
14
|
+
expect(wordsFromText("turn-1", "")).toEqual([])
|
|
15
|
+
expect(wordsFromText("turn-1", " ")).toEqual([])
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word identity is positional now that the document is plain text: a word IS
|
|
3
|
+
* the i-th whitespace-delimited token of its turn. Word.id is derived
|
|
4
|
+
* (`turnId#index`) — an opaque, recomputable key for consumers (karaoke,
|
|
5
|
+
* follow-playback), never persisted and never on the wire.
|
|
6
|
+
*/
|
|
7
|
+
export function wordId(turnId: string, index: number): string {
|
|
8
|
+
return `${turnId}#${index}`
|
|
9
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { layoutWords } from "./layoutWords"
|
|
2
|
+
import type { Word } from "../../types/editor"
|
|
3
|
+
import type { ApiWord } from "../../types/api"
|
|
4
|
+
|
|
5
|
+
/** Build the word list from an API turn payload (see layoutWords). */
|
|
6
|
+
export function wordsFromApi(turnId: string, apiWords: ApiWord[]): Word[] {
|
|
7
|
+
return layoutWords(
|
|
8
|
+
turnId,
|
|
9
|
+
apiWords.map((w) => ({
|
|
10
|
+
text: w.word ?? "",
|
|
11
|
+
...(w.stime !== undefined && { startTime: w.stime }),
|
|
12
|
+
...(w.etime !== undefined && { endTime: w.etime }),
|
|
13
|
+
...(w.confidence !== undefined && { confidence: w.confidence }),
|
|
14
|
+
})),
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { tokenize } from "../tokenize"
|
|
2
|
+
import { wordId } from "./wordId"
|
|
3
|
+
import type { Word } from "../../types/editor"
|
|
4
|
+
|
|
5
|
+
/** Derive the word list from a turn's plain text (no timestamps — those are
|
|
6
|
+
* carried over or broadcast by the server). */
|
|
7
|
+
export function wordsFromText(turnId: string, text: string): Word[] {
|
|
8
|
+
return tokenize(text).map((t, i) => ({
|
|
9
|
+
id: wordId(turnId, i),
|
|
10
|
+
text: t.text,
|
|
11
|
+
charStart: t.charStart,
|
|
12
|
+
charEnd: t.charEnd,
|
|
13
|
+
}))
|
|
14
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { EditorDocument } from "../types/editor"
|
|
2
|
+
|
|
3
|
+
export class DocumentValidationError extends Error {
|
|
4
|
+
path: string
|
|
5
|
+
constructor(path: string, message: string) {
|
|
6
|
+
super(`${path}: ${message}`)
|
|
7
|
+
this.name = "DocumentValidationError"
|
|
8
|
+
this.path = path
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateEditorDocument(doc: unknown): asserts doc is EditorDocument {
|
|
13
|
+
if (doc == null || typeof doc !== "object") {
|
|
14
|
+
throw new DocumentValidationError("document", "must be a non-null object")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const d = doc as Record<string, unknown>
|
|
18
|
+
|
|
19
|
+
if (typeof d.title !== "string") {
|
|
20
|
+
throw new DocumentValidationError("document.title", "must be a string")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!(d.speakers instanceof Map)) {
|
|
24
|
+
throw new DocumentValidationError("document.speakers", "must be a Map")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!Array.isArray(d.channels)) {
|
|
28
|
+
throw new DocumentValidationError("document.channels", "must be an array")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (let ci = 0; ci < d.channels.length; ci++) {
|
|
32
|
+
const ch = d.channels[ci] as Record<string, unknown>
|
|
33
|
+
const cp = `channels[${ci}]`
|
|
34
|
+
|
|
35
|
+
if (ch == null || typeof ch !== "object") {
|
|
36
|
+
throw new DocumentValidationError(cp, "must be a non-null object")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof ch.id !== "string") {
|
|
40
|
+
throw new DocumentValidationError(`${cp}.id`, "must be a string")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof ch.name !== "string") {
|
|
44
|
+
throw new DocumentValidationError(`${cp}.name`, "must be a string")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof ch.duration !== "number") {
|
|
48
|
+
throw new DocumentValidationError(`${cp}.duration`, "must be a number")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!Array.isArray(ch.translations)) {
|
|
52
|
+
throw new DocumentValidationError(`${cp}.translations`, "must be an array")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (let ti = 0; ti < ch.translations.length; ti++) {
|
|
56
|
+
const tr = ch.translations[ti] as Record<string, unknown>
|
|
57
|
+
const tp = `${cp}.translations[${ti}]`
|
|
58
|
+
|
|
59
|
+
if (tr == null || typeof tr !== "object") {
|
|
60
|
+
throw new DocumentValidationError(tp, "must be a non-null object")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof tr.id !== "string") {
|
|
64
|
+
throw new DocumentValidationError(`${tp}.id`, "must be a string")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!Array.isArray(tr.languages)) {
|
|
68
|
+
throw new DocumentValidationError(`${tp}.languages`, "must be an array")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof tr.isSource !== "boolean") {
|
|
72
|
+
throw new DocumentValidationError(`${tp}.isSource`, "must be a boolean")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!Array.isArray(tr.turns)) {
|
|
76
|
+
throw new DocumentValidationError(`${tp}.turns`, "must be an array")
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes raw waveform values (any scale, e.g. 16-bit ints from the
|
|
3
|
+
* audiowaveform format) to [-1, 1] floats as expected by WaveSurfer.
|
|
4
|
+
* Scales against the 98th percentile (values above it clip at 1) so a few
|
|
5
|
+
* isolated loud peaks don't flatten the rest of the waveform.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizePeaks(data: number[]): Float32Array {
|
|
8
|
+
const peaks = new Float32Array(data.length)
|
|
9
|
+
if (data.length === 0) return peaks
|
|
10
|
+
|
|
11
|
+
const sorted = data.map(Math.abs).sort((a, b) => a - b)
|
|
12
|
+
const reference = sorted[Math.floor((sorted.length - 1) * 0.98)] ?? 0
|
|
13
|
+
if (reference === 0) return peaks
|
|
14
|
+
|
|
15
|
+
// Gamma > 1 expands the contrast: average speech stays mid-height while
|
|
16
|
+
// near-silence drops to a flat line instead of a thin permanent bar.
|
|
17
|
+
const gamma = 1.5
|
|
18
|
+
for (let i = 0; i < data.length; i++) {
|
|
19
|
+
const value = (data[i] ?? 0) / reference
|
|
20
|
+
const clipped = Math.max(-1, Math.min(1, value))
|
|
21
|
+
peaks[i] = Math.sign(clipped) * Math.abs(clipped) ** gamma
|
|
22
|
+
}
|
|
23
|
+
return peaks
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderWaveform(
|
|
27
|
+
channels: (number[] | Float32Array)[],
|
|
28
|
+
ctx: CanvasRenderingContext2D,
|
|
29
|
+
): void {
|
|
30
|
+
const { width, height } = ctx.canvas
|
|
31
|
+
const channel = channels[0]!
|
|
32
|
+
const scale = channel.length / width
|
|
33
|
+
const step = 0.5
|
|
34
|
+
|
|
35
|
+
ctx.translate(0, height / 2)
|
|
36
|
+
ctx.strokeStyle = ctx.fillStyle
|
|
37
|
+
ctx.beginPath()
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < width; i += step * 2) {
|
|
40
|
+
// Average all values that fall into this pixel bucket: on long audios
|
|
41
|
+
// (several data points per pixel) point-sampling picks loud windows too
|
|
42
|
+
// often and the waveform turns into a solid mass.
|
|
43
|
+
const start = Math.floor(i * scale)
|
|
44
|
+
const end = Math.max(start + 1, Math.floor((i + step * 2) * scale))
|
|
45
|
+
let sum = 0
|
|
46
|
+
for (let j = start; j < end; j++) sum += Math.abs(channel[j] ?? 0)
|
|
47
|
+
const value = sum / (end - start)
|
|
48
|
+
let x = i
|
|
49
|
+
let y = value * (height / 2)
|
|
50
|
+
|
|
51
|
+
ctx.moveTo(x, 0)
|
|
52
|
+
ctx.lineTo(x, y)
|
|
53
|
+
ctx.lineTo(x + step, 0)
|
|
54
|
+
|
|
55
|
+
x = x + step
|
|
56
|
+
y = -y
|
|
57
|
+
ctx.moveTo(x, 0)
|
|
58
|
+
ctx.lineTo(x, y)
|
|
59
|
+
ctx.lineTo(x + step, 0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ctx.stroke()
|
|
63
|
+
ctx.closePath()
|
|
64
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Core } from "../core/types"
|
|
2
|
+
import type { Turn } from "../types/editor"
|
|
3
|
+
import { parseWordId } from "./turnWords"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Locate a word in the rendered DOM as a character Range — the shared anchor
|
|
7
|
+
* for the karaoke highlight (CSS Custom Highlight API) and follow-playback
|
|
8
|
+
* scrolling. Nothing is ever inserted into the content: a Range is a
|
|
9
|
+
* read-only view over the existing text nodes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Resolve the active word id (`turnId#index`) into a DOM Range under `root`
|
|
13
|
+
* (the editor DOM or any container holding the rendered turns). */
|
|
14
|
+
export function activeWordRange(
|
|
15
|
+
root: ParentNode,
|
|
16
|
+
core: Core,
|
|
17
|
+
wordId: string,
|
|
18
|
+
): Range | null {
|
|
19
|
+
const parsed = parseWordId(wordId)
|
|
20
|
+
if (!parsed) return null
|
|
21
|
+
const translation = core.activeChannel.value?.activeTranslation.value
|
|
22
|
+
const turn = translation?.turns.value.find((t) => t.id === parsed.turnId)
|
|
23
|
+
if (!turn) return null
|
|
24
|
+
return findWordRange(root, turn, parsed.index)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function findWordRange(
|
|
28
|
+
root: ParentNode,
|
|
29
|
+
turn: Turn,
|
|
30
|
+
index: number,
|
|
31
|
+
): Range | null {
|
|
32
|
+
const word = turn.words[index]
|
|
33
|
+
if (!word || word.charStart == null || word.charEnd == null) return null
|
|
34
|
+
|
|
35
|
+
const container = root.querySelector(
|
|
36
|
+
`[data-turn-id="${cssEscape(turn.id)}"] .turn-text`,
|
|
37
|
+
)
|
|
38
|
+
if (!container) return null
|
|
39
|
+
|
|
40
|
+
// Map the char offsets onto the container's text nodes. The store offsets
|
|
41
|
+
// come from tokenizing the same text PM renders, so a full walk always
|
|
42
|
+
// lands — unless an edit is mid-flight, in which case bail (null).
|
|
43
|
+
const range = document.createRange()
|
|
44
|
+
let offset = 0
|
|
45
|
+
let startSet = false
|
|
46
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT)
|
|
47
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
48
|
+
const len = node.nodeValue?.length ?? 0
|
|
49
|
+
if (!startSet && word.charStart < offset + len) {
|
|
50
|
+
range.setStart(node, word.charStart - offset)
|
|
51
|
+
startSet = true
|
|
52
|
+
}
|
|
53
|
+
if (word.charEnd <= offset + len) {
|
|
54
|
+
if (!startSet) return null
|
|
55
|
+
range.setEnd(node, word.charEnd - offset)
|
|
56
|
+
return range
|
|
57
|
+
}
|
|
58
|
+
offset += len
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cssEscape(value: string): string {
|
|
64
|
+
return typeof CSS !== "undefined" && CSS.escape
|
|
65
|
+
? CSS.escape(value)
|
|
66
|
+
: value.replace(/["\\]/g, "\\$&")
|
|
67
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Word } from "../types/editor"
|
|
2
|
+
|
|
3
|
+
/** True if at least one word carries a timestamp. Robust to sparse timing:
|
|
4
|
+
* freshly typed words (and split/merge products) may have no timestamp yet,
|
|
5
|
+
* and they can sit anywhere in the list — not just at index 0. */
|
|
6
|
+
export function hasWordTimestamps(words: Word[]): boolean {
|
|
7
|
+
return words.some((w) => w.startTime != null)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** First defined word start in the list (skips untimed leading words). */
|
|
11
|
+
export function firstWordStart(words: Word[]): number | undefined {
|
|
12
|
+
for (const w of words) if (w.startTime != null) return w.startTime
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Last defined word end in the list (skips untimed trailing words). */
|
|
17
|
+
export function lastWordEnd(words: Word[]): number | undefined {
|
|
18
|
+
for (let i = words.length - 1; i >= 0; i--) {
|
|
19
|
+
const e = words[i]!.endTime
|
|
20
|
+
if (e != null) return e
|
|
21
|
+
}
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Margin in seconds around a word's [startTime, endTime] interval.
|
|
27
|
+
* Avoids highlight "holes" during inter-word micro-gaps (silences,
|
|
28
|
+
* punctuation). Beyond this margin no word is active.
|
|
29
|
+
*/
|
|
30
|
+
const ACTIVE_WORD_MARGIN = 1
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns the first word whose [startTime - margin, endTime] interval contains
|
|
34
|
+
* `time`, or null when none does. Words without timestamps are skipped (a typed
|
|
35
|
+
* word with no timing yet is never "active").
|
|
36
|
+
*/
|
|
37
|
+
export function findActiveWord(words: Word[], time: number): string | null {
|
|
38
|
+
for (const word of words) {
|
|
39
|
+
if (word.startTime == null || word.endTime == null) continue
|
|
40
|
+
if (word.startTime - ACTIVE_WORD_MARGIN <= time && time <= word.endTime) {
|
|
41
|
+
return word.id
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
}
|