@julianfrank/opensource
Version:
Open Source Projects from Julian Frank
864 lines (747 loc) • 29.2 kB
text/typescript
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;