@dxos/pipeline-transcription 0.0.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 (127) hide show
  1. package/dist/lib/neutral/chunk-Y44GOMGN.mjs +22 -0
  2. package/dist/lib/neutral/chunk-Y44GOMGN.mjs.map +7 -0
  3. package/dist/lib/neutral/index.mjs +886 -0
  4. package/dist/lib/neutral/index.mjs.map +7 -0
  5. package/dist/lib/neutral/meta.json +1 -0
  6. package/dist/lib/neutral/testing/index.mjs +192 -0
  7. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  8. package/dist/types/src/PipelineConfig.d.ts +49 -0
  9. package/dist/types/src/PipelineConfig.d.ts.map +1 -0
  10. package/dist/types/src/PipelineConfig.test.d.ts +2 -0
  11. package/dist/types/src/PipelineConfig.test.d.ts.map +1 -0
  12. package/dist/types/src/PipelineRuntime.d.ts +46 -0
  13. package/dist/types/src/PipelineRuntime.d.ts.map +1 -0
  14. package/dist/types/src/PipelineRuntime.test.d.ts +2 -0
  15. package/dist/types/src/PipelineRuntime.test.d.ts.map +1 -0
  16. package/dist/types/src/Stage.d.ts +80 -0
  17. package/dist/types/src/Stage.d.ts.map +1 -0
  18. package/dist/types/src/TranscriptEvent.d.ts +23 -0
  19. package/dist/types/src/TranscriptEvent.d.ts.map +1 -0
  20. package/dist/types/src/asr/audio-recorder.d.ts +47 -0
  21. package/dist/types/src/asr/audio-recorder.d.ts.map +1 -0
  22. package/dist/types/src/asr/index.d.ts +5 -0
  23. package/dist/types/src/asr/index.d.ts.map +1 -0
  24. package/dist/types/src/asr/pipeline.d.ts +42 -0
  25. package/dist/types/src/asr/pipeline.d.ts.map +1 -0
  26. package/dist/types/src/asr/sentence-buffer.d.ts +25 -0
  27. package/dist/types/src/asr/sentence-buffer.d.ts.map +1 -0
  28. package/dist/types/src/asr/sentence-buffer.test.d.ts +2 -0
  29. package/dist/types/src/asr/sentence-buffer.test.d.ts.map +1 -0
  30. package/dist/types/src/asr/transcriber.d.ts +115 -0
  31. package/dist/types/src/asr/transcriber.d.ts.map +1 -0
  32. package/dist/types/src/asr/transcriber.test.d.ts +2 -0
  33. package/dist/types/src/asr/transcriber.test.d.ts.map +1 -0
  34. package/dist/types/src/asr/util.d.ts +2 -0
  35. package/dist/types/src/asr/util.d.ts.map +1 -0
  36. package/dist/types/src/dispatch.d.ts +19 -0
  37. package/dist/types/src/dispatch.d.ts.map +1 -0
  38. package/dist/types/src/dispatch.test.d.ts +2 -0
  39. package/dist/types/src/dispatch.test.d.ts.map +1 -0
  40. package/dist/types/src/index.d.ts +11 -0
  41. package/dist/types/src/index.d.ts.map +1 -0
  42. package/dist/types/src/live.d.ts +24 -0
  43. package/dist/types/src/live.d.ts.map +1 -0
  44. package/dist/types/src/live.test.d.ts +2 -0
  45. package/dist/types/src/live.test.d.ts.map +1 -0
  46. package/dist/types/src/lookup.d.ts +35 -0
  47. package/dist/types/src/lookup.d.ts.map +1 -0
  48. package/dist/types/src/lookup.test.d.ts +2 -0
  49. package/dist/types/src/lookup.test.d.ts.map +1 -0
  50. package/dist/types/src/model-routing.d.ts +12 -0
  51. package/dist/types/src/model-routing.d.ts.map +1 -0
  52. package/dist/types/src/pipeline-integration.test.d.ts +2 -0
  53. package/dist/types/src/pipeline-integration.test.d.ts.map +1 -0
  54. package/dist/types/src/stages/correction-llm.d.ts +14 -0
  55. package/dist/types/src/stages/correction-llm.d.ts.map +1 -0
  56. package/dist/types/src/stages/correction-llm.test.d.ts +2 -0
  57. package/dist/types/src/stages/correction-llm.test.d.ts.map +1 -0
  58. package/dist/types/src/stages/correction.d.ts +29 -0
  59. package/dist/types/src/stages/correction.d.ts.map +1 -0
  60. package/dist/types/src/stages/correction.test.d.ts +2 -0
  61. package/dist/types/src/stages/correction.test.d.ts.map +1 -0
  62. package/dist/types/src/stages/diarization.d.ts +11 -0
  63. package/dist/types/src/stages/diarization.d.ts.map +1 -0
  64. package/dist/types/src/stages/extraction.d.ts +40 -0
  65. package/dist/types/src/stages/extraction.d.ts.map +1 -0
  66. package/dist/types/src/stages/extraction.test.d.ts +2 -0
  67. package/dist/types/src/stages/extraction.test.d.ts.map +1 -0
  68. package/dist/types/src/stages/index.d.ts +18 -0
  69. package/dist/types/src/stages/index.d.ts.map +1 -0
  70. package/dist/types/src/stages/summarization.d.ts +12 -0
  71. package/dist/types/src/stages/summarization.d.ts.map +1 -0
  72. package/dist/types/src/stages/translation.d.ts +11 -0
  73. package/dist/types/src/stages/translation.d.ts.map +1 -0
  74. package/dist/types/src/testing/conversation-parser.d.ts +26 -0
  75. package/dist/types/src/testing/conversation-parser.d.ts.map +1 -0
  76. package/dist/types/src/testing/conversation.test.d.ts +2 -0
  77. package/dist/types/src/testing/conversation.test.d.ts.map +1 -0
  78. package/dist/types/src/testing/fixtures.d.ts +6 -0
  79. package/dist/types/src/testing/fixtures.d.ts.map +1 -0
  80. package/dist/types/src/testing/index.d.ts +5 -0
  81. package/dist/types/src/testing/index.d.ts.map +1 -0
  82. package/dist/types/src/testing/scripted-source.d.ts +16 -0
  83. package/dist/types/src/testing/scripted-source.d.ts.map +1 -0
  84. package/dist/types/src/testing/stream-simulator.d.ts +38 -0
  85. package/dist/types/src/testing/stream-simulator.d.ts.map +1 -0
  86. package/dist/types/tsconfig.tsbuildinfo +1 -0
  87. package/package.json +61 -0
  88. package/src/PipelineConfig.test.ts +39 -0
  89. package/src/PipelineConfig.ts +71 -0
  90. package/src/PipelineRuntime.test.ts +74 -0
  91. package/src/PipelineRuntime.ts +192 -0
  92. package/src/Stage.ts +86 -0
  93. package/src/TranscriptEvent.ts +23 -0
  94. package/src/asr/audio-recorder.ts +56 -0
  95. package/src/asr/index.ts +8 -0
  96. package/src/asr/pipeline.ts +105 -0
  97. package/src/asr/sentence-buffer.test.ts +53 -0
  98. package/src/asr/sentence-buffer.ts +88 -0
  99. package/src/asr/transcriber.test.ts +104 -0
  100. package/src/asr/transcriber.ts +337 -0
  101. package/src/asr/util.ts +14 -0
  102. package/src/dispatch.test.ts +37 -0
  103. package/src/dispatch.ts +56 -0
  104. package/src/index.ts +14 -0
  105. package/src/live.test.ts +63 -0
  106. package/src/live.ts +56 -0
  107. package/src/lookup.test.ts +41 -0
  108. package/src/lookup.ts +60 -0
  109. package/src/model-routing.ts +25 -0
  110. package/src/pipeline-integration.test.ts +70 -0
  111. package/src/stages/correction-llm.test.ts +46 -0
  112. package/src/stages/correction-llm.ts +40 -0
  113. package/src/stages/correction.test.ts +26 -0
  114. package/src/stages/correction.ts +72 -0
  115. package/src/stages/diarization.ts +24 -0
  116. package/src/stages/extraction.test.ts +79 -0
  117. package/src/stages/extraction.ts +151 -0
  118. package/src/stages/index.ts +44 -0
  119. package/src/stages/summarization.ts +37 -0
  120. package/src/stages/translation.ts +26 -0
  121. package/src/testing/conversation-parser.ts +101 -0
  122. package/src/testing/conversation.test.ts +53 -0
  123. package/src/testing/conversation.txt +23 -0
  124. package/src/testing/fixtures.ts +18 -0
  125. package/src/testing/index.ts +8 -0
  126. package/src/testing/scripted-source.ts +35 -0
  127. package/src/testing/stream-simulator.ts +125 -0
@@ -0,0 +1,79 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { afterEach, beforeEach, describe, test } from 'vitest';
6
+
7
+ import { Obj } from '@dxos/echo';
8
+ import { EchoTestBuilder } from '@dxos/echo-client/testing';
9
+ import { EffectEx } from '@dxos/effect';
10
+ import { Organization } from '@dxos/types';
11
+
12
+ import { makeDatabaseLookup } from '../lookup';
13
+ import { extractProperNouns, makeExtractionStage } from './extraction';
14
+
15
+ describe('extraction', () => {
16
+ test('extractProperNouns finds capitalized runs minus stop-words', ({ expect }) => {
17
+ expect(extractProperNouns('We should ship Munich with Anna')).toEqual(['Munich', 'Anna']);
18
+ });
19
+
20
+ test('reports unmatched proper nouns as candidates when no db is provided', async ({ expect }) => {
21
+ const stage = makeExtractionStage();
22
+ const block = { _tag: 'transcript' as const, started: 's', text: 'ship Munich on Friday' };
23
+ const write = await EffectEx.runPromise(stage.run({ window: [block] }, { model: stage.model! }));
24
+ const candidates = (write.blockUpdates?.[0].candidates ?? []).map((candidate) => candidate.text);
25
+ expect(candidates).toContain('Munich');
26
+ expect(candidates).toContain('Friday');
27
+ });
28
+ });
29
+
30
+ describe('extraction (with full-text index)', () => {
31
+ let builder: EchoTestBuilder;
32
+ beforeEach(async () => {
33
+ builder = await new EchoTestBuilder().open();
34
+ });
35
+ afterEach(async () => {
36
+ await builder.close();
37
+ });
38
+
39
+ test('links an unambiguous match and reports the rest as candidates', async ({ expect }) => {
40
+ const { db } = await builder.createDatabase({ types: [Organization.Organization] });
41
+ db.add(Obj.make(Organization.Organization, { name: 'Amco' }));
42
+ await db.flush({ indexes: true });
43
+
44
+ const stage = makeExtractionStage();
45
+ const block = { _tag: 'transcript' as const, started: 's', text: 'we met Amco and Globex today' };
46
+ const write = await EffectEx.runPromise(
47
+ stage.run({ window: [block] }, { lookup: makeDatabaseLookup(db), model: stage.model! }),
48
+ );
49
+
50
+ const update = write.blockUpdates![0];
51
+ expect(update.references).toHaveLength(1);
52
+ // Matched entity is rewritten as an inline echo link (dx-anchor) in the corrected text.
53
+ expect(update.corrected).toMatch(/\[Amco\]\(echo:\/[^)]+\)/);
54
+ const candidates = (update.candidates ?? []).map((candidate) => candidate.text);
55
+ expect(candidates).toContain('Globex');
56
+ expect(candidates).not.toContain('Amco');
57
+ });
58
+
59
+ test('leaves an ambiguous noun (multiple matches) as a candidate rather than linking', async ({ expect }) => {
60
+ const { db } = await builder.createDatabase({ types: [Organization.Organization] });
61
+ // Two objects match the same surface noun, so the match is ambiguous.
62
+ db.add(Obj.make(Organization.Organization, { name: 'Acme' }));
63
+ db.add(Obj.make(Organization.Organization, { name: 'Acme' }));
64
+ await db.flush({ indexes: true });
65
+
66
+ const stage = makeExtractionStage();
67
+ const block = { _tag: 'transcript' as const, started: 's', text: 'we met Acme today' };
68
+ const write = await EffectEx.runPromise(
69
+ stage.run({ window: [block] }, { lookup: makeDatabaseLookup(db), model: stage.model! }),
70
+ );
71
+
72
+ const update = write.blockUpdates?.[0];
73
+ // Ambiguous noun is surfaced as a candidate, not auto-linked.
74
+ expect(update?.references ?? []).toHaveLength(0);
75
+ expect(update?.corrected).toBeUndefined();
76
+ const candidates = (update?.candidates ?? []).map((candidate) => candidate.text);
77
+ expect(candidates).toContain('Acme');
78
+ });
79
+ });
@@ -0,0 +1,151 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { insertReferences } from '@dxos/assistant/extraction';
8
+ import { type Ref } from '@dxos/echo';
9
+ import { type ContentBlock } from '@dxos/types';
10
+
11
+ import { type EntityLookup } from '../lookup';
12
+ import { DEFAULT_STAGE_MODEL } from '../PipelineConfig';
13
+ import { type BlockUpdate, type Stage, StageWrite } from '../Stage';
14
+
15
+ export type ExtractionInput = { window: ContentBlock.Transcript[] };
16
+
17
+ const STOP_WORDS = new Set([
18
+ 'I',
19
+ 'Yes',
20
+ 'No',
21
+ 'But',
22
+ 'And',
23
+ 'Or',
24
+ 'So',
25
+ 'They',
26
+ 'We',
27
+ 'He',
28
+ 'She',
29
+ 'It',
30
+ 'The',
31
+ 'A',
32
+ 'An',
33
+ 'Is',
34
+ 'Are',
35
+ 'Was',
36
+ 'Were',
37
+ 'Be',
38
+ 'Been',
39
+ 'This',
40
+ 'That',
41
+ ]);
42
+
43
+ /**
44
+ * Deterministic proper-noun heuristic: capitalized word runs, minus sentence-initial stop-words.
45
+ * The LLM seam (see `run`) replaces this with model-driven NER.
46
+ */
47
+ export const extractProperNouns = (text: string): string[] => {
48
+ const matches = text.match(/\b[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*\b/g) ?? [];
49
+ return Array.from(new Set(matches.filter((word) => !STOP_WORDS.has(word))));
50
+ };
51
+
52
+ /** Marker that a block's text already carries inline echo links, so extraction can skip it. */
53
+ export const isLinked = (text: string): boolean => text.includes('](echo:');
54
+
55
+ /** Result of resolving a block's proper nouns against the {@link EntityLookup}. */
56
+ export type ExtractedEntities = {
57
+ references: Ref.Ref<any>[];
58
+ quotes: { quote: string; id: string }[];
59
+ candidates: ContentBlock.Candidate[];
60
+ };
61
+
62
+ /** Resolve a block's proper nouns: matched → references/quotes, unmatched → candidates. */
63
+ export const resolveEntities = async (text: string, lookup?: EntityLookup): Promise<ExtractedEntities> => {
64
+ const nouns = extractProperNouns(text);
65
+ const references: Ref.Ref<any>[] = [];
66
+ const quotes: { quote: string; id: string }[] = [];
67
+ const matched = new Set<string>();
68
+ if (lookup) {
69
+ for (const noun of nouns) {
70
+ // TODO(LLM seam): replace heuristic nouns with model NER, and disambiguate tied candidates
71
+ // against the conversation window (the stage's `window` + `resolvedReferents`) instead of
72
+ // leaving them as candidates. The lookup backend stays injected.
73
+ const candidates = await lookup(noun); // ranked best-first.
74
+ // Auto-link only a clear winner: the top candidate must strictly out-rank the runner-up. A tie
75
+ // at the top is genuine ambiguity, left for the context-aware resolver / the user — so we never
76
+ // link the wrong object on a guess. (Text search returns weak spurious matches too, so counting
77
+ // candidates is not a reliable ambiguity signal; the rank gap is.)
78
+ const unambiguous =
79
+ candidates.length === 1 || (candidates.length > 1 && candidates[0].score > candidates[1].score);
80
+ if (candidates.length > 0 && unambiguous) {
81
+ references.push(candidates[0].ref);
82
+ quotes.push({ quote: noun, id: candidates[0].id });
83
+ matched.add(noun.toLowerCase());
84
+ }
85
+ }
86
+ }
87
+ const candidates: ContentBlock.Candidate[] = nouns
88
+ .filter((noun) => !matched.has(noun.toLowerCase()))
89
+ .map((noun) => {
90
+ const start = Math.max(0, text.indexOf(noun));
91
+ return { text: noun, kind: 'proper-noun' as const, start, end: start + noun.length };
92
+ });
93
+ return { references, quotes, candidates };
94
+ };
95
+
96
+ /**
97
+ * Rewrite text with inline `[noun](echo:/<id>)` links for entities matched by `lookup`, so markdown
98
+ * consumers decorate them as dx-anchors. Reused by the live recording driver's post-process seam.
99
+ */
100
+ export const linkEntities = async (text: string, lookup: EntityLookup): Promise<string> => {
101
+ if (isLinked(text)) {
102
+ return text;
103
+ }
104
+ const { quotes } = await resolveEntities(text, lookup);
105
+ return quotes.length > 0 ? insertReferences(text, { references: quotes }) : text;
106
+ };
107
+
108
+ /**
109
+ * Stage ②: surface proper nouns across the window, link those that match objects (via the injected
110
+ * {@link EntityLookup}), and report the rest as candidates the user can act on.
111
+ *
112
+ * Processes every not-yet-linked block in the window (not just the newest). Under `latest-wins`
113
+ * with a fast stream, earlier per-block invocations are interrupted, so the surviving invocation
114
+ * must cover the whole window — otherwise blocks behind the head never get extracted.
115
+ */
116
+ export const makeExtractionStage = (): Stage<ExtractionInput> => ({
117
+ id: 'extract',
118
+ trigger: 'per-block',
119
+ window: { blocks: 8 },
120
+ concurrency: 'latest-wins',
121
+ model: DEFAULT_STAGE_MODEL,
122
+ run: ({ window }, ctx) =>
123
+ Effect.gen(function* () {
124
+ const updates: BlockUpdate[] = [];
125
+ for (let index = 0; index < window.length; index++) {
126
+ const block = window[index];
127
+ const text = block.corrected ?? block.text;
128
+ // Idempotent across windowed re-invocations: already-linked blocks need no further work.
129
+ if (isLinked(text)) {
130
+ continue;
131
+ }
132
+ const { references, quotes, candidates } = yield* Effect.promise(() => resolveEntities(text, ctx.lookup));
133
+
134
+ const update: BlockUpdate = { index };
135
+ if (references.length > 0) {
136
+ update.references = references;
137
+ }
138
+ if (candidates.length > 0) {
139
+ update.candidates = candidates;
140
+ }
141
+ // Inline links so markdown consumers render dx-anchors; structured references stay for others.
142
+ if (quotes.length > 0) {
143
+ update.corrected = insertReferences(text, { references: quotes });
144
+ }
145
+ if (update.references || update.candidates || update.corrected) {
146
+ updates.push(update);
147
+ }
148
+ }
149
+ return updates.length > 0 ? StageWrite.blocks(updates) : StageWrite.empty();
150
+ }),
151
+ });
@@ -0,0 +1,44 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { log } from '@dxos/log';
6
+
7
+ import { type Stage } from '../Stage';
8
+ import { makeCorrectionStage } from './correction';
9
+ import { makeDiarizationStage } from './diarization';
10
+ import { makeExtractionStage } from './extraction';
11
+ import { makeSummarizationStage } from './summarization';
12
+ import { makeTranslationStage } from './translation';
13
+
14
+ export * from './correction';
15
+ export * from './correction-llm';
16
+ export * from './extraction';
17
+ export * from './summarization';
18
+ export * from './translation';
19
+ export * from './diarization';
20
+
21
+ /**
22
+ * The default registry of stage factories keyed by stage id. Consumers build a stage list for a
23
+ * `PipelineConfig` by selecting enabled ids in config order.
24
+ */
25
+ export const STAGE_FACTORIES: Readonly<Record<string, () => Stage<any, any>>> = Object.freeze({
26
+ correct: makeCorrectionStage,
27
+ extract: makeExtractionStage,
28
+ summarize: makeSummarizationStage,
29
+ translate: makeTranslationStage,
30
+ diarize: makeDiarizationStage,
31
+ });
32
+
33
+ /** Build the ordered, enabled stage list for a set of stage configs. */
34
+ export const buildStages = (configs: readonly { id: string; enabled: boolean }[]): Stage<any, any>[] => {
35
+ // Surface unknown ids (stale / mistyped persisted config) rather than silently dropping them, so a
36
+ // config migration miss is visible at setup instead of leaving the runtime quietly under-configured.
37
+ const unknown = configs.filter((config) => config.enabled && !STAGE_FACTORIES[config.id]).map((config) => config.id);
38
+ if (unknown.length > 0) {
39
+ log.warn('ignoring unknown pipeline stage ids', { unknown, known: Object.keys(STAGE_FACTORIES) });
40
+ }
41
+ return configs
42
+ .filter((config) => config.enabled && STAGE_FACTORIES[config.id])
43
+ .map((config) => STAGE_FACTORIES[config.id]());
44
+ };
@@ -0,0 +1,37 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { type ContentBlock } from '@dxos/types';
8
+
9
+ import { DEFAULT_STAGE_MODEL } from '../PipelineConfig';
10
+ import { type Stage, StageWrite } from '../Stage';
11
+
12
+ export type SummarizationInput = { window: ContentBlock.Transcript[] };
13
+
14
+ /**
15
+ * Stage ④: maintain a cumulative summary. Fires on the silence edge, skips if a summary is already
16
+ * in flight. The deterministic implementation concatenates corrected utterances; the LLM seam
17
+ * (see `run`) replaces it with a cumulative model summary plus referent resolution.
18
+ */
19
+ export const makeSummarizationStage = (): Stage<SummarizationInput> => ({
20
+ id: 'summarize',
21
+ trigger: 'on-silence',
22
+ concurrency: 'skip-if-busy',
23
+ model: DEFAULT_STAGE_MODEL,
24
+ run: ({ window }) =>
25
+ Effect.sync(() => {
26
+ const utterances = window.map((block) => (block.corrected ?? block.text).trim()).filter(Boolean);
27
+ if (utterances.length === 0) {
28
+ return StageWrite.empty();
29
+ }
30
+ // TODO(LLM seam): replace with a cumulative LLM summary that resolves deictic referents
31
+ // ("I" → speaker, "there" → prior entity) and writes `resolvedReferents`.
32
+ return StageWrite.transcript({
33
+ summary: utterances.join(' '),
34
+ summaryUpdatedAt: new Date().toISOString(),
35
+ });
36
+ }),
37
+ });
@@ -0,0 +1,26 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { type ContentBlock } from '@dxos/types';
8
+
9
+ import { DEFAULT_STAGE_MODEL } from '../PipelineConfig';
10
+ import { type Stage, StageWrite } from '../Stage';
11
+
12
+ export type TranslationInput = { window: ContentBlock.Transcript[] };
13
+
14
+ /**
15
+ * Stage ③ (interface only / deferred): translate the newest block into `targetLanguage`.
16
+ * Conforms to the `Stage` contract and is inert until a translation backend is wired.
17
+ */
18
+ export const makeTranslationStage = (_targetLanguage = 'en'): Stage<TranslationInput> => ({
19
+ id: 'translate',
20
+ trigger: 'per-block',
21
+ window: { blocks: 1 },
22
+ concurrency: 'latest-wins',
23
+ model: DEFAULT_STAGE_MODEL,
24
+ // TODO: implement translation (per-block; writes block.translation via a model call).
25
+ run: () => Effect.succeed(StageWrite.empty()),
26
+ });
@@ -0,0 +1,101 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ // Parser for the human-authored conversation fixture (see `conversation.txt`).
6
+ // Captures both the seed entities (Persons, Organizations, …) and the speaker-tagged
7
+ // dialogue used by `stream-simulator.ts` to feed the pipeline in tests.
8
+
9
+ export type ConversationEntity = {
10
+ /** Type name as written in the fixture, e.g. `Person`, `Organization`. */
11
+ kind: string;
12
+ /** Free-form field values; consumers map these to the relevant ECHO schema. */
13
+ fields: Record<string, string>;
14
+ };
15
+
16
+ export type ConversationUtterance = {
17
+ /** Speaker label as written in the fixture (e.g. `A`, `B`, `Rich`). */
18
+ speaker: string;
19
+ /** Raw utterance text. */
20
+ text: string;
21
+ };
22
+
23
+ export type ParsedConversation = {
24
+ entities: ConversationEntity[];
25
+ utterances: ConversationUtterance[];
26
+ /** Distinct speaker labels in first-seen order. */
27
+ speakers: string[];
28
+ };
29
+
30
+ /**
31
+ * Parse the on-disk conversation format into structured data.
32
+ *
33
+ * Tolerates leading whitespace inside blocks and ignores blank / `#` comment lines.
34
+ * Throws if the input doesn't contain a `Conversation:` block — `Entities:` is optional.
35
+ */
36
+ export const parseConversation = (input: string): ParsedConversation => {
37
+ const lines = input.split('\n').map((line) => line.replace(/\s+$/, ''));
38
+
39
+ const entities: ConversationEntity[] = [];
40
+ const utterances: ConversationUtterance[] = [];
41
+ const speakerOrder = new Set<string>();
42
+
43
+ type Section = 'none' | 'entities' | 'conversation';
44
+ let section: Section = 'none';
45
+ let currentEntity: ConversationEntity | undefined;
46
+
47
+ for (const raw of lines) {
48
+ const line = raw.trim();
49
+ if (line.length === 0 || line.startsWith('#')) {
50
+ continue;
51
+ }
52
+
53
+ // Section headers (must be left-aligned to switch sections).
54
+ if (raw.startsWith('Entities:') || raw.startsWith('Entitites:')) {
55
+ // tolerate the typo present in earlier fixtures
56
+ section = 'entities';
57
+ currentEntity = undefined;
58
+ continue;
59
+ }
60
+ if (raw.startsWith('Conversation:')) {
61
+ section = 'conversation';
62
+ currentEntity = undefined;
63
+ continue;
64
+ }
65
+
66
+ if (section === 'entities') {
67
+ // `<TypeName>:` starts a new entity; nested `<field>: <value>` lines fill it.
68
+ const typeMatch = line.match(/^([A-Za-z][A-Za-z0-9_-]*):$/);
69
+ if (typeMatch) {
70
+ currentEntity = { kind: typeMatch[1], fields: {} };
71
+ entities.push(currentEntity);
72
+ continue;
73
+ }
74
+ const fieldMatch = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.+)$/);
75
+ if (fieldMatch && currentEntity) {
76
+ currentEntity.fields[fieldMatch[1]] = fieldMatch[2];
77
+ continue;
78
+ }
79
+ // Silently skip unrecognised lines so authors can add free-form annotations.
80
+ continue;
81
+ }
82
+
83
+ if (section === 'conversation') {
84
+ const utteranceMatch = line.match(/^([^:]+):\s*(.+)$/);
85
+ if (utteranceMatch) {
86
+ const speaker = utteranceMatch[1].trim();
87
+ const text = utteranceMatch[2].trim();
88
+ if (text.length > 0) {
89
+ utterances.push({ speaker, text });
90
+ speakerOrder.add(speaker);
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ if (utterances.length === 0) {
97
+ throw new Error('parseConversation: no utterances found');
98
+ }
99
+
100
+ return { entities, utterances, speakers: Array.from(speakerOrder) };
101
+ };
@@ -0,0 +1,53 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import * as fs from 'node:fs';
6
+ import * as path from 'node:path';
7
+ import * as url from 'node:url';
8
+ import { describe, test } from 'vitest';
9
+
10
+ import { parseConversation } from './conversation-parser';
11
+ import { simulateStream } from './stream-simulator';
12
+
13
+ const fixture = () =>
14
+ fs.readFileSync(path.join(path.dirname(url.fileURLToPath(import.meta.url)), 'conversation.txt'), 'utf8');
15
+
16
+ describe('conversation fixture', () => {
17
+ test('parses entities + speaker-tagged utterances', ({ expect }) => {
18
+ const conversation = parseConversation(fixture());
19
+ expect(conversation.speakers).toEqual(['A', 'B']);
20
+ expect(conversation.utterances.length).toBe(7);
21
+ expect(conversation.entities.map((entity) => entity.fields.fullName ?? entity.fields.name)).toContain('Chad');
22
+ expect(conversation.entities.map((entity) => entity.fields.name)).toContain('Blue Yard');
23
+ });
24
+
25
+ test('simulates a chronological, per-utterance batch schedule', ({ expect }) => {
26
+ const conversation = parseConversation(fixture());
27
+ const batches = simulateStream(conversation, { wordsPerSecond: 2.5, gapSeconds: 0.5 });
28
+
29
+ expect(batches.length).toBe(conversation.utterances.length);
30
+ expect(batches[0].offsetMs).toBe(0);
31
+ expect(batches.every((batch) => batch.blocks.length > 0)).toBe(true);
32
+ // Offsets are strictly increasing (chronological delivery).
33
+ for (let index = 1; index < batches.length; index++) {
34
+ expect(batches[index].offsetMs).toBeGreaterThan(batches[index - 1].offsetMs);
35
+ }
36
+ });
37
+
38
+ test('merges consecutive same-speaker utterances when batchSeconds is set', ({ expect }) => {
39
+ const conversation = {
40
+ entities: [],
41
+ speakers: ['A', 'B'],
42
+ utterances: [
43
+ { speaker: 'A', text: 'one two three' },
44
+ { speaker: 'A', text: 'four five six' },
45
+ { speaker: 'B', text: 'seven eight' },
46
+ ],
47
+ };
48
+ // A large window coalesces the two consecutive A turns into one batch (A, B).
49
+ const merged = simulateStream(conversation, { batchSeconds: 60 });
50
+ expect(merged.length).toBe(2);
51
+ expect(merged[0].blocks.length).toBe(2);
52
+ });
53
+ });
@@ -0,0 +1,23 @@
1
+ # Conversation fixture for transcription pipeline tests.
2
+ # Format:
3
+ # `Entities:` block — declares ECHO objects to seed into the test space.
4
+ # Each entry is `<Type>:` followed by indented `<field>: <value>` lines.
5
+ # `Conversation:` block — one utterance per line, `<speaker>: <text>`.
6
+ # Speakers map to distinct virtual Whisper streams in the simulator.
7
+ # Lines starting with `#` are comments and are ignored by the parser.
8
+
9
+ Entities:
10
+ Person:
11
+ fullName: Chad
12
+ employer: Blue Yard
13
+ Organization:
14
+ name: Blue Yard
15
+
16
+ Conversation:
17
+ A: I think I'm going to go.
18
+ B: Are you meeting with them?
19
+ A: Yes I think so.
20
+ B: But I thought they were in Munich?
21
+ A: They are, but the offsite is in Los Angeles.
22
+ B: Is Chad going to be there?
23
+ A: Yes, the whole team. It's their annual offsite.
@@ -0,0 +1,18 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type ScriptedBlock } from './scripted-source';
6
+
7
+ /** A short multi-utterance meeting transcript (raw, lower-cased — as Whisper tends to emit). */
8
+ export const SAMPLE_MEETING: readonly ScriptedBlock[] = Object.freeze([
9
+ { text: 'so i think we should ship munich on friday' },
10
+ { text: 'lets confirm the plan with anna first' },
11
+ { text: 'agreed we can review it tomorrow' },
12
+ ]);
13
+
14
+ /** A single-speaker dictation transcript (for the Notes preset). */
15
+ export const SAMPLE_NOTES: readonly ScriptedBlock[] = Object.freeze([
16
+ { text: 'remember to email the quarterly report' },
17
+ { text: 'and schedule a follow up with the design team' },
18
+ ]);
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './conversation-parser';
6
+ export * from './fixtures';
7
+ export * from './scripted-source';
8
+ export * from './stream-simulator';
@@ -0,0 +1,35 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Stream from 'effect/Stream';
6
+
7
+ import { TranscriptEvent } from '../TranscriptEvent';
8
+
9
+ export type ScriptedBlock = { text: string; started?: string };
10
+
11
+ export type ScriptedSourceOptions = {
12
+ /** Append a trailing silence event (drives on-silence stages). Default: true. */
13
+ readonly silence?: boolean;
14
+ };
15
+
16
+ /**
17
+ * Build a deterministic {@link TranscriptEvent} stream from a fixture of blocks. Emits a `block`
18
+ * event per entry, then (by default) a trailing `silence`. No audio or network — for testbench and CI.
19
+ */
20
+ export const scriptedSource = (
21
+ blocks: readonly ScriptedBlock[],
22
+ options: ScriptedSourceOptions = {},
23
+ ): Stream.Stream<TranscriptEvent> => {
24
+ const events: TranscriptEvent[] = blocks.map((block, index) =>
25
+ TranscriptEvent.block({
26
+ _tag: 'transcript',
27
+ started: block.started ?? new Date(index * 1000).toISOString(),
28
+ text: block.text,
29
+ }),
30
+ );
31
+ if (options.silence !== false) {
32
+ events.push(TranscriptEvent.silence(5000));
33
+ }
34
+ return Stream.fromIterable(events);
35
+ };