profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
audio.ts 2,104 bytes
1 export interface PushToTalkRecorder {
2 start(onLevel?: (level: number) => void): Promise<void>
3 stop(): Promise<Blob>
4 }
5
6 export function createPushToTalkRecorder(): PushToTalkRecorder {
7 let mediaRecorder: MediaRecorder | null = null
8 let stream: MediaStream | null = null
9 let chunks: BlobPart[] = []
10 let audioContext: AudioContext | null = null
11 let rafId = 0
12
13 return {
14 async start(onLevel) {
15 stream = await navigator.mediaDevices.getUserMedia({ audio: true })
16 chunks = []
17 mediaRecorder = new MediaRecorder(stream)
18 mediaRecorder.ondataavailable = (event) => {
19 if (event.data.size > 0) chunks.push(event.data)
20 }
21 mediaRecorder.start()
22
23 if (onLevel) {
24 audioContext = new AudioContext()
25 const source = audioContext.createMediaStreamSource(stream)
26 const analyser = audioContext.createAnalyser()
27 analyser.fftSize = 512
28 source.connect(analyser)
29 const data = new Uint8Array(analyser.fftSize)
30 const tick = () => {
31 analyser.getByteTimeDomainData(data)
32 let sum = 0
33 for (let i = 0; i < data.length; i++) {
34 const v = ((data[i] ?? 128) - 128) / 128
35 sum += v * v
36 }
37 onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4))
38 rafId = requestAnimationFrame(tick)
39 }
40 rafId = requestAnimationFrame(tick)
41 }
42 },
43
44 stop() {
45 return new Promise<Blob>((resolve, reject) => {
46 cancelAnimationFrame(rafId)
47 if (audioContext) {
48 void audioContext.close()
49 audioContext = null
50 }
51 if (!mediaRecorder) {
52 reject(new Error('recording was not started'))
53 return
54 }
55 const recorder = mediaRecorder
56 const activeStream = stream
57 recorder.onstop = () => {
58 resolve(new Blob(chunks, { type: recorder.mimeType || 'audio/webm' }))
59 activeStream?.getTracks().forEach((track) => track.stop())
60 }
61 recorder.stop()
62 mediaRecorder = null
63 stream = null
64 })
65 },
66 }
67 }
68