import { defineStore, type _ActionsTree, type StateTree, type StoreDefinition } from 'pinia' import type { Microphone } from './interfaces/Microphone' import { useAudioStore } from './audio' import microphoneList from '~/assets/microphone_list.json' import type { Logger } from 'pino' let pendingMicrophonePromise: Promise | null = null export const useMicStore: StoreDefinition = defineStore< 'microphone', StateTree, _ActionsTree >('microphone', { state: () => ({ microphone: {} as Microphone | null, availableDevices: [] as MediaDeviceInfo[], microphoneActive: false, }), actions: { async getMicrophone (context?: AudioContext): Promise { logger: useNuxtApp().$logger as Logger if(!context){ await useAudioStore().ensureAudioContextRunning() context = useAudioStore().getContext() } if (this.microphone && this.microphone.microphoneNode && this.microphone.microphoneStream ) { return this.microphone } // Wenn bereits eine Anfrage läuft, darauf warten if (pendingMicrophonePromise) { return pendingMicrophonePromise } pendingMicrophonePromise = (async () => { await this.updateAvailableDevices() const audioCtx = context || await useAudioStore().getContext() this.microphone.microphoneStream = await this.getMediaStream() this.microphone.microphoneNode = audioCtx.createMediaStreamSource(this.microphone.microphoneStream) const track = this.microphone.microphoneStream.getAudioTracks()[0].label this.microphone.label ||= track.label || 'Unknown Microphone' return this.microphone })() // Warten auf Ergebnis, danach Promise wieder löschen const result = await pendingMicrophonePromise pendingMicrophonePromise = null return result }, /** * This function updates the audioContext of an microphoneNode. the given * context's readyness is checked before the microphone node is destroyed * and recreated within the new context. * @param context */ async updateMicrophoneNodeContext(context: AudioContext) { if(context.state !== "running") { const audioStore = useAudioStore() try { await audioStore.resumeAudioContext() } catch (error) { const logger = useNuxtApp().$logger as Logger logger.error("Resume audio context failed" + context.state) } } const mediaStream = this.microphone.microphoneStream const label = this.microphone.microphoneLabel this.microphone.microphoneNode.disconnect() this.microphone.microphoneNode = null const microphoneNode = context.createMediaStreamSource(mediaStream) this.microphone = { microphoneNode, microphoneStream: mediaStream, label, } } , /** * This function is especially for the Howler.js audio API and creates a * microphone audio node within the audio context of Howler, to connect * audio nodes of howler direct or indirect with the microphone. * @returns Promise */ async getMicrophoneInHowlerContext (): Promise { const audioContext = Howler.ctx if ( this.microphone && this.microphone.microphoneNode && this.microphone.microphoneStream ) { if(this.microphone.microphoneNode.context !== audioContext) { await this.updateMicrophoneNodeContext(audioContext) } return this.microphone as Microphone } // Wenn bereits eine Anfrage läuft, darauf warten if (pendingMicrophonePromise) { return pendingMicrophonePromise } pendingMicrophonePromise = (async () => { await this.updateAvailableDevices() const mediaStream = await this.getMediaStream() const audioCtx = Howler.ctx this.microphone.microphoneNode ||= audioCtx.createMediaStreamSource(mediaStream) const track = this.microphone.microphoneStream.getAudioTracks()[0] this.microphone.microphoneLabel = track.label || 'Unknown Microphone' return this.microphone })() // Warten auf Ergebnis, danach Promise wieder löschen const result = await pendingMicrophonePromise pendingMicrophonePromise = null return result }, /** * This function interacts with the browser User-API to get the microphone * it requests the permission to access the microphone, then sets the * active state of microphone usage what can be seen in the browser tab. * To avoid wrong calculations the stream is requested without any preprocessing. */ async attachMicrophone () { if (this.microphone && !this.microphone.microphoneStream) { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, video: false }) this.microphone.microphoneStream = stream this.microphoneActive = true } }, /** * This function stops the usage of the microphone. It stops all tracks of the used * MediaStream and sets the internal state to null. After that the browser should not * detect any usage of the microphone caused by the microphone store. */ detachMicrophone () { if (this.microphone && this.microphone.microphoneStream) { this.detachMediaStream() this.microphoneActive = false } }, /** * This function updates the List of possible microphone sources. */ async updateAvailableDevices () { const logger = useNuxtApp().$logger as Logger try { const devices = await navigator.mediaDevices.enumerateDevices() this.availableDevices = devices.filter( device => device.kind === 'audioinput' ) if (this.availableDevices.length === 0) { logger.warn( 'No audio input devices found. This might be due to missing permissions or no connected devices.' ) } } catch (error) { logger.error('Error enumerating devices:', error) } }, /** * This function is used to request the media stream without worring about any preprocessing of the * Stream nor preferred devices the algorithm works best with. * @returns { Promise } */ async getMediaStream (): Promise { if(this.microphone && this.microphone.microphoneStream) { return this.microphone.microphoneStream } const preferredDevice = this.getPreferredDevice() const constraints: MediaStreamConstraints = { audio: preferredDevice ? { deviceId: { exact: preferredDevice.deviceId } } : { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, video: false } if(this.microphone === null) this.microphone = {} this.microphone.microphoneStream = await navigator.mediaDevices.getUserMedia( constraints ) return this.microphone.microphoneStream }, /** * Attempts to find a preferred microphone device from the available devices. * * This method iterates through a list of preferred microphone names and tries to match them * against the labels of available audio input devices. It uses a case-insensitive regex match, * allowing for partial matches. The method includes safety measures such as input sanitization, * length limits, and timeout protection against potential regex-based attacks. * * If a match is found, it returns the corresponding MediaDeviceInfo object. * If no match is found, it returns undefined, indicating that the default device should be used. * * @returns {MediaDeviceInfo | undefined} The matched device info or undefined if no match is found. */ getPreferredDevice (): MediaDeviceInfo | undefined { const logger = useNuxtApp().$logger as Logger for (const preferredMic of microphoneList) { if (preferredMic.length > 200) { // Limit the length of preferred mic names logger.warn( `Skipping overly long preferred microphone name: ${preferredMic.substring( 0, 20 )}...` ) continue } // Sanitize the preferred microphone name const sanitizedMicName = preferredMic.replace(/[^\w\s-]/gi, '') // Create a case-insensitive regular expression from the sanitized name const preferredMicRegex = new RegExp(sanitizedMicName, 'i') const matchingDevice = this.availableDevices.find( (device: { label: string }) => { // Use a timeout to prevent long-running regex matches const timeout = setTimeout(() => { return false }, 1000) // 1 second timeout const match = preferredMicRegex.test(device.label) clearTimeout(timeout) return match } ) if (matchingDevice) { // Found preferred microphone return matchingDevice } } return undefined }, /** * This function is used to detach the media stream an give free all resources. */ detachMediaStream () { this.microphone?.microphoneStream .getTracks() .forEach((track: MediaStreamTrack) => { track.stop() }) this.microphone = {} pendingMicrophonePromise = null }, /** * This function is an helper to switch the microphone if a deviceId is provided. This changed also the microphone node. * @param deviceId * @param context */ async switchMicrophone (deviceId: string, context?: AudioContext) { const logger = useNuxtApp().$logger as Logger try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: deviceId } } }) // Update store by new stream this.microphone = { microphoneStream: stream, microphoneNode: null, deviceId:deviceId, label: stream.getAudioTracks() } // Create new Audiocontext and add stream const audioContext = context || await useAudioStore().getContext() const source = audioContext.createMediaStreamSource(stream) this.microphone.microphoneStream ||= stream this.microphone.microphoneNode ||= source this.microphone.deviceId ||= deviceId this.microphone.deviceId ||= deviceId } catch (error) { logger.error('Error switching microphone:', error) throw error } }, } })