Spaces:
Sleeping
Sleeping
| /** | |
| * Copyright 2024 Google LLC | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| import { audioContext } from "./utils"; | |
| import AudioRecordingWorklet from "./worklets/audio-processing"; | |
| import SafariAudioRecordingWorklet from "./worklets/safari-audio-processing"; | |
| import VolMeterWorket from "./worklets/vol-meter"; | |
| import { createWorketFromSrc } from "./audioworklet-registry"; | |
| import EventEmitter from "eventemitter3"; | |
| function arrayBufferToBase64(buffer: ArrayBuffer) { | |
| var binary = ""; | |
| var bytes = new Uint8Array(buffer); | |
| var len = bytes.byteLength; | |
| for (var i = 0; i < len; i++) { | |
| binary += String.fromCharCode(bytes[i]); | |
| } | |
| return window.btoa(binary); | |
| } | |
| // Add Safari-specific audio context creation | |
| async function createSafariAudioContext(sampleRate: number): Promise<AudioContext> { | |
| console.log('Creating Safari audio context with options:', { sampleRate }); | |
| // Safari requires webkit prefix | |
| const AudioContextClass = (window as any).webkitAudioContext || window.AudioContext; | |
| console.log('Using AudioContext class:', AudioContextClass.name); | |
| const ctx = new AudioContextClass({ | |
| sampleRate, | |
| latencyHint: 'interactive' | |
| }); | |
| console.log('Safari AudioContext initial state:', { | |
| state: ctx.state, | |
| sampleRate: ctx.sampleRate, | |
| baseLatency: ctx.baseLatency, | |
| destination: ctx.destination, | |
| }); | |
| // Safari requires user interaction to start audio context | |
| if (ctx.state === 'suspended') { | |
| console.log('Attempting to resume suspended Safari audio context...'); | |
| try { | |
| await ctx.resume(); | |
| console.log('Successfully resumed Safari audio context:', ctx.state); | |
| } catch (err) { | |
| console.error('Failed to resume Safari audio context:', err); | |
| throw err; | |
| } | |
| } | |
| return ctx; | |
| } | |
| export class AudioRecorder extends EventEmitter { | |
| stream: MediaStream | undefined; | |
| audioContext: AudioContext | undefined; | |
| source: MediaStreamAudioSourceNode | undefined; | |
| recording: boolean = false; | |
| recordingWorklet: AudioWorkletNode | undefined; | |
| vuWorklet: AudioWorkletNode | undefined; | |
| private starting: Promise<void> | null = null; | |
| // Add browser detection | |
| isSafari: boolean; | |
| isIOS: boolean; | |
| constructor(public sampleRate = 16000) { | |
| super(); | |
| this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); | |
| this.isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream; | |
| console.log('AudioRecorder initialized:', { | |
| isSafari: this.isSafari, | |
| isIOS: this.isIOS, | |
| sampleRate: this.sampleRate, | |
| userAgent: navigator.userAgent, | |
| webAudioSupport: !!(window.AudioContext || (window as any).webkitAudioContext), | |
| mediaDevicesSupport: !!navigator.mediaDevices | |
| }); | |
| } | |
| async start() { | |
| if (!navigator.mediaDevices?.getUserMedia) { | |
| console.error('MediaDevices API not available:', { | |
| mediaDevices: !!navigator.mediaDevices, | |
| getUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) | |
| }); | |
| throw new Error("Could not request user media"); | |
| } | |
| console.log('Starting AudioRecorder with full environment info:', { | |
| userAgent: navigator.userAgent, | |
| platform: navigator.platform, | |
| vendor: navigator.vendor, | |
| audioWorkletSupport: !!(window.AudioWorklet), | |
| sampleRate: this.sampleRate, | |
| existingAudioContext: !!this.audioContext, | |
| existingStream: !!this.stream, | |
| isSafari: this.isSafari | |
| }); | |
| this.starting = new Promise(async (resolve, reject) => { | |
| try { | |
| if (this.isSafari) { | |
| // Safari implementation | |
| console.log('Safari detected - using Safari-specific audio initialization'); | |
| // 1. First get audio permissions | |
| console.log('Requesting audio permissions first for Safari...'); | |
| const constraints = { | |
| audio: { | |
| echoCancellation: false, | |
| noiseSuppression: false, | |
| autoGainControl: false, | |
| sampleRate: this.sampleRate, | |
| channelCount: 1 | |
| } | |
| }; | |
| console.log('Safari audio constraints:', constraints); | |
| try { | |
| this.stream = await navigator.mediaDevices.getUserMedia(constraints); | |
| const track = this.stream.getAudioTracks()[0]; | |
| console.log('Safari audio permissions granted:', { | |
| track: track.label, | |
| settings: track.getSettings(), | |
| constraints: track.getConstraints(), | |
| enabled: track.enabled, | |
| muted: track.muted, | |
| readyState: track.readyState | |
| }); | |
| } catch (err) { | |
| console.error('Failed to get Safari audio permissions:', err); | |
| throw err; | |
| } | |
| // 2. Create and initialize audio context | |
| try { | |
| this.audioContext = await createSafariAudioContext(this.sampleRate); | |
| console.log('Safari audio context ready:', { | |
| state: this.audioContext.state, | |
| currentTime: this.audioContext.currentTime | |
| }); | |
| } catch (err) { | |
| console.error('Failed to initialize Safari audio context:', err); | |
| throw err; | |
| } | |
| // 3. Create and connect audio source | |
| try { | |
| console.log('Creating Safari audio source...'); | |
| this.source = this.audioContext.createMediaStreamSource(this.stream); | |
| console.log('Safari audio source created successfully:', { | |
| numberOfInputs: this.source.numberOfInputs, | |
| numberOfOutputs: this.source.numberOfOutputs, | |
| channelCount: this.source.channelCount | |
| }); | |
| } catch (err) { | |
| console.error('Failed to create Safari audio source:', err); | |
| throw err; | |
| } | |
| // 4. Load and create worklet | |
| try { | |
| const workletName = "audio-recorder-worklet"; | |
| console.log('Loading Safari audio worklet...'); | |
| const src = createWorketFromSrc(workletName, SafariAudioRecordingWorklet); | |
| await this.audioContext.audioWorklet.addModule(src); | |
| console.log('Safari audio worklet module loaded'); | |
| this.recordingWorklet = new AudioWorkletNode( | |
| this.audioContext, | |
| workletName, | |
| { | |
| numberOfInputs: 1, | |
| numberOfOutputs: 1, | |
| channelCount: 1, | |
| processorOptions: { | |
| sampleRate: this.sampleRate | |
| } | |
| } | |
| ); | |
| // Add detailed error handlers | |
| this.recordingWorklet.onprocessorerror = (event) => { | |
| console.error('Safari AudioWorklet processor error:', event); | |
| }; | |
| this.recordingWorklet.port.onmessageerror = (event) => { | |
| console.error('Safari AudioWorklet message error:', event); | |
| }; | |
| // Add data handler with detailed logging | |
| this.recordingWorklet.port.onmessage = (ev: MessageEvent) => { | |
| const data = ev.data.data; | |
| console.log('Safari AudioWorklet message received:', { | |
| eventType: ev.data.event, | |
| hasData: !!data, | |
| dataType: data ? typeof data : null, | |
| timestamp: Date.now() | |
| }); | |
| if (data?.int16arrayBuffer) { | |
| console.log('Processing Safari audio chunk:', { | |
| byteLength: data.int16arrayBuffer.byteLength, | |
| timestamp: Date.now() | |
| }); | |
| const arrayBufferString = arrayBufferToBase64(data.int16arrayBuffer); | |
| this.emit("data", arrayBufferString); | |
| } else { | |
| console.warn('Invalid Safari audio chunk received:', ev.data); | |
| } | |
| }; | |
| console.log('Safari AudioWorkletNode created successfully'); | |
| } catch (err) { | |
| console.error('Failed to setup Safari audio worklet:', err); | |
| throw err; | |
| } | |
| // 5. Connect nodes | |
| try { | |
| console.log('Connecting Safari audio nodes...'); | |
| this.source.connect(this.recordingWorklet); | |
| console.log('Safari audio nodes connected successfully'); | |
| } catch (err) { | |
| console.error('Failed to connect Safari audio nodes:', err); | |
| throw err; | |
| } | |
| } else { | |
| // Chrome/other browsers implementation | |
| console.log('Non-Safari browser detected - using standard audio initialization'); | |
| // Get media stream first for Chrome | |
| const constraints = { | |
| audio: { | |
| echoCancellation: true, | |
| noiseSuppression: true, | |
| autoGainControl: true, | |
| sampleRate: this.sampleRate | |
| } | |
| }; | |
| console.log('Chrome audio constraints:', constraints); | |
| try { | |
| this.stream = await navigator.mediaDevices.getUserMedia(constraints); | |
| const track = this.stream.getAudioTracks()[0]; | |
| console.log('Chrome audio permissions granted:', { | |
| track: track.label, | |
| settings: track.getSettings() | |
| }); | |
| } catch (err) { | |
| console.error('Failed to get Chrome audio permissions:', err); | |
| throw err; | |
| } | |
| // Create audio context after getting stream for Chrome | |
| try { | |
| console.log('Creating Chrome audio context...'); | |
| this.audioContext = await audioContext({ sampleRate: this.sampleRate }); | |
| console.log('Chrome audio context created:', { | |
| state: this.audioContext.state, | |
| sampleRate: this.audioContext.sampleRate | |
| }); | |
| } catch (err) { | |
| console.error('Failed to create Chrome audio context:', err); | |
| throw err; | |
| } | |
| // Create media stream source | |
| try { | |
| console.log('Creating Chrome audio source...'); | |
| this.source = this.audioContext.createMediaStreamSource(this.stream); | |
| console.log('Chrome audio source created'); | |
| } catch (err) { | |
| console.error('Failed to create Chrome audio source:', err); | |
| throw err; | |
| } | |
| // Load and create standard worklet | |
| try { | |
| const workletName = "audio-recorder-worklet"; | |
| console.log('Loading Chrome audio worklet...'); | |
| const src = createWorketFromSrc(workletName, AudioRecordingWorklet); | |
| await this.audioContext.audioWorklet.addModule(src); | |
| console.log('Chrome audio worklet loaded'); | |
| this.recordingWorklet = new AudioWorkletNode( | |
| this.audioContext, | |
| workletName, | |
| { | |
| numberOfInputs: 1, | |
| numberOfOutputs: 1, | |
| channelCount: 1, | |
| processorOptions: { | |
| sampleRate: this.sampleRate | |
| } | |
| } | |
| ); | |
| // Add error handlers | |
| this.recordingWorklet.onprocessorerror = (event) => { | |
| console.error('Chrome AudioWorklet processor error:', event); | |
| }; | |
| this.recordingWorklet.port.onmessageerror = (event) => { | |
| console.error('Chrome AudioWorklet message error:', event); | |
| }; | |
| // Add data handler | |
| this.recordingWorklet.port.onmessage = async (ev: MessageEvent) => { | |
| const arrayBuffer = ev.data.data?.int16arrayBuffer; | |
| if (arrayBuffer) { | |
| const arrayBufferString = arrayBufferToBase64(arrayBuffer); | |
| this.emit("data", arrayBufferString); | |
| } else { | |
| console.warn('Invalid Chrome audio chunk received:', ev.data); | |
| } | |
| }; | |
| console.log('Chrome AudioWorkletNode created'); | |
| } catch (err) { | |
| console.error('Failed to setup Chrome audio worklet:', err); | |
| throw err; | |
| } | |
| // Connect nodes | |
| try { | |
| console.log('Connecting Chrome audio nodes...'); | |
| this.source.connect(this.recordingWorklet); | |
| console.log('Chrome audio nodes connected'); | |
| // Set up VU meter | |
| const vuWorkletName = "vu-meter"; | |
| await this.audioContext.audioWorklet.addModule( | |
| createWorketFromSrc(vuWorkletName, VolMeterWorket), | |
| ); | |
| this.vuWorklet = new AudioWorkletNode(this.audioContext, vuWorkletName); | |
| this.vuWorklet.port.onmessage = (ev: MessageEvent) => { | |
| this.emit("volume", ev.data.volume); | |
| }; | |
| this.source.connect(this.vuWorklet); | |
| console.log('Chrome VU meter connected'); | |
| } catch (err) { | |
| console.error('Failed to connect Chrome audio nodes:', err); | |
| throw err; | |
| } | |
| } | |
| this.recording = true; | |
| console.log('Recording started successfully'); | |
| resolve(); | |
| this.starting = null; | |
| } catch (error) { | |
| console.error('Failed to start recording:', error); | |
| this.stop(); | |
| reject(error); | |
| this.starting = null; | |
| } | |
| }); | |
| return this.starting; | |
| } | |
| stop() { | |
| console.log('Stopping audio recorder...'); | |
| // its plausible that stop would be called before start completes | |
| // such as if the websocket immediately hangs up | |
| const handleStop = () => { | |
| try { | |
| if (this.source) { | |
| console.log('Disconnecting audio source...'); | |
| this.source.disconnect(); | |
| } | |
| if (this.stream) { | |
| console.log('Stopping media stream tracks...'); | |
| this.stream.getTracks().forEach(track => { | |
| track.stop(); | |
| console.log('Stopped track:', track.label); | |
| }); | |
| } | |
| if (this.audioContext && this.isSafari) { | |
| console.log('Closing Safari audio context...'); | |
| this.audioContext.close(); | |
| } | |
| this.stream = undefined; | |
| this.recordingWorklet = undefined; | |
| this.vuWorklet = undefined; | |
| console.log('Audio recorder stopped successfully'); | |
| } catch (err) { | |
| console.error('Error while stopping audio recorder:', err); | |
| } | |
| }; | |
| if (this.starting) { | |
| console.log('Stop called while starting - waiting for start to complete...'); | |
| this.starting.then(handleStop); | |
| return; | |
| } | |
| handleStop(); | |
| } | |
| } | |