mindboost-rnbo-test-project/stores/microphone.ts

288 lines
11 KiB
TypeScript

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<Microphone> | 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<Microphone> {
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<Microphone>
*/
async getMicrophoneInHowlerContext (): Promise<Microphone> {
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<MediaStream> }
*/
async getMediaStream (): Promise<MediaStream> {
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
}
},
}
})