@dxos/plugin-transformer 0.7.5-staging.2ff1350
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 +8 -0
- package/README.md +15 -0
- package/dist/lib/browser/index.mjs +52 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/browser/types/index.mjs +1 -0
- package/dist/lib/browser/types/index.mjs.map +7 -0
- package/dist/lib/node/index.cjs +71 -0
- package/dist/lib/node/index.cjs.map +7 -0
- package/dist/lib/node/meta.json +1 -0
- package/dist/lib/node/types/index.cjs +2 -0
- package/dist/lib/node/types/index.cjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +54 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/lib/node-esm/types/index.mjs +2 -0
- package/dist/lib/node-esm/types/index.mjs.map +7 -0
- package/dist/types/src/TransformerPlugin.d.ts +2 -0
- package/dist/types/src/TransformerPlugin.d.ts.map +1 -0
- package/dist/types/src/capabilities/index.d.ts +1 -0
- package/dist/types/src/capabilities/index.d.ts.map +1 -0
- package/dist/types/src/components/DebugInfo.d.ts +14 -0
- package/dist/types/src/components/DebugInfo.d.ts.map +1 -0
- package/dist/types/src/components/Voice.d.ts +7 -0
- package/dist/types/src/components/Voice.d.ts.map +1 -0
- package/dist/types/src/components/Voice.stories.d.ts +8 -0
- package/dist/types/src/components/Voice.stories.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +3 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useAudioStream.d.ts +12 -0
- package/dist/types/src/hooks/useAudioStream.d.ts.map +1 -0
- package/dist/types/src/hooks/usePipeline.d.ts +41 -0
- package/dist/types/src/hooks/usePipeline.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/meta.d.ts +10 -0
- package/dist/types/src/meta.d.ts.map +1 -0
- package/dist/types/src/testing/model.test.d.ts +1 -0
- package/dist/types/src/testing/model.test.d.ts.map +1 -0
- package/dist/types/src/testing/node-pipeline.d.ts +12 -0
- package/dist/types/src/testing/node-pipeline.d.ts.map +1 -0
- package/dist/types/src/testing/pipeline.d.ts +28 -0
- package/dist/types/src/testing/pipeline.d.ts.map +1 -0
- package/dist/types/src/testing/pipeline.test.d.ts +2 -0
- package/dist/types/src/testing/pipeline.test.d.ts.map +1 -0
- package/dist/types/src/testing/web-pipeline.d.ts +12 -0
- package/dist/types/src/testing/web-pipeline.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +9 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +1 -0
- package/dist/types/src/types/index.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +80 -0
- package/src/TransformerPlugin.tsx +34 -0
- package/src/capabilities/index.ts +3 -0
- package/src/components/DebugInfo.tsx +79 -0
- package/src/components/Voice.stories.tsx +32 -0
- package/src/components/Voice.tsx +110 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useAudioStream.ts +252 -0
- package/src/hooks/usePipeline.ts +153 -0
- package/src/index.ts +7 -0
- package/src/meta.ts +16 -0
- package/src/testing/model.test.ts +3 -0
- package/src/testing/node-pipeline.ts +35 -0
- package/src/testing/pipeline.test.ts +90 -0
- package/src/testing/pipeline.ts +74 -0
- package/src/testing/web-pipeline.ts +46 -0
- package/src/translations.ts +15 -0
- package/src/types/index.ts +3 -0
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dxos/plugin-transformer",
|
|
3
|
+
"version": "0.7.5-staging.2ff1350",
|
|
4
|
+
"description": "Client transformer",
|
|
5
|
+
"homepage": "https://dxos.org",
|
|
6
|
+
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "DXOS.org",
|
|
9
|
+
"sideEffects": true,
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/types/src/index.d.ts",
|
|
14
|
+
"browser": "./dist/lib/browser/index.mjs",
|
|
15
|
+
"node": "./dist/lib/node-esm/index.mjs"
|
|
16
|
+
},
|
|
17
|
+
"./types": {
|
|
18
|
+
"types": "./dist/types/src/types/index.d.ts",
|
|
19
|
+
"browser": "./dist/lib/browser/types/index.mjs",
|
|
20
|
+
"node": "./dist/lib/node-esm/types/index.mjs"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"types": "dist/types/src/index.d.ts",
|
|
24
|
+
"typesVersions": {
|
|
25
|
+
"*": {
|
|
26
|
+
"types": [
|
|
27
|
+
"dist/types/src/types/index.d.ts"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@effect/schema": "^0.75.5",
|
|
37
|
+
"@huggingface/transformers": "^3.3.3",
|
|
38
|
+
"@preact/signals-core": "^1.6.0",
|
|
39
|
+
"date-fns": "^3.3.1",
|
|
40
|
+
"lodash.get": "^4.4.2",
|
|
41
|
+
"@dxos/async": "0.7.5-staging.2ff1350",
|
|
42
|
+
"@dxos/app-framework": "0.7.5-staging.2ff1350",
|
|
43
|
+
"@dxos/display-name": "0.7.5-staging.2ff1350",
|
|
44
|
+
"@dxos/echo-schema": "0.7.5-staging.2ff1350",
|
|
45
|
+
"@dxos/invariant": "0.7.5-staging.2ff1350",
|
|
46
|
+
"@dxos/live-object": "0.7.5-staging.2ff1350",
|
|
47
|
+
"@dxos/log": "0.7.5-staging.2ff1350",
|
|
48
|
+
"@dxos/plugin-graph": "0.7.5-staging.2ff1350",
|
|
49
|
+
"@dxos/plugin-client": "0.7.5-staging.2ff1350",
|
|
50
|
+
"@dxos/react-client": "0.7.5-staging.2ff1350",
|
|
51
|
+
"@dxos/plugin-space": "0.7.5-staging.2ff1350",
|
|
52
|
+
"@dxos/react-ui-attention": "0.7.5-staging.2ff1350",
|
|
53
|
+
"@dxos/react-ui-stack": "0.7.5-staging.2ff1350",
|
|
54
|
+
"@dxos/util": "0.7.5-staging.2ff1350"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@phosphor-icons/react": "^2.1.5",
|
|
58
|
+
"@types/lodash.get": "^4.4.7",
|
|
59
|
+
"@types/react": "~18.2.0",
|
|
60
|
+
"@types/react-dom": "~18.2.0",
|
|
61
|
+
"@xenova/transformers": "^2.17.2",
|
|
62
|
+
"react": "~18.2.0",
|
|
63
|
+
"react-dom": "~18.2.0",
|
|
64
|
+
"vite": "5.4.7",
|
|
65
|
+
"@dxos/random": "0.7.5-staging.2ff1350",
|
|
66
|
+
"@dxos/react-ui": "0.7.5-staging.2ff1350",
|
|
67
|
+
"@dxos/react-ui-theme": "0.7.5-staging.2ff1350",
|
|
68
|
+
"@dxos/storybook-utils": "0.7.5-staging.2ff1350"
|
|
69
|
+
},
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"@phosphor-icons/react": "^2.1.5",
|
|
72
|
+
"react": "~18.2.0",
|
|
73
|
+
"react-dom": "~18.2.0",
|
|
74
|
+
"@dxos/react-ui": "0.7.5-staging.2ff1350",
|
|
75
|
+
"@dxos/react-ui-theme": "0.7.5-staging.2ff1350"
|
|
76
|
+
},
|
|
77
|
+
"publishConfig": {
|
|
78
|
+
"access": "public"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Capabilities, contributes, defineModule, definePlugin, Events } from '@dxos/app-framework';
|
|
6
|
+
import { ClientCapabilities, ClientEvents } from '@dxos/plugin-client';
|
|
7
|
+
|
|
8
|
+
// import { IntentResolver } from './capabilities';
|
|
9
|
+
import { meta } from './meta';
|
|
10
|
+
import translations from './translations';
|
|
11
|
+
|
|
12
|
+
export const TransformerPlugin = () =>
|
|
13
|
+
definePlugin(meta, [
|
|
14
|
+
defineModule({
|
|
15
|
+
id: `${meta.id}/module/translations`,
|
|
16
|
+
activatesOn: Events.SetupTranslations,
|
|
17
|
+
activate: () => contributes(Capabilities.Translations, translations),
|
|
18
|
+
}),
|
|
19
|
+
defineModule({
|
|
20
|
+
id: `${meta.id}/module/metadata`,
|
|
21
|
+
activatesOn: Events.SetupMetadata,
|
|
22
|
+
activate: () => [],
|
|
23
|
+
}),
|
|
24
|
+
defineModule({
|
|
25
|
+
id: `${meta.id}/module/schema`,
|
|
26
|
+
activatesOn: ClientEvents.SetupSchema,
|
|
27
|
+
activate: () => contributes(ClientCapabilities.Schema, []),
|
|
28
|
+
}),
|
|
29
|
+
// defineModule({
|
|
30
|
+
// id: `${meta.id}/module/intent-resolver`,
|
|
31
|
+
// activatesOn: Events.SetupIntentResolver,
|
|
32
|
+
// activate: IntentResolver,
|
|
33
|
+
// }),
|
|
34
|
+
]);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { type FC } from 'react';
|
|
6
|
+
|
|
7
|
+
export type DebugInfoProps = {
|
|
8
|
+
error: string;
|
|
9
|
+
isModelLoading: boolean;
|
|
10
|
+
stream: MediaStream | null;
|
|
11
|
+
isTranscribing: boolean;
|
|
12
|
+
transcription: string;
|
|
13
|
+
audioLevel: number;
|
|
14
|
+
gpuInfo: string;
|
|
15
|
+
model: string;
|
|
16
|
+
debug: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const DebugInfo: FC<Partial<DebugInfoProps>> = ({
|
|
20
|
+
error,
|
|
21
|
+
isModelLoading,
|
|
22
|
+
stream,
|
|
23
|
+
isTranscribing,
|
|
24
|
+
transcription,
|
|
25
|
+
audioLevel,
|
|
26
|
+
gpuInfo,
|
|
27
|
+
model,
|
|
28
|
+
debug = false,
|
|
29
|
+
}) => {
|
|
30
|
+
return (
|
|
31
|
+
<div className='p-4'>
|
|
32
|
+
{error && (
|
|
33
|
+
<div className='mb-4 text-red-600'>
|
|
34
|
+
<strong>Error:</strong> {error}
|
|
35
|
+
</div>
|
|
36
|
+
)}
|
|
37
|
+
{isModelLoading && (
|
|
38
|
+
<div className='mb-4'>
|
|
39
|
+
<div>Loading model...</div>
|
|
40
|
+
<div className='text-sm text-gray-500'>This may take a few moments</div>
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
{stream ? (
|
|
44
|
+
<div>
|
|
45
|
+
<div className='mb-2 text-green-600'>
|
|
46
|
+
<strong>Status:</strong> Microphone is active
|
|
47
|
+
{debug && audioLevel && (
|
|
48
|
+
<div className='mt-2 w-48 h-5 bg-gray-200 rounded relative'>
|
|
49
|
+
<div
|
|
50
|
+
className='h-full bg-green-500 transition-all duration-100 rounded'
|
|
51
|
+
style={{ width: `${(audioLevel / 255) * 100}%` }}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
{isTranscribing && <div className='mb-2 text-gray-500'>Processing audio...</div>}
|
|
57
|
+
{debug && (
|
|
58
|
+
<div className='mb-4 text-sm text-gray-500 space-y-1'>
|
|
59
|
+
<div>Model: {model}</div>
|
|
60
|
+
<div>Sample Rate: 16000 Hz</div>
|
|
61
|
+
<div>Format: audio/wav</div>
|
|
62
|
+
<div>Chunk Size: 10 seconds</div>
|
|
63
|
+
<div>GPU: {gpuInfo || 'Not available'}</div>
|
|
64
|
+
<div>Backend: WebGPU</div>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
{transcription && (
|
|
68
|
+
<div className='mt-4'>
|
|
69
|
+
<strong>Transcription:</strong>
|
|
70
|
+
<p className='mt-2 p-4 bg-gray-100 rounded whitespace-pre-wrap'>{transcription}</p>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
) : (
|
|
75
|
+
<div>{!isModelLoading && !error && <div className='text-gray-500'>Microphone is inactive</div>}</div>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxos-theme';
|
|
6
|
+
|
|
7
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
8
|
+
|
|
9
|
+
import { withLayout, withTheme } from '@dxos/storybook-utils';
|
|
10
|
+
|
|
11
|
+
import { Voice } from './Voice';
|
|
12
|
+
|
|
13
|
+
const meta: Meta<typeof Voice> = {
|
|
14
|
+
title: 'plugins/plugin-transformer/Voice',
|
|
15
|
+
component: Voice,
|
|
16
|
+
decorators: [withTheme, withLayout()],
|
|
17
|
+
parameters: {
|
|
18
|
+
layout: 'centered',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
|
|
24
|
+
type Story = StoryObj<typeof Voice>;
|
|
25
|
+
|
|
26
|
+
export const Default: Story = {
|
|
27
|
+
args: {
|
|
28
|
+
debug: true,
|
|
29
|
+
active: true,
|
|
30
|
+
model: 'Xenova/whisper-tiny',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { useState, useCallback, useEffect } from 'react';
|
|
6
|
+
|
|
7
|
+
import { log } from '@dxos/log';
|
|
8
|
+
|
|
9
|
+
import { DebugInfo } from './DebugInfo';
|
|
10
|
+
import { useAudioStream, usePipeline } from '../hooks';
|
|
11
|
+
|
|
12
|
+
export type VoiceProps = {
|
|
13
|
+
active?: boolean;
|
|
14
|
+
debug?: boolean;
|
|
15
|
+
model?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const Voice = ({ active, debug, model = 'Xenova/whisper-base' }: VoiceProps) => {
|
|
19
|
+
const [isTranscribing, setIsTranscribing] = useState(false);
|
|
20
|
+
const [transcription, setTranscription] = useState<string>('');
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
transcribe,
|
|
24
|
+
gpuInfo,
|
|
25
|
+
isLoaded: isModelLoaded,
|
|
26
|
+
isLoading: isModelLoading,
|
|
27
|
+
error: pipelineError,
|
|
28
|
+
} = usePipeline({ active, debug, model });
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
stream,
|
|
32
|
+
error: audioError,
|
|
33
|
+
audioLevel,
|
|
34
|
+
} = useAudioStream({
|
|
35
|
+
active,
|
|
36
|
+
debug,
|
|
37
|
+
// onAudioData: handleAudioData
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const handleAudioData = useCallback(
|
|
41
|
+
async (audioData: Float32Array) => {
|
|
42
|
+
if (!isModelLoaded) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isTranscribing) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setIsTranscribing(true);
|
|
51
|
+
try {
|
|
52
|
+
const result = await transcribe(audioData, {
|
|
53
|
+
sampling_rate: 16000,
|
|
54
|
+
chunk_length_s: 5,
|
|
55
|
+
stride_length_s: 1,
|
|
56
|
+
return_timestamps: false,
|
|
57
|
+
language: 'english',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (result?.text?.trim()) {
|
|
61
|
+
setTranscription((prev) => prev + ' ' + result.text);
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
log.error('transcription error', { err });
|
|
65
|
+
throw err;
|
|
66
|
+
} finally {
|
|
67
|
+
setIsTranscribing(false);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
[transcribe, isTranscribing],
|
|
71
|
+
);
|
|
72
|
+
log.info('handleAudioData', { handleAudioData });
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (debug) {
|
|
76
|
+
log.info('audio state', {
|
|
77
|
+
hasStream: !!stream,
|
|
78
|
+
audioError,
|
|
79
|
+
audioLevel,
|
|
80
|
+
shouldBeActive: active && isModelLoaded,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}, [debug, stream, audioError, audioLevel, active, isModelLoaded]);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (debug) {
|
|
87
|
+
log.info('transcription state', {
|
|
88
|
+
active,
|
|
89
|
+
isModelLoaded,
|
|
90
|
+
isModelLoading,
|
|
91
|
+
isTranscribing,
|
|
92
|
+
pipelineError,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}, [active, debug, isModelLoaded, isModelLoading, pipelineError, isTranscribing]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<DebugInfo
|
|
99
|
+
error={audioError || pipelineError || undefined}
|
|
100
|
+
isModelLoading={isModelLoading}
|
|
101
|
+
stream={stream}
|
|
102
|
+
isTranscribing={isTranscribing}
|
|
103
|
+
transcription={transcription}
|
|
104
|
+
audioLevel={audioLevel}
|
|
105
|
+
gpuInfo={gpuInfo}
|
|
106
|
+
model={model}
|
|
107
|
+
debug={debug}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
6
|
+
|
|
7
|
+
import { log } from '@dxos/log';
|
|
8
|
+
|
|
9
|
+
export type AudioStreamConfig = {
|
|
10
|
+
active?: boolean;
|
|
11
|
+
debug?: boolean;
|
|
12
|
+
onAudioData?: (audioData: Float32Array) => Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type AudioStreamState = {
|
|
16
|
+
stream: MediaStream | null;
|
|
17
|
+
error: string | null;
|
|
18
|
+
audioLevel: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const useAudioStream = ({ active, debug, onAudioData }: AudioStreamConfig) => {
|
|
22
|
+
const [state, setState] = useState<AudioStreamState>({
|
|
23
|
+
stream: null,
|
|
24
|
+
error: null,
|
|
25
|
+
audioLevel: 0,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// TODO(burdon): Convert to class.
|
|
29
|
+
const audioContextRef = useRef<AudioContext | null>(null);
|
|
30
|
+
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
31
|
+
const animationFrameRef = useRef<number>();
|
|
32
|
+
const workletNodeRef = useRef<AudioWorkletNode | null>(null);
|
|
33
|
+
const isProcessingRef = useRef(false);
|
|
34
|
+
const mediaStreamRef = useRef<MediaStream | null>(null);
|
|
35
|
+
const audioBufferRef = useRef<Float32Array[]>([]);
|
|
36
|
+
|
|
37
|
+
// Stats for visualization.
|
|
38
|
+
const updateAudioLevel = useCallback(() => {
|
|
39
|
+
if (analyserRef.current) {
|
|
40
|
+
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
|
41
|
+
analyserRef.current.getByteFrequencyData(dataArray);
|
|
42
|
+
const average = dataArray.reduce((acc, val) => acc + val, 0) / dataArray.length;
|
|
43
|
+
setState((prev) => ({ ...prev, audioLevel: average }));
|
|
44
|
+
animationFrameRef.current = requestAnimationFrame(updateAudioLevel);
|
|
45
|
+
}
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const cleanup = useCallback(() => {
|
|
49
|
+
log('cleaning up audio resources');
|
|
50
|
+
|
|
51
|
+
// Stop all tracks.
|
|
52
|
+
if (mediaStreamRef.current) {
|
|
53
|
+
mediaStreamRef.current.getTracks().forEach((track) => {
|
|
54
|
+
track.stop();
|
|
55
|
+
track.enabled = false;
|
|
56
|
+
});
|
|
57
|
+
mediaStreamRef.current = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Disconnect and cleanup audio nodes.
|
|
61
|
+
if (workletNodeRef.current) {
|
|
62
|
+
workletNodeRef.current.disconnect();
|
|
63
|
+
workletNodeRef.current = null;
|
|
64
|
+
}
|
|
65
|
+
if (analyserRef.current) {
|
|
66
|
+
analyserRef.current.disconnect();
|
|
67
|
+
analyserRef.current = null;
|
|
68
|
+
}
|
|
69
|
+
if (audioContextRef.current) {
|
|
70
|
+
void audioContextRef.current.close();
|
|
71
|
+
audioContextRef.current = null;
|
|
72
|
+
}
|
|
73
|
+
if (animationFrameRef.current) {
|
|
74
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
75
|
+
animationFrameRef.current = undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
audioBufferRef.current = [];
|
|
79
|
+
setState({
|
|
80
|
+
stream: null,
|
|
81
|
+
error: null,
|
|
82
|
+
audioLevel: 0,
|
|
83
|
+
});
|
|
84
|
+
}, [debug]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
let mounted = true;
|
|
88
|
+
|
|
89
|
+
const startStream = async () => {
|
|
90
|
+
try {
|
|
91
|
+
if (active) {
|
|
92
|
+
cleanup();
|
|
93
|
+
log.info('initializing audio stream...');
|
|
94
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
95
|
+
audio: {
|
|
96
|
+
channelCount: 1,
|
|
97
|
+
sampleRate: 16_000,
|
|
98
|
+
echoCancellation: true,
|
|
99
|
+
noiseSuppression: true,
|
|
100
|
+
autoGainControl: true,
|
|
101
|
+
},
|
|
102
|
+
video: false,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!mounted || !active) {
|
|
106
|
+
stream.getTracks().forEach((track) => {
|
|
107
|
+
track.stop();
|
|
108
|
+
track.enabled = false;
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
mediaStreamRef.current = stream;
|
|
114
|
+
|
|
115
|
+
// Create AudioContext for proper audio format.
|
|
116
|
+
const context = new AudioContext({ sampleRate: 16_000 });
|
|
117
|
+
|
|
118
|
+
// Add the audio worklet module.
|
|
119
|
+
await context.audioWorklet.addModule(
|
|
120
|
+
URL.createObjectURL(
|
|
121
|
+
new Blob(
|
|
122
|
+
[
|
|
123
|
+
`class AudioProcessor extends AudioWorkletProcessor {
|
|
124
|
+
constructor() {
|
|
125
|
+
super();
|
|
126
|
+
this._buffer = [];
|
|
127
|
+
this._samplesProcessed = 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
process(inputs, outputs) {
|
|
131
|
+
const input = inputs[0];
|
|
132
|
+
const channel = input[0];
|
|
133
|
+
|
|
134
|
+
if (channel) {
|
|
135
|
+
this._buffer.push(new Float32Array(channel));
|
|
136
|
+
this._samplesProcessed += channel.length;
|
|
137
|
+
|
|
138
|
+
// Process every 2 seconds (32000 samples at 16kHz).
|
|
139
|
+
if (this._samplesProcessed >= 32000) {
|
|
140
|
+
const combinedLength = this._buffer.reduce((acc, curr) => acc + curr.length, 0);
|
|
141
|
+
const combinedAudio = new Float32Array(combinedLength);
|
|
142
|
+
let offset = 0;
|
|
143
|
+
|
|
144
|
+
for (const buffer of this._buffer) {
|
|
145
|
+
combinedAudio.set(buffer, offset);
|
|
146
|
+
offset += buffer.length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.port.postMessage({ type: 'audio-data', data: combinedAudio });
|
|
150
|
+
|
|
151
|
+
// Reset buffer and counter.
|
|
152
|
+
this._buffer = [];
|
|
153
|
+
this._samplesProcessed = 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
registerProcessor('audio-processor', AudioProcessor);`,
|
|
161
|
+
],
|
|
162
|
+
{ type: 'application/javascript' },
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const source = context.createMediaStreamSource(stream);
|
|
168
|
+
const analyser = context.createAnalyser();
|
|
169
|
+
analyserRef.current = analyser;
|
|
170
|
+
|
|
171
|
+
// Create and connect the audio worklet node.
|
|
172
|
+
const workletNode = new AudioWorkletNode(context, 'audio-processor');
|
|
173
|
+
workletNodeRef.current = workletNode;
|
|
174
|
+
|
|
175
|
+
workletNode.port.onmessage = async (event) => {
|
|
176
|
+
if (!mounted || !active) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (event.data.type === 'audio-data') {
|
|
181
|
+
isProcessingRef.current = true;
|
|
182
|
+
try {
|
|
183
|
+
log('processing audio', {
|
|
184
|
+
sampleRate: context.sampleRate,
|
|
185
|
+
length: event.data.data.length,
|
|
186
|
+
min: Math.min(...event.data.data),
|
|
187
|
+
max: Math.max(...event.data.data),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await onAudioData?.(event.data.data);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (mounted) {
|
|
193
|
+
setState((prev) => ({
|
|
194
|
+
...prev,
|
|
195
|
+
error: 'Error processing audio: ' + (err as Error).message,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
log.error('audio processing error', { err });
|
|
199
|
+
} finally {
|
|
200
|
+
isProcessingRef.current = false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Connect the audio nodes.
|
|
206
|
+
source.connect(analyser);
|
|
207
|
+
analyser.connect(workletNode);
|
|
208
|
+
workletNode.connect(context.destination);
|
|
209
|
+
|
|
210
|
+
if (debug) {
|
|
211
|
+
analyser.fftSize = 256;
|
|
212
|
+
updateAudioLevel();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
audioContextRef.current = context;
|
|
216
|
+
if (mounted && active) {
|
|
217
|
+
setState({
|
|
218
|
+
stream,
|
|
219
|
+
error: null,
|
|
220
|
+
audioLevel: 0,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (mounted) {
|
|
226
|
+
setState((prev) => ({
|
|
227
|
+
...prev,
|
|
228
|
+
error: 'Error accessing microphone: ' + (err as Error).message,
|
|
229
|
+
stream: null,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
log.error('microphone error', { err });
|
|
233
|
+
cleanup();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
void startStream();
|
|
238
|
+
|
|
239
|
+
return () => {
|
|
240
|
+
mounted = false;
|
|
241
|
+
cleanup();
|
|
242
|
+
};
|
|
243
|
+
}, [active, debug, onAudioData, updateAudioLevel, cleanup]);
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (!active) {
|
|
247
|
+
cleanup();
|
|
248
|
+
}
|
|
249
|
+
}, [active, cleanup]);
|
|
250
|
+
|
|
251
|
+
return state;
|
|
252
|
+
};
|