UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

523 lines (476 loc) 21.2 kB
import { isDevEnvironment, showBalloonError, showBalloonWarning } from "../engine/debug/index.js"; import { Application } from "../engine/engine_application.js"; import { RoomEvents } from "../engine/engine_networking.js"; import { disposeStream, NetworkedStreamEvents, NetworkedStreams, StreamEndedEvent, StreamReceivedEvent } from "../engine/engine_networking_streams.js" import { serializable } from "../engine/engine_serialization_decorator.js"; import { delay, DeviceUtilities, getParam } from "../engine/engine_utils.js"; import { getIconElement } from "../engine/webcomponents/icons.js"; import { Behaviour } from "./Component.js"; import { EventList } from "./EventList.js"; export const noVoip = "noVoip"; const debugParam = getParam("debugvoip"); /** * [Voip](https://engine.needle.tools/docs/api/Voip) Voice over IP (VoIP) component for real-time audio communication between users. * Allows sending and receiving audio streams in networked rooms. * * **Requirements:** * - Active network connection (via {@link SyncedRoom} or manual connection) * - User permission for microphone access (requested automatically) * - HTTPS connection (required for WebRTC) * * **Features:** * - Automatic connection when joining rooms (`autoConnect`) * - Background audio support (`runInBackground`) * - Optional UI toggle button (`createMenuButton`) * - Mute/unmute control * * **Debug:** Use `?debugvoip` URL parameter or set `debug = true` for logging. * Press 'v' to toggle mute, 'c' to connect/disconnect when debugging. * * @example Enable VoIP in your scene * ```ts * const voip = myObject.addComponent(Voip); * voip.autoConnect = true; * voip.createMenuButton = true; * * // Manual control * voip.connect(); // Start sending audio * voip.disconnect(); // Stop sending * voip.setMuted(true); // Mute microphone * ``` * * @summary Voice over IP for networked audio communication * @category Networking * @group Components * @see {@link SyncedRoom} for room management * @see {@link NetworkedStreams} for the underlying streaming * @see {@link ScreenCapture} for video streaming * @link https://engine.needle.tools/docs/networking.html */ export class Voip extends Behaviour { /** When enabled, VoIP will start when a room is joined or when this component is enabled while already in a room. * @default true */ @serializable() autoConnect: boolean = true; /** * When enabled, VoIP will stay connected even when the browser tab is not focused/active anymore. * @default true */ @serializable() runInBackground: boolean = true; /** * When enabled, a menu button will be created to allow the user to toggle VoIP on and off. */ @serializable() createMenuButton: boolean = true; /** * When enabled debug messages will be printed to the console. This is useful for debugging audio issues. You can also append ?debugvoip to the URL to enable this. */ debug: boolean = false; private _volume: number = 1; /** * Volume for incoming audio streams (0 = silent, 1 = full volume). * Changes apply immediately to all active incoming streams. */ @serializable() get volume(): number { return this._volume; } set volume(val: number) { this._volume = val; for (const audio of this._incomingStreams.values()) { audio.volume = val; } } /** * Get the incoming audio element for a specific user. * Use this to route audio through the Web Audio API for spatial audio, effects, or analysis. * @param userId The connection ID of the remote user * @returns The HTMLAudioElement for this user's stream, or undefined if not connected * @example * ```ts * const audioEl = voip.getAudioElement(userId); * if (audioEl) { * const audioCtx = new AudioContext(); * const source = audioCtx.createMediaElementSource(audioEl); * const panner = audioCtx.createPanner(); * source.connect(panner).connect(audioCtx.destination); * } * ``` */ getAudioElement(userId: string): HTMLAudioElement | undefined { return this._incomingStreams.get(userId); } /** * Get all active incoming audio streams as a Map of userId → HTMLAudioElement. * Useful for iterating over all connected voice users. */ get incomingStreams(): ReadonlyMap<string, HTMLAudioElement> { return this._incomingStreams; } /** * Threshold for speaking detection (0–255). When a user's audio amplitude exceeds this, * they are considered "speaking". Default is 30. */ @serializable() speakingThreshold: number = 30; /** * Event fired when a user's speaking state changes. * Passes `{ userId: string, isSpeaking: boolean, volume: number }`. */ @serializable(EventList) onSpeakingChanged: EventList = new EventList(); private _speakingStates = new Map<string, boolean>(); private _analysers = new Map<string, { analyser: AnalyserNode, data: Uint8Array, context: AudioContext }>(); private _net?: NetworkedStreams; private _menubutton?: HTMLElement; /** @internal */ awake() { if (debugParam) this.debug = true; if (this.debug) { console.log("VOIP debugging: press 'v' to toggle mute or 'c' to toggle connect/disconnect"); window.addEventListener("keydown", async (evt) => { const key = evt.key.toLowerCase(); switch (key) { case "v": console.log("MUTE?", !this.isMuted) this.setMuted(!this.isMuted); break; case "c": if (this.isSending) this.disconnect(); else this.connect(); break; } }); // mute unfocused window.addEventListener("blur", () => { console.log("VOIP: MUTE ON BLUR") this.setMuted(true); }); window.addEventListener("focus", () => { console.log("VOIP: UNMUTE ON FOCUS") this.setMuted(false); }); } } /** @internal */ onEnable(): void { if (!this._net) this._net = NetworkedStreams.create(this); if (this.debug) this._net.debug = true; this._net.addEventListener(NetworkedStreamEvents.StreamReceived, this.onReceiveStream); this._net.addEventListener(NetworkedStreamEvents.StreamEnded, this.onStreamEnded); this._net.enable(); if (this.autoConnect) { if (this.context.connection.isConnected) this.connect(); } this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onJoinedRoom); this.context.connection.beginListen(RoomEvents.LeftRoom, this.onLeftRoom); this.onEnabledChanged(); this.updateButton(); window.addEventListener("visibilitychange", this.onVisibilityChanged); } /** @internal */ onDisable(): void { if (this._net) { this._net.stopSendingStream(this._outputStream); //@ts-ignore this._net.removeEventListener(NetworkedStreamEvents.StreamReceived, this.onReceiveStream); //@ts-ignore this._net.removeEventListener(NetworkedStreamEvents.StreamEnded, this.onStreamEnded) this._net?.disable(); } this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onJoinedRoom); this.context.connection.stopListen(RoomEvents.LeftRoom, this.onLeftRoom); this.onEnabledChanged(); this.updateButton(); window.removeEventListener("visibilitychange", this.onVisibilityChanged); // Clean up analysers for (const userId of [...this._analysers.keys()]) { this.cleanupAnalyser(userId); } } /** @internal */ onDestroy(): void { this._menubutton?.remove(); this._menubutton = undefined; // Clean up all streams and analysers for (const userId of [...this._analysers.keys()]) { this.cleanupAnalyser(userId); } for (const incoming of this._incomingStreams.values()) { disposeStream(incoming.srcObject as MediaStream); } this._incomingStreams.clear(); this._speakingStates.clear(); } /** Set via the mic button (e.g. when the websocket connection closes and rejoins but the user was muted before we don't want to enable VOIP again automatically) */ private _allowSending = true; private _outputStream: MediaStream | null = null; /** * @returns true if the component is currently sending audio */ get isSending() { return this._outputStream != null && this._outputStream.active; } /** Start sending audio. */ async connect(audioSource?: MediaTrackConstraints) { if (!this._net) { console.error("Cannot connect to voice chat - NetworkedStreams not initialized. Make sure the component is enabled before calling this method."); return false; } if (!this.context.connection.isConnected) { console.error("Cannot connect to voice chat - not connected to server"); this.updateButton(); return false; } else if (!await DeviceUtilities.microphonePermissionsGranted()) { console.error("Cannot connect to voice chat - microphone permissions not granted"); this.updateButton(); return false; } this._allowSending = true; this._net?.stopSendingStream(this._outputStream); disposeStream(this._outputStream); this._outputStream = await this.getAudioStream(audioSource); if (this._outputStream) { if (this.debug) console.log("VOIP: Got audio stream"); this._net?.startSendingStream(this._outputStream); this.updateButton(); return true; } else { this.updateButton(); if (!await DeviceUtilities.microphonePermissionsGranted()) { showBalloonError("Microphone permissions not granted: Please grant microphone permissions to use voice chat"); } else console.error("VOIP: Could not get audio stream - please make sure to connect an audio device and grant microphone permissions"); } if (this.debug || isDevEnvironment()) console.log("VOIP: Failed to get audio stream"); return false; } /** Stop sending audio (muting your own microphone) */ disconnect(opts?: { remember: boolean }) { if (opts?.remember) { this._allowSending = false; } this._net?.stopSendingStream(this._outputStream); disposeStream(this._outputStream); this._outputStream = null; this.updateButton(); } /** * Mute or unmute the audio stream (this will only mute incoming streams and not mute your own microphone. Use disconnect() to mute your own microphone) */ setMuted(mute: boolean) { const audio = this._outputStream?.getAudioTracks(); if (audio) { for (const track of audio) { track.enabled = !mute } } } /** Returns true if the audio stream is currently muted */ get isMuted() { if (this._outputStream === null) return false; const audio = this._outputStream?.getAudioTracks(); if (audio) { for (const track of audio) { if (!track.enabled) return true; } } return false; } private async updateButton() { if (this.createMenuButton) { if (!this._menubutton) { this._menubutton = document.createElement("button"); this._menubutton.addEventListener("click", () => { if (this.isSending) { this.disconnect({ remember: true }); } else this.connect(); DeviceUtilities.microphonePermissionsGranted().then(res => { if (!res) showBalloonWarning("<strong>Microphone permissions not granted</strong>. Please allow your browser to use the microphone to be able to talk. Click on the button on the left side of your browser's address bar to allow microphone permissions."); }) }); } if (this._menubutton) { this.context.menu.appendChild(this._menubutton); if (this.activeAndEnabled) { this._menubutton.style.display = ""; } else { this._menubutton.style.display = "none"; } this._menubutton.title = this.isSending ? "Click to disable your microphone" : "Click to enable your microphone"; let label = this.isSending ? "" : ""; let icon = this.isSending ? "mic" : "mic_off"; const hasPermission = await DeviceUtilities.microphonePermissionsGranted(); if (!hasPermission) { label = "No Permission"; icon = "mic_off"; this._menubutton.title = "Microphone permissions not granted. Please allow your browser to use the microphone to be able to talk. This can usually be done in the addressbar of the webpage."; } this._menubutton.innerText = label; this._menubutton.prepend(getIconElement(icon)); if (this.context.connection.isConnected == false) this._menubutton.setAttribute("disabled", ""); else this._menubutton.removeAttribute("disabled"); } } else if (!this.activeAndEnabled) { this._menubutton?.remove(); } } // private _analyzer?: AudioAnalyser; /** @deprecated */ public getFrequency(_userId: string | null): number | null { if (!this["unsupported_getfrequency"]) { this["unsupported_getfrequency"] = true; if (isDevEnvironment()) showBalloonWarning("VOIP: getFrequency is currently not supported"); console.warn("VOIP: getFrequency is currently not supported"); } // null is get the first with some data // if (userId === null) { // for (const c in this._incomingStreams) { // const call = this._incomingStreams[c]; // if (call && call.currentAnalyzer) return call.currentAnalyzer.getAverageFrequency(); // } // return null; // } // const call = this._incomingStreams.get(userId); // if (call && call.currentAnalyzer) return call.currentAnalyzer.getAverageFrequency(); return null; } private async getAudioStream(audio?: MediaTrackConstraints) { if (!navigator.mediaDevices.getUserMedia) { console.error("No getDisplayMedia support"); return null; } const getUserMedia = async (constraints?: MediaTrackConstraints): Promise<MediaStream | null> => { return await navigator.mediaDevices.getUserMedia({ audio: constraints ?? true, video: false }) .catch((err) => { console.warn("VOIP failed getting audio stream", err); return null; });; } const stream = await getUserMedia(audio); if (!stream) return null; // NE-5445, on iOS after calling `getUserMedia` it automatically switches the audio to the built-in microphone and speakers even if headphones are connected // if there's no device selected explictly we will try to automatically select an external device if (DeviceUtilities.isiOS() && audio?.deviceId === undefined) { const devices = await navigator.mediaDevices.enumerateDevices(); // select anything that doesn't have "iPhone" is likely "AirPods" or other bluetooth headphones const nonBuiltInAudioSource = devices.find((device) => (device.kind === "audioinput" || device.kind === "audiooutput") && !device.label.includes("iPhone")); if (nonBuiltInAudioSource) { const constraints = Object.assign({}, audio); constraints.deviceId = nonBuiltInAudioSource.deviceId; return await getUserMedia(constraints); } } return stream; } // we have to wait for the user to connect to a room when "auto connect" is enabled private onJoinedRoom = async () => { if (this.debug) console.log("VOIP: Joined room"); // Wait a moment for user list to be populated await delay(300) if (this.autoConnect && !this.isSending && this._allowSending) { this.connect(); } } private onLeftRoom = () => { if (this.debug) console.log("VOIP: Left room"); this.disconnect(); for (const incoming of this._incomingStreams.values()) { disposeStream(incoming.srcObject as MediaStream); } this._incomingStreams.clear(); for (const userId of this._analysers.keys()) { this.cleanupAnalyser(userId); } } private _incomingStreams: Map<string, HTMLAudioElement> = new Map(); /** @internal */ update() { // Only run speaking detection if someone is listening if (!this.onSpeakingChanged || this.onSpeakingChanged.listenerCount <= 0) return; for (const [userId, info] of this._analysers) { info.analyser.getByteFrequencyData(info.data as Uint8Array<ArrayBuffer>); // Average amplitude let sum = 0; for (let i = 0; i < info.data.length; i++) sum += info.data[i]; const avg = sum / info.data.length; const wasSpeaking = this._speakingStates.get(userId) ?? false; const isSpeaking = avg > this.speakingThreshold; if (isSpeaking !== wasSpeaking) { this._speakingStates.set(userId, isSpeaking); this.onSpeakingChanged.invoke({ userId, isSpeaking, volume: avg / 255 }); } } } private setupAnalyser(userId: string, _audioElement: HTMLAudioElement, stream: MediaStream) { // Only set up if someone is listening or might listen if (this._analysers.has(userId)) return; try { const audioCtx = new AudioContext(); const source = audioCtx.createMediaStreamSource(stream); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 256; source.connect(analyser); const data = new Uint8Array(analyser.frequencyBinCount); this._analysers.set(userId, { analyser, data, context: audioCtx }); } catch (err) { if (this.debug) console.warn("VOIP: Failed to create analyser for", userId, err); } } private cleanupAnalyser(userId: string) { const info = this._analysers.get(userId); if (info) { info.context.close(); this._analysers.delete(userId); } this._speakingStates.delete(userId); } private onReceiveStream = (evt: StreamReceivedEvent) => { const userId = evt.target.userId; const stream = evt.stream; let audioElement = this._incomingStreams.get(userId); if (!audioElement) { audioElement = new Audio() this._incomingStreams.set(userId, audioElement); } audioElement.srcObject = stream; audioElement.volume = this._volume; audioElement.setAttribute("autoplay", "true"); this.setupAnalyser(userId, audioElement, stream); // for mobile we need to wait for user interaction to play audio. Auto play doesnt work on android when the page is refreshed Application.registerWaitForInteraction(() => { audioElement?.play().catch((err) => { console.error("VOIP: Failed to play audio", err); }); }) } private onStreamEnded = (evt: StreamEndedEvent) => { const existing = this._incomingStreams.get(evt.userId); disposeStream(existing?.srcObject as MediaStream); this._incomingStreams.delete(evt.userId); this.cleanupAnalyser(evt.userId); } private onEnabledChanged = () => { for (const key of this._incomingStreams) { const element = key[1]; element.muted = !this.enabled; } } private onVisibilityChanged = () => { if (this.runInBackground) return; const visible = document.visibilityState === "visible"; const muted = !visible; this.setMuted(muted); for (const element of this._incomingStreams) { const str = element[1]; str.muted = muted; } }; }