mindboost-rnbo-test-project/lib/AudioFunctions.ts

386 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { createDevice, type Device } from '@rnbo/js'
import { Howl } from 'howler'
import { ensureAudio } from '~/stores/audio'
import { ANC } from '~/stores/interfaces/ANC'
import { HeadsetType } from '~/stores/interfaces/HeadsetType'
import { useUserStore } from '~/stores/user'
function waitForCanPlay(audio: HTMLMediaElement): Promise<void> {
return new Promise((resolve) => {
if (audio.readyState >= 3) {
resolve()
} else {
const onCanPlay = () => {
audio.removeEventListener('canplay', onCanPlay)
resolve()
}
audio.addEventListener('canplay', onCanPlay)
}
})
}
async function onFallback(reason: string, ctx?: AudioContext, src?:string): Promise<AudioNode | null> {
useNuxtApp().$logger.warn(`[Audio-Fallback] ⚠️ ${reason}`)
if (reason.includes('captureStream nicht verfügbar')) {
// → Safari oder alter Browser
useNuxtApp().$logger.info('Nutze Fallback via decodeAudioData()...')
// Trigger eigene Fallback-Logik, z.B.:
if(ctx && src) return await createAudioSource(ctx, src)
}
else if (reason.includes('kein AudioTrack')) {
useNuxtApp().$logger.warn('Audio-Stream hat keinen Track evtl. nicht geladen?')
// Optionale Retry-Logik hier?
}
else if (reason.includes('Audioelement hat einen Fehler')) {
alert('Audio konnte nicht geladen werden. Bitte prüfen Sie die Quelle oder Ihre Netzwerkverbindung.')
}
else if (reason.includes('MediaStreamSource')) {
useNuxtApp().$logger.error('Web Audio konnte keine Verbindung zum Stream herstellen.')
if(ctx && src) return await createAudioSource(ctx, src)
}
else {
useNuxtApp().$logger.error(`[Audio-Fallback] Unbekannter Fehler: ${reason}`)
}
return null
}
async function setupAudioSource(
audioElement: HTMLMediaElement,
audioContext: AudioContext): Promise<MediaElementAudioSourceNode | AudioBufferSourceNode | MediaStreamAudioSourceNode | null> {
await ensureAudio()
useNuxtApp().$logger.log("HTML5 Element vor dem umwandeln = ", {audioElement})
try {
// 1. Quelle vorhanden?
if (!audioElement.src && !audioElement.srcObject) {
onFallback('Audioelement hat keine gültige Quelle')
return null
}
// 2. Fehler prüfen
if (audioElement.error) {
onFallback('Audioelement hat einen Fehler (z.B. 404, CORS, etc.)', audioContext, audioElement.src )
return null
}
// 3. Versuche zu laden
try {
await waitForCanPlay(audioElement)
audioElement.volume = 0.0
await audioElement.play()
} catch (err) {
useNuxtApp().$logger.info('Audio konnte nicht abgespielt werden (Autoplay blockiert?):', err)
await ensureAudio()
audioElement.volume = 0.0
await audioElement.play()
}
// 4. Ist ready?
if (audioElement.readyState < 2) {
setTimeout(() => {
setupAudioSource(audioElement, audioContext)
}, 500)
onFallback('Audioelement ist nicht bereit (readyState < 2)')
return null
}
// 5. Nutze den MediaStream
const stream = getMediaStreamFromElement(audioElement)
if (!stream) {
onFallback('captureStream (inkl. mozCaptureStream) nicht verfügbar')
if(audioElement.src) useNuxtApp().$logger.log("Audio Src is a string")
if(audioElement.srcObject) useNuxtApp().$logger.log("Audio Src is a srcObject")
return createAudioSource(audioContext, audioElement.src)
}
if (stream.getAudioTracks().length === 0) {
onFallback('captureStream liefert keinen AudioTrack')
return createAudioSource(audioContext, audioElement.src)
}
// 6. Teste node-Erstellung
const node = audioContext.createMediaStreamSource(stream)
return node
} catch (error) {
onFallback(`Fehler beim Aufbau der MediaStreamSource: ${error}`)
return null
}
}
function getMediaStreamFromElement(mediaElement: HTMLMediaElement): MediaStream | null {
const element = mediaElement as HTMLMediaElement & {
captureStream?: () => MediaStream
mozCaptureStream?: () => MediaStream
}
if (typeof element.captureStream === 'function') {
return element.captureStream()
} else if (typeof element.mozCaptureStream === 'function') {
useNuxtApp().$logger.warn('[AudioBridge] ⚠️ captureStream() nicht verfügbar, versuche mozCaptureStream() (Firefox-Fallback)')
return element.mozCaptureStream()
}
useNuxtApp().$logger.warn('[AudioBridge] ❌ Weder captureStream() noch mozCaptureStream() verfügbar.')
return null
}
async function createStreamingAudioSource (ctx: AudioContext, fileName: string): Promise<Howl> {
if(Howler.ctx != null) {
Howler.ctx.close()
}
if(fileName == null || fileName == undefined) {
useNuxtApp().$logger.error('Could not find file with name ' + fileName)
}
// Howler.ctx = ctx
// Load your audio file with streaming enabled
const baseURL = window.location.origin
const sound: Howl = await new Howl({
src: [fileName],
html5: false, // Enables streaming
loop: true
})
if(sound.state() === "unloaded") {
useNuxtApp().$logger.warn('Could not find file with name, please check the following resource -> '+ fileName)
}
// Assuming you have the Web Audio API context (Howler.ctx)
// and it's not null or undefined, you can proceed to create a custom AudioNode
useNuxtApp().$logger.log( {sound })
return sound
// Note: This is a simplified example. Depending on what you're doing,
// you might not want to connect directly to the destination but to other nodes for further processing.
}
function getAttenuationFactor () {
const user = useUserStore().user as any
const anc = user.settings.anc_type
const ht = user.settings.headphone_type
if (anc === ANC.Yes && ht === HeadsetType.OverEar) {
return 0.0562
}
if (anc === ANC.No && ht === HeadsetType.OverEar) {
return 0.5623
}
if (anc === ANC.Yes && ht === HeadsetType.InEar) {
return 0.1778
}
if (anc === ANC.No && ht === HeadsetType.InEar) {
return 0.0316
}
return 0.5623
}
function throttle<T extends (...args: any[]) => void>(
func: T,
limit: number
): T {
let inThrottle: boolean
return function (this: any, ...args: any[]) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
} as T
}
async function createAudioSource (
ctx: AudioContext,
path: string
): Promise<AudioBufferSourceNode> {
// Works but is slow
const response: Response = await fetch(path)
const source: AudioBufferSourceNode = ctx.createBufferSource()
// Check if the partial content was successfully fetched
if (response.status === 206 || response.status === 200) {
// Retrieve the chunk as an ArrayBuffer
const buffer: ArrayBuffer = await response.arrayBuffer()
// Decode the audio data
const decodedAudio: AudioBuffer = await ctx.decodeAudioData(buffer)
// checkClipping(decodedAudio)
// useNuxtApp().$logger.log('Noise decoded successfully')
source.buffer = decodedAudio
source.loop = true
return source
} else {
throw new Error(
`Failed to fetch audio chunk: HTTP status ${response.status}`
)
}
/*
const decodedAudio: AudioBuffer = await fetchAndDecodeAudioChunk(ctx, path, startByte, endByte)
const source: AudioBufferSourceNode = ctx.createBufferSource()
source.buffer = decodedAudio
return source
*/
}
function createRNBODevice (ctx: AudioContext, patcher: any): Promise<Device> {
return createDevice({
context: ctx,
patcher
})
}
function connectAudioNodes (
from: AudioNode,
to: AudioNode,
output?: number,
input?: number
): void {
/*
// useNuxtApp().$logger.log('Connecting the Node: ', { from }, ' with ' + from.numberOfInputs + ' inputs',
' and with ' + from.numberOfOutputs + ' outputs ... with ')
// useNuxtApp().$logger.log('the Node: ', { to }, ' with ' + to.numberOfInputs + ' inputs',
' and with ' + to.numberOfOutputs + ' outputs ... defined Channels are o/i: ' + output + ' and ' + input)
*/
// useNuxtApp().$logger.log('new Node connected ', { allNodes })
try {
if (output !== undefined && input !== undefined) {
from.connect(to, output, input)
// useNuxtApp().$logger.log('Connect specific channels o/i ' + output, input, { to }, { from })
} else if (output !== undefined) {
from.connect(to, output)
// useNuxtApp().$logger.log('Connect with specific output channel ' + output, { to }, { from })
} else {
from.connect(to)
// useNuxtApp().$logger.log('Connect without specific channels', { to }, { from })
}
} catch (error) {
// useNuxtApp().$logger.log('Error while connecting' + error)
}
}
function checkClipping (buffer: AudioBuffer): boolean {
let isClipping = false
for (
let channelNumber = 0;
channelNumber < buffer.numberOfChannels;
channelNumber++
) {
const element: Float32Array = buffer.getChannelData(channelNumber)
// Iterate through buffer to check if any of the |values| exceeds 1.
for (let i = 0; i < buffer.length; i++) {
const absValue = Math.abs(element[i])
if (absValue >= 1.0) {
isClipping = true
break
}
}
}
return isClipping
}
function calculateNormalizedVolume (db: number) : number {
const minDB = -18
const maxDB = 6
if (db < minDB || db > maxDB) {
return Math.pow(10, db/ 20)
}
return Math.pow(10, db / 20)
}
function stereoToMono (stereoNode: AudioNode): AudioNode {
if (stereoNode.channelCount !== 2) {
useNuxtApp().$logger.warn('Not a stereo node. Mismatch of channels, expected 2 channels')
return stereoNode
}
const context = stereoNode.context
const splitter = context.createChannelSplitter(2)
const gainL = context.createGain()
const gainR = context.createGain()
gainL.gain.value = 0.5
gainR.gain.value = 0.5
// ChannelMerger, aber wir nutzen nur den ersten Output-Kanal
const merger = context.createChannelMerger(1)
// Verkabelung
stereoNode.connect(splitter)
splitter.connect(gainL, 0) // Left channel
splitter.connect(gainR, 1) // Right channel
gainL.connect(merger, 0, 0)
gainR.connect(merger, 0, 0)
useNuxtApp().$logger.info('Stereo node umgewandelt')
useNuxtApp().$logger.log({merger })
return merger
}
/**
* Analysiert, ob ein Mikrofon innerhalb eines Zeitfensters aktiv war (z.B. Sprache, Geräusche).
*
* @param micNode - Eine MediaStreamAudioSourceNode (z.B. von getUserMedia)
* @param durationMs - Die Dauer der Analyse in Millisekunden
* @param threshold - Optional: Empfindlichkeit (0.01 ist guter Standardwert)
* @returns Promise<boolean> - true, wenn Aktivität erkannt wurde, sonst false
NOT TESTED
async function analyseMicActivity(
micNode: MediaStreamAudioSourceNode,
durationMs: number,
threshold: number = 0.01
): Promise<boolean> {
const audioContext = micNode.context
const analyser = audioContext.createAnalyser()
analyser.fftSize = 2048
const bufferLength = analyser.fftSize
const dataArray = new Float32Array(bufferLength)
micNode.connect(analyser)
return new Promise((resolve) => {
let activeDetected = false
const startTime = performance.now()
const analyze = () => {
analyser.getFloatTimeDomainData(dataArray)
// RMS berechnen
let sum = 0
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i] * dataArray[i]
}
const rms = Math.sqrt(sum / bufferLength)
if (rms > threshold) {
activeDetected = true
}
if (performance.now() - startTime < durationMs) {
requestAnimationFrame(analyze)
} else {
micNode.disconnect(analyser)
resolve(activeDetected)
}
}
analyze()
})
}
*/
/**
* EXPORTS
*/
export {
createAudioSource,
createRNBODevice,
createStreamingAudioSource,
connectAudioNodes,
checkClipping,
getAttenuationFactor,
throttle,
calculateNormalizedVolume,
stereoToMono,
setupAudioSource,
getMediaStreamFromElement
}