/* * SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 * * 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 { useReducer, useRef } from "react"; import logger from "../utils/logger"; const USER_SPEECH_SAMPLE_RATE = 16_000; export interface MicrophoneState { micAccessState: MicAccessState; error: Error | null; isRecording: boolean; } enum MicrophoneActionType { MIC_ACCESS_REQUESTED = "MIC_ACCESS_REQUESTED", MIC_ACCESS_GRANTED = "MIC_ACCESS_GRANTED", MIC_ACCESS_ERROR = "MIC_ACCESS_ERROR", RECORDING_STARTED = "RECORDING_STARTED", RECORDING_STOPPED = "RECORDING_STOPPED", } export enum MicAccessState { INITIAL = "INITIAL", LOADING = "LOADING", GRANTED = "GRANTED", ERROR = "ERROR", } interface MicrophoneStateActionMicAccessRequested { type: MicrophoneActionType.MIC_ACCESS_REQUESTED; } interface MicrophoneStateActionMicAccessGranted { type: MicrophoneActionType.MIC_ACCESS_GRANTED; } interface MicrophoneStateActionMicAccessError { type: MicrophoneActionType.MIC_ACCESS_ERROR; payload: Error; } interface MicrophoneStateActionRecordingStarted { type: MicrophoneActionType.RECORDING_STARTED; } interface MicrophoneStateActionRecordingStopped { type: MicrophoneActionType.RECORDING_STOPPED; } type MicrophoneStateAction = | MicrophoneStateActionMicAccessRequested | MicrophoneStateActionMicAccessGranted | MicrophoneStateActionMicAccessError | MicrophoneStateActionRecordingStarted | MicrophoneStateActionRecordingStopped; function reducer( state: MicrophoneState, action: MicrophoneStateAction ): MicrophoneState { switch (action.type) { case MicrophoneActionType.MIC_ACCESS_REQUESTED: return { ...state, micAccessState: MicAccessState.LOADING }; case MicrophoneActionType.MIC_ACCESS_GRANTED: return { ...state, micAccessState: MicAccessState.GRANTED, }; case MicrophoneActionType.MIC_ACCESS_ERROR: return { ...state, micAccessState: MicAccessState.ERROR, error: action.payload, }; case MicrophoneActionType.RECORDING_STARTED: return { ...state, isRecording: true, }; case MicrophoneActionType.RECORDING_STOPPED: return { ...state, isRecording: false, }; default: return state; } } const INITIAL_STATE: MicrophoneState = { micAccessState: MicAccessState.INITIAL, error: null, isRecording: false, }; export default function useMicrophone({ onAudioChunkAvailable, onError, }: { onAudioChunkAvailable: ( buffer: ArrayBuffer, sampleRate: number, numChannels: number ) => void; onError: (error: Error) => void; }): { microphoneState: MicrophoneState; startRecording: () => Promise; stopRecording: () => void; source: AudioNode | null; } { const [microphoneState, dispatch] = useReducer(reducer, INITIAL_STATE); const audioSourceRef = useRef(null); const audioCtxRef = useRef(null); async function requestAccess() { dispatch({ type: MicrophoneActionType.MIC_ACCESS_REQUESTED, }); try { if (!isSecureContext) { throw new Error( `Cannot enable microphone in insecure context. To fix this issue, add the URL "${window.location.origin}" URL to chrome://flags/#unsafely-treat-insecure-origin-as-secure` ); } const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: "default" }, }); if (!audioCtxRef.current) { audioCtxRef.current = new AudioContext({ sampleRate: USER_SPEECH_SAMPLE_RATE, }); } audioSourceRef.current = audioCtxRef.current.createMediaStreamSource(stream); await audioCtxRef.current.audioWorklet.addModule( "linear-pcm-processor.worklet.js" ); const audioWorkletNode = new AudioWorkletNode( audioCtxRef.current, "linear-pcm-processor" ); audioSourceRef.current.connect(audioWorkletNode); audioWorkletNode.connect(audioCtxRef.current.destination); audioWorkletNode.port.onmessage = (e: MessageEvent) => { onAudioChunkAvailable( e.data.buffer, audioCtxRef.current!.sampleRate, 1 ); }; dispatch({ type: MicrophoneActionType.MIC_ACCESS_GRANTED, }); } catch (e) { logger.error(e); onError(e as Error); dispatch({ type: MicrophoneActionType.MIC_ACCESS_ERROR, payload: e as Error, }); } } async function startRecording() { if (microphoneState.micAccessState !== MicAccessState.GRANTED) { await requestAccess(); } logger.log("resuming..."); audioCtxRef.current?.resume(); dispatch({ type: MicrophoneActionType.RECORDING_STARTED, }); } function stopRecording() { audioCtxRef.current?.suspend(); dispatch({ type: MicrophoneActionType.RECORDING_STOPPED, }); } return { microphoneState, startRecording, stopRecording, source: audioSourceRef.current ?? null, }; }