288 lines
11 KiB
TypeScript
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
|
|
}
|
|
},
|
|
}
|
|
})
|