UNPKG

@julianfrank/opensource

Version:

Open Source Projects from Julian Frank

864 lines (747 loc) 29.2 kB
import "./MicManager.css"; import {version} from "../package.json" interface Microphone { deviceId: string; label: string; } interface StreamTarget { // Required methods that a stream target must implement setStream(stream: MediaStream | null): void; start?(): void; stop?(): void; // Event handlers that will be called by MicManager onStreamStart?: () => void; onStreamStop?: () => void; onStreamError?: (error: Error) => void; } interface WaveformConfig { enabled?: boolean; width?: number; height?: number; resolution?: number; refreshRate?: number; backgroundColor?: string; waveformColor?: string; } type MicUIParameters = { rootElement?: HTMLElement; streamTarget?: StreamTarget; onMicListChange?: (microphones: Microphone[]) => void; onStartRecording?: (stream: MediaStream) => void; onStopRecording?: () => void; onAudioElementError?: (error: Error) => void; startButtonText?: string; stopButtonText?: string; waveform?: WaveformConfig; showMicSettings?: boolean; }; type MicUIElements = { micWidget: HTMLDivElement; micSettingsContainer: HTMLDivElement; settingsButton: HTMLDivElement; micList: HTMLSelectElement; startButton: HTMLDivElement; stopButton: HTMLDivElement; audioElement: HTMLAudioElement; waveformCanvas?: HTMLCanvasElement; waveformContainer?: HTMLDivElement; }; // Custom error types for better error handling class MicManagerError extends Error { constructor(message: string) { super(message); this.name = "MicManagerError"; } } class StreamError extends MicManagerError { constructor(message: string) { super(message); this.name = "StreamError"; } } class DeviceError extends MicManagerError { constructor(message: string) { super(message); this.name = "DeviceError"; } } export class MicManager { private stream: MediaStream | null = null; private rootElement: HTMLElement; private static _instanceCreated = false; private elements: MicUIElements | null = null; private startButton: HTMLDivElement | null = null; private stopButton: HTMLDivElement | null = null; private streamTarget: StreamTarget | null = null; private useDefaultAudioElement: boolean = true; private eventListeners: Map< HTMLElement, { type: string; listener: EventListener }[] > = new Map(); private micListCache: Microphone[] | null = null; private micListCacheTimestamp: number = 0; private readonly MIC_LIST_CACHE_DURATION = 5000; // Cache duration in milliseconds // Waveform visualization properties private audioContext: AudioContext | null = null; private analyser: AnalyserNode | null = null; private waveformConfig: WaveformConfig = { enabled: true, width: 64, height: 32, resolution: 32, refreshRate: 1, backgroundColor: "#000000", waveformColor: "#00ff00", }; private animationFrameId: number | null = null; constructor(params: MicUIParameters) { if (MicManager._instanceCreated) { throw new MicManagerError("MicManager instance already created"); } MicManager._instanceCreated = true; this.rootElement = params?.rootElement || document.body; if (params.streamTarget) { this.setStreamTarget(params.streamTarget); } console.log(`Mic Manager\tVersion: ${version}\tAuthor: Julian Frank`) } /** * Safely adds an event listener and stores it for cleanup * @param element The DOM element to attach the listener to * @param type Event type * @param listener Event listener function */ private addEventListenerWithCleanup( element: HTMLElement, type: string, listener: EventListener, ): void { element.addEventListener(type, listener); if (!this.eventListeners.has(element)) { this.eventListeners.set(element, []); } this.eventListeners.get(element)?.push({ type, listener }); } /** * Removes all event listeners from an element * @param element The DOM element to clean up */ private removeEventListeners(element: HTMLElement): void { const listeners = this.eventListeners.get(element); if (listeners) { listeners.forEach(({ type, listener }) => { element.removeEventListener(type, listener); }); // amazonq-ignore-next-line this.eventListeners.delete(element); } } /** * Sets a custom stream target for audio output * @param target The stream target implementation */ setStreamTarget(target: StreamTarget): void { if (this.stream) { this.stopRecording(); } this.streamTarget = target; this.useDefaultAudioElement = false; if (this.elements) { this.elements.audioElement.style.display = "none"; } } /** * Removes the custom stream target and reverts to using the default audio element */ clearStreamTarget(): void { if (this.stream) { this.stopRecording(); } this.streamTarget = null; this.useDefaultAudioElement = true; if (this.elements) { this.elements.audioElement.style.display = "block"; } } /** * Gets the list of available microphones, using cache when possible * @returns Promise<Microphone[]> */ async getMicrophoneList(): Promise<Microphone[]> { // Check if we have a valid cached list const now = Date.now(); if ( this.micListCache && (now - this.micListCacheTimestamp) < this.MIC_LIST_CACHE_DURATION ) { return this.micListCache; } if (!navigator.mediaDevices) { throw new DeviceError("MediaDevices API not supported"); } try { // Request permission first with optimal audio settings // amazonq-ignore-next-line await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, }); const devices = await navigator.mediaDevices.enumerateDevices(); const microphones = devices .filter((device) => device.kind === "audioinput") .map((device) => ({ deviceId: device.deviceId, label: device.label || `Microphone (${device.deviceId})`, })); // Update cache this.micListCache = microphones; this.micListCacheTimestamp = now; return microphones; } catch (error) { console.error("Error getting microphone list:", error); throw error instanceof Error ? new DeviceError(error.message) : new DeviceError("Failed to get microphone list"); } } private updateMicList( micList: HTMLSelectElement, microphones: Microphone[], ): void { // Clear existing options while (micList.firstChild) { micList.removeChild(micList.firstChild); } // Create document fragment for better performance const fragment = document.createDocumentFragment(); microphones.forEach((mic) => { const option = document.createElement("option"); option.value = mic.deviceId; option.text = mic.label; fragment.appendChild(option); }); micList.appendChild(fragment); } createMicUI(params: MicUIParameters): MicUIElements { const { rootElement = this.rootElement, startButtonText = "🎙️", stopButtonText = "🛑", onStartRecording, onStopRecording, onAudioElementError, streamTarget, waveform, showMicSettings = false, } = params; // Update streamTarget if provided if (streamTarget) { this.setStreamTarget(streamTarget); } // Update waveform configuration if provided if (waveform) { this.waveformConfig = { ...this.waveformConfig, ...waveform, }; } const micWidget = document.createElement("div"); micWidget.classList.add("mic-widget"); rootElement.appendChild(micWidget); // Create a container for the mic list and settings button const micSettingsContainer = document.createElement("div"); micSettingsContainer.classList.add("mic-settings-container"); if (!showMicSettings) { micSettingsContainer.style.display = "none"; } micWidget.appendChild(micSettingsContainer); // Create settings button const settingsButton = document.createElement("div"); settingsButton.classList.add("settings-button"); settingsButton.textContent = "⚙️"; settingsButton.setAttribute("role", "button"); settingsButton.setAttribute("tabindex", "0"); micSettingsContainer.appendChild(settingsButton); const micList = document.createElement("select"); micList.classList.add("mic-list"); micList.classList.add("mic-list-hidden"); // Add hidden by default micSettingsContainer.appendChild(micList); // Add click handler for settings button this.addEventListenerWithCleanup(settingsButton, "click", () => { micList.classList.toggle("mic-list-hidden"); }); const startButton = document.createElement("div"); startButton.classList.add("start-button"); startButton.textContent = startButtonText; startButton.setAttribute("role", "button"); startButton.setAttribute("tabindex", "0"); micWidget.appendChild(startButton); this.startButton = startButton; const stopButton = document.createElement("div"); stopButton.classList.add("stop-button"); stopButton.textContent = stopButtonText; stopButton.setAttribute("role", "button"); stopButton.setAttribute("tabindex", "0"); micWidget.appendChild(stopButton); this.stopButton = stopButton; const audioElement = document.createElement("audio"); audioElement.classList.add("audio-element"); // Hide audio element if using custom stream target if (!this.useDefaultAudioElement) { audioElement.style.display = "none"; } micWidget.appendChild(audioElement); // Create waveform container and canvas if enabled let waveformCanvas: HTMLCanvasElement | undefined; let waveformContainer: HTMLDivElement | undefined; if (this.waveformConfig.enabled) { // Create waveform container as a sibling to mic-widget waveformContainer = document.createElement("div"); waveformContainer.classList.add("waveform-container", "hidden"); rootElement.appendChild(waveformContainer); // Position the waveform container relative to the mic-widget waveformContainer.style.position = "absolute"; waveformContainer.style.width = `${ this.waveformConfig.width || 300 }px`; waveformContainer.style.display = "block"; // Ensure display is set waveformContainer.style.visibility = "visible"; // Ensure visibility is set waveformCanvas = document.createElement("canvas"); waveformCanvas.classList.add("waveform-canvas"); waveformCanvas.width = this.waveformConfig.width || 300; waveformCanvas.height = this.waveformConfig.height || 150; waveformCanvas.style.display = "block"; // Ensure canvas is displayed waveformContainer.appendChild(waveformCanvas); // Force a reflow to ensure proper positioning waveformContainer.offsetHeight; // Update position initially and on window resize this.updateWaveformPosition(); window.addEventListener("resize", this.updateWaveformPosition); // Log waveform creation // console.log("Waveform container created:", { // width: waveformCanvas.width, // height: waveformCanvas.height, // containerWidth: waveformContainer.style.width, // }); } // Event Listeners with cleanup registration this.addEventListenerWithCleanup( micList, "change", this.handleMicChange.bind(this), ); this.addEventListenerWithCleanup(startButton, "click", async () => { try { await this.startRecording(micList.value); onStartRecording?.(this.stream!); // Set up waveform if enabled if (this.waveformConfig.enabled && this.stream) { this.setupWaveform(this.stream); } //Update UI Buttons startButton.style.display = "none"; stopButton.style.display = "block"; } catch (error) { console.error("Error starting recording:", error); const streamError = error instanceof Error ? new StreamError(error.message) : new StreamError("Failed to start recording"); onAudioElementError?.(streamError); } }); this.addEventListenerWithCleanup(stopButton, "click", () => { this.stopRecording(); onStopRecording?.(); //Update UI Buttons startButton.style.display = "block"; stopButton.style.display = "none"; }); // Initialize microphone list with error handling this.getMicrophoneList() .then((mics) => { this.updateMicList(micList, mics); params.onMicListChange?.(mics); }) .catch((error) => { const deviceError = error instanceof Error ? new DeviceError(error.message) : new DeviceError("Failed to initialize microphone list"); params.onAudioElementError?.(deviceError); }); this.elements = { micWidget, micSettingsContainer, settingsButton, micList, startButton, stopButton, audioElement, waveformCanvas, waveformContainer, }; return this.elements; } private async handleMicChange(): Promise<void> { if (this.stream) { this.stopRecording(); //Update UI Buttons if (this.startButton && this.stopButton) { this.startButton.style.display = "block"; this.stopButton.style.display = "none"; } } } private async startRecording(deviceId: string): Promise<void> { if (this.stream) { this.stopRecording(); } const constraints: MediaStreamConstraints = { audio: { deviceId: deviceId ? { exact: deviceId } : undefined, echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, }; try { // amazonq-ignore-next-line this.stream = await navigator.mediaDevices.getUserMedia( constraints, ); if (this.stream) { // Handle stream routing based on target if (this.streamTarget) { this.streamTarget.setStream(this.stream); this.streamTarget.start?.(); this.streamTarget.onStreamStart?.(); } else if (this.useDefaultAudioElement && this.elements) { this.elements.audioElement.srcObject = this.stream; await this.elements.audioElement.play(); } } } catch (error) { console.error("Error starting recording:", error); const streamError = error instanceof Error ? new StreamError(error.message) : new StreamError("Failed to start recording"); if (this.streamTarget) { this.streamTarget.onStreamError?.(streamError); } throw streamError; } } private stopRecording(): void { if (this.stream) { try { // Stop waveform visualization if active this.stopWaveform(); // Stop all tracks this.stream.getTracks().forEach((track) => { try { track.stop(); } catch (error) { console.warn("Error stopping track:", error); } }); // Handle stream cleanup based on target if (this.streamTarget) { this.streamTarget.setStream(null); this.streamTarget.stop?.(); this.streamTarget.onStreamStop?.(); } else if (this.elements) { this.elements.audioElement.srcObject = null; } } catch (error) { console.error("Error in stopRecording:", error); } finally { this.stream = null; } } } private updateWaveformPosition = (): void => { if (this.elements?.waveformContainer && this.elements?.micWidget) { const micWidgetRect = this.elements.micWidget .getBoundingClientRect(); this.elements.waveformContainer.style.left = `${micWidgetRect.left}px`; this.elements.waveformContainer.style.top = `${ micWidgetRect.bottom + 8 }px`; // 8px gap } }; private setupWaveform(stream: MediaStream): void { if (!this.waveformConfig.enabled) { // console.log("Waveform visualization is disabled"); return; } if ( !this.elements?.waveformCanvas || !this.elements?.waveformContainer ) { console.error("Waveform elements not found"); return; } try { // Show the waveform container and ensure it's visible this.elements.waveformContainer.classList.remove("hidden"); this.elements.waveformContainer.style.display = "block"; this.elements.waveformContainer.style.visibility = "visible"; // Force layout recalculation this.elements.waveformContainer.offsetHeight; // Create audio context if it doesn't exist if (!this.audioContext) { this.audioContext = new AudioContext(); // console.log("Created new AudioContext"); } // Create analyzer node with error checking try { this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = this.waveformConfig.resolution! * 2; // Must be power of 2 // console.log( // "Analyzer created with fftSize:", // this.analyser.fftSize, // ); } catch (error) { console.error("Failed to create analyzer:", error); return; } // Connect stream to analyzer with error checking try { const source = this.audioContext.createMediaStreamSource( stream, ); source.connect(this.analyser); // console.log("Stream connected to analyzer"); } catch (error) { console.error("Failed to connect stream to analyzer:", error); return; } // Update waveform position this.updateWaveformPosition(); // Start animation loop // console.log("Starting waveform animation"); this.drawWaveform(); } catch (error) { console.error("Error in setupWaveform:", error); } } private drawWaveform(): void { if (!this.waveformConfig.enabled) { // console.log("Waveform visualization is disabled"); return; } if (!this.analyser || !this.elements?.waveformCanvas) { console.error("Missing analyser or canvas element"); return; } const canvas = this.elements.waveformCanvas; const ctx = canvas.getContext("2d"); if (!ctx) { console.error("Failed to get canvas context."); return; } // Ensure canvas dimensions are set correctly canvas.width = this.waveformConfig.width || 300; canvas.height = this.waveformConfig.height || 150; const bufferLength = this.analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); const draw = () => { if (!this.analyser || !ctx) { // console.warn("Analyser or context lost during animation. This can be ignored safely."); return; } try { this.animationFrameId = requestAnimationFrame(draw); // Get waveform data this.analyser.getByteTimeDomainData(dataArray); // Clear canvas with background color ctx.fillStyle = this.waveformConfig.backgroundColor || "#000000"; ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw waveform ctx.lineWidth = 2; ctx.strokeStyle = this.waveformConfig.waveformColor || "#00ff00"; ctx.beginPath(); const sliceWidth = canvas.width / bufferLength; let x = 0; for (let i = 0; i < bufferLength; i++) { const v = dataArray[i] / 128.0; const y = (v * canvas.height) / 2; if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } x += sliceWidth; } ctx.lineTo(canvas.width, canvas.height / 2); ctx.stroke(); } catch (error) { console.error("Error in draw loop:", error); if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } } }; // Start animation loop with specified refresh rate if ( this.waveformConfig.refreshRate && this.waveformConfig.refreshRate < 60 ) { const interval = 1000 / this.waveformConfig.refreshRate; let lastDraw = 0; const throttledDraw = (timestamp: number) => { if (!lastDraw || timestamp - lastDraw >= interval) { draw(); lastDraw = timestamp; } this.animationFrameId = requestAnimationFrame(throttledDraw); }; this.animationFrameId = requestAnimationFrame(throttledDraw); } else { draw(); } // console.log("Waveform drawing started"); } private stopWaveform(): void { if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } if (this.analyser) { this.analyser.disconnect(); this.analyser = null; } if (this.audioContext) { this.audioContext.close(); this.audioContext = null; } // Clear canvas if it exists and hide the container if (this.elements?.waveformCanvas) { const ctx = this.elements.waveformCanvas.getContext("2d"); if (ctx) { ctx.clearRect( 0, 0, this.elements.waveformCanvas.width, this.elements.waveformCanvas.height, ); } } // Hide the waveform container if (this.elements?.waveformContainer) { this.elements.waveformContainer.classList.add("hidden"); } } /** * Enables or disables the mic settings interface * @param enabled Whether the mic settings should be shown */ toggleMicSettings(enabled: boolean): void { if (!this.elements?.micSettingsContainer) { console.warn("Mic settings container not initialized"); return; } this.elements.micSettingsContainer.style.display = enabled ? "flex" : "none"; } /** * Enables or disables the waveform visualization * @param enabled Whether the waveform should be shown */ toggleWaveform(enabled: boolean): void { this.waveformConfig.enabled = enabled; if (!enabled) { this.stopWaveform(); } else if (this.stream) { // If we have an active stream, restart the waveform this.setupWaveform(this.stream); } } /** * Sets custom styles for various UI components * @param styles Object containing style configurations */ setStyle(styles: { backgroundColor?: string; waveformColor?: string; waveformBackgroundColor?: string; buttonBackgroundColor?: string; buttonShadowColor?: string; micListBackgroundColor?: string; micListBorderColor?: string; }): void { // Update waveform colors if provided if (styles.waveformColor) { this.waveformConfig.waveformColor = styles.waveformColor; } if (styles.waveformBackgroundColor) { this.waveformConfig.backgroundColor = styles.waveformBackgroundColor; } if (!this.elements) { console.warn("UI elements not initialized"); return; } // Apply background color to the widget if (styles.backgroundColor) { this.elements.micWidget.style.backgroundColor = styles.backgroundColor; } // Apply button styles if (styles.buttonBackgroundColor || styles.buttonShadowColor) { const buttons = [ this.elements.startButton, this.elements.stopButton, this.elements.settingsButton ]; buttons.forEach(button => { if (styles.buttonBackgroundColor) { button.style.backgroundColor = styles.buttonBackgroundColor; } if (styles.buttonShadowColor) { button.style.boxShadow = `0rem 0rem 0.5rem ${styles.buttonShadowColor}`; } }); } // Apply mic list styles if (styles.micListBackgroundColor || styles.micListBorderColor) { if (styles.micListBackgroundColor) { this.elements.micList.style.backgroundColor = styles.micListBackgroundColor; } if (styles.micListBorderColor) { this.elements.micList.style.borderColor = styles.micListBorderColor; } } } dispose(): void { this.stopRecording(); // Remove window resize event listener if it exists if (this.elements?.waveformContainer) { window.removeEventListener("resize", this.updateWaveformPosition); } // Clean up all registered event listeners if (this.elements) { // Clean up event listeners for all elements Object.values(this.elements).forEach((element) => { if (element instanceof HTMLElement) { this.removeEventListeners(element); // Remove the waveform container from DOM if it exists if (element === this.elements?.waveformContainer) { element.remove(); } } }); // Remove the widget from DOM this.elements.micWidget.remove(); this.elements = null; } // Clear cached data this.micListCache = null; this.micListCacheTimestamp = 0; // Reset instance flag MicManager._instanceCreated = false; } } export default MicManager;