diff --git a/src/assets/microphone_list.json b/src/assets/microphone_list.json new file mode 100644 index 0000000..d1d3dde --- /dev/null +++ b/src/assets/microphone_list.json @@ -0,0 +1,63 @@ +[ + "MacBook Pro-Mikrofon", + "MacBook Pro-Mikrofon (Built-in)", + "MacBook Air-Mikrofon", + "MacBook Air-Mikrofon (Built-in)", + "iMac-Mikrofon", + "iMac-Mikrofon (Built-in)", + "Studio Display-Mikrofon", + "Studio Display-Mikrofon (Built-in)", + "Surface-Mikrofon", + "Surface-Mikrofon (Built-in)", + "Internal Microphone Array", + "Internal Microphone Array (Built-in)", + "Realtek Audio Device", + "Realtek Audio Device (Built-in)", + "Dell Microphone Array", + "Dell Microphone Array (Built-in)", + "HP Wide Vision HD Microphone", + "HP Wide Vision HD Microphone (Built-in)", + "ThinkPad Microphone", + "ThinkPad Microphone (Built-in)", + "Realtek High Definition Audio", + "Realtek High Definition Audio (Built-in)", + "ASUS Microphone Array", + "ASUS Microphone Array (Built-in)", + "Acer HD Microphone", + "Acer HD Microphone (Built-in)", + "Razer Internal Microphone", + "Razer Internal Microphone (Built-in)", + "Blue Yeti Microphone", + "Blue Yeti Microphone (Built-in)", + "RØDE NT-USB", + "RØDE NT-USB (Built-in)", + "Shure MV7", + "Shure MV7 (Built-in)", + "HyperX QuadCast", + "HyperX QuadCast (Built-in)", + "Logitech Webcam Microphone", + "Logitech Webcam Microphone (Built-in)", + "Built-in Microphone", + "Internal Mic", + "Internal Mic (Built-in)", + "Primary Mic", + "Primary Mic (Built-in)", + "Secondary Mic", + "Secondary Mic (Built-in)", + "Microphone Array", + "Microphone Array (Built-in)", + "iPhone Microphone", + "iPhone Microphone (Built-in)", + "iPad Microphone", + "iPad Microphone (Built-in)", + "Front Microphone", + "Front Microphone (Built-in)", + "Back Microphone", + "Back Microphone (Built-in)", + "Bottom Microphone", + "Bottom Microphone (Built-in)", + "Top Microphone", + "Top Microphone (Built-in)", + "Internal Microphone", + "Internal Microphone (Built-in)" +] diff --git a/src/composables/Player.ts b/src/composables/Player.ts new file mode 100644 index 0000000..13a36de --- /dev/null +++ b/src/composables/Player.ts @@ -0,0 +1,40 @@ +export function setupPlayButton( + sources: Map, + gains: Map, + context: AudioContext + ) { + const button = document.createElement('button') + button.textContent = '▶ Play All' + button.style.fontSize = '1.5rem' + button.style.padding = '10px' + button.style.marginTop = '20px' + + button.onclick = () => { + const startTime = context.currentTime + 0.1 // 100ms in Zukunft + const rampTime = 2 + + sources.forEach((source, freq) => { + const gainNode = gains.get(freq) + if (!gainNode) return + + // Ramp up GainNode + const gain = gainNode.gain + gain.setValueAtTime(0.001, startTime) + gain.exponentialRampToValueAtTime(1.0, startTime + rampTime) + + // Start BufferSource + try { + source.start(startTime) + console.log(`Started ${freq}Hz at ${startTime.toFixed(2)}`) + } catch (err) { + console.warn(`Start error for ${freq}Hz`, err) + } + }) + + button.disabled = true + button.textContent = '🔊 Playing...' + } + + document.body.appendChild(button) + } + \ No newline at end of file diff --git a/src/composables/RNBOParameterStore.ts b/src/composables/RNBOParameterStore.ts new file mode 100644 index 0000000..a5b6244 --- /dev/null +++ b/src/composables/RNBOParameterStore.ts @@ -0,0 +1,8 @@ +// composables/useRnboParamStore.ts +const parameterValues = new Map() + +export const useRnboParamStore = () => ({ + get: (id: string) => parameterValues.get(id), + set: (id: string, val: number) => parameterValues.set(id, val), + all: () => parameterValues +}) diff --git a/src/dbValueListener.ts b/src/dbValueListener.ts new file mode 100644 index 0000000..fa68333 --- /dev/null +++ b/src/dbValueListener.ts @@ -0,0 +1,40 @@ +import { useRnboParamStore } from './composables/RNBOParameterStore' +import type { Device } from '@rnbo/js' + +export function attachDBValueListener(noiseDevice: Device, freq: number) { + const paramStore = useRnboParamStore() + const deviceId = freq.toString() || 'unknown' + createDisplayElement(deviceId) + + console.log(`🔊 Attaching message listener for freq ${freq} on device ${deviceId}`) + + noiseDevice.messageEvent.subscribe((ev: any) => { + if (ev.tag === 'out1') { + const payload = Array.isArray(ev.payload) ? ev.payload[0] : ev.payload + paramStore.set(deviceId, payload) + updateDisplay(deviceId, payload) + } + }) +} + +function createDisplayElement(id: string) { + const div = document.createElement('div') + div.id = id + div.style.fontFamily = 'monospace' + div.style.padding = '4px' + div.style.marginBottom = '2px' + div.style.border = '1px solid #ccc' + div.style.width = 'max-content' + div.textContent = `${id}: (waiting...)` + document.body.appendChild(div) + return div +} + +function updateDisplay(id: string, value: number) { + const el = document.getElementById(id) + if (el) { + el.textContent = `${id}: ${value.toFixed(2)}` + } +} + + diff --git a/src/lib/microphone.ts b/src/lib/microphone.ts new file mode 100644 index 0000000..e8e9cbb --- /dev/null +++ b/src/lib/microphone.ts @@ -0,0 +1,80 @@ +/** + * 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. + */ +import microphoneList from '../assets/microphone_list.json' + +export async function getPreferredDevice (): Promise { + // 1) Berechtigung anfragen, sonst sind Labels leer + try { + await navigator.mediaDevices.getUserMedia({ audio: true }) + } catch { + window.alert("Keine Zugriff aufs Mikrofon.") + } + const availableDevices = await updateAvailableDevices() + for (const preferredMic of microphoneList) { + if (preferredMic.length > 200) { + // Limit the length of preferred mic names + console.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 = 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 updates the List of possible microphone sources. + */ + async function updateAvailableDevices (): Promise { + try { + const devices = await navigator.mediaDevices.enumerateDevices() + const newDevices = devices.filter( + device => device.kind === 'audioinput' + ) + if (newDevices.length === 0) { + console.warn( + 'No audio input devices found. This might be due to missing permissions or no connected devices.' + ) + } + return newDevices + } catch (error) { + console.error('Error enumerating devices:', error) + } + return [] + } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..1d20fda --- /dev/null +++ b/src/main.ts @@ -0,0 +1,160 @@ +import { createDevice, type Device } from '@rnbo/js' +import { attachDBValueListener } from './dbValueListener' +import controlValuesPatcher from './assets/patch/LAF-Controll-Values_Simple_Band1000.rnbopat.export.json' +import { fetchOrLoadBuffer, createBufferSource } from './composables/BufferStore' +import { setupPlayButton } from './composables/Player'; +import { getPreferredDevice } from './lib/microphone'; + +interface DeviceInfo { + id: number; + name: string; + device: Device; + audioNode: AudioNode; +} + +// create context & master gain +const ctx = new AudioContext() // match your assets +const mobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) +console.log("AudioContext created") + +// +// Gains to control the audio +// +const masterGain = ctx.createGain() + +const bands = mobile ? [150, 1500, 8000] : [63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000] +const devices = new Map() +const sources = new Map() +const gains = new Map() + +// Audio Files +const urlsWebM = mobile ? [ + { name: bands[0], url: 'audio/masking/3bands/low_band_256kbps.webm' }, + { name: bands[1], url: 'audio/masking/3bands/mid_band_256kbps.webm' }, + { name: bands[2], url: 'audio/masking/3bands/high_band_256kbps.webm' }, +] : +[ + { name: bands[0], url: 'audio/masking/9bands/Quellrauschen63.webm' }, + { name: bands[1], url: 'audio/masking/9bands/Quellrauschen125.webm' }, + { name: bands[2], url: 'audio/masking/9bands/Quellrauschen250.webm' }, + { name: bands[3], url: 'audio/masking/9bands/Quellrauschen500.webm' }, + { name: bands[4], url: 'audio/masking/9bands/Quellrauschen1000.webm' }, + { name: bands[5], url: 'audio/masking/9bands/Quellrauschen2000.webm' }, + { name: bands[6], url: 'audio/masking/9bands/Quellrauschen4000.webm' }, + { name: bands[7], url: 'audio/masking/9bands/Quellrauschen8000.webm' }, + { name: bands[8], url: 'audio/masking/9bands/Quellrauschen16000.webm' }, +] + +async function init() { + + const newDevices = await Promise.all( + bands.map(async (freq, index) => { + const device = await createBandDevice(freq, ctx) as DeviceInfo + await fetchOrLoadBuffer(freq.toString(), + urlsWebM[index].url, + ctx + ) + const sourceNode = await createBufferSource(freq.toString(),ctx) + const bandGainNode = ctx.createGain() + // patch them together + sourceNode.node?.connect(bandGainNode) + bandGainNode.connect(masterGain) + //update the source list + if(sourceNode.node instanceof AudioBufferSourceNode && bandGainNode instanceof GainNode ) { + sources.set(freq, sourceNode.node) + gains.set(freq, bandGainNode) + } + return {freq, device} + } + )) + newDevices.forEach(({freq, device}) => { + devices.set(freq, device) + }) + console.log("RNBO Devices created") + + await initMicrophone() + devices.forEach((devices, freq)=> { + try{ + if(devices && devices.device) { + attachDBValueListener(devices.device,freq) + } + }catch { + console.warn("couldnt attach rnbo listener") + } + }) + console.log("Everything is setup, display a play button") + setupPlayButton(sources, gains,ctx) + masterGain.connect(ctx.destination) +} + +/** + * Enables the Microphone to use it. + */ + +async function initMicrophone() { + + try { + const preferredDevice = await getPreferredDevice() + console.log("INIT MIC", { preferredDevice}) + if(preferredDevice){ + console.log("prefered device !!! ") + const constraints: MediaStreamConstraints = { + audio: { deviceId: { exact: preferredDevice.deviceId }, echoCancellation: false, noiseSuppression: false, + autoGainControl: false }, + video: false + } + const stream = await navigator.mediaDevices.getUserMedia( + constraints + ) + const source = ctx.createMediaStreamSource(stream) + console.log("SOURCE MICROFON", {source}) + const splitter = ctx.createChannelSplitter(source.channelCount) + + try { + source.connect(splitter) + devices.forEach((device, freq) => { + splitter.connect(device.audioNode, 0, 0) + console.log('🎤 Mikrofon mono-signal wurde an RNBO-Device mit Freq: '+freq+' geroutet.') + }) + } catch (error) { + console.error("Error!: connecting the microphone to deivces... connected") + } + + + + + }else { + console.log("Das Gerät wurde nicht gefunden. Lieber nochmal den Mikrofon-Zugriff checken") + + } + +} catch (err) { + console.error('❌ Fehler beim Zugriff auf Mikrofon:', err) + } +} + + +/** + * This function creates a RNBODevice with a given frequency. If the microphone is connected + * this devices sends out control Values as messages. + * @param freq + * @param ctx + * @returns RNBO Device + */ +async function createBandDevice(freq: number, ctx: AudioContext): Promise { + const patcher = await controlValuesPatcher as any + const rnboDevice = await createDevice({ + context: ctx, + patcher + }) + const param = rnboDevice.parametersById.get('centerFrequency') + param.value = freq + console.log("Device created for "+ freq) + return { id: freq, name: ""+freq, device: rnboDevice, audioNode: rnboDevice.node } +} + +init() // hey ho lets go + +// resume only after interaction inside the iframe (mobile safe) +window.addEventListener('click', () => ctx.resume(), { once: true }) +console.log("Await unlock context")