@deepseek-ai/dsh-experimental-client-ui-voice-input 0.2.2-alpha.3 → 0.2.2-rc.2
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/lib/client.js +1 -3710
- package/lib/client.js.map +1 -0
- package/lib/types/client/MicrophonePicker.d.ts.map +1 -0
- package/lib/types/client/MicrophonePicker.js.map +1 -0
- package/lib/types/client/PreparationCard.d.ts.map +1 -0
- package/lib/types/client/PreparationCard.js.map +1 -0
- package/lib/types/client/VoiceInput.d.ts.map +1 -0
- package/lib/types/client/VoiceInput.js.map +1 -0
- package/lib/types/client/VoiceSetupDialog.d.ts.map +1 -0
- package/lib/types/client/VoiceSetupDialog.js.map +1 -0
- package/lib/types/client/VoiceSetupPrompt.d.ts.map +1 -0
- package/lib/types/client/VoiceSetupPrompt.js.map +1 -0
- package/lib/types/client/Waveform.d.ts.map +1 -0
- package/lib/types/client/Waveform.js.map +1 -0
- package/lib/types/client/audio.d.ts.map +1 -0
- package/lib/types/client/audio.js.map +1 -0
- package/lib/types/client/failure-text.d.ts.map +1 -0
- package/lib/types/client/failure-text.js.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js.map +1 -0
- package/lib/types/client/microphone-device.d.ts.map +1 -0
- package/lib/types/client/microphone-device.js.map +1 -0
- package/lib/types/client/mount.d.ts.map +1 -0
- package/lib/types/client/mount.js.map +1 -0
- package/lib/types/client/readiness.d.ts.map +1 -0
- package/lib/types/client/readiness.js.map +1 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js.map +1 -0
- package/package.json +23 -20
- package/src/client/MicrophonePicker.tsx +140 -0
- package/src/client/PreparationCard.tsx +165 -0
- package/src/client/VoiceInput.module.css +55 -0
- package/src/client/VoiceInput.tsx +182 -0
- package/src/client/VoiceSetupDialog.tsx +32 -0
- package/src/client/VoiceSetupPrompt.tsx +27 -0
- package/src/client/Waveform.tsx +33 -0
- package/src/client/audio.ts +188 -0
- package/src/client/failure-text.ts +15 -0
- package/src/client/index.ts +15 -0
- package/src/client/locales.ts +834 -0
- package/src/client/microphone-device.ts +20 -0
- package/src/client/mount.ts +77 -0
- package/src/client/readiness.ts +40 -0
- package/src/css-modules.d.ts +5 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** Browser-owned microphone capture and native Web Audio resampling. */
|
|
2
|
+
|
|
3
|
+
/** Capture failure whose message is localized by the caller. */
|
|
4
|
+
export class RecordingError extends Error {
|
|
5
|
+
constructor(readonly kind: 'unavailable' | 'permission' | 'empty' | 'cancelled' | 'interrupted' | 'deviceMissing') { super(kind); this.name = 'RecordingError' }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Encode mono floating-point samples as the canonical PCM16 WAV accepted by the Host.
|
|
10
|
+
* @param samples - native-resampled 16 kHz mono samples.
|
|
11
|
+
* @returns complete little-endian WAV bytes.
|
|
12
|
+
*/
|
|
13
|
+
export function encodeWave(samples: Float32Array): Uint8Array<ArrayBuffer> {
|
|
14
|
+
const bytes = new Uint8Array(44 + samples.length * 2)
|
|
15
|
+
const view = new DataView(bytes.buffer)
|
|
16
|
+
const text = (at: number, value: string): void => { for (let i = 0; i < value.length; i++) bytes[at + i] = value.charCodeAt(i) }
|
|
17
|
+
text(0, 'RIFF'); view.setUint32(4, bytes.length - 8, true); text(8, 'WAVE'); text(12, 'fmt ')
|
|
18
|
+
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true)
|
|
19
|
+
view.setUint32(24, 16000, true); view.setUint32(28, 32000, true)
|
|
20
|
+
view.setUint16(32, 2, true); view.setUint16(34, 16, true); text(36, 'data')
|
|
21
|
+
view.setUint32(40, samples.length * 2, true)
|
|
22
|
+
for (const [i, sample] of samples.entries()) {
|
|
23
|
+
const value = Math.max(-1, Math.min(1, sample))
|
|
24
|
+
view.setInt16(44 + i * 2, Math.round(value * (value < 0 ? 32768 : 32767)), true)
|
|
25
|
+
}
|
|
26
|
+
return bytes
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Encode the binary recording for the existing JSON Remote carrier.
|
|
31
|
+
* @param bytes - complete recording.
|
|
32
|
+
* @returns base64 with no data URL prefix.
|
|
33
|
+
*/
|
|
34
|
+
export function audioBase64(bytes: Uint8Array): string {
|
|
35
|
+
let text = ''
|
|
36
|
+
for (let i = 0; i < bytes.length; i += 8192) text += String.fromCharCode(...bytes.subarray(i, i + 8192))
|
|
37
|
+
return btoa(text)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One microphone acquisition, including a permission prompt that may settle after cancellation. */
|
|
41
|
+
export class Recording {
|
|
42
|
+
private stream: MediaStream | undefined
|
|
43
|
+
private recorder: MediaRecorder | undefined
|
|
44
|
+
private context: AudioContext | undefined
|
|
45
|
+
private analyser: AnalyserNode | undefined
|
|
46
|
+
private samples = new Float32Array(256)
|
|
47
|
+
private chunks: Blob[] = []
|
|
48
|
+
private readonly lifetime = new AbortController()
|
|
49
|
+
private disposal: Promise<void> | undefined
|
|
50
|
+
|
|
51
|
+
constructor(private readonly onDispose: () => void, private readonly deviceId = '') {}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Acquire the selected microphone without recording or playing audio.
|
|
55
|
+
* @param onError - receives device interruptions before resource release completes.
|
|
56
|
+
* @returns after the level analyser starts; cancelled grants release their tracks immediately.
|
|
57
|
+
*/
|
|
58
|
+
async preview(onError?: (error: RecordingError) => void): Promise<void> {
|
|
59
|
+
const devices = (navigator as Partial<Navigator>).mediaDevices
|
|
60
|
+
if (!devices) throw new RecordingError('unavailable')
|
|
61
|
+
let stream: MediaStream
|
|
62
|
+
try {
|
|
63
|
+
stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true,
|
|
64
|
+
...(this.deviceId === '' ? {} : { deviceId: { exact: this.deviceId } }) }, video: false })
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof DOMException) {
|
|
67
|
+
if (error.name === 'NotAllowedError') throw new RecordingError('permission')
|
|
68
|
+
if (error.name === 'NotFoundError' || error.name === 'OverconstrainedError') throw new RecordingError('deviceMissing')
|
|
69
|
+
}
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
if (this.lifetime.signal.aborted) { stream.getTracks().forEach((track) => { track.stop() }); throw new RecordingError('cancelled') }
|
|
73
|
+
this.stream = stream
|
|
74
|
+
try {
|
|
75
|
+
this.context = new AudioContext()
|
|
76
|
+
this.analyser = this.context.createAnalyser()
|
|
77
|
+
this.analyser.fftSize = this.samples.length
|
|
78
|
+
this.context.createMediaStreamSource(stream).connect(this.analyser)
|
|
79
|
+
for (const track of stream.getTracks()) track.addEventListener('ended', () => {
|
|
80
|
+
this.interrupted(onError)
|
|
81
|
+
}, { signal: this.lifetime.signal })
|
|
82
|
+
} catch (error) { await this.dispose(); throw error }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Acquire the selected microphone and record audio for transcription.
|
|
87
|
+
* @param onError - receives failures during capture, before asynchronous resource release finishes.
|
|
88
|
+
* @returns after capture starts; a cancelled permission grant immediately releases its tracks.
|
|
89
|
+
*/
|
|
90
|
+
async start(onError?: (error: RecordingError) => void): Promise<void> {
|
|
91
|
+
if (typeof MediaRecorder === 'undefined') throw new RecordingError('unavailable')
|
|
92
|
+
await this.preview(onError)
|
|
93
|
+
if (this.lifetime.signal.aborted) throw new RecordingError('cancelled')
|
|
94
|
+
try {
|
|
95
|
+
this.recorder = new MediaRecorder(this.stream as MediaStream)
|
|
96
|
+
this.recorder.ondataavailable = (event) => { if (!this.lifetime.signal.aborted && event.data.size > 0) this.chunks.push(event.data) }
|
|
97
|
+
this.recorder.onerror = () => { this.interrupted(onError) }
|
|
98
|
+
this.recorder.start()
|
|
99
|
+
} catch (error) { await this.dispose(); throw error }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private interrupted(onError: ((error: RecordingError) => void) | undefined): void {
|
|
103
|
+
if (this.lifetime.signal.aborted) return
|
|
104
|
+
void this.dispose().catch(() => undefined)
|
|
105
|
+
try { onError?.(new RecordingError('interrupted')) } catch (error) {
|
|
106
|
+
console.error('Speech recording error handler failed', error)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read the live microphone signal.
|
|
112
|
+
* @returns the measured RMS level, or zero outside capture.
|
|
113
|
+
*/
|
|
114
|
+
amplitude(): number {
|
|
115
|
+
if (!this.analyser) return 0
|
|
116
|
+
this.analyser.getFloatTimeDomainData(this.samples)
|
|
117
|
+
let sum = 0
|
|
118
|
+
for (const sample of this.samples) sum += sample * sample
|
|
119
|
+
return Math.sqrt(sum / this.samples.length)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Identify the input actually acquired by this operation.
|
|
124
|
+
* @returns the audio track's name and device group, or undefined outside capture.
|
|
125
|
+
*/
|
|
126
|
+
deviceInfo(): Pick<MediaDeviceInfo, 'label' | 'groupId'> | undefined {
|
|
127
|
+
const track = this.stream?.getAudioTracks()[0]
|
|
128
|
+
return track ? { label: track.label, groupId: track.getSettings().groupId ?? '' } : undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Finish capture and resample the recording.
|
|
133
|
+
* @param maxDurationSeconds - truncate timer overshoot to the Host limit.
|
|
134
|
+
* @returns one recording after the final MediaRecorder chunk arrives.
|
|
135
|
+
*/
|
|
136
|
+
async stop(maxDurationSeconds: number): Promise<Uint8Array<ArrayBuffer>> {
|
|
137
|
+
const recorder = this.recorder
|
|
138
|
+
const context = this.context
|
|
139
|
+
if (!recorder || !context || recorder.state !== 'recording') { await this.dispose(); throw new RecordingError('empty') }
|
|
140
|
+
try {
|
|
141
|
+
await new Promise<void>((resolve, reject) => {
|
|
142
|
+
recorder.onstop = () => { resolve() }
|
|
143
|
+
recorder.onerror = () => { reject(new RecordingError('empty')) }
|
|
144
|
+
recorder.stop()
|
|
145
|
+
})
|
|
146
|
+
this.stream?.getTracks().forEach((track) => { track.stop() })
|
|
147
|
+
this.lifetime.signal.throwIfAborted()
|
|
148
|
+
const blob = new Blob(this.chunks, { type: recorder.mimeType })
|
|
149
|
+
if (blob.size === 0) throw new RecordingError('empty')
|
|
150
|
+
const decoded = await context.decodeAudioData(await blob.arrayBuffer())
|
|
151
|
+
this.lifetime.signal.throwIfAborted()
|
|
152
|
+
const offline = new OfflineAudioContext(1, Math.max(1, Math.floor(Math.min(decoded.duration, maxDurationSeconds) * 16000)), 16000)
|
|
153
|
+
const source = offline.createBufferSource()
|
|
154
|
+
source.buffer = decoded
|
|
155
|
+
source.connect(offline.destination)
|
|
156
|
+
source.start()
|
|
157
|
+
const resampled = await offline.startRendering()
|
|
158
|
+
this.lifetime.signal.throwIfAborted()
|
|
159
|
+
return encodeWave(resampled.getChannelData(0))
|
|
160
|
+
} finally { await this.dispose() }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Release this recording and invalidate pending permission grants.
|
|
165
|
+
* @returns the shared release promise, including any AudioContext close failure.
|
|
166
|
+
*/
|
|
167
|
+
dispose(): Promise<void> {
|
|
168
|
+
if (!this.disposal) {
|
|
169
|
+
const closing = Promise.withResolvers<void>()
|
|
170
|
+
this.disposal = closing.promise
|
|
171
|
+
void this.release().then(closing.resolve, closing.reject)
|
|
172
|
+
}
|
|
173
|
+
return this.disposal
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async release(): Promise<void> {
|
|
177
|
+
this.lifetime.abort(new RecordingError('cancelled'))
|
|
178
|
+
if (this.recorder?.state === 'recording') this.recorder.stop()
|
|
179
|
+
this.stream?.getTracks().forEach((track) => { track.stop() })
|
|
180
|
+
this.stream = undefined
|
|
181
|
+
const context = this.context
|
|
182
|
+
this.context = undefined
|
|
183
|
+
this.analyser = undefined
|
|
184
|
+
this.chunks = []
|
|
185
|
+
try { if (context && context.state !== 'closed') await context.close() }
|
|
186
|
+
finally { this.onDispose() }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Locale-owned messages shared by microphone preview and dictation. */
|
|
2
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
3
|
+
import { RecordingError } from './audio.ts'
|
|
4
|
+
import type { NS } from './locales.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Translate a capture or transcription failure.
|
|
8
|
+
* @param failure - the rejected value.
|
|
9
|
+
* @param t - the active voice-input translator.
|
|
10
|
+
* @returns localized failure text.
|
|
11
|
+
*/
|
|
12
|
+
export function failureText(failure: unknown, t: PropsLocale<typeof NS>['t']): string {
|
|
13
|
+
return failure instanceof RecordingError ? t(failure.kind)
|
|
14
|
+
: t('failed', { message: failure instanceof Error ? failure.message : String(failure) })
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Browser entry for the optional speech Remote contribution and composer control. */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
3
|
+
import speechRemote from '@deepseek-ai/dsh-experimental-api-speech-to-text/remote'
|
|
4
|
+
import { mountVoiceInput } from './mount.ts'
|
|
5
|
+
|
|
6
|
+
export { inject } from './mount.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Activate the experimental microphone contribution.
|
|
10
|
+
* @param ctx - Client runtime.
|
|
11
|
+
* @returns complete UI and Remote disposer.
|
|
12
|
+
*/
|
|
13
|
+
export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
|
14
|
+
return await mountVoiceInput(ctx, speechRemote)
|
|
15
|
+
}
|