@dxos/plugin-transcription 0.7.5-main.b19bfc8

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 (172) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +15 -0
  3. package/dist/lib/browser/TranscriptContainer-G7NUUBPJ.mjs +10 -0
  4. package/dist/lib/browser/TranscriptContainer-G7NUUBPJ.mjs.map +7 -0
  5. package/dist/lib/browser/app-graph-builder-ZSSJIFY7.mjs +7 -0
  6. package/dist/lib/browser/app-graph-builder-ZSSJIFY7.mjs.map +7 -0
  7. package/dist/lib/browser/chunk-7V6ZINS5.mjs +8 -0
  8. package/dist/lib/browser/chunk-7V6ZINS5.mjs.map +7 -0
  9. package/dist/lib/browser/chunk-D4ZMHRVK.mjs +241 -0
  10. package/dist/lib/browser/chunk-D4ZMHRVK.mjs.map +7 -0
  11. package/dist/lib/browser/chunk-QIHUPVQD.mjs +80 -0
  12. package/dist/lib/browser/chunk-QIHUPVQD.mjs.map +7 -0
  13. package/dist/lib/browser/chunk-ZMES3U2D.mjs +78 -0
  14. package/dist/lib/browser/chunk-ZMES3U2D.mjs.map +7 -0
  15. package/dist/lib/browser/index.mjs +652 -0
  16. package/dist/lib/browser/index.mjs.map +7 -0
  17. package/dist/lib/browser/intent-resolver-EAR2TFK2.mjs +32 -0
  18. package/dist/lib/browser/intent-resolver-EAR2TFK2.mjs.map +7 -0
  19. package/dist/lib/browser/meta.json +1 -0
  20. package/dist/lib/browser/react-surface-IXF7SETX.mjs +26 -0
  21. package/dist/lib/browser/react-surface-IXF7SETX.mjs.map +7 -0
  22. package/dist/lib/browser/types/index.mjs +17 -0
  23. package/dist/lib/browser/types/index.mjs.map +7 -0
  24. package/dist/lib/node/TranscriptContainer-IKMCPYQ2.cjs +31 -0
  25. package/dist/lib/node/TranscriptContainer-IKMCPYQ2.cjs.map +7 -0
  26. package/dist/lib/node/app-graph-builder-YFBX3OTP.cjs +26 -0
  27. package/dist/lib/node/app-graph-builder-YFBX3OTP.cjs.map +7 -0
  28. package/dist/lib/node/chunk-JBN3P6ZF.cjs +97 -0
  29. package/dist/lib/node/chunk-JBN3P6ZF.cjs.map +7 -0
  30. package/dist/lib/node/chunk-SQHF7N3B.cjs +40 -0
  31. package/dist/lib/node/chunk-SQHF7N3B.cjs.map +7 -0
  32. package/dist/lib/node/chunk-UIHEGU6Q.cjs +103 -0
  33. package/dist/lib/node/chunk-UIHEGU6Q.cjs.map +7 -0
  34. package/dist/lib/node/chunk-Y2GCR535.cjs +267 -0
  35. package/dist/lib/node/chunk-Y2GCR535.cjs.map +7 -0
  36. package/dist/lib/node/index.cjs +661 -0
  37. package/dist/lib/node/index.cjs.map +7 -0
  38. package/dist/lib/node/intent-resolver-3KJZGGUQ.cjs +44 -0
  39. package/dist/lib/node/intent-resolver-3KJZGGUQ.cjs.map +7 -0
  40. package/dist/lib/node/meta.json +1 -0
  41. package/dist/lib/node/react-surface-L4NQQWN4.cjs +49 -0
  42. package/dist/lib/node/react-surface-L4NQQWN4.cjs.map +7 -0
  43. package/dist/lib/node/types/index.cjs +39 -0
  44. package/dist/lib/node/types/index.cjs.map +7 -0
  45. package/dist/lib/node-esm/TranscriptContainer-HJTGGLU5.mjs +11 -0
  46. package/dist/lib/node-esm/TranscriptContainer-HJTGGLU5.mjs.map +7 -0
  47. package/dist/lib/node-esm/app-graph-builder-77GQG6UF.mjs +9 -0
  48. package/dist/lib/node-esm/app-graph-builder-77GQG6UF.mjs.map +7 -0
  49. package/dist/lib/node-esm/chunk-F4J7FV6Q.mjs +10 -0
  50. package/dist/lib/node-esm/chunk-F4J7FV6Q.mjs.map +7 -0
  51. package/dist/lib/node-esm/chunk-KEG3ME4O.mjs +82 -0
  52. package/dist/lib/node-esm/chunk-KEG3ME4O.mjs.map +7 -0
  53. package/dist/lib/node-esm/chunk-TNJUQCWM.mjs +242 -0
  54. package/dist/lib/node-esm/chunk-TNJUQCWM.mjs.map +7 -0
  55. package/dist/lib/node-esm/chunk-XYU6B73V.mjs +80 -0
  56. package/dist/lib/node-esm/chunk-XYU6B73V.mjs.map +7 -0
  57. package/dist/lib/node-esm/index.mjs +653 -0
  58. package/dist/lib/node-esm/index.mjs.map +7 -0
  59. package/dist/lib/node-esm/intent-resolver-UGLYRD4X.mjs +33 -0
  60. package/dist/lib/node-esm/intent-resolver-UGLYRD4X.mjs.map +7 -0
  61. package/dist/lib/node-esm/meta.json +1 -0
  62. package/dist/lib/node-esm/react-surface-TX4ULXGK.mjs +27 -0
  63. package/dist/lib/node-esm/react-surface-TX4ULXGK.mjs.map +7 -0
  64. package/dist/lib/node-esm/types/index.mjs +18 -0
  65. package/dist/lib/node-esm/types/index.mjs.map +7 -0
  66. package/dist/types/src/TranscriptionPlugin.d.ts +2 -0
  67. package/dist/types/src/TranscriptionPlugin.d.ts.map +1 -0
  68. package/dist/types/src/capabilities/app-graph-builder.d.ts +180 -0
  69. package/dist/types/src/capabilities/app-graph-builder.d.ts.map +1 -0
  70. package/dist/types/src/capabilities/capabilities.d.ts +4 -0
  71. package/dist/types/src/capabilities/capabilities.d.ts.map +1 -0
  72. package/dist/types/src/capabilities/index.d.ts +181 -0
  73. package/dist/types/src/capabilities/index.d.ts.map +1 -0
  74. package/dist/types/src/capabilities/intent-resolver.d.ts +4 -0
  75. package/dist/types/src/capabilities/intent-resolver.d.ts.map +1 -0
  76. package/dist/types/src/capabilities/react-surface.d.ts +4 -0
  77. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -0
  78. package/dist/types/src/components/Transcript/Transcript.d.ts +7 -0
  79. package/dist/types/src/components/Transcript/Transcript.d.ts.map +1 -0
  80. package/dist/types/src/components/Transcript/Transcript.stories.d.ts +9 -0
  81. package/dist/types/src/components/Transcript/Transcript.stories.d.ts.map +1 -0
  82. package/dist/types/src/components/Transcript/index.d.ts +2 -0
  83. package/dist/types/src/components/Transcript/index.d.ts.map +1 -0
  84. package/dist/types/src/components/TranscriptContainer.d.ts +7 -0
  85. package/dist/types/src/components/TranscriptContainer.d.ts.map +1 -0
  86. package/dist/types/src/components/index.d.ts +6 -0
  87. package/dist/types/src/components/index.d.ts.map +1 -0
  88. package/dist/types/src/components/transcription.stories.d.ts +14 -0
  89. package/dist/types/src/components/transcription.stories.d.ts.map +1 -0
  90. package/dist/types/src/hooks/index.d.ts +6 -0
  91. package/dist/types/src/hooks/index.d.ts.map +1 -0
  92. package/dist/types/src/hooks/useAudioFile.d.ts +7 -0
  93. package/dist/types/src/hooks/useAudioFile.d.ts.map +1 -0
  94. package/dist/types/src/hooks/useAudioTrack.d.ts +5 -0
  95. package/dist/types/src/hooks/useAudioTrack.d.ts.map +1 -0
  96. package/dist/types/src/hooks/useIsSpeaking.d.ts +2 -0
  97. package/dist/types/src/hooks/useIsSpeaking.d.ts.map +1 -0
  98. package/dist/types/src/hooks/useTranscriber.d.ts +10 -0
  99. package/dist/types/src/hooks/useTranscriber.d.ts.map +1 -0
  100. package/dist/types/src/hooks/useVoiceInput.d.ts +11 -0
  101. package/dist/types/src/hooks/useVoiceInput.d.ts.map +1 -0
  102. package/dist/types/src/index.d.ts +7 -0
  103. package/dist/types/src/index.d.ts.map +1 -0
  104. package/dist/types/src/meta.d.ts +11 -0
  105. package/dist/types/src/meta.d.ts.map +1 -0
  106. package/dist/types/src/sanity.test.d.ts +2 -0
  107. package/dist/types/src/sanity.test.d.ts.map +1 -0
  108. package/dist/types/src/transcriber/audio-recorder.d.ts +47 -0
  109. package/dist/types/src/transcriber/audio-recorder.d.ts.map +1 -0
  110. package/dist/types/src/transcriber/index.d.ts +4 -0
  111. package/dist/types/src/transcriber/index.d.ts.map +1 -0
  112. package/dist/types/src/transcriber/media-stream-recorder.d.ts +26 -0
  113. package/dist/types/src/transcriber/media-stream-recorder.d.ts.map +1 -0
  114. package/dist/types/src/transcriber/transcriber.d.ts +46 -0
  115. package/dist/types/src/transcriber/transcriber.d.ts.map +1 -0
  116. package/dist/types/src/transcriber/transcriber.test.d.ts +2 -0
  117. package/dist/types/src/transcriber/transcriber.test.d.ts.map +1 -0
  118. package/dist/types/src/translations.d.ts +40 -0
  119. package/dist/types/src/translations.d.ts.map +1 -0
  120. package/dist/types/src/types/index.d.ts +3 -0
  121. package/dist/types/src/types/index.d.ts.map +1 -0
  122. package/dist/types/src/types/schema.d.ts +63 -0
  123. package/dist/types/src/types/schema.d.ts.map +1 -0
  124. package/dist/types/src/types/types.d.ts +28 -0
  125. package/dist/types/src/types/types.d.ts.map +1 -0
  126. package/dist/types/src/util/array.d.ts +2 -0
  127. package/dist/types/src/util/array.d.ts.map +1 -0
  128. package/dist/types/src/util/get-time-str.d.ts +2 -0
  129. package/dist/types/src/util/get-time-str.d.ts.map +1 -0
  130. package/dist/types/src/util/index.d.ts +5 -0
  131. package/dist/types/src/util/index.d.ts.map +1 -0
  132. package/dist/types/src/util/monitor-audio-level.d.ts +6 -0
  133. package/dist/types/src/util/monitor-audio-level.d.ts.map +1 -0
  134. package/dist/types/src/util/random-queue-dxn.d.ts +3 -0
  135. package/dist/types/src/util/random-queue-dxn.d.ts.map +1 -0
  136. package/dist/types/tsconfig.tsbuildinfo +1 -0
  137. package/package.json +93 -0
  138. package/src/TranscriptionPlugin.tsx +57 -0
  139. package/src/capabilities/app-graph-builder.ts +7 -0
  140. package/src/capabilities/capabilities.ts +12 -0
  141. package/src/capabilities/index.ts +11 -0
  142. package/src/capabilities/intent-resolver.ts +24 -0
  143. package/src/capabilities/react-surface.tsx +21 -0
  144. package/src/components/Transcript/Transcript.stories.tsx +65 -0
  145. package/src/components/Transcript/Transcript.tsx +69 -0
  146. package/src/components/Transcript/index.ts +5 -0
  147. package/src/components/TranscriptContainer.tsx +167 -0
  148. package/src/components/index.ts +10 -0
  149. package/src/components/transcription.stories.tsx +198 -0
  150. package/src/hooks/index.ts +9 -0
  151. package/src/hooks/useAudioFile.ts +62 -0
  152. package/src/hooks/useAudioTrack.ts +35 -0
  153. package/src/hooks/useIsSpeaking.ts +77 -0
  154. package/src/hooks/useTranscriber.ts +62 -0
  155. package/src/hooks/useVoiceInput.ts +66 -0
  156. package/src/index.ts +11 -0
  157. package/src/meta.ts +17 -0
  158. package/src/sanity.test.ts +11 -0
  159. package/src/transcriber/audio-recorder.ts +56 -0
  160. package/src/transcriber/index.ts +7 -0
  161. package/src/transcriber/media-stream-recorder.ts +109 -0
  162. package/src/transcriber/transcriber.test.ts +230 -0
  163. package/src/transcriber/transcriber.ts +247 -0
  164. package/src/translations.ts +28 -0
  165. package/src/types/index.ts +6 -0
  166. package/src/types/schema.ts +60 -0
  167. package/src/types/types.ts +34 -0
  168. package/src/util/array.ts +14 -0
  169. package/src/util/get-time-str.ts +12 -0
  170. package/src/util/index.ts +8 -0
  171. package/src/util/monitor-audio-level.ts +58 -0
  172. package/src/util/random-queue-dxn.ts +9 -0
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './capabilities';
6
+ export * from './components';
7
+ export * from './hooks';
8
+ export * from './meta';
9
+ export * from './transcriber';
10
+
11
+ export * from './TranscriptionPlugin';
package/src/meta.ts ADDED
@@ -0,0 +1,17 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type PluginMeta } from '@dxos/app-framework';
6
+
7
+ export const TRANSCRIPTION_PLUGIN = 'dxos.org/plugin/transcription';
8
+
9
+ export const meta = {
10
+ id: TRANSCRIPTION_PLUGIN,
11
+ name: 'Transcription',
12
+ description: 'Voice transcription.',
13
+ icon: 'ph--microphone--regular',
14
+ source: 'https://github.com/dxos/dxos/tree/main/packages/plugins/experimental/plugin-transcription',
15
+ tags: ['experimental'],
16
+ screenshots: [],
17
+ } satisfies PluginMeta;
@@ -0,0 +1,11 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, expect, test } from 'vitest';
6
+
7
+ describe('sanity', () => {
8
+ test('sanity', () => {
9
+ expect(true).toBe(true);
10
+ });
11
+ });
@@ -0,0 +1,56 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export type AudioChunk = {
6
+ /**
7
+ * Timestamp of the beginning of the chunk.
8
+ */
9
+ timestamp: number;
10
+
11
+ /**
12
+ * 16-bit PCM data.
13
+ */
14
+ data: Float64Array;
15
+ };
16
+
17
+ export type WavConfig = {
18
+ channels: number;
19
+ sampleRate: number;
20
+ bitDepthCode: string;
21
+ };
22
+
23
+ /**
24
+ * Records MediaStreamTrack to Uint8Array 16-bit PCM data.
25
+ * The data is passed to the onChunk callback with the timestamp of the chunk.
26
+ * All chunks are aligned and sequential.
27
+ * Time ─────────────────────────────────────────────────►
28
+ *
29
+ * Recording:
30
+ * t1 ┌──────┐
31
+ * t2 ┌──────┬──────┐
32
+ * t3 ┌──────┬──────┬──────┐
33
+ * t4 ┌──────┬──────┬──────┬──────┐
34
+ * └──────┴──────┴──────┴──────┘
35
+ * 1st 2nd 3rd 4th
36
+ * chunk chunk chunk chunk
37
+ *
38
+ */
39
+ export interface AudioRecorder {
40
+ wavConfig: WavConfig;
41
+
42
+ /**
43
+ * Sets callback for receiving audio chunks.
44
+ */
45
+ setOnChunk(onChunk: (chunk: AudioChunk) => void): void;
46
+
47
+ /**
48
+ * Starts the recorder.
49
+ */
50
+ start(): Promise<void>;
51
+
52
+ /**
53
+ * Stops the recorder.
54
+ */
55
+ stop(): Promise<void>;
56
+ }
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './audio-recorder';
6
+ export * from './media-stream-recorder';
7
+ export * from './transcriber';
@@ -0,0 +1,109 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type IBlobEvent, type IMediaRecorder, MediaRecorder, register } from 'extendable-media-recorder';
6
+ import { WaveFile } from 'wavefile';
7
+
8
+ import { synchronized } from '@dxos/async';
9
+ import { invariant } from '@dxos/invariant';
10
+ import { log } from '@dxos/log';
11
+ import { trace } from '@dxos/tracing';
12
+
13
+ import { type WavConfig, type AudioChunk, type AudioRecorder } from './audio-recorder';
14
+
15
+ let initializingPromise: Promise<void> | undefined;
16
+
17
+ const initializeExtendableMediaRecorder = async () => {
18
+ const { connect } = await import('extendable-media-recorder-wav-encoder');
19
+ await register(await connect());
20
+ };
21
+
22
+ /**
23
+ * Recorder that uses the MediaContext API and AudioNode API to record audio.
24
+ *
25
+ * It records MediaStream using https://www.npmjs.com/package/extendable-media-recorder.
26
+ */
27
+ @trace.resource()
28
+ export class MediaStreamRecorder implements AudioRecorder {
29
+ private readonly _mediaStreamTrack: MediaStreamTrack;
30
+ /**
31
+ * Default MediaRecorder implementation do not support wav encoding.
32
+ */
33
+ private readonly _interval: number;
34
+ private _mediaRecorder?: IMediaRecorder = undefined;
35
+ private _header?: Uint8Array = undefined;
36
+ private _onChunk?: (chunk: AudioChunk) => void;
37
+
38
+ constructor({ mediaStreamTrack, interval }: { mediaStreamTrack: MediaStreamTrack; interval: number }) {
39
+ this._mediaStreamTrack = mediaStreamTrack;
40
+ this._interval = interval;
41
+ }
42
+
43
+ get wavConfig(): WavConfig {
44
+ const settings = this._mediaStreamTrack.getSettings();
45
+ return {
46
+ channels: settings.channelCount ?? 1,
47
+ sampleRate: settings.sampleRate ?? 16000,
48
+ bitDepthCode: settings.sampleSize ? String(settings.sampleSize) : '16',
49
+ };
50
+ }
51
+
52
+ setOnChunk(onChunk: (chunk: AudioChunk) => void): void {
53
+ this._onChunk = onChunk;
54
+ }
55
+
56
+ async start() {
57
+ await (initializingPromise ??= initializeExtendableMediaRecorder()).catch((err) =>
58
+ log.info('initializeExtendableMediaRecorder', { err }),
59
+ );
60
+
61
+ if (!this._mediaRecorder) {
62
+ const stream = new MediaStream([this._mediaStreamTrack]);
63
+ this._mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/wav' });
64
+ }
65
+
66
+ if (this._mediaRecorder.state === 'recording') {
67
+ return;
68
+ }
69
+ invariant(this._onChunk, 'MediaStreamRecorder: onChunk is not set');
70
+ this._mediaRecorder.ondataavailable = (event) => this._ondataavailable(event);
71
+ this._mediaRecorder.start(this._interval);
72
+ }
73
+
74
+ async stop() {
75
+ if (this._mediaRecorder?.state !== 'recording') {
76
+ return;
77
+ }
78
+ this._mediaRecorder.stop();
79
+ }
80
+
81
+ @synchronized
82
+ private async _ondataavailable(event: IBlobEvent) {
83
+ const blob = event.data;
84
+ const uint8Array = new Uint8Array(await blob.arrayBuffer());
85
+
86
+ // First chunk from the MediaRecorder has a header.
87
+ const isHeader =
88
+ uint8Array[0] === 0x52 && // R
89
+ uint8Array[1] === 0x49 && // I
90
+ uint8Array[2] === 0x46 && // F
91
+ uint8Array[3] === 0x46; // F
92
+ let wav: WaveFile;
93
+
94
+ if (isHeader) {
95
+ wav = new WaveFile(uint8Array);
96
+ this._header = uint8Array.slice(0, 44);
97
+ } else if (this._header) {
98
+ wav = new WaveFile(new Uint8Array([...this._header, ...uint8Array]));
99
+ } else {
100
+ log.warn('MediaStreamRecorder: no header');
101
+ return;
102
+ }
103
+
104
+ this._onChunk!({
105
+ timestamp: Date.now(),
106
+ data: wav.getSamples(),
107
+ });
108
+ }
109
+ }
@@ -0,0 +1,230 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { describe, expect, test } from 'vitest';
8
+ import { WaveFile } from 'wavefile';
9
+
10
+ import { Trigger } from '@dxos/async';
11
+ import { log } from '@dxos/log';
12
+ import { openAndClose } from '@dxos/test-utils';
13
+ import { trace, TRACE_PROCESSOR } from '@dxos/tracing';
14
+
15
+ import { type AudioChunk, type AudioRecorder, Transcriber } from '../transcriber';
16
+ import { type TranscriptSegment } from '../types';
17
+ import { mergeFloat64Arrays } from '../util';
18
+
19
+ // TODO(burdon): ReferenceError: Worker is not defined (Can only run with web workers).
20
+
21
+ // This is a playground for testing the transcription, requires `calls-service` to be running.
22
+ describe.skip('Transcriber', () => {
23
+ const DIR_PATH = path.join(__dirname, 'assets');
24
+
25
+ const readFile = async (filename: string) => {
26
+ return fs.readFileSync(path.join(DIR_PATH, filename));
27
+ };
28
+
29
+ const writeOutputFile = async (filename: string, data: Uint8Array) => {
30
+ const outputPath = path.join(DIR_PATH, 'out', filename);
31
+ if (!fs.existsSync(path.dirname(outputPath))) {
32
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
33
+ }
34
+
35
+ return fs.writeFileSync(outputPath, data);
36
+ };
37
+
38
+ test.skip('split wav file', async () => {
39
+ // Read the WAV file
40
+ const fileBuffer = await readFile('./testing/test.wav');
41
+ const wav = new WaveFile(fileBuffer);
42
+
43
+ // Get PCM samples and format info.
44
+ const samples = wav.getSamples();
45
+ const format = wav.fmt as any;
46
+
47
+ // Take second half of samples.
48
+ const halfSamples = samples.slice(Math.floor(samples.length / 2));
49
+
50
+ // Create new WAV file with half the samples.
51
+ const halfWav = new WaveFile();
52
+ halfWav.fromScratch(1, format.sampleRate!, '16', halfSamples);
53
+
54
+ // Save the half-length file
55
+ await writeOutputFile('half.wav', halfWav.toBuffer());
56
+ });
57
+
58
+ test.skip('merge audio chunks', async () => {
59
+ const chunks: AudioChunk[] = [];
60
+ const trigger = new Trigger({ autoReset: true });
61
+
62
+ const recorder = new MockAudioRecorder({
63
+ buffer: await readFile('test.wav'),
64
+ chunkDuration: 3_000,
65
+ });
66
+
67
+ recorder.setOnChunk((chunk) => {
68
+ chunks.push(chunk);
69
+ trigger.wake();
70
+ });
71
+
72
+ await recorder.start();
73
+
74
+ const chunksNumber = 3;
75
+ for (let i = 0; i < chunksNumber; i++) {
76
+ recorder.emitChunk();
77
+ }
78
+
79
+ while (chunks.length < chunksNumber) {
80
+ await trigger.wait();
81
+ }
82
+
83
+ const wav = new WaveFile();
84
+ wav.fromScratch(
85
+ 1, //
86
+ recorder.wavConfig.sampleRate,
87
+ '16',
88
+ mergeFloat64Arrays(chunks.map((chunk) => chunk.data)),
89
+ );
90
+ await writeOutputFile('merged.wav', wav.toBuffer());
91
+
92
+ await recorder.stop();
93
+ });
94
+
95
+ test.skip('transcription of audio recording', { timeout: 10_000 }, async () => {
96
+ const trigger = new Trigger<TranscriptSegment[]>({ autoReset: true });
97
+ const recorder = new MockAudioRecorder({
98
+ buffer: await readFile('test.wav'),
99
+ chunkDuration: 3_000,
100
+ });
101
+ const transcription = new Transcriber({
102
+ recorder,
103
+ onSegments: async (segments) => {
104
+ log.info('transcription', { segments });
105
+ trigger.wake(segments);
106
+ },
107
+ config: {
108
+ transcribeAfterChunksAmount: 1000,
109
+ prefixBufferChunksAmount: 1,
110
+ },
111
+ });
112
+
113
+ await openAndClose(transcription);
114
+
115
+ transcription.startChunksRecording();
116
+
117
+ recorder.emitChunk();
118
+ recorder.emitChunk();
119
+ recorder.emitChunk();
120
+
121
+ transcription.stopChunksRecording();
122
+
123
+ // Could fail, not critical.
124
+ expect(
125
+ (await trigger.wait()).some((segment) => segment.text.includes('I will tell you some information about myself')),
126
+ );
127
+ });
128
+
129
+ test.skip('transcription of audio recording with overlapping chunks', { timeout: 20_000 }, async () => {
130
+ const trigger = new Trigger<TranscriptSegment[]>({ autoReset: true });
131
+ const recorder = new MockAudioRecorder({
132
+ buffer: await readFile('test.wav'),
133
+ chunkDuration: 3_000,
134
+ });
135
+ const transcription = new Transcriber({
136
+ recorder,
137
+ onSegments: async (segments) => {
138
+ log.info('transcription', { segments });
139
+ trigger.wake(segments);
140
+ },
141
+ config: {
142
+ transcribeAfterChunksAmount: 1000,
143
+ prefixBufferChunksAmount: 1,
144
+ },
145
+ });
146
+
147
+ await openAndClose(transcription);
148
+
149
+ transcription.startChunksRecording();
150
+ recorder.emitChunk();
151
+ recorder.emitChunk();
152
+ transcription.stopChunksRecording();
153
+ expect((await trigger.wait()).some((segment) => segment.text.includes("Hello, I'm")));
154
+
155
+ transcription.startChunksRecording();
156
+ recorder.emitChunk();
157
+ recorder.emitChunk();
158
+ transcription.stopChunksRecording();
159
+ expect(
160
+ (await trigger.wait()).some((segment) => segment.text.includes('I will tell you some information about myself')),
161
+ );
162
+
163
+ log.info('Done', { trace: TRACE_PROCESSOR.getDiagnostics() });
164
+ });
165
+
166
+ test.skip('double file', async () => {
167
+ const fileBuffer = await readFile('test.wav');
168
+ const wav = new WaveFile(fileBuffer);
169
+ const samples = wav.getSamples();
170
+ const newWav = new WaveFile();
171
+ newWav.fromScratch(1, 24000, '16', mergeFloat64Arrays([samples, samples]));
172
+ await writeOutputFile('double.wav', newWav.toBuffer());
173
+ });
174
+ });
175
+
176
+ @trace.resource()
177
+ class MockAudioRecorder implements AudioRecorder {
178
+ public chunks: AudioChunk[] = [];
179
+ public wav: WaveFile;
180
+ public chunkDuration: number;
181
+
182
+ private _onChunk?: (chunk: AudioChunk) => void = undefined;
183
+
184
+ constructor({ buffer, chunkDuration }: { buffer: Uint8Array; chunkDuration: number }) {
185
+ this.wav = new WaveFile(buffer);
186
+ this.chunkDuration = chunkDuration;
187
+ }
188
+
189
+ get wavConfig() {
190
+ return {
191
+ channels: 1,
192
+ sampleRate: (this.wav.fmt as any).sampleRate,
193
+ bitDepthCode: '16',
194
+ };
195
+ }
196
+
197
+ setOnChunk(onChunk: (chunk: AudioChunk) => void) {
198
+ this._onChunk = onChunk;
199
+ }
200
+
201
+ /**
202
+ * Emit a chunk in controlled manner for testing purposes.
203
+ */
204
+ emitChunk() {
205
+ const chunk = this.chunks.shift();
206
+ if (!chunk) {
207
+ return;
208
+ }
209
+
210
+ this._onChunk!(chunk);
211
+ }
212
+
213
+ @trace.span()
214
+ async start() {
215
+ log.info('start mock audio recorder', { wavParams: this.wav.fmt });
216
+ const now = Date.now();
217
+ const samples = this.wav.getSamples();
218
+ const sampleRate = this.wavConfig.sampleRate;
219
+ const increment = sampleRate * (this.chunkDuration / 1000);
220
+ for (let i = 0; i < samples.length; i += increment) {
221
+ const chunk = samples.slice(i, i + increment);
222
+ this.chunks.push({
223
+ timestamp: now + (i / increment) * this.chunkDuration,
224
+ data: chunk,
225
+ });
226
+ }
227
+ }
228
+
229
+ async stop() {}
230
+ }
@@ -0,0 +1,247 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { WaveFile } from 'wavefile';
6
+
7
+ import { DeferredTask, Trigger } from '@dxos/async';
8
+ import { type Context, LifecycleState, Resource } from '@dxos/context';
9
+ import { log } from '@dxos/log';
10
+ import { trace } from '@dxos/tracing';
11
+
12
+ import { type AudioRecorder, type AudioChunk } from './audio-recorder';
13
+ import { TRANSCRIPTION_URL, type TranscriptSegment } from '../types';
14
+ import { mergeFloat64Arrays } from '../util';
15
+
16
+ type WhisperWord = {
17
+ word: string;
18
+
19
+ /**
20
+ * Time in seconds from the start of the audio.
21
+ */
22
+ start: number;
23
+
24
+ /**
25
+ * Time in seconds from the start of the audio.
26
+ */
27
+ end: number;
28
+ };
29
+
30
+ type WhisperSegment = {
31
+ text: string;
32
+
33
+ /**
34
+ * Time in seconds from the start of the audio.
35
+ */
36
+ start: number;
37
+
38
+ /**
39
+ * Time in seconds from the start of the audio.
40
+ */
41
+ end: number;
42
+
43
+ /**
44
+ * Probability of no speech in the segment.
45
+ */
46
+ no_speech_prob: number;
47
+
48
+ words: WhisperWord[];
49
+ };
50
+
51
+ export type TranscribeConfig = {
52
+ /**
53
+ * Number of chunks to transcribe automatically after.
54
+ */
55
+ transcribeAfterChunksAmount: number;
56
+
57
+ /**
58
+ * Number of chunks to save before the user starts speaking.
59
+ * This is needed to catch the beginning of the user's speech.
60
+ */
61
+ prefixBufferChunksAmount: number;
62
+ };
63
+
64
+ export type TranscriberParams = {
65
+ config: TranscribeConfig;
66
+ recorder: AudioRecorder;
67
+ onSegments: (segments: TranscriptSegment[]) => Promise<void>;
68
+ };
69
+
70
+ /**
71
+ * Handles transcription of audio chunks.
72
+ * If user is not speaking, the last `minChunksAmount` chunks are saved and transcribed.
73
+ * If user is speaking, the chunks are added to the buffer until the user is done talking.
74
+ */
75
+ export class Transcriber extends Resource {
76
+ private _audioChunks: AudioChunk[] = [];
77
+ private _lastUsedTimestamp = 0;
78
+
79
+ private readonly _openTrigger = new Trigger({ autoReset: true });
80
+
81
+ private readonly _config: TranscribeConfig;
82
+ private readonly _recorder: AudioRecorder;
83
+ private readonly _onSegments: TranscriberParams['onSegments'];
84
+
85
+ private _recording = false;
86
+ private _transcribeTask?: DeferredTask = undefined;
87
+
88
+ constructor({ config, recorder, onSegments }: TranscriberParams) {
89
+ super();
90
+ this._config = config;
91
+ this._recorder = recorder;
92
+ this._onSegments = onSegments;
93
+ }
94
+
95
+ protected override async _open(ctx: Context) {
96
+ this._recorder.setOnChunk((chunk) => this._saveAudioChunk(chunk));
97
+ await this._recorder.start();
98
+ this._transcribeTask = new DeferredTask(ctx, async () => this._transcribe());
99
+ this._openTrigger.wake();
100
+ }
101
+
102
+ protected override async _close() {
103
+ this._recording = false;
104
+ this._transcribeTask = undefined;
105
+ await this._recorder.stop();
106
+ }
107
+
108
+ startChunksRecording() {
109
+ if (this._lifecycleState !== LifecycleState.OPEN) {
110
+ return;
111
+ }
112
+ this._recording = true;
113
+ }
114
+
115
+ stopChunksRecording() {
116
+ if (this._lifecycleState !== LifecycleState.OPEN || !this._recording) {
117
+ return;
118
+ }
119
+ this._recording = false;
120
+ this._transcribeTask?.schedule();
121
+ }
122
+
123
+ private _saveAudioChunk(chunk: AudioChunk) {
124
+ log('saving audio chunk', { chunk });
125
+ this._audioChunks.push(chunk);
126
+
127
+ // Clean the buffer if the user is not speaking and the transcription task is not scheduled.
128
+ if (
129
+ !this._recording &&
130
+ (!this._transcribeTask || !this._transcribeTask.scheduled) &&
131
+ this._audioChunks.length >= this._config.prefixBufferChunksAmount
132
+ ) {
133
+ this._audioChunks = this._dropOldChunks();
134
+ } else if (this._audioChunks.length >= this._config.transcribeAfterChunksAmount && this._recording) {
135
+ this._transcribeTask?.schedule();
136
+ }
137
+ }
138
+
139
+ private async _transcribe() {
140
+ log('transcribing', { chunks: this._audioChunks.length });
141
+ const chunks = this._audioChunks;
142
+ this._audioChunks = this._dropOldChunks();
143
+ if (chunks.length === 0) {
144
+ return;
145
+ }
146
+
147
+ const audio = await this._mergeAudioChunks(chunks);
148
+ const segments = await this._fetchTranscription(audio);
149
+ if (!Array.isArray(segments) || segments.length === 0) {
150
+ return;
151
+ }
152
+
153
+ const alignedSegments = this._alignSegments(segments, chunks);
154
+ if (alignedSegments.length > 0) {
155
+ await this._onSegments(alignedSegments);
156
+ }
157
+ }
158
+
159
+ private _dropOldChunks() {
160
+ return this._config.prefixBufferChunksAmount > 0
161
+ ? this._audioChunks.slice(-this._config.prefixBufferChunksAmount)
162
+ : [];
163
+ }
164
+
165
+ @trace.span({ showInBrowserTimeline: true })
166
+ private async _mergeAudioChunks(chunks: AudioChunk[]) {
167
+ const file = new WaveFile();
168
+ const wavConfig = this._recorder.wavConfig;
169
+
170
+ file.fromScratch(
171
+ wavConfig.channels,
172
+ wavConfig.sampleRate,
173
+ wavConfig.bitDepthCode,
174
+ mergeFloat64Arrays(chunks.map(({ data }) => data)),
175
+ );
176
+
177
+ return file.toBase64();
178
+ }
179
+
180
+ @trace.span({ showInBrowserTimeline: true })
181
+ private async _fetchTranscription(audio: string) {
182
+ if (audio.length === 0) {
183
+ this._audioChunks = [];
184
+ throw new Error('No audio to send for transcribing');
185
+ }
186
+
187
+ const response = await fetch(`${TRANSCRIPTION_URL}/transcribe`, {
188
+ method: 'POST',
189
+ body: JSON.stringify({ audio }),
190
+ headers: {
191
+ 'Content-Type': 'application/json',
192
+ },
193
+ });
194
+
195
+ if (!response.ok) {
196
+ this._audioChunks = [];
197
+ throw new Error('Failed to transcribe');
198
+ }
199
+
200
+ const {
201
+ segments,
202
+ }: {
203
+ segments: WhisperSegment[];
204
+ } = await response.json();
205
+
206
+ log.info('transcription response', {
207
+ segments,
208
+ string: segments.map((segments) => segments.text).join(' '),
209
+ });
210
+
211
+ return segments;
212
+ }
213
+
214
+ private _alignSegments(segments: WhisperSegment[], originalChunks: AudioChunk[]): TranscriptSegment[] {
215
+ // Absolute zero for all relative timestamps in the segments.
216
+ const zeroTimestamp = originalChunks.at(0)!.timestamp;
217
+
218
+ const filteredSegments = segments
219
+ // Use segments that end after the last used timestamp.
220
+ // Segments could overlap, so we need to filter words that starts after the last used timestamp.
221
+ .filter((segment) => zeroTimestamp + segment.end * 1_000 >= this._lastUsedTimestamp)
222
+ // Use words that starts after the last used timestamp.
223
+ .map((segment) => {
224
+ const words = segment.words.filter((word) => zeroTimestamp + word.start * 1_000 >= this._lastUsedTimestamp);
225
+ return {
226
+ ...segment,
227
+ words,
228
+ text: words.map((word) => word.word).join(''),
229
+ start: words.at(0)?.start ?? segment.end,
230
+ };
231
+ })
232
+ .filter((segment) => segment.words.length > 0);
233
+
234
+ if (filteredSegments.length === 0) {
235
+ return [];
236
+ }
237
+
238
+ // Update last timestamp.
239
+ this._lastUsedTimestamp = zeroTimestamp + filteredSegments.at(-1)!.end * 1_000;
240
+
241
+ // Add absolute timestamp to each segment.
242
+ return filteredSegments.map((segment) => ({
243
+ started: new Date(zeroTimestamp + segment.start * 1_000),
244
+ text: segment.text,
245
+ }));
246
+ }
247
+ }