@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.
- package/dist/lib/neutral/chunk-Y44GOMGN.mjs +22 -0
- package/dist/lib/neutral/chunk-Y44GOMGN.mjs.map +7 -0
- package/dist/lib/neutral/index.mjs +886 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/neutral/testing/index.mjs +192 -0
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/PipelineConfig.d.ts +49 -0
- package/dist/types/src/PipelineConfig.d.ts.map +1 -0
- package/dist/types/src/PipelineConfig.test.d.ts +2 -0
- package/dist/types/src/PipelineConfig.test.d.ts.map +1 -0
- package/dist/types/src/PipelineRuntime.d.ts +46 -0
- package/dist/types/src/PipelineRuntime.d.ts.map +1 -0
- package/dist/types/src/PipelineRuntime.test.d.ts +2 -0
- package/dist/types/src/PipelineRuntime.test.d.ts.map +1 -0
- package/dist/types/src/Stage.d.ts +80 -0
- package/dist/types/src/Stage.d.ts.map +1 -0
- package/dist/types/src/TranscriptEvent.d.ts +23 -0
- package/dist/types/src/TranscriptEvent.d.ts.map +1 -0
- package/dist/types/src/asr/audio-recorder.d.ts +47 -0
- package/dist/types/src/asr/audio-recorder.d.ts.map +1 -0
- package/dist/types/src/asr/index.d.ts +5 -0
- package/dist/types/src/asr/index.d.ts.map +1 -0
- package/dist/types/src/asr/pipeline.d.ts +42 -0
- package/dist/types/src/asr/pipeline.d.ts.map +1 -0
- package/dist/types/src/asr/sentence-buffer.d.ts +25 -0
- package/dist/types/src/asr/sentence-buffer.d.ts.map +1 -0
- package/dist/types/src/asr/sentence-buffer.test.d.ts +2 -0
- package/dist/types/src/asr/sentence-buffer.test.d.ts.map +1 -0
- package/dist/types/src/asr/transcriber.d.ts +115 -0
- package/dist/types/src/asr/transcriber.d.ts.map +1 -0
- package/dist/types/src/asr/transcriber.test.d.ts +2 -0
- package/dist/types/src/asr/transcriber.test.d.ts.map +1 -0
- package/dist/types/src/asr/util.d.ts +2 -0
- package/dist/types/src/asr/util.d.ts.map +1 -0
- package/dist/types/src/dispatch.d.ts +19 -0
- package/dist/types/src/dispatch.d.ts.map +1 -0
- package/dist/types/src/dispatch.test.d.ts +2 -0
- package/dist/types/src/dispatch.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +11 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/live.d.ts +24 -0
- package/dist/types/src/live.d.ts.map +1 -0
- package/dist/types/src/live.test.d.ts +2 -0
- package/dist/types/src/live.test.d.ts.map +1 -0
- package/dist/types/src/lookup.d.ts +35 -0
- package/dist/types/src/lookup.d.ts.map +1 -0
- package/dist/types/src/lookup.test.d.ts +2 -0
- package/dist/types/src/lookup.test.d.ts.map +1 -0
- package/dist/types/src/model-routing.d.ts +12 -0
- package/dist/types/src/model-routing.d.ts.map +1 -0
- package/dist/types/src/pipeline-integration.test.d.ts +2 -0
- package/dist/types/src/pipeline-integration.test.d.ts.map +1 -0
- package/dist/types/src/stages/correction-llm.d.ts +14 -0
- package/dist/types/src/stages/correction-llm.d.ts.map +1 -0
- package/dist/types/src/stages/correction-llm.test.d.ts +2 -0
- package/dist/types/src/stages/correction-llm.test.d.ts.map +1 -0
- package/dist/types/src/stages/correction.d.ts +29 -0
- package/dist/types/src/stages/correction.d.ts.map +1 -0
- package/dist/types/src/stages/correction.test.d.ts +2 -0
- package/dist/types/src/stages/correction.test.d.ts.map +1 -0
- package/dist/types/src/stages/diarization.d.ts +11 -0
- package/dist/types/src/stages/diarization.d.ts.map +1 -0
- package/dist/types/src/stages/extraction.d.ts +40 -0
- package/dist/types/src/stages/extraction.d.ts.map +1 -0
- package/dist/types/src/stages/extraction.test.d.ts +2 -0
- package/dist/types/src/stages/extraction.test.d.ts.map +1 -0
- package/dist/types/src/stages/index.d.ts +18 -0
- package/dist/types/src/stages/index.d.ts.map +1 -0
- package/dist/types/src/stages/summarization.d.ts +12 -0
- package/dist/types/src/stages/summarization.d.ts.map +1 -0
- package/dist/types/src/stages/translation.d.ts +11 -0
- package/dist/types/src/stages/translation.d.ts.map +1 -0
- package/dist/types/src/testing/conversation-parser.d.ts +26 -0
- package/dist/types/src/testing/conversation-parser.d.ts.map +1 -0
- package/dist/types/src/testing/conversation.test.d.ts +2 -0
- package/dist/types/src/testing/conversation.test.d.ts.map +1 -0
- package/dist/types/src/testing/fixtures.d.ts +6 -0
- package/dist/types/src/testing/fixtures.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +5 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/scripted-source.d.ts +16 -0
- package/dist/types/src/testing/scripted-source.d.ts.map +1 -0
- package/dist/types/src/testing/stream-simulator.d.ts +38 -0
- package/dist/types/src/testing/stream-simulator.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +61 -0
- package/src/PipelineConfig.test.ts +39 -0
- package/src/PipelineConfig.ts +71 -0
- package/src/PipelineRuntime.test.ts +74 -0
- package/src/PipelineRuntime.ts +192 -0
- package/src/Stage.ts +86 -0
- package/src/TranscriptEvent.ts +23 -0
- package/src/asr/audio-recorder.ts +56 -0
- package/src/asr/index.ts +8 -0
- package/src/asr/pipeline.ts +105 -0
- package/src/asr/sentence-buffer.test.ts +53 -0
- package/src/asr/sentence-buffer.ts +88 -0
- package/src/asr/transcriber.test.ts +104 -0
- package/src/asr/transcriber.ts +337 -0
- package/src/asr/util.ts +14 -0
- package/src/dispatch.test.ts +37 -0
- package/src/dispatch.ts +56 -0
- package/src/index.ts +14 -0
- package/src/live.test.ts +63 -0
- package/src/live.ts +56 -0
- package/src/lookup.test.ts +41 -0
- package/src/lookup.ts +60 -0
- package/src/model-routing.ts +25 -0
- package/src/pipeline-integration.test.ts +70 -0
- package/src/stages/correction-llm.test.ts +46 -0
- package/src/stages/correction-llm.ts +40 -0
- package/src/stages/correction.test.ts +26 -0
- package/src/stages/correction.ts +72 -0
- package/src/stages/diarization.ts +24 -0
- package/src/stages/extraction.test.ts +79 -0
- package/src/stages/extraction.ts +151 -0
- package/src/stages/index.ts +44 -0
- package/src/stages/summarization.ts +37 -0
- package/src/stages/translation.ts +26 -0
- package/src/testing/conversation-parser.ts +101 -0
- package/src/testing/conversation.test.ts +53 -0
- package/src/testing/conversation.txt +23 -0
- package/src/testing/fixtures.ts +18 -0
- package/src/testing/index.ts +8 -0
- package/src/testing/scripted-source.ts +35 -0
- package/src/testing/stream-simulator.ts +125 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type ContentBlock } from '@dxos/types';
|
|
6
|
+
|
|
7
|
+
import { type LivePipeline, runLivePipeline } from '../live';
|
|
8
|
+
import { type RunOptions } from '../PipelineRuntime';
|
|
9
|
+
import { type AudioRecorder } from './audio-recorder';
|
|
10
|
+
import { SentenceBuffer } from './sentence-buffer';
|
|
11
|
+
import { type TranscribeConfig, type TranscribeFn, Transcriber } from './transcriber';
|
|
12
|
+
|
|
13
|
+
export type AsrPipelineOptions = Omit<RunOptions, 'source'> & {
|
|
14
|
+
/** Audio source — browser `MediaStreamRecorder` or any `AudioRecorder` implementation. */
|
|
15
|
+
recorder: AudioRecorder;
|
|
16
|
+
/** Optional ASR transport override (default: `fetch` to the edge transcription service). */
|
|
17
|
+
transcribe?: TranscribeFn;
|
|
18
|
+
/** Transcriber chunking configuration. */
|
|
19
|
+
config: TranscribeConfig;
|
|
20
|
+
/**
|
|
21
|
+
* Re-segment the ASR output into complete sentences before it enters the pipeline, merging
|
|
22
|
+
* fragments that the transcriber cut mid-sentence at chunk boundaries. @default false
|
|
23
|
+
*/
|
|
24
|
+
segmentSentences?: boolean;
|
|
25
|
+
/** Optional raw-segment hook invoked before each block enters the pipeline (e.g. live display). */
|
|
26
|
+
onSegment?: (block: ContentBlock.Transcript) => void;
|
|
27
|
+
/** Silence (ms) reported on `end` so on-silence stages fire over the final window. */
|
|
28
|
+
drainSilenceMs?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Imperative handle to a running ASR → pipeline session. */
|
|
32
|
+
export type AsrPipeline = {
|
|
33
|
+
/** Open the recorder + transcriber. */
|
|
34
|
+
open: () => Promise<void>;
|
|
35
|
+
/** Begin capturing audio for transcription. */
|
|
36
|
+
start: () => void;
|
|
37
|
+
/** Stop capturing (buffered audio is still flushed by `end`). */
|
|
38
|
+
stop: () => void;
|
|
39
|
+
/** Abort the in-flight transcription request. */
|
|
40
|
+
abort: () => void;
|
|
41
|
+
/** Flush buffered audio, signal silence, and drain the pipeline. */
|
|
42
|
+
end: () => Promise<void>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The single end-to-end wiring: an ASR source ({@link Transcriber}) feeding the live orchestration
|
|
47
|
+
* engine ({@link runLivePipeline}). Every consumer (editor driver, message-list recording, stories)
|
|
48
|
+
* differs only by recorder, stages, and commit sink — this is the one place audio → text → stages →
|
|
49
|
+
* sink is assembled.
|
|
50
|
+
*/
|
|
51
|
+
export const runAsrPipeline = ({
|
|
52
|
+
recorder,
|
|
53
|
+
transcribe,
|
|
54
|
+
config,
|
|
55
|
+
segmentSentences = false,
|
|
56
|
+
onSegment,
|
|
57
|
+
drainSilenceMs = 5_000,
|
|
58
|
+
...runOptions
|
|
59
|
+
}: AsrPipelineOptions): AsrPipeline => {
|
|
60
|
+
const live: LivePipeline = runLivePipeline(runOptions);
|
|
61
|
+
// Optionally merge mid-sentence ASR fragments into complete sentences before the pipeline sees them.
|
|
62
|
+
const sentences = segmentSentences ? new SentenceBuffer() : undefined;
|
|
63
|
+
const emit = (block: ContentBlock.Transcript) => {
|
|
64
|
+
onSegment?.(block);
|
|
65
|
+
live.block(block);
|
|
66
|
+
};
|
|
67
|
+
const handle = (blocks: ContentBlock.Transcript[]) => {
|
|
68
|
+
for (const block of blocks) {
|
|
69
|
+
if (sentences) {
|
|
70
|
+
for (const sentence of sentences.push(block)) {
|
|
71
|
+
emit(sentence);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
emit(block);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const transcriber = new Transcriber({
|
|
79
|
+
config,
|
|
80
|
+
recorder,
|
|
81
|
+
transcribe,
|
|
82
|
+
onSegments: async (blocks: ContentBlock.Transcript[]) => handle(blocks),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
open: async () => {
|
|
87
|
+
await transcriber.open();
|
|
88
|
+
},
|
|
89
|
+
start: () => transcriber.startChunksRecording(),
|
|
90
|
+
stop: () => transcriber.stopChunksRecording(),
|
|
91
|
+
abort: () => transcriber.abort(),
|
|
92
|
+
end: async () => {
|
|
93
|
+
await transcriber.flush();
|
|
94
|
+
// Emit any sentence still buffered after the final flush.
|
|
95
|
+
if (sentences) {
|
|
96
|
+
for (const sentence of sentences.flush()) {
|
|
97
|
+
emit(sentence);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
live.silence(drainSilenceMs);
|
|
101
|
+
await live.end();
|
|
102
|
+
await transcriber.close();
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { SentenceBuffer, splitSentences } from './sentence-buffer';
|
|
8
|
+
|
|
9
|
+
const block = (text: string, started = 's') => ({ _tag: 'transcript' as const, started, text });
|
|
10
|
+
|
|
11
|
+
describe('splitSentences', () => {
|
|
12
|
+
test('splits on terminal punctuation, keeps the remainder', ({ expect }) => {
|
|
13
|
+
expect(splitSentences('Hello there. How are')).toEqual({ sentences: ['Hello there.'], rest: 'How are' });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('treats an ellipsis as non-terminal', ({ expect }) => {
|
|
17
|
+
expect(splitSentences('I was thinking... maybe later')).toEqual({
|
|
18
|
+
sentences: [],
|
|
19
|
+
rest: 'I was thinking... maybe later',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('handles multiple sentences', ({ expect }) => {
|
|
24
|
+
expect(splitSentences('One. Two! Three?')).toEqual({ sentences: ['One.', 'Two!', 'Three?'], rest: '' });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('SentenceBuffer', () => {
|
|
29
|
+
test('merges mid-sentence fragments into a single sentence', ({ expect }) => {
|
|
30
|
+
const buffer = new SentenceBuffer();
|
|
31
|
+
// Mirrors the file-transcription story's fragmented Whisper output.
|
|
32
|
+
expect(buffer.push(block("I've been living in London for about..."))).toEqual([]);
|
|
33
|
+
expect(buffer.push(block('Years,'))).toEqual([]);
|
|
34
|
+
const emitted = buffer.push(block('maybe a bit longer.'));
|
|
35
|
+
expect(emitted.map((entry) => entry.text)).toEqual([
|
|
36
|
+
"I've been living in London for about... Years, maybe a bit longer.",
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('emits a completed sentence and buffers the next', ({ expect }) => {
|
|
41
|
+
const buffer = new SentenceBuffer();
|
|
42
|
+
const emitted = buffer.push(block('I moved to Camden originally. And now I'));
|
|
43
|
+
expect(emitted.map((entry) => entry.text)).toEqual(['I moved to Camden originally.']);
|
|
44
|
+
expect(buffer.flush().map((entry) => entry.text)).toEqual(['And now I']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('inherits the timestamp of the fragment that began the sentence', ({ expect }) => {
|
|
48
|
+
const buffer = new SentenceBuffer();
|
|
49
|
+
buffer.push(block('Hello', '00:00:00'));
|
|
50
|
+
const [emitted] = buffer.push(block('world.', '00:00:03'));
|
|
51
|
+
expect(emitted.started).toBe('00:00:00');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type ContentBlock } from '@dxos/types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Splits text into complete sentences plus a trailing incomplete remainder.
|
|
9
|
+
*
|
|
10
|
+
* A run of `.`/`!`/`?` followed by whitespace or end-of-string terminates a sentence; an ellipsis
|
|
11
|
+
* (`..`+) is treated as non-terminal so mid-thought pauses keep accumulating. Abbreviations are not
|
|
12
|
+
* special-cased — this is a deterministic heuristic, not a full sentence tokenizer.
|
|
13
|
+
*/
|
|
14
|
+
export const splitSentences = (text: string): { sentences: string[]; rest: string } => {
|
|
15
|
+
const sentences: string[] = [];
|
|
16
|
+
let start = 0;
|
|
17
|
+
for (let index = 0; index < text.length; index++) {
|
|
18
|
+
const char = text[index];
|
|
19
|
+
if (char !== '.' && char !== '!' && char !== '?') {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
// Collapse a run of terminators (e.g. "?!" or "...").
|
|
23
|
+
let end = index;
|
|
24
|
+
while (end + 1 < text.length && (text[end + 1] === '.' || text[end + 1] === '!' || text[end + 1] === '?')) {
|
|
25
|
+
end++;
|
|
26
|
+
}
|
|
27
|
+
const isEllipsis = text.slice(index, end + 1).startsWith('..');
|
|
28
|
+
const next = text[end + 1];
|
|
29
|
+
const atBoundary = next === undefined || /\s/.test(next);
|
|
30
|
+
if (!isEllipsis && atBoundary) {
|
|
31
|
+
const sentence = text.slice(start, end + 1).trim();
|
|
32
|
+
if (sentence.length > 0) {
|
|
33
|
+
sentences.push(sentence);
|
|
34
|
+
}
|
|
35
|
+
start = end + 1;
|
|
36
|
+
}
|
|
37
|
+
index = end;
|
|
38
|
+
}
|
|
39
|
+
return { sentences, rest: text.slice(start).trim() };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Accumulates ASR fragments (which cut mid-sentence at chunk boundaries) and re-emits complete,
|
|
44
|
+
* sentence-aligned transcript blocks. Each emitted block inherits the `started` timestamp of the
|
|
45
|
+
* fragment that began the sentence. `flush` emits whatever remains (e.g. on stop).
|
|
46
|
+
*/
|
|
47
|
+
export class SentenceBuffer {
|
|
48
|
+
#text = '';
|
|
49
|
+
#started?: string;
|
|
50
|
+
|
|
51
|
+
/** Append a fragment; returns any complete sentences it produced. */
|
|
52
|
+
push(block: ContentBlock.Transcript): ContentBlock.Transcript[] {
|
|
53
|
+
const fragment = block.text.trim();
|
|
54
|
+
if (fragment.length === 0) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
if (this.#text.length === 0) {
|
|
58
|
+
this.#started = block.started;
|
|
59
|
+
}
|
|
60
|
+
this.#text = this.#text.length === 0 ? fragment : `${this.#text} ${fragment}`;
|
|
61
|
+
|
|
62
|
+
const { sentences, rest } = splitSentences(this.#text);
|
|
63
|
+
if (sentences.length === 0) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const startedForSentences = this.#started;
|
|
67
|
+
this.#text = rest;
|
|
68
|
+
// The remainder starts a new sentence whose timestamp we don't know yet; cleared until the next push.
|
|
69
|
+
this.#started = rest.length > 0 ? this.#started : undefined;
|
|
70
|
+
return sentences.map((text, index) => ({
|
|
71
|
+
_tag: 'transcript' as const,
|
|
72
|
+
started: index === 0 ? (startedForSentences ?? block.started) : block.started,
|
|
73
|
+
text,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Emit any buffered text as a final sentence. */
|
|
78
|
+
flush(): ContentBlock.Transcript[] {
|
|
79
|
+
const remaining = this.#text.trim();
|
|
80
|
+
this.#text = '';
|
|
81
|
+
const started = this.#started;
|
|
82
|
+
this.#started = undefined;
|
|
83
|
+
if (remaining.length === 0) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
return [{ _tag: 'transcript', started: started ?? new Date(0).toISOString(), text: remaining }];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { type AudioChunk } from './audio-recorder';
|
|
8
|
+
import { type WhisperSegment, alignWhisperSegments } from './transcriber';
|
|
9
|
+
|
|
10
|
+
const T0 = Date.parse('2026-01-01T00:00:00.000Z');
|
|
11
|
+
|
|
12
|
+
const chunk = (offsetMs = 0, samples = 16): AudioChunk => ({
|
|
13
|
+
timestamp: T0 + offsetMs,
|
|
14
|
+
data: new Float64Array(samples),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const segment = (overrides: Partial<WhisperSegment>): WhisperSegment => ({
|
|
18
|
+
text: '',
|
|
19
|
+
start: 0,
|
|
20
|
+
end: 0,
|
|
21
|
+
no_speech_prob: 0,
|
|
22
|
+
words: [],
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('alignWhisperSegments', () => {
|
|
27
|
+
test('returns empty when no chunks were transcribed', ({ expect }) => {
|
|
28
|
+
const result = alignWhisperSegments(
|
|
29
|
+
[segment({ text: 'x', end: 1, words: [{ word: 'x', start: 0, end: 1 }] })],
|
|
30
|
+
[],
|
|
31
|
+
0,
|
|
32
|
+
);
|
|
33
|
+
expect(result.transcripts).toEqual([]);
|
|
34
|
+
expect(result.lastTimestamp).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('absolute-timestamps and deduplicates segments against lastTimestamp', ({ expect }) => {
|
|
38
|
+
const segments: WhisperSegment[] = [
|
|
39
|
+
segment({
|
|
40
|
+
text: 'hello world',
|
|
41
|
+
start: 0,
|
|
42
|
+
end: 1,
|
|
43
|
+
words: [
|
|
44
|
+
{ word: 'hello ', start: 0, end: 0.5 },
|
|
45
|
+
{ word: 'world', start: 0.5, end: 1 },
|
|
46
|
+
],
|
|
47
|
+
}),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const first = alignWhisperSegments(segments, [chunk(0)], 0);
|
|
51
|
+
expect(first.transcripts).toHaveLength(1);
|
|
52
|
+
expect(first.transcripts[0]._tag).toBe('transcript');
|
|
53
|
+
expect(first.transcripts[0].text).toBe('hello world');
|
|
54
|
+
expect(new Date(first.transcripts[0].started).getTime()).toBe(T0);
|
|
55
|
+
expect(first.lastTimestamp).toBe(T0 + 1_000);
|
|
56
|
+
|
|
57
|
+
// Same segments, same chunk start: every word's absolute start is < lastTimestamp -> filtered out.
|
|
58
|
+
const second = alignWhisperSegments(segments, [chunk(0)], first.lastTimestamp!);
|
|
59
|
+
expect(second.transcripts).toEqual([]);
|
|
60
|
+
expect(second.lastTimestamp).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('keeps later words when earlier words fall before lastTimestamp', ({ expect }) => {
|
|
64
|
+
const segments: WhisperSegment[] = [
|
|
65
|
+
segment({
|
|
66
|
+
text: 'old new',
|
|
67
|
+
start: 0,
|
|
68
|
+
end: 2,
|
|
69
|
+
words: [
|
|
70
|
+
{ word: 'old ', start: 0, end: 1 },
|
|
71
|
+
{ word: 'new', start: 1.5, end: 2 },
|
|
72
|
+
],
|
|
73
|
+
}),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// First word starts at T0, second at T0+1500 — only the second survives a cursor at T0+1000.
|
|
77
|
+
const result = alignWhisperSegments(segments, [chunk(0)], T0 + 1_000);
|
|
78
|
+
expect(result.transcripts).toHaveLength(1);
|
|
79
|
+
expect(result.transcripts[0].text).toBe('new');
|
|
80
|
+
expect(new Date(result.transcripts[0].started).getTime()).toBe(T0 + 1_500);
|
|
81
|
+
expect(result.lastTimestamp).toBe(T0 + 2_000);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('drops segments that end before lastTimestamp', ({ expect }) => {
|
|
85
|
+
const segments: WhisperSegment[] = [
|
|
86
|
+
segment({
|
|
87
|
+
text: 'stale',
|
|
88
|
+
start: 0,
|
|
89
|
+
end: 0.5,
|
|
90
|
+
words: [{ word: 'stale', start: 0, end: 0.5 }],
|
|
91
|
+
}),
|
|
92
|
+
segment({
|
|
93
|
+
text: 'fresh',
|
|
94
|
+
start: 1,
|
|
95
|
+
end: 2,
|
|
96
|
+
words: [{ word: 'fresh', start: 1.5, end: 2 }],
|
|
97
|
+
}),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const result = alignWhisperSegments(segments, [chunk(0)], T0 + 1_000);
|
|
101
|
+
expect(result.transcripts).toHaveLength(1);
|
|
102
|
+
expect(result.transcripts[0].text).toBe('fresh');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { WaveFile } from 'wavefile';
|
|
6
|
+
|
|
7
|
+
import { DeferredTask, Trigger, synchronized } from '@dxos/async';
|
|
8
|
+
import { EDGE_SERVICE_DEFAULTS, EdgeServiceName } from '@dxos/config';
|
|
9
|
+
import { type Context, LifecycleState, Resource } from '@dxos/context';
|
|
10
|
+
import { log } from '@dxos/log';
|
|
11
|
+
import { trace } from '@dxos/tracing';
|
|
12
|
+
import { type ContentBlock } from '@dxos/types';
|
|
13
|
+
|
|
14
|
+
import { type AudioChunk, type AudioRecorder } from './audio-recorder';
|
|
15
|
+
import { mergeFloat64Arrays } from './util';
|
|
16
|
+
|
|
17
|
+
export type WhisperWord = {
|
|
18
|
+
word: string;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Time in seconds from the start of the audio.
|
|
22
|
+
*/
|
|
23
|
+
start: number;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Time in seconds from the start of the audio.
|
|
27
|
+
*/
|
|
28
|
+
end: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type WhisperSegment = {
|
|
32
|
+
text: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Time in seconds from the start of the audio.
|
|
36
|
+
*/
|
|
37
|
+
start: number;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Time in seconds from the start of the audio.
|
|
41
|
+
*/
|
|
42
|
+
end: number;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Probability of no speech in the segment.
|
|
46
|
+
*/
|
|
47
|
+
no_speech_prob: number;
|
|
48
|
+
|
|
49
|
+
words: WhisperWord[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type TranscribeConfig = {
|
|
53
|
+
/**
|
|
54
|
+
* Number of chunks to transcribe automatically after.
|
|
55
|
+
*/
|
|
56
|
+
transcribeAfterChunksAmount: number;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Number of chunks to save before the user starts speaking.
|
|
60
|
+
* This is needed to catch the beginning of the user's speech.
|
|
61
|
+
*/
|
|
62
|
+
prefixBufferChunksAmount: number;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Override the transcription endpoint base URL.
|
|
66
|
+
* Defaults to the DXOS calls service.
|
|
67
|
+
*/
|
|
68
|
+
endpoint?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Function that converts a base64-encoded WAV payload into Whisper segments.
|
|
73
|
+
* Allows callers to swap in alternative providers or mock the transport.
|
|
74
|
+
*/
|
|
75
|
+
export type TranscribeFn = (audio: string) => Promise<WhisperSegment[]>;
|
|
76
|
+
|
|
77
|
+
export type TranscriberProps = {
|
|
78
|
+
config: TranscribeConfig;
|
|
79
|
+
recorder: AudioRecorder;
|
|
80
|
+
/**
|
|
81
|
+
* Optional override of the transcription transport. When provided, supersedes `config.endpoint`.
|
|
82
|
+
*/
|
|
83
|
+
transcribe?: TranscribeFn;
|
|
84
|
+
/**
|
|
85
|
+
* Callback to handle the transcribed segments, after all segment transformers are applied.
|
|
86
|
+
* @param segments - The transcribed segments.
|
|
87
|
+
*/
|
|
88
|
+
onSegments: (segments: ContentBlock.Transcript[]) => Promise<void>;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Handles transcription of audio chunks.
|
|
93
|
+
* If user is not speaking, the last `minChunksAmount` chunks are saved and transcribed.
|
|
94
|
+
* If user is speaking, the chunks are added to the buffer until the user is done talking.
|
|
95
|
+
*/
|
|
96
|
+
export class Transcriber extends Resource {
|
|
97
|
+
private _audioChunks: AudioChunk[] = [];
|
|
98
|
+
private _lastTimestamp = 0;
|
|
99
|
+
|
|
100
|
+
private readonly _openTrigger = new Trigger({ autoReset: true });
|
|
101
|
+
|
|
102
|
+
private readonly _config: TranscribeConfig;
|
|
103
|
+
private readonly _recorder: AudioRecorder;
|
|
104
|
+
private readonly _onSegments: TranscriberProps['onSegments'];
|
|
105
|
+
private readonly _transcribeFn?: TranscribeFn;
|
|
106
|
+
|
|
107
|
+
private _recording = false;
|
|
108
|
+
private _transcribeTask?: DeferredTask = undefined;
|
|
109
|
+
/** Aborts the in-flight transcription request (e.g. when the user cancels the drain). */
|
|
110
|
+
private _transcribeAbort?: AbortController = undefined;
|
|
111
|
+
|
|
112
|
+
constructor({ config, recorder, onSegments, transcribe }: TranscriberProps) {
|
|
113
|
+
super();
|
|
114
|
+
this._config = config;
|
|
115
|
+
this._recorder = recorder;
|
|
116
|
+
this._onSegments = onSegments;
|
|
117
|
+
this._transcribeFn = transcribe;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
protected override async _open(ctx: Context): Promise<void> {
|
|
121
|
+
log.info('opening');
|
|
122
|
+
this._recorder.setOnChunk((chunk) => this._saveAudioChunk(chunk));
|
|
123
|
+
await this._recorder.start();
|
|
124
|
+
this._transcribeTask = new DeferredTask(ctx, async () => this._transcribe());
|
|
125
|
+
this._openTrigger.wake();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
protected override async _close(): Promise<void> {
|
|
129
|
+
log.info('closing');
|
|
130
|
+
this._recording = false;
|
|
131
|
+
this._transcribeTask = undefined;
|
|
132
|
+
this._transcribeAbort?.abort();
|
|
133
|
+
await this._recorder.stop();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Abort the in-flight transcription request (e.g. user cancelled the drain). */
|
|
137
|
+
abort(): void {
|
|
138
|
+
this._transcribeAbort?.abort();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
startChunksRecording(): void {
|
|
142
|
+
log.info('starting');
|
|
143
|
+
if (this._lifecycleState !== LifecycleState.OPEN) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this._recording = true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
stopChunksRecording(): void {
|
|
151
|
+
if (this._lifecycleState !== LifecycleState.OPEN || !this._recording) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this._recording = false;
|
|
156
|
+
this._transcribeTask?.schedule();
|
|
157
|
+
log.info('stopped');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Stop recording and transcribe any buffered audio, resolving once the final segments have been
|
|
162
|
+
* delivered via `onSegments`. Used to drain the pipeline when recording stops so the tail of speech
|
|
163
|
+
* (and its downstream enrichment) is not lost.
|
|
164
|
+
*/
|
|
165
|
+
async flush(): Promise<void> {
|
|
166
|
+
if (this._lifecycleState !== LifecycleState.OPEN) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this._recording = false;
|
|
171
|
+
await this._transcribe();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private _saveAudioChunk(chunk: AudioChunk): void {
|
|
175
|
+
log('saving audio chunk', { chunk });
|
|
176
|
+
this._audioChunks.push(chunk);
|
|
177
|
+
|
|
178
|
+
// Clean the buffer if the user is not speaking and the transcription task is not scheduled.
|
|
179
|
+
if (
|
|
180
|
+
!this._recording &&
|
|
181
|
+
(!this._transcribeTask || !this._transcribeTask.scheduled) &&
|
|
182
|
+
this._audioChunks.length >= this._config.prefixBufferChunksAmount
|
|
183
|
+
) {
|
|
184
|
+
this._audioChunks = this._dropOldChunks();
|
|
185
|
+
} else if (this._audioChunks.length >= this._config.transcribeAfterChunksAmount && this._recording) {
|
|
186
|
+
this._transcribeTask?.schedule();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Serialized so a threshold-scheduled run and a flush()/deferred run cannot read the same buffer
|
|
191
|
+
// concurrently and duplicate or drop tail segments.
|
|
192
|
+
@synchronized
|
|
193
|
+
private async _transcribe(): Promise<void> {
|
|
194
|
+
log('transcribing', { chunks: this._audioChunks.length });
|
|
195
|
+
const chunks = this._audioChunks;
|
|
196
|
+
this._audioChunks = this._dropOldChunks();
|
|
197
|
+
if (chunks.length === 0) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const audio = await this._mergeAudioChunks(chunks);
|
|
202
|
+
const segments = await this._fetchTranscription(audio);
|
|
203
|
+
if (!Array.isArray(segments) || segments.length === 0) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const alignedSegments = this._alignSegments(segments, chunks);
|
|
208
|
+
if (alignedSegments.length > 0) {
|
|
209
|
+
await this._onSegments(alignedSegments);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private _dropOldChunks(): AudioChunk[] {
|
|
214
|
+
return this._config.prefixBufferChunksAmount > 0
|
|
215
|
+
? this._audioChunks.slice(-this._config.prefixBufferChunksAmount)
|
|
216
|
+
: [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@trace.span({ showInBrowserTimeline: true })
|
|
220
|
+
private async _mergeAudioChunks(chunks: AudioChunk[]): Promise<string> {
|
|
221
|
+
const file = new WaveFile();
|
|
222
|
+
const wavConfig = this._recorder.wavConfig;
|
|
223
|
+
|
|
224
|
+
file.fromScratch(
|
|
225
|
+
wavConfig.channels,
|
|
226
|
+
wavConfig.sampleRate,
|
|
227
|
+
wavConfig.bitDepthCode,
|
|
228
|
+
mergeFloat64Arrays(chunks.map(({ data }) => data)),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return file.toBase64();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@trace.span({ showInBrowserTimeline: true })
|
|
235
|
+
private async _fetchTranscription(audio: string): Promise<WhisperSegment[]> {
|
|
236
|
+
if (audio.length === 0) {
|
|
237
|
+
throw new Error('No audio to send for transcribing');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let segments: unknown;
|
|
241
|
+
if (this._transcribeFn) {
|
|
242
|
+
segments = await this._transcribeFn(audio);
|
|
243
|
+
} else {
|
|
244
|
+
// TODO(burdon): Create separate endpoint?
|
|
245
|
+
const endpoint = this._config.endpoint ?? EDGE_SERVICE_DEFAULTS[EdgeServiceName.Transcription];
|
|
246
|
+
this._transcribeAbort = new AbortController();
|
|
247
|
+
let response: Response;
|
|
248
|
+
try {
|
|
249
|
+
response = await fetch(`${endpoint}/transcribe`, {
|
|
250
|
+
method: 'POST',
|
|
251
|
+
body: JSON.stringify({ audio }),
|
|
252
|
+
headers: {
|
|
253
|
+
'Content-Type': 'application/json',
|
|
254
|
+
},
|
|
255
|
+
signal: this._transcribeAbort.signal,
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
// Aborting (user cancelled the drain) is a clean stop, not a failed transcription cycle.
|
|
259
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
} finally {
|
|
264
|
+
this._transcribeAbort = undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!response.ok) {
|
|
268
|
+
throw new Error(`Transcription failed: ${response.statusText}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
({ segments } = (await response.json()) as { segments?: unknown });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!Array.isArray(segments)) {
|
|
275
|
+
throw new Error('Transcription response payload is invalid');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
log.info('transcription response', { segments: segments.length });
|
|
279
|
+
return segments as WhisperSegment[];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private _alignSegments(segments: WhisperSegment[], originalChunks: AudioChunk[]): ContentBlock.Transcript[] {
|
|
283
|
+
const result = alignWhisperSegments(segments, originalChunks, this._lastTimestamp);
|
|
284
|
+
if (result.lastTimestamp !== undefined) {
|
|
285
|
+
this._lastTimestamp = result.lastTimestamp;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return result.transcripts;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Pure helper that filters and absolute-timestamps Whisper segments against a chunk timeline.
|
|
294
|
+
* Exported for testing.
|
|
295
|
+
*
|
|
296
|
+
* @param segments - Raw segments returned by the Whisper service for `originalChunks`.
|
|
297
|
+
* @param originalChunks - Chunks fed into the transcription request (used for the zero timestamp).
|
|
298
|
+
* @param lastTimestamp - Most recent absolute timestamp already emitted; segments whose end
|
|
299
|
+
* falls strictly before this point are deduped out, as are individual words whose start does.
|
|
300
|
+
*/
|
|
301
|
+
export const alignWhisperSegments = (
|
|
302
|
+
segments: WhisperSegment[],
|
|
303
|
+
originalChunks: AudioChunk[],
|
|
304
|
+
lastTimestamp: number,
|
|
305
|
+
): { transcripts: ContentBlock.Transcript[]; lastTimestamp?: number } => {
|
|
306
|
+
if (originalChunks.length === 0) {
|
|
307
|
+
return { transcripts: [] };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const zeroTimestamp = originalChunks[0].timestamp;
|
|
311
|
+
|
|
312
|
+
const filteredSegments = segments
|
|
313
|
+
.filter((segment) => zeroTimestamp + segment.end * 1_000 >= lastTimestamp)
|
|
314
|
+
.map((segment) => {
|
|
315
|
+
const words = segment.words.filter((word) => zeroTimestamp + word.start * 1_000 >= lastTimestamp);
|
|
316
|
+
return {
|
|
317
|
+
...segment,
|
|
318
|
+
words,
|
|
319
|
+
text: words.map((word) => word.word).join(''),
|
|
320
|
+
start: words.at(0)?.start ?? segment.end,
|
|
321
|
+
};
|
|
322
|
+
})
|
|
323
|
+
.filter((segment) => segment.words.length > 0);
|
|
324
|
+
|
|
325
|
+
if (filteredSegments.length === 0) {
|
|
326
|
+
return { transcripts: [] };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
lastTimestamp: zeroTimestamp + filteredSegments.at(-1)!.end * 1_000,
|
|
331
|
+
transcripts: filteredSegments.map((segment) => ({
|
|
332
|
+
_tag: 'transcript',
|
|
333
|
+
started: new Date(zeroTimestamp + segment.start * 1_000).toISOString(),
|
|
334
|
+
text: segment.text.trim(),
|
|
335
|
+
})),
|
|
336
|
+
};
|
|
337
|
+
};
|
package/src/asr/util.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
export const mergeFloat64Arrays = (arrays: Float64Array[]) => {
|
|
6
|
+
const newArray = new Float64Array(arrays.reduce((acc, array) => array.length + acc, 0));
|
|
7
|
+
let offset = 0;
|
|
8
|
+
for (const array of arrays) {
|
|
9
|
+
newArray.set(array, offset);
|
|
10
|
+
offset += array.length;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return newArray;
|
|
14
|
+
};
|