Needs a lot of cleanup. Data has been de-duplicated, and where identical copies existed, one of them has been replaced with a symlink. Some files have been excluded, such as binaries, installers and debug dumps. Some of that may still be present.
277 lines
14 KiB
JavaScript
277 lines
14 KiB
JavaScript
"use strict";
|
|
/*jslint nomen: true, plusplus: true, vars: true*/
|
|
/*global AvatarList, Entities, EntityViewer, Script, SoundCache, Audio, print, randFloat*/
|
|
//
|
|
// ACAudioSearchAndInject.js
|
|
// audio
|
|
//
|
|
// Created by Eric Levin and Howard Stearns 2/1/2016
|
|
// Copyright 2016 High Fidelity, Inc.
|
|
//
|
|
// Keeps track of all sounds within QUERY_RADIUS of an avatar, where a "sound" is specified in entity userData.
|
|
// Inject as many as practical into the audio mixer.
|
|
// See acAudioSearchAndCompatibilityEntitySpawner.js.
|
|
//
|
|
// This implementation takes some precautions to scale well:
|
|
// - It doesn't hastle the entity server because it issues at most one octree query every UPDATE_TIME period, regardless of the number of avatars.
|
|
// - It does not load itself because it only gathers entities once every UPDATE_TIME period, and only
|
|
// checks entity properties for those small number of entities that are currently playing (plus a RECHECK_TIME period examination of all entities).
|
|
// This implementation tries to use all the available injectors.
|
|
//
|
|
// Distributed under the Apache License, Version 2.0.
|
|
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
|
|
|
|
var MSEC_PER_SEC = 1000;
|
|
var SOUND_DATA_KEY = "io.highfidelity.soundKey"; // Sound data is specified in userData under this key.
|
|
var old_sound_data_key = "soundKey"; // For backwards compatibility.
|
|
var QUERY_RADIUS = 50; // meters
|
|
var UPDATE_TIME = 100; // ms. We'll update just one thing on this period.
|
|
var EXPIRATION_TIME = 5 * MSEC_PER_SEC; // ms. Remove sounds that have been out of range for this time.
|
|
var RECHECK_TIME = 10 * MSEC_PER_SEC; // ms. Check for new userData properties this often when not currently playing.
|
|
// (By not checking most of the time when not playing, we can efficiently go through all entities without getEntityProperties.)
|
|
var UPDATES_PER_STATS_LOG = RECHECK_TIME / UPDATE_TIME; // (It's nice to smooth out the results by straddling a recheck.)
|
|
|
|
var DEFAULT_SOUND_DATA = {
|
|
volume: 0.5, // userData cannot specify zero volume with our current method of defaulting.
|
|
loop: false, // Default must be false with our current method of defaulting, else there's no way to get a false value.
|
|
playbackGap: MSEC_PER_SEC, // in ms
|
|
playbackGapRange: 0 // in ms
|
|
};
|
|
|
|
//var isACScript = this.EntityViewer !== undefined;
|
|
var isACScript = true;
|
|
|
|
Script.include("http://hifi-content.s3.amazonaws.com/ryan/development/utils_ryan.js");
|
|
if (isACScript) {
|
|
Agent.isAvatar = true; // This puts a robot at 0,0,0, but is currently necessary in order to use AvatarList.
|
|
Avatar.skeletonModelURL = "http://hifi-content.s3.amazonaws.com/ozan/dev/avatars/invisible_avatar/invisible_avatar.fst";
|
|
}
|
|
function ignore() {}
|
|
function debug() { // Display the arguments not just [Object object].
|
|
//print.apply(null, [].map.call(arguments, JSON.stringify));
|
|
}
|
|
|
|
if (isACScript) {
|
|
EntityViewer.setCenterRadius(QUERY_RADIUS);
|
|
}
|
|
|
|
// ENTITY DATA CACHE
|
|
//
|
|
var entityCache = {}; // A dictionary of unexpired EntityData objects.
|
|
var examinationCount = 0;
|
|
function EntityDatum(entityIdentifier) { // Just the data of an entity that we need to know about.
|
|
// This data is only use for our sound injection. There is no need to store such info in the replicated entity on everyone's computer.
|
|
var that = this;
|
|
that.lastUserDataUpdate = 0; // new entity is in need of rechecking user data
|
|
// State Transitions:
|
|
// no data => no data | sound data | expired
|
|
// expired => stop => remove
|
|
// sound data => downloading
|
|
// downloading => downloading | waiting
|
|
// waiting => playing | waiting (if too many already playing)
|
|
// playing => update position etc | no data
|
|
that.stop = function stop() {
|
|
if (!that.sound) {
|
|
return;
|
|
}
|
|
print("stopping sound", entityIdentifier, that.url);
|
|
delete that.sound;
|
|
delete that.url;
|
|
if (!that.injector) {
|
|
return;
|
|
}
|
|
that.injector.stop();
|
|
delete that.injector;
|
|
};
|
|
this.update = function stateTransitions(expirationCutoff, userDataCutoff, now) {
|
|
if (that.timestamp < expirationCutoff) { // EXPIRED => STOP => REMOVE
|
|
that.stop(); // Alternatively, we could fade out and then stop...
|
|
delete entityCache[entityIdentifier];
|
|
return;
|
|
}
|
|
var properties, soundData; // Latest data, pulled from local octree.
|
|
// getEntityProperties locks the tree, which competes with the asynchronous processing of queryOctree results.
|
|
// Most entity updates are fast and only a very few do getEntityProperties.
|
|
function ensureSoundData() { // We only getEntityProperities when we need to.
|
|
if (properties) {
|
|
return;
|
|
}
|
|
properties = Entities.getEntityProperties(entityIdentifier, ['userData', 'position']);
|
|
examinationCount++; // Collect statistics on how many getEntityProperties we do.
|
|
debug("updating", that, properties);
|
|
try {
|
|
var userData = properties.userData && JSON.parse(properties.userData);
|
|
soundData = userData && (userData[SOUND_DATA_KEY] || userData[old_sound_data_key]); // Don't store soundData yet. Let state changes compare.
|
|
that.lastUserDataUpdate = now; // But do update these ...
|
|
that.url = soundData && soundData.url;
|
|
that.playAfter = that.url && now;
|
|
} catch (err) {
|
|
print(err, properties.userData);
|
|
}
|
|
}
|
|
// Stumbling on big new pile of entities will do a lot of getEntityProperties. Once.
|
|
if (that.lastUserDataUpdate < userDataCutoff) { // NO DATA => SOUND DATA
|
|
ensureSoundData();
|
|
}
|
|
if (!that.url) { // NO DATA => NO DATA
|
|
return that.stop();
|
|
}
|
|
if (!that.sound) { // SOUND DATA => DOWNLOADING
|
|
that.sound = SoundCache.getSound(soundData.url); // SoundCache can manage duplicates better than we can.
|
|
}
|
|
if (!that.sound.downloaded) { // DOWNLOADING => DOWNLOADING
|
|
return;
|
|
}
|
|
if (that.playAfter > now) { // DOWNLOADING | WAITING => WAITING
|
|
return;
|
|
}
|
|
ensureSoundData(); // We'll try to play/setOptions and will need position, so we might as well get soundData, too.
|
|
if (soundData.url !== that.url) { // WAITING => NO DATA (update next time around)
|
|
return that.stop();
|
|
}
|
|
var options = {
|
|
position: properties.position,
|
|
loop: soundData.loop || DEFAULT_SOUND_DATA.loop,
|
|
volume: soundData.volume || DEFAULT_SOUND_DATA.volume
|
|
};
|
|
function repeat() {
|
|
return !options.loop && (soundData.playbackGap >= 0);
|
|
}
|
|
function randomizedNextPlay() { // time of next play or recheck, randomized to distribute the work
|
|
var range = soundData.playbackGapRange || DEFAULT_SOUND_DATA.playbackGapRange,
|
|
base = repeat() ? ((that.sound.duration * MSEC_PER_SEC) + (soundData.playbackGap || DEFAULT_SOUND_DATA.playbackGap)) : RECHECK_TIME;
|
|
return now + base + randFloat(-Math.min(base, range), range);
|
|
}
|
|
if (that.injector && soundData.playing === false) {
|
|
that.injector.stop();
|
|
that.injector = null;
|
|
}
|
|
if (!that.injector) {
|
|
if (soundData.playing === false) { // WAITING => PLAYING | WAITING
|
|
return;
|
|
}
|
|
debug("starting", that, options);
|
|
that.injector = Audio.playSound(that.sound, options); // Might be null if at at injector limit. Will try again later.
|
|
if (that.injector) {
|
|
print("started", entityIdentifier, that.url);
|
|
} else { // Don't hammer ensureSoundData or injector manager.
|
|
that.playAfter = randomizedNextPlay();
|
|
}
|
|
return;
|
|
}
|
|
that.injector.setOptions(options); // PLAYING => UPDATE POSITION ETC
|
|
if (!that.injector.playing) { // Subtle: a looping sound will not check playbackGap.
|
|
if (repeat()) { // WAITING => PLAYING
|
|
// Setup next play just once, now. Changes won't be looked at while we wait.
|
|
that.playAfter = randomizedNextPlay();
|
|
// Subtle: if the restart fails b/c we're at injector limit, we won't try again until next playAfter.
|
|
that.injector.restart();
|
|
} else { // PLAYING => NO DATA
|
|
that.playAfter = Infinity; // was one-shot and we're finished
|
|
}
|
|
}
|
|
};
|
|
}
|
|
function internEntityDatum(entityIdentifier, timestamp, avatarPosition, avatar) {
|
|
ignore(avatarPosition, avatar); // We could use avatars and/or avatarPositions to prioritize which ones to play.
|
|
var entitySound = entityCache[entityIdentifier];
|
|
if (!entitySound) {
|
|
entitySound = entityCache[entityIdentifier] = new EntityDatum(entityIdentifier);
|
|
}
|
|
entitySound.timestamp = timestamp; // Might be updated for multiple avatars. That's fine.
|
|
}
|
|
var nUpdates = UPDATES_PER_STATS_LOG, lastStats = Date.now();
|
|
function updateAllEntityData() { // A fast update of all entities we know about. A few make sounds.
|
|
var now = Date.now(),
|
|
expirationCutoff = now - EXPIRATION_TIME,
|
|
userDataRecheckCutoff = now - RECHECK_TIME;
|
|
Object.keys(entityCache).forEach(function (entityIdentifier) {
|
|
entityCache[entityIdentifier].update(expirationCutoff, userDataRecheckCutoff, now);
|
|
});
|
|
if (nUpdates-- <= 0) { // Report statistics.
|
|
// For example, with:
|
|
// injector-limit = 40 (in C++ code)
|
|
// N_SOUNDS = 1000 (from userData in, e.g., acAudioSearchCompatibleEntitySpawner.js)
|
|
// replay-period = 3 + 20 = 23 (seconds, ditto)
|
|
// stats-period = UPDATES_PER_STATS_LOG * UPDATE_TIME / MSEC_PER_SEC = 10 seconds
|
|
// The log should show between each stats report:
|
|
// "start" lines ~= injector-limit * P(finish) = injector-limit * stats-period/replay-period = 17 ?
|
|
// total attempts at starting ("start" lines + "could not thread" lines) ~= N_SOUNDS = 1000 ?
|
|
// entities > N_SOUNDS * (1+ N_SILENT_ENTITIES_PER_SOUND) = 11000 + whatever was in the scene before running spawner
|
|
// sounds = N_SOUNDS = 1000
|
|
// getEntityPropertiesPerUpdate ~= playing + failed-starts/UPDATES_PER_STATS_LOG + other-rechecks-each-update
|
|
// = injector-limit + (total attempts - "start" lines)/UPDATES_PER_STATS__LOG
|
|
// + (entities - playing - failed-starts/UPDATES_PER_STATS_LOG) * P(recheck-in-update)
|
|
// where failed-starts/UPDATES_PER_STATS_LOG = (1000-17)/100 = 10
|
|
// = 40 + 10 + (11000 - 40 - 10)*UPDATE_TIME/RECHECK_TIME
|
|
// = 40 + 10 + 10950*0.01 = 159 (mostly proportional to enties/RECHECK_TIME)
|
|
// millisecondsPerUpdate ~= UPDATE_TIME = 100 (+ some timer machinery time)
|
|
// this assignment client activity monitor < 100% cpu
|
|
var stats = {
|
|
entities: 0,
|
|
sounds: 0,
|
|
playing: 0,
|
|
getEntityPropertiesPerUpdate: examinationCount / UPDATES_PER_STATS_LOG,
|
|
millisecondsPerUpdate: (now - lastStats) / UPDATES_PER_STATS_LOG
|
|
};
|
|
nUpdates = UPDATES_PER_STATS_LOG;
|
|
lastStats = now;
|
|
examinationCount = 0;
|
|
Object.keys(entityCache).forEach(function (entityIdentifier) {
|
|
var datum = entityCache[entityIdentifier];
|
|
stats.entities++;
|
|
if (datum.url) {
|
|
stats.sounds++;
|
|
if (datum.injector && datum.injector.playing) {
|
|
stats.playing++;
|
|
}
|
|
}
|
|
});
|
|
print(JSON.stringify(stats));
|
|
}
|
|
}
|
|
|
|
// Update the set of which EntityData we know about.
|
|
//
|
|
function updateEntiesForAvatar(avatarIdentifier) { // Just one piece of update work.
|
|
// This does at most:
|
|
// one queryOctree request of the entity server, and
|
|
// one findEntities geometry query of our own octree, and
|
|
// a quick internEntityDatum of each of what may be a large number of entityIdentifiers.
|
|
// The idea is that this is a nice bounded piece of work that should not be done too frequently.
|
|
// However, it means that we won't learn about new entities until, on average (nAvatars * UPDATE_TIME) + query round trip.
|
|
var avatar = AvatarList.getAvatar(avatarIdentifier), avatarPosition = avatar && avatar.position;
|
|
if (!avatarPosition) { // No longer here.
|
|
return;
|
|
}
|
|
var timestamp = Date.now();
|
|
if (isACScript) {
|
|
EntityViewer.setPosition(avatarPosition);
|
|
EntityViewer.queryOctree(); // Requests an update, but there's no telling when we'll actually see different results.
|
|
}
|
|
var entities = Entities.findEntities(avatarPosition, QUERY_RADIUS);
|
|
debug("found", entities.length, "entities near", avatar.name || "unknown", "at", avatarPosition);
|
|
entities.forEach(function (entityIdentifier) {
|
|
internEntityDatum(entityIdentifier, timestamp, avatarPosition, avatar);
|
|
});
|
|
}
|
|
|
|
// Slowly update the set of data we have to work with.
|
|
//
|
|
var workQueue = [];
|
|
function updateWorkQueueForAvatarsPresent() { // when nothing else to do, fill queue with individual avatar updates
|
|
workQueue = AvatarList.getAvatarIdentifiers().map(function (avatarIdentifier) {
|
|
return function () {
|
|
updateEntiesForAvatar(avatarIdentifier);
|
|
};
|
|
});
|
|
}
|
|
Script.setInterval(function () {
|
|
// There might be thousands of EntityData known to us, but only a few will require any work to update.
|
|
updateAllEntityData(); // i.e., this better be pretty fast.
|
|
// Each interval, we do no more than one updateEntitiesforAvatar.
|
|
if (!workQueue.length) {
|
|
workQueue = [updateWorkQueueForAvatarsPresent];
|
|
}
|
|
workQueue.pop()(); // There's always one
|
|
}, UPDATE_TIME);
|