// ambientSound.js // // This entity script will allow you to create an ambient sound that loops when a person is within a given // range of this entity. Great way to add one or more ambisonic soundfields to your environment. // // In the userData section for the entity, add/edit three values: // userData.soundURL should be a string giving the URL to the sound file. Defaults to 100 meters if not set. // userData.range should be an integer for the max distance away from the entity where the sound will be audible. // userData.maxVolume is the max volume at which the clip should play. Defaults to 1.0 full volume. // userData.disabled is an optionanl boolean flag which can be used to disable the ambient sound. Defaults to false. // // The rotation of the entity is copied to the ambisonic field, so by rotating the entity you will rotate the // direction in-which a certain sound comes from. // // Remember that the entity has to be visible to the user for the sound to play at all, so make sure the entity is // large enough to be loaded at the range you set, particularly for large ranges. // // Copyright 2016 High Fidelity, Inc. // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // (function(){ var VERSION = "0.0.1"; // This sample clip and range will be used if you don't add userData to the entity (see above) var DEFAULT_RANGE = 100; var DEFAULT_URL = "http://hifi-content.s3.amazonaws.com/ken/samples/forest_ambiX.wav"; var DEFAULT_VOLUME = 1.0; var soundURL = ""; var soundName = ""; var startTime; var soundOptions = { loop: true, localOnly: true, //ignorePenumbra: true, }; var range = DEFAULT_RANGE; var maxVolume = DEFAULT_VOLUME; var disabled = false; var UPDATE_INTERVAL_MSECS = 100; var rotation; var entity; var ambientSound; var center; var soundPlaying; var checkTimer = false; var _this; var COLOR_OFF = { red: 128, green: 128, blue: 128 }; var COLOR_ON = { red: 255, green: 0, blue: 0 }; var WANT_DEBUG = true; function debugPrint(string) { if (WANT_DEBUG) { print("ambientSound | " + string); } } var WANT_DEBUG_BROADCASTS = "ambientSound.js"; var WANT_DEBUG_OVERLAY = false; var LINEHEIGHT = 0.1; // Optionally enable debug overlays using a Settings value WANT_DEBUG_OVERLAY = WANT_DEBUG_OVERLAY || /ambientSound/.test(Settings.getValue("WANT_DEBUG_OVERLAY")); this.updateSettings = function() { // Check user data on the entity for any changes var oldSoundURL = soundURL; var props = Entities.getEntityProperties(entity, [ "userData" ]); if (props.userData) { var data = JSON.parse(props.userData); if (data.soundURL && !(soundURL === data.soundURL)) { soundURL = data.soundURL; soundName = (soundURL||"").split("/").pop(); // just filename part debugPrint("Read ambient sound URL: " + soundURL); } if (data.range && !(range === data.range)) { range = data.range; debugPrint("Read ambient sound range: " + range); } // Check known aliases for the "volume" setting (which allows for inplace upgrade of existing marketplace entities) data.maxVolume = data.maxVolume || data.soundVolume || data.volume; if (data.maxVolume && !(maxVolume === data.maxVolume)) { maxVolume = data.maxVolume; debugPrint("Read ambient sound volume: " + maxVolume); } if ("disabled" in data && !(disabled === data.disabled)) { disabled = data.disabled; debugPrint("Read ambient disabled state: " + disabled); if (disabled) { this.cleanup(); debugState("disabled"); return; } } /*if ("loop" in data && !(soundOptions.loop === data.loop)) { soundOptions.loop = data.loop; debugPrint("Read ambient loop state: " + soundOptions.loop); }*/ } if (!(soundURL === oldSoundURL) || (soundURL === "")) { if (soundURL) { debugState("downloading", "Loading ambient sound into cache"); // Use prefetch to detect URL loading errors var resource = SoundCache.prefetch(soundURL); function onStateChanged() { if (resource.state === Resource.State.FINISHED) { resource.stateChanged.disconnect(onStateChanged); ambientSound = SoundCache.getSound(soundURL); debugState("idle"); } else if (resource.state === Resource.State.FAILED) { resource.stateChanged.disconnect(onStateChanged); debugPrint("Failed to download ambient sound: " + soundURL); debugState("error"); } debugPrint("onStateChanged: " + JSON.stringify({ sound: soundName, state: resource.state, stateName: Object.keys(Resource.State).filter(function(key) { return Resource.State[key] === resource.state; }) })); } resource.stateChanged.connect(onStateChanged); onStateChanged(resource.state); } if (soundPlaying && soundPlaying.playing) { debugPrint("URL changed, stopping current ambient sound"); soundPlaying.stop(); soundPlaying = false; } } } this.clickDownOnEntity = function(entityID, mouseEvent) { print("click"); if (mouseEvent.isPrimaryButton) { this._toggle("primary click"); } }; this.startFarTrigger = function() { this._toggle("far click"); }; this._toggle = function(hint) { // Toggle between ON/OFF state, but only if not in edit mode if (Settings.getValue("io.highfidelity.isEditting")) { return; } var props = Entities.getEntityProperties(entity, [ "userData", "age", "scriptTimestamp" ]); var data = JSON.parse(props.userData); data.disabled = !data.disabled; debugPrint(hint + " -- triggering ambient sound " + (data.disabled ? "OFF" : "ON") + " (" + soundName + ")"); var oldState = _debugState; if (WANT_DEBUG_BROADCASTS) { Messages.sendMessage(WANT_DEBUG_BROADCASTS /*entity*/, JSON.stringify({ palName: MyAvatar.sessionDisplayName, soundName: soundName, hint: hint, scriptTimestamp: props.scriptTimestamp, oldState: oldState, newState: _debugState, age: props.age })); } this.cleanup(); // Save the userData and bump scriptTimestamp, which causes all nearby listeners to apply the state change Entities.editEntity(entity, { userData: JSON.stringify(data), scriptTimestamp: Math.round(props.age * 1000) }); //this._updateColor(data.disabled); }; this._updateColor = function(disabled) { // Update Shape or Text Entity color based on ON/OFF status var props = Entities.getEntityProperties(entity, [ "color", "textColor" ]); var targetColor = disabled ? COLOR_OFF : COLOR_ON; var currentColor = props.textColor || props.color; var newProps = props.textColor ? { textColor: targetColor } : { color: targetColor }; if (currentColor.red !== targetColor.red || currentColor.green !== targetColor.green || currentColor.blue !== targetColor.blue) { Entities.editEntity(entity, newProps); } }; this.preload = function(entityID) { // Load the sound and range from the entity userData fields, and note the position of the entity. debugPrint("Ambient sound preload " + VERSION); entity = entityID; _this = this; if (WANT_DEBUG_OVERLAY) { _createDebugOverlays(); } var props = Entities.getEntityProperties(entity, [ "userData" ]); if (props.userData) { var data = JSON.parse(props.userData); this._updateColor(data.disabled); if (data.disabled) { _this.maybeUpdate(); return; } } checkTimer = Script.setInterval(_this.maybeUpdate, UPDATE_INTERVAL_MSECS); }; this.maybeUpdate = function() { // Every UPDATE_INTERVAL_MSECS, update the volume of the ambient sound based on distance from my avatar _this.updateSettings(); var HYSTERESIS_FRACTION = 0.1; var props = Entities.getEntityProperties(entity, [ "position", "rotation" ]); if (!props.position) { // FIXME: this mysterious state has been happening while testing // and might indicate a bug where an Entity can become unreachable without `unload` having been called.. print("FIXME: ambientSound.js -- expected Entity unavailable!") if (WANT_DEBUG_BROADCASTS) { Messages.sendMessage(WANT_DEBUG_BROADCASTS /*entity*/, JSON.stringify({ palName: MyAvatar.sessionDisplayName, soundName: soundName, hint: "FIXME: maybeUpdate", oldState: _debugState })); } return _this.cleanup(); } center = props.position; rotation = props.rotation; var distance = Vec3.length(Vec3.subtract(MyAvatar.position, center)); if (distance <= range) { var volume = (1.0 - distance / range) * maxVolume; soundOptions.orientation = Quat.rotation; soundOptions.volume = volume; if (!soundPlaying && ambientSound && ambientSound.downloaded) { debugState("playing", "Starting ambient sound: " + soundName + " (duration: " + ambientSound.duration + ")"); soundPlaying = Audio.playSound(ambientSound, soundOptions); } else if (soundPlaying && soundPlaying.playing) { soundPlaying.setOptions(soundOptions); } } else if (soundPlaying && soundPlaying.playing && (distance > range * HYSTERESIS_FRACTION)) { soundPlaying.stop(); soundPlaying = false; debugState("idle", "Out of range, stopping ambient sound: " + soundName); } if (WANT_DEBUG_OVERLAY) { updateDebugOverlay(distance); } } this.unload = function(entityID) { debugPrint("Ambient sound unload "); if (WANT_DEBUG_BROADCASTS) { var offset = ambientSound && (new Date - startTime)/1000 % ambientSound.duration; Messages.sendMessage(WANT_DEBUG_BROADCASTS /*entity*/, JSON.stringify({ palName: MyAvatar.sessionDisplayName, soundName: soundName, hint: "unload", oldState: _debugState, offset: offset })); } if (WANT_DEBUG_OVERLAY) { _removeDebugOverlays(); } this.cleanup(); }; this.cleanup = function() { if (checkTimer) { Script.clearInterval(checkTimer); checkTimer = false; } if (soundPlaying && soundPlaying.playing) { soundPlaying.stop(); soundPlaying = false; } }; // Visual debugging overlay (to see set WANT_DEBUG_OVERLAY = true) var DEBUG_COLORS = { //preload: { red: 0, green: 80, blue: 80 }, disabled: { red: 0, green: 0, blue: 0, alpha: 0.0 }, downloading: { red: 255, green: 255, blue: 0 }, error: { red: 255, green: 0, blue: 0 }, playing: { red: 0, green: 200, blue: 0 }, idle: { red: 0, green: 100, blue: 0 } }; var _debugOverlay; var _debugState = ""; function debugState(state, message) { if (state === "playing") { startTime = new Date; } _debugState = state; if (message) { debugPrint(message); } updateDebugOverlay(); if (WANT_DEBUG_BROADCASTS) { // Broadcast state changes to an implicit entity channel, making multi-user scenarios easier to verify from a single console Messages.sendMessage(WANT_DEBUG_BROADCASTS /*entity*/, JSON.stringify({ palName: MyAvatar.sessionDisplayName, soundName: soundName, state: state })); } } function updateDebugOverlay(distance) { var props = Entities.getEntityProperties(entity, [ "name", "dimensions" ]); if (!props.dimensions) { return print("ambientSound.js: updateDebugOverlay -- entity no longer available " + entity); } var options = soundPlaying && soundPlaying.options; if (options) { var offset = soundPlaying.playing && ambientSound && (new Date - startTime)/1000 % ambientSound.duration; var deg = Quat.safeEulerAngles(options.orientation); var orientation = [ deg.x, deg.y, deg.z].map(Math.round).join(", "); var volume = options.volume; } var info = { //loudness: soundPlaying.loudness && soundPlaying.loudness.toFixed(4) || undefined, offset: offset && ("00"+offset.toFixed(1)).substr(-4)+"s" || undefined, orientation: orientation, injector: soundPlaying && soundPlaying.playing && "playing", resource: ambientSound && ambientSound.downloaded && "ready (" + ambientSound.duration.toFixed(1) + "s)", name: props.name || undefined, uuid: entity.split(/\W/)[1], // extracts just the first part of the UUID sound: soundName, volume: Math.max(0,volume||0).toFixed(2) + " / " + maxVolume.toFixed(2), distance: (distance||0).toFixed(1) + "m / " + range.toFixed(1) + "m", state: _debugState.toUpperCase(), }; // Pretty print key/value pairs, excluding any undefined values var outputText = Object.keys(info).filter(function(key) { return info[key] !== undefined; }).map(function(key) { return key + ": " + info[key]; }).join("\n"); // Calculate a local position for displaying info just above the Entity var textSize = Overlays.textSize(_debugOverlay, outputText); var size = { x: textSize.width + LINEHEIGHT, y: textSize.height + LINEHEIGHT }; var pos = { x: 0, y: props.dimensions.y + size.y/2, z: 0 }; var backgroundColor = DEBUG_COLORS[_debugState]; var backgroundAlpha = backgroundColor ? backgroundColor.alpha : 0.6; Overlays.editOverlay(_debugOverlay, { visible: true, backgroundColor: backgroundColor, backgroundAlpha: backgroundAlpha, text: outputText, localPosition: pos, size: size, }); } function _removeDebugOverlays() { if (_debugOverlay) { Overlays.deleteOverlay(_debugOverlay); _debugOverlay = 0; } } function _createDebugOverlays() { _debugOverlay = Overlays.addOverlay("text3d", { visible: true, lineHeight: LINEHEIGHT, leftMargin: LINEHEIGHT/2, topMargin: LINEHEIGHT/2, localPosition: Vec3.ZERO, parentID: entity, ignoreRayIntersection: true, isFacingAvatar: true, textAlpha: 0.6, //drawInFront: true, }); } })