@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.
Files changed (47) hide show
  1. package/lib/client.js +1 -3710
  2. package/lib/client.js.map +1 -0
  3. package/lib/types/client/MicrophonePicker.d.ts.map +1 -0
  4. package/lib/types/client/MicrophonePicker.js.map +1 -0
  5. package/lib/types/client/PreparationCard.d.ts.map +1 -0
  6. package/lib/types/client/PreparationCard.js.map +1 -0
  7. package/lib/types/client/VoiceInput.d.ts.map +1 -0
  8. package/lib/types/client/VoiceInput.js.map +1 -0
  9. package/lib/types/client/VoiceSetupDialog.d.ts.map +1 -0
  10. package/lib/types/client/VoiceSetupDialog.js.map +1 -0
  11. package/lib/types/client/VoiceSetupPrompt.d.ts.map +1 -0
  12. package/lib/types/client/VoiceSetupPrompt.js.map +1 -0
  13. package/lib/types/client/Waveform.d.ts.map +1 -0
  14. package/lib/types/client/Waveform.js.map +1 -0
  15. package/lib/types/client/audio.d.ts.map +1 -0
  16. package/lib/types/client/audio.js.map +1 -0
  17. package/lib/types/client/failure-text.d.ts.map +1 -0
  18. package/lib/types/client/failure-text.js.map +1 -0
  19. package/lib/types/client/index.d.ts.map +1 -0
  20. package/lib/types/client/index.js.map +1 -0
  21. package/lib/types/client/locales.d.ts.map +1 -0
  22. package/lib/types/client/locales.js.map +1 -0
  23. package/lib/types/client/microphone-device.d.ts.map +1 -0
  24. package/lib/types/client/microphone-device.js.map +1 -0
  25. package/lib/types/client/mount.d.ts.map +1 -0
  26. package/lib/types/client/mount.js.map +1 -0
  27. package/lib/types/client/readiness.d.ts.map +1 -0
  28. package/lib/types/client/readiness.js.map +1 -0
  29. package/lib/types/index.d.ts.map +1 -0
  30. package/lib/types/index.js.map +1 -0
  31. package/package.json +23 -20
  32. package/src/client/MicrophonePicker.tsx +140 -0
  33. package/src/client/PreparationCard.tsx +165 -0
  34. package/src/client/VoiceInput.module.css +55 -0
  35. package/src/client/VoiceInput.tsx +182 -0
  36. package/src/client/VoiceSetupDialog.tsx +32 -0
  37. package/src/client/VoiceSetupPrompt.tsx +27 -0
  38. package/src/client/Waveform.tsx +33 -0
  39. package/src/client/audio.ts +188 -0
  40. package/src/client/failure-text.ts +15 -0
  41. package/src/client/index.ts +15 -0
  42. package/src/client/locales.ts +834 -0
  43. package/src/client/microphone-device.ts +20 -0
  44. package/src/client/mount.ts +77 -0
  45. package/src/client/readiness.ts +40 -0
  46. package/src/css-modules.d.ts +5 -0
  47. 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
+ }