@dxos/react-ui-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/LICENSE +105 -0
- package/dist/lib/browser/index.mjs +1002 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/node-esm/index.mjs +1004 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/types/src/capture/create-transcriber.d.ts +18 -0
- package/dist/types/src/capture/create-transcriber.d.ts.map +1 -0
- package/dist/types/src/capture/index.d.ts +3 -0
- package/dist/types/src/capture/index.d.ts.map +1 -0
- package/dist/types/src/capture/media-stream-recorder.d.ts +30 -0
- package/dist/types/src/capture/media-stream-recorder.d.ts.map +1 -0
- package/dist/types/src/components/MicSettings/MicSettings.d.ts +25 -0
- package/dist/types/src/components/MicSettings/MicSettings.d.ts.map +1 -0
- package/dist/types/src/components/MicSettings/index.d.ts +2 -0
- package/dist/types/src/components/MicSettings/index.d.ts.map +1 -0
- package/dist/types/src/components/PipelineStatus/PipelineStatus.d.ts +20 -0
- package/dist/types/src/components/PipelineStatus/PipelineStatus.d.ts.map +1 -0
- package/dist/types/src/components/PipelineStatus/index.d.ts +2 -0
- package/dist/types/src/components/PipelineStatus/index.d.ts.map +1 -0
- package/dist/types/src/components/Transcription/Transcription.d.ts +11 -0
- package/dist/types/src/components/Transcription/Transcription.d.ts.map +1 -0
- package/dist/types/src/components/Transcription/index.d.ts +3 -0
- package/dist/types/src/components/Transcription/index.d.ts.map +1 -0
- package/dist/types/src/components/Transcription/transcription-extension.d.ts +15 -0
- package/dist/types/src/components/Transcription/transcription-extension.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +4 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +8 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useAudioFile.d.ts +7 -0
- package/dist/types/src/hooks/useAudioFile.d.ts.map +1 -0
- package/dist/types/src/hooks/useAudioTrack.d.ts +6 -0
- package/dist/types/src/hooks/useAudioTrack.d.ts.map +1 -0
- package/dist/types/src/hooks/useFeedModelAdapter.d.ts +12 -0
- package/dist/types/src/hooks/useFeedModelAdapter.d.ts.map +1 -0
- package/dist/types/src/hooks/useIsSpeaking.d.ts +5 -0
- package/dist/types/src/hooks/useIsSpeaking.d.ts.map +1 -0
- package/dist/types/src/hooks/useRecordingPipeline.d.ts +40 -0
- package/dist/types/src/hooks/useRecordingPipeline.d.ts.map +1 -0
- package/dist/types/src/hooks/useSpeechRecognition.d.ts +9 -0
- package/dist/types/src/hooks/useSpeechRecognition.d.ts.map +1 -0
- package/dist/types/src/hooks/useTranscriber.d.ts +10 -0
- package/dist/types/src/hooks/useTranscriber.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +7 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/model/index.d.ts +2 -0
- package/dist/types/src/model/index.d.ts.map +1 -0
- package/dist/types/src/model/transcript-model.d.ts +68 -0
- package/dist/types/src/model/transcript-model.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +3 -0
- package/dist/types/src/types.d.ts.map +1 -0
- package/dist/types/src/util/index.d.ts +2 -0
- package/dist/types/src/util/index.d.ts.map +1 -0
- package/dist/types/src/util/render-byline.d.ts +4 -0
- package/dist/types/src/util/render-byline.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +69 -0
- package/src/capture/create-transcriber.ts +49 -0
- package/src/capture/index.ts +6 -0
- package/src/capture/media-stream-recorder.ts +137 -0
- package/src/components/MicSettings/MicSettings.tsx +126 -0
- package/src/components/MicSettings/index.ts +5 -0
- package/src/components/PipelineStatus/PipelineStatus.tsx +71 -0
- package/src/components/PipelineStatus/index.ts +5 -0
- package/src/components/Transcription/Transcription.tsx +67 -0
- package/src/components/Transcription/index.ts +6 -0
- package/src/components/Transcription/transcription-extension.ts +203 -0
- package/src/components/index.ts +7 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/useAudioFile.ts +88 -0
- package/src/hooks/useAudioTrack.ts +56 -0
- package/src/hooks/useFeedModelAdapter.ts +46 -0
- package/src/hooks/useIsSpeaking.ts +42 -0
- package/src/hooks/useRecordingPipeline.ts +166 -0
- package/src/hooks/useSpeechRecognition.ts +56 -0
- package/src/hooks/useTranscriber.ts +45 -0
- package/src/index.ts +10 -0
- package/src/model/index.ts +5 -0
- package/src/model/transcript-model.ts +229 -0
- package/src/types.ts +6 -0
- package/src/util/index.ts +5 -0
- package/src/util/render-byline.ts +32 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Extension, RangeSetBuilder } from '@codemirror/state';
|
|
6
|
+
import { EditorView, GutterMarker, ViewPlugin, type ViewUpdate, gutter } from '@codemirror/view';
|
|
7
|
+
import { format } from 'date-fns/format';
|
|
8
|
+
import { intervalToDuration } from 'date-fns/intervalToDuration';
|
|
9
|
+
|
|
10
|
+
import { type CleanupFn, addEventListener, combine } from '@dxos/async';
|
|
11
|
+
import { type Message } from '@dxos/types';
|
|
12
|
+
|
|
13
|
+
import { EditorChunkDocument, type TranscriptModel } from '../../model';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Data structure that maps Chunks queue to lines with transcript state.
|
|
17
|
+
*/
|
|
18
|
+
export type TranscriptionOptions = {
|
|
19
|
+
model: TranscriptModel<Message.Message>;
|
|
20
|
+
started?: Date;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Scrolling transcript with timestamps.
|
|
25
|
+
*/
|
|
26
|
+
export const transcription = ({ model, started }: TranscriptionOptions): Extension => {
|
|
27
|
+
return [
|
|
28
|
+
// Show timestamps in the gutter.
|
|
29
|
+
gutter({
|
|
30
|
+
class: 'cm-timestamp-gutter',
|
|
31
|
+
lineMarkerChange: (update) => update.docChanged || update.viewportChanged,
|
|
32
|
+
markers: (view) => {
|
|
33
|
+
const start = getStartTime(started, model.chunks[0]);
|
|
34
|
+
const builder = new RangeSetBuilder<GutterMarker>();
|
|
35
|
+
for (const { from, to } of view.visibleRanges) {
|
|
36
|
+
let line = view.state.doc.lineAt(from);
|
|
37
|
+
while (line.from <= to) {
|
|
38
|
+
const block = model.getChunkAtLine(line.number)?.blocks[0];
|
|
39
|
+
const timestamp = block?._tag === 'transcript' && block.started;
|
|
40
|
+
if (timestamp) {
|
|
41
|
+
builder.add(line.from, line.from, new TimestampMarker(line.number, new Date(timestamp), start));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (line.to + 1 > view.state.doc.length) {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
line = view.state.doc.lineAt(line.to + 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return builder.finish();
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
|
|
56
|
+
// Listen for model updates.
|
|
57
|
+
ViewPlugin.fromClass(
|
|
58
|
+
class {
|
|
59
|
+
private readonly _controls?: HTMLDivElement;
|
|
60
|
+
private readonly _cleanup: CleanupFn;
|
|
61
|
+
private readonly _adapter: EditorChunkDocument;
|
|
62
|
+
private _initialized = false;
|
|
63
|
+
|
|
64
|
+
constructor(view: EditorView) {
|
|
65
|
+
this._adapter = new EditorChunkDocument(view);
|
|
66
|
+
|
|
67
|
+
const scroller = view.scrollDOM;
|
|
68
|
+
let isAutoScrolling = false;
|
|
69
|
+
let hasScrolled = false;
|
|
70
|
+
|
|
71
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
72
|
+
const scrollToBottom = (smooth = false) => {
|
|
73
|
+
scroller.style.scrollBehavior = smooth ? 'smooth' : '';
|
|
74
|
+
|
|
75
|
+
// Temporarily hide scrollbar to prevent flicker.
|
|
76
|
+
scroller.classList.add('cm-hide-scrollbar');
|
|
77
|
+
isAutoScrolling = true;
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
timeout = setTimeout(() => {
|
|
80
|
+
this._controls?.classList.add('opacity-0');
|
|
81
|
+
scroller.classList.remove('cm-hide-scrollbar');
|
|
82
|
+
isAutoScrolling = false;
|
|
83
|
+
}, 500);
|
|
84
|
+
|
|
85
|
+
// Scroll to bottom.
|
|
86
|
+
view.dispatch({
|
|
87
|
+
effects: EditorView.scrollIntoView(view.state.doc.length, { y: 'end' }),
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Event listeners.
|
|
92
|
+
this._cleanup = combine(
|
|
93
|
+
addEventListener(view.scrollDOM, 'scroll', () => {
|
|
94
|
+
if (!isAutoScrolling) {
|
|
95
|
+
hasScrolled = true;
|
|
96
|
+
this._controls?.classList.remove('opacity-0');
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
|
|
100
|
+
model.update.on(() => {
|
|
101
|
+
// Check if clamped to bottom.
|
|
102
|
+
const autoScroll =
|
|
103
|
+
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight === 0 || !hasScrolled;
|
|
104
|
+
|
|
105
|
+
// Sync.
|
|
106
|
+
model.sync(this._adapter);
|
|
107
|
+
|
|
108
|
+
// Scroll.
|
|
109
|
+
if (autoScroll) {
|
|
110
|
+
scrollToBottom(true);
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
update(_update: ViewUpdate) {
|
|
117
|
+
// Initial sync.
|
|
118
|
+
if (!this._initialized) {
|
|
119
|
+
this._initialized = true;
|
|
120
|
+
setTimeout(() => {
|
|
121
|
+
model.sync(this._adapter);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
destroy() {
|
|
127
|
+
this._controls?.remove();
|
|
128
|
+
this._cleanup();
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
|
|
133
|
+
EditorView.theme({
|
|
134
|
+
'.cm-gutters': {
|
|
135
|
+
backgroundColor: 'var(--color-base-surface)',
|
|
136
|
+
},
|
|
137
|
+
'.cm-timestamp-gutter': {
|
|
138
|
+
width: '6rem',
|
|
139
|
+
paddingLeft: '0.5rem',
|
|
140
|
+
paddingRight: '1rem',
|
|
141
|
+
},
|
|
142
|
+
'.cm-timestamp-gutter > .cm-gutterElement > div': {
|
|
143
|
+
display: 'inline-flex',
|
|
144
|
+
textAlign: 'right',
|
|
145
|
+
padding: '3px',
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
];
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Gutter marker that displays a timestamp.
|
|
153
|
+
*/
|
|
154
|
+
class TimestampMarker extends GutterMarker {
|
|
155
|
+
constructor(
|
|
156
|
+
readonly _line: number,
|
|
157
|
+
readonly _timestamp: Date,
|
|
158
|
+
readonly _started?: Date,
|
|
159
|
+
) {
|
|
160
|
+
super();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
override eq(other: this) {
|
|
164
|
+
return this._timestamp === other._timestamp;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
override toDOM(view: EditorView) {
|
|
168
|
+
const el = document.createElement('div');
|
|
169
|
+
el.className = 'text-sm text-subdued hover:bg-hover-surface cursor-pointer';
|
|
170
|
+
el.textContent = formatTimestamp(this._timestamp, this._started);
|
|
171
|
+
// TODO(burdon): Click to bookmark or copy hyperlink.
|
|
172
|
+
el.onclick = () => {
|
|
173
|
+
const pos = view.state.doc.line(this._line).from;
|
|
174
|
+
view.dispatch({
|
|
175
|
+
effects: EditorView.scrollIntoView(pos, { y: 'start' }),
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return el;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const getStartTime = (started?: Date, message?: Message.Message): Date | undefined => {
|
|
184
|
+
if (started) {
|
|
185
|
+
return started;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (message?.blocks[0]?._tag === 'transcript' && message.blocks[0].started) {
|
|
189
|
+
return new Date(message.blocks[0].started);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return undefined;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const formatTimestamp = (timestamp: Date, relative?: Date) => {
|
|
196
|
+
if (relative) {
|
|
197
|
+
const pad = (n = 0) => String(n).padStart(2, '0');
|
|
198
|
+
const { hours, minutes, seconds } = intervalToDuration({ start: relative, end: timestamp });
|
|
199
|
+
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
|
200
|
+
} else {
|
|
201
|
+
return format(timestamp, 'HH:mm:ss');
|
|
202
|
+
}
|
|
203
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
export * from './useAudioFile';
|
|
6
|
+
export * from './useAudioTrack';
|
|
7
|
+
export * from './useFeedModelAdapter';
|
|
8
|
+
export * from './useIsSpeaking';
|
|
9
|
+
export * from './useRecordingPipeline';
|
|
10
|
+
export * from './useSpeechRecognition';
|
|
11
|
+
export * from './useTranscriber';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { scheduleTask } from '@dxos/async';
|
|
8
|
+
import { Context } from '@dxos/context';
|
|
9
|
+
import { log } from '@dxos/log';
|
|
10
|
+
|
|
11
|
+
export type UseAudioState = {
|
|
12
|
+
audio?: HTMLAudioElement;
|
|
13
|
+
stream?: MediaStream;
|
|
14
|
+
track?: MediaStreamTrack;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const useAudioFile = (audioUrl: string, constraints?: MediaTrackConstraints): UseAudioState => {
|
|
18
|
+
const [{ audio, stream, track }, setStream] = useState<UseAudioState>({});
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const ctx = new Context();
|
|
21
|
+
|
|
22
|
+
scheduleTask(ctx, async () => {
|
|
23
|
+
// TODO(wittjosiah): Fetch to external url fails in headless storybook test.
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(audioUrl);
|
|
26
|
+
const blob = await response.blob();
|
|
27
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
28
|
+
ctx.onDispose(() => {
|
|
29
|
+
URL.revokeObjectURL(objectUrl);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const audio = new Audio();
|
|
33
|
+
audio.src = objectUrl;
|
|
34
|
+
|
|
35
|
+
await new Promise<void>((resolve, reject) => {
|
|
36
|
+
audio.addEventListener(
|
|
37
|
+
'error',
|
|
38
|
+
(err) => {
|
|
39
|
+
log.error('error', { err });
|
|
40
|
+
reject(err);
|
|
41
|
+
},
|
|
42
|
+
{ once: true },
|
|
43
|
+
);
|
|
44
|
+
audio.addEventListener(
|
|
45
|
+
'canplay',
|
|
46
|
+
async () => {
|
|
47
|
+
resolve();
|
|
48
|
+
},
|
|
49
|
+
{ once: true },
|
|
50
|
+
);
|
|
51
|
+
audio.load();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const audioCtx = new AudioContext();
|
|
55
|
+
|
|
56
|
+
// Resume AudioContext if it's suspended.
|
|
57
|
+
if (audioCtx.state === 'suspended') {
|
|
58
|
+
await audioCtx.resume();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const destination = audioCtx.createMediaStreamDestination();
|
|
62
|
+
destination.channelCount = 1;
|
|
63
|
+
|
|
64
|
+
const source = audioCtx.createMediaElementSource(audio);
|
|
65
|
+
source.connect(destination);
|
|
66
|
+
const track = destination.stream.getAudioTracks()[0];
|
|
67
|
+
if (constraints) {
|
|
68
|
+
await track.applyConstraints(constraints).catch((err) => log.catch(err));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Also connect to speakers so audio is audible.
|
|
72
|
+
setStream({
|
|
73
|
+
audio,
|
|
74
|
+
stream: destination.stream,
|
|
75
|
+
track: destination.stream.getAudioTracks()[0],
|
|
76
|
+
});
|
|
77
|
+
} catch (err) {
|
|
78
|
+
log.catch(err);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
void ctx.dispose();
|
|
84
|
+
};
|
|
85
|
+
}, [audioUrl]);
|
|
86
|
+
|
|
87
|
+
return { audio, stream, track };
|
|
88
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Acquires a microphone audio track while `active`. Re-acquires when the `constraints` change (e.g.
|
|
9
|
+
* a different input device is selected) so the new device takes effect without a remount.
|
|
10
|
+
*/
|
|
11
|
+
// TODO(burdon): Reconcile with react-ui-experimental and plugin-calls.
|
|
12
|
+
export const useAudioTrack = (active?: boolean, constraints?: MediaTrackConstraints): MediaStreamTrack | undefined => {
|
|
13
|
+
const [track, setTrack] = useState<MediaStreamTrack>();
|
|
14
|
+
// Re-acquire on a value change, not on every re-created constraints object identity.
|
|
15
|
+
const constraintsKey = constraints ? JSON.stringify(constraints) : '';
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!active) {
|
|
18
|
+
track?.stop();
|
|
19
|
+
setTrack(undefined);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
let acquired: MediaStreamTrack | undefined;
|
|
25
|
+
// Drop any previous (now-stopped) track while reacquiring, so callers never hold a dead track.
|
|
26
|
+
setTrack(undefined);
|
|
27
|
+
const initAudio = async () => {
|
|
28
|
+
try {
|
|
29
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: constraints ?? true });
|
|
30
|
+
const [next] = stream.getAudioTracks();
|
|
31
|
+
if (cancelled || !next) {
|
|
32
|
+
next?.stop();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
acquired = next;
|
|
36
|
+
setTrack(next);
|
|
37
|
+
} catch {
|
|
38
|
+
// Permission denied / device unavailable: leave the track cleared rather than rejecting.
|
|
39
|
+
if (!cancelled) {
|
|
40
|
+
setTrack(undefined);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
void initAudio();
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
acquired?.stop();
|
|
50
|
+
};
|
|
51
|
+
// `constraints` is read via the serialized `constraintsKey`.
|
|
52
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
53
|
+
}, [active, constraintsKey]);
|
|
54
|
+
|
|
55
|
+
return track;
|
|
56
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect, useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
import type { Obj } from '@dxos/echo';
|
|
8
|
+
|
|
9
|
+
import { type ChunkRenderer, TranscriptModel } from '../model';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Feeds an array of objects (typically from `useQuery(db, Query.from(feed))`) into a
|
|
13
|
+
* `TranscriptModel`, appending each new tail item as the array grows.
|
|
14
|
+
*
|
|
15
|
+
* @param renderer - chunk renderer.
|
|
16
|
+
* @param objects - current snapshot of feed items.
|
|
17
|
+
* @param initialChunks - chunks to seed the model with on first render.
|
|
18
|
+
*/
|
|
19
|
+
export const useFeedModelAdapter = <T extends Obj.Unknown>(
|
|
20
|
+
renderer: ChunkRenderer<T>,
|
|
21
|
+
objects: readonly T[],
|
|
22
|
+
initialChunks: T[] = [],
|
|
23
|
+
): TranscriptModel<T> => {
|
|
24
|
+
const model = useMemo(() => new TranscriptModel<T>(renderer, initialChunks), [renderer]);
|
|
25
|
+
|
|
26
|
+
// Seed the model with the current snapshot on first render and whenever the
|
|
27
|
+
// identity of `objects` changes (e.g. feed swapped).
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
for (const chunk of objects) {
|
|
30
|
+
model.appendChunk(chunk);
|
|
31
|
+
}
|
|
32
|
+
}, [model, objects]);
|
|
33
|
+
|
|
34
|
+
// Append new tail items as the array grows.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (model.chunks.length === objects.length) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const chunk = objects.at(-1);
|
|
40
|
+
if (chunk) {
|
|
41
|
+
model.appendChunk(chunk);
|
|
42
|
+
}
|
|
43
|
+
}, [model, objects.length]);
|
|
44
|
+
|
|
45
|
+
return model;
|
|
46
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { scheduleTask } from '@dxos/async';
|
|
8
|
+
import { SpeakingMonitor } from '@dxos/av';
|
|
9
|
+
import { Context } from '@dxos/context';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tracks voice activity on an audio track via {@link SpeakingMonitor}.
|
|
13
|
+
*/
|
|
14
|
+
export const useIsSpeaking = (track?: MediaStreamTrack) => {
|
|
15
|
+
const [isSpeaking, setIsSpeaking] = useState(false);
|
|
16
|
+
const speakingMonitor = useMemo(() => {
|
|
17
|
+
if (!track) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return new SpeakingMonitor(track);
|
|
22
|
+
}, [track]);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!speakingMonitor) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ctx = new Context();
|
|
30
|
+
scheduleTask(ctx, async () => {
|
|
31
|
+
speakingMonitor.speakingChanged.on(ctx, () => setIsSpeaking(speakingMonitor.isSpeaking));
|
|
32
|
+
await speakingMonitor.open();
|
|
33
|
+
ctx.onDispose(() => speakingMonitor.close());
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return () => {
|
|
37
|
+
void ctx.dispose();
|
|
38
|
+
};
|
|
39
|
+
}, [speakingMonitor]);
|
|
40
|
+
|
|
41
|
+
return isSpeaking;
|
|
42
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { log } from '@dxos/log';
|
|
8
|
+
import {
|
|
9
|
+
type AsrPipeline,
|
|
10
|
+
type CommitFn,
|
|
11
|
+
type EntityLookup,
|
|
12
|
+
type Stage,
|
|
13
|
+
type TranscribeConfig,
|
|
14
|
+
runAsrPipeline,
|
|
15
|
+
} from '@dxos/transcription-pipeline';
|
|
16
|
+
import { type ContentBlock } from '@dxos/types';
|
|
17
|
+
|
|
18
|
+
import { MediaStreamRecorder } from '../capture';
|
|
19
|
+
import { type PipelinePhase } from '../types';
|
|
20
|
+
import { useAudioTrack } from './useAudioTrack';
|
|
21
|
+
|
|
22
|
+
// Recorder chunk interval; the transcriber's chunk threshold derives from this.
|
|
23
|
+
const RECORDER_INTERVAL_MS = 200;
|
|
24
|
+
|
|
25
|
+
// Silence reported on drain so on-silence stages (e.g. summarization) fire over the final window.
|
|
26
|
+
const DRAIN_SILENCE_MS = 5_000;
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TRANSCRIBE_CONFIG: TranscribeConfig = {
|
|
29
|
+
transcribeAfterChunksAmount: 50,
|
|
30
|
+
prefixBufferChunksAmount: 10,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type RecordingPipelineOptions = {
|
|
34
|
+
/** Override the transcriber chunk configuration. */
|
|
35
|
+
config?: Partial<TranscribeConfig>;
|
|
36
|
+
/** Re-segment ASR output into complete sentences before the pipeline (merges mid-sentence cuts). */
|
|
37
|
+
segmentSentences?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Raw capture flag — recording (mic) or playing (file). When it flips false the hook drains
|
|
40
|
+
* (flushing the transcriber's buffered audio) before returning to idle.
|
|
41
|
+
*/
|
|
42
|
+
active: boolean;
|
|
43
|
+
/** Use the microphone as the ASR source; the hook owns the track and keeps it alive through drain. */
|
|
44
|
+
microphone?: boolean;
|
|
45
|
+
/** Externally-supplied track (file source). Used only when `microphone` is false. */
|
|
46
|
+
track?: MediaStreamTrack;
|
|
47
|
+
/** Audio constraints for the microphone source. */
|
|
48
|
+
audioConstraints?: MediaTrackConstraints;
|
|
49
|
+
/** Ordered pipeline stages applied to captured blocks. */
|
|
50
|
+
stages: readonly Stage<any, any>[];
|
|
51
|
+
/** Commit sink for enriched stage output. */
|
|
52
|
+
commit: CommitFn;
|
|
53
|
+
/** Optional entity-reference resolver injected into stages. */
|
|
54
|
+
lookup?: EntityLookup;
|
|
55
|
+
/** Optional raw-segment hook (pre-pipeline), e.g. for live display before enrichment. */
|
|
56
|
+
onSegment?: (block: ContentBlock.Transcript) => void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type RecordingPipeline = {
|
|
60
|
+
/** Lifecycle phase (idle → recording → draining → idle). */
|
|
61
|
+
phase: PipelinePhase;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Single ASR-capture engine binding for React consumers: drives an {@link AsrPipeline}
|
|
66
|
+
* ({@link runAsrPipeline}) off a capture flag, sourcing audio from the microphone (owned here) or an
|
|
67
|
+
* external track. The editor driver, message-list recording, and stories all build on this — varying
|
|
68
|
+
* only by source, stages, and commit sink.
|
|
69
|
+
*/
|
|
70
|
+
export const useRecordingPipeline = ({
|
|
71
|
+
active,
|
|
72
|
+
microphone,
|
|
73
|
+
track: externalTrack,
|
|
74
|
+
audioConstraints,
|
|
75
|
+
stages,
|
|
76
|
+
commit,
|
|
77
|
+
lookup,
|
|
78
|
+
onSegment,
|
|
79
|
+
config,
|
|
80
|
+
segmentSentences,
|
|
81
|
+
}: RecordingPipelineOptions): RecordingPipeline => {
|
|
82
|
+
// Phase machine: hold 'recording' for the commit when `active` flips off, then 'draining' until the
|
|
83
|
+
// transcriber flushes, then 'idle' — so the tail of speech and its enrichment are not lost.
|
|
84
|
+
const [phase, setPhase] = useState<PipelinePhase>('idle');
|
|
85
|
+
const inUse = phase !== 'idle';
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
setPhase((current) => (active ? 'recording' : current === 'recording' ? 'draining' : current));
|
|
88
|
+
}, [active]);
|
|
89
|
+
|
|
90
|
+
// Keep the mic alive through the drain; external (file) tracks are owned by the caller.
|
|
91
|
+
const micTrack = useAudioTrack(!!microphone && inUse, audioConstraints);
|
|
92
|
+
const track = microphone ? micTrack : externalTrack;
|
|
93
|
+
|
|
94
|
+
// A fresh recorder per track so a stopped (encoder-flushed) instance is never reused.
|
|
95
|
+
const recorder = useMemo(
|
|
96
|
+
() =>
|
|
97
|
+
track
|
|
98
|
+
? new MediaStreamRecorder({ mediaStreamTrack: track, config: { interval: RECORDER_INTERVAL_MS } })
|
|
99
|
+
: undefined,
|
|
100
|
+
[track],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const pipelineRef = useRef<AsrPipeline | null>(null);
|
|
104
|
+
|
|
105
|
+
// Start a live ASR pipeline session when recording. Stages/commit/lookup are captured for the
|
|
106
|
+
// lifetime of the session (a config change mid-recording would orphan the run).
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (phase !== 'recording' || !recorder) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const asr = runAsrPipeline({
|
|
112
|
+
recorder,
|
|
113
|
+
config: { ...DEFAULT_TRANSCRIBE_CONFIG, ...config },
|
|
114
|
+
segmentSentences,
|
|
115
|
+
stages,
|
|
116
|
+
commit,
|
|
117
|
+
lookup,
|
|
118
|
+
onSegment,
|
|
119
|
+
drainSilenceMs: DRAIN_SILENCE_MS,
|
|
120
|
+
});
|
|
121
|
+
pipelineRef.current = asr;
|
|
122
|
+
let cancelled = false;
|
|
123
|
+
void asr.open().then(
|
|
124
|
+
() => {
|
|
125
|
+
if (!cancelled) {
|
|
126
|
+
asr.start();
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
(err) => {
|
|
130
|
+
if (!cancelled) {
|
|
131
|
+
log.catch(err);
|
|
132
|
+
setPhase('idle');
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
return () => {
|
|
137
|
+
cancelled = true;
|
|
138
|
+
};
|
|
139
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
140
|
+
}, [phase, recorder]);
|
|
141
|
+
|
|
142
|
+
// Drain on stop: flush buffered audio, signal silence, end the session.
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (phase !== 'draining') {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
let cancelled = false;
|
|
148
|
+
void (async () => {
|
|
149
|
+
try {
|
|
150
|
+
await pipelineRef.current?.end();
|
|
151
|
+
} catch (err) {
|
|
152
|
+
log.catch(err);
|
|
153
|
+
} finally {
|
|
154
|
+
pipelineRef.current = null;
|
|
155
|
+
if (!cancelled) {
|
|
156
|
+
setPhase((current) => (current === 'draining' ? 'idle' : current));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
return () => {
|
|
161
|
+
cancelled = true;
|
|
162
|
+
};
|
|
163
|
+
}, [phase]);
|
|
164
|
+
|
|
165
|
+
return { phase };
|
|
166
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect, useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
import { log } from '@dxos/log';
|
|
8
|
+
|
|
9
|
+
const cleanText = (text: string) =>
|
|
10
|
+
text
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[.,!?]/g, '')
|
|
13
|
+
.replace(/\s+/g, ' ')
|
|
14
|
+
.trim();
|
|
15
|
+
|
|
16
|
+
export type UseSpeechRecognitionProps = {
|
|
17
|
+
active: boolean;
|
|
18
|
+
onTranscript: (transcript: string) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Use browser speech recognition API to transcribe speech to text.
|
|
23
|
+
*/
|
|
24
|
+
export const useSpeechRecognition = ({ active, onTranscript }: UseSpeechRecognitionProps) => {
|
|
25
|
+
const recognition = useMemo(() => {
|
|
26
|
+
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
|
27
|
+
const SpeechGrammarList = (window as any).SpeechGrammarList || (window as any).webkitSpeechGrammarList;
|
|
28
|
+
if (!SpeechRecognition || !SpeechGrammarList) {
|
|
29
|
+
log.error('Speech recognition not supported');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return Object.assign(new SpeechRecognition(), {
|
|
34
|
+
grammars: new SpeechGrammarList(),
|
|
35
|
+
continuous: true,
|
|
36
|
+
interimResults: true,
|
|
37
|
+
lang: 'en-US',
|
|
38
|
+
maxAlternatives: 1,
|
|
39
|
+
onresult: (event: any) => {
|
|
40
|
+
const transcript = cleanText(event.results[event.resultIndex][0].transcript);
|
|
41
|
+
onTranscript(transcript);
|
|
42
|
+
},
|
|
43
|
+
onerror: (event: any) => {
|
|
44
|
+
log.error('Speech recognition error', { error: event.error });
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}, [onTranscript]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (active) {
|
|
51
|
+
recognition?.start();
|
|
52
|
+
} else {
|
|
53
|
+
recognition?.stop();
|
|
54
|
+
}
|
|
55
|
+
}, [active, recognition]);
|
|
56
|
+
};
|