Initial commit
This commit is contained in:
287
stores/microphone.ts
Normal file
287
stores/microphone.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
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
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
Reference in New Issue
Block a user