Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Merge branch 'lru-14'

  • Loading branch information...
commit 8eafabf2fba7b5aa90c48a2fecbadf2f873e1a7e 2 parents 3da8e2c + 1206aa1
@parente parente authored
View
227 JSonic.js
@@ -28,8 +28,10 @@ uow.audio.initJSonic = function(args) {
dojo.declare('uow.audio.JSonic', dijit._Widget, {
// root of the JSonic server REST API, defaults to / (read-only)
jsonicURI: '/',
- // cache speech / sounds by default or not? defaults to false for privacy
+ // cache speech by default or not? defaults to false for privacy
defaultCaching: false,
+ // maximum size of the speech cache, defaults to 50 most recently used
+ cacheSize: 50,
constructor: function() {
if(uow.audio._jsonicInstance) {
throw new Error('JSonic instance already exists');
@@ -42,7 +44,8 @@ dojo.declare('uow.audio.JSonic', dijit._Widget, {
this._channels = {};
// channel-shared cache of sounds and speech
this._cache = new uow.audio.JSonicCache({
- jsonicURI : this.jsonicURI
+ jsonicURI : this.jsonicURI,
+ cacheSize: this.cacheSize
});
},
@@ -550,36 +553,112 @@ dojo.declare('uow.audio.JSonicDeferred', null, {
}
});
+dojo.declare('uow.audio.LRUCache', null, {
+ constructor: function(args) {
+ this.maxSize = args.maxSize;
+ this.size = 0;
+ this._head = null;
+ this._tail = null;
+ this._index = {};
+ },
+
+ toArray: function() {
+ var curr = this._head;
+ var arr = [];
+ while(curr) {
+ arr.push([curr.key, curr.value]);
+ curr = curr.next;
+ }
+ return arr;
+ },
+
+ fromArray: function(arr) {
+ for(var i=0, l=arr.length; i < l; i++) {
+ var node = arr[i];
+ this.push(node[0], node[1]);
+ }
+ },
+
+ get: function(key) {
+ var node = this._index[key];
+ if(node) {
+ return node.value;
+ }
+ },
+
+ push: function(key, value) {
+ console.log('pushing', key);
+ // see if the key is already in the cache
+ var curr = this._index[key];
+ if(curr) {
+ // if so, remove it from the list
+ if(curr === this._head) {
+ this._head = curr.next;
+ }
+ if(curr === this._tail) {
+ this._tail = curr.prev;
+ }
+ if(curr.next) {
+ curr.next.prev = curr.prev;
+ delete curr.next;
+ }
+ if(curr.prev) {
+ curr.prev.next = curr.next;
+ delete curr.prev;
+ }
+ // update value
+ curr.value = value;
+ } else {
+ // if not, create a node for it
+ curr = {
+ key : key,
+ value : value
+ };
+ this._index[key] = curr;
+ this.size++;
+ }
+ if(!this._head) {
+ // set the first node as the head and tail
+ this._head = curr;
+ this._tail = curr;
+ } else {
+ // put it at the tail
+ this._tail.next = curr;
+ curr.prev = this._tail;
+ this._tail = curr;
+ }
+ // check if the cache is bigger than the max
+ if(this.size > this.maxSize) {
+ // pop the head
+ curr = this._head;
+ this._head = curr.next;
+ if(this._tail === curr) {
+ this._tail = null;
+ }
+ this.size--;
+ return curr;
+ }
+ return null;
+ }
+});
+
/**
* Private. Shared cache implementation for JSonic.
*/
dojo.declare('uow.audio.JSonicCache', dijit._Widget, {
jsonicURI: null,
+ cacheSize: 50,
postMixInProperties: function() {
// speech engines and their details
this._engineCache = null;
- // cache of speech utterances
- this._speechCache = {};
// cache of speech filenames
+ this._speechCache = new uow.audio.LRUCache({maxSize : this.cacheSize});
if(localStorage) {
- // clear the cache if versions don't match
- if(localStorage['jsonic.version'] !== uow.audio._jsonicVersion) {
- // reset the cache
- this.resetCache();
- }
- // warm the cache from localStorage
- try {
- this._speechFiles = dojo.fromJson(localStorage['jsonic.cache']) || {};
- } catch(e) {
- this._speechFiles = {};
- }
+ var arr = this._unserialize();
+ this._speechCache.fromArray(arr);
// register to persist on page unload
- dojo.addOnUnload(this, '_persist');
- } else {
- this._speechFiles = {};
+ dojo.addOnUnload(this, '_serialize');
}
- // cache of sound files
- this._soundCache = {};
// cache of requests for speech rendering in progress
this._speechRenderings = {};
// determine extension to use
@@ -592,22 +671,43 @@ dojo.declare('uow.audio.JSonicCache', dijit._Widget, {
throw new Error('no known media supported');
}
},
+
+ uninitialize: function() {
+ // persist cache before cleanup
+ this._serialize();
+ this._destroyed = true;
+ },
- _persist: function() {
- localStorage['jsonic.cache'] = dojo.toJson(this._speechFiles);
+ _serialize: function() {
+ if(this._destroyed) {
+ // don't persist if instance is destroyed
+ return;
+ }
+ localStorage['jsonic.cache'] = dojo.toJson(this._speechCache.toArray());
+ },
+
+ _unserialize: function() {
+ // clear the cache if versions don't match
+ if(localStorage['jsonic.version'] !== uow.audio._jsonicVersion) {
+ // reset the cache
+ this.resetCache();
+ }
+ // warm the cache from localStorage
+ try {
+ return dojo.fromJson(localStorage['jsonic.cache']) || [];
+ } catch(e) {
+ return [];
+ }
},
- resetCache: function(args) {
+ resetCache: function() {
if(localStorage) {
// clear out the cache
delete localStorage['jsonic.cache'];
// update the version number
localStorage['jsonic.version'] = uow.audio._jsonicVersion;
}
- this._speechFiles = {};
- if(args) {
- delete this._speechCache[args.key];
- }
+ this._speechCache = new uow.audio.LRUCache({maxSize : this.maxSize});
},
getEngines: function() {
@@ -659,20 +759,11 @@ dojo.declare('uow.audio.JSonicCache', dijit._Widget, {
getSound: function(args) {
var resultDef = new dojo.Deferred();
- var node = this._soundCache[args.url];
- if(node) {
- resultDef.callback(node);
- return resultDef;
- } else {
- node = {}; //dojo.create('audio');
- node.autobuffer = true;
- node.src = args.url+this._ext;
- if(args.cache) {
- this._soundCache[args.url] = node;
- }
- resultDef.callback(node);
- return resultDef;
- }
+ node = {}; //dojo.create('audio');
+ node.autobuffer = true;
+ node.src = args.url+this._ext;
+ resultDef.callback(node);
+ return resultDef;
},
_getSpeechCacheKey: function(text, props) {
@@ -693,38 +784,38 @@ dojo.declare('uow.audio.JSonicCache', dijit._Widget, {
getSpeech: function(args, props) {
// get the client cache key
- var key = this._getSpeechCacheKey(args.text, props);
+ var key = this._getSpeechCacheKey(args.text, props),
+ resultDef, audioNode, fileName, speechParams, request;
args.key = key;
- var resultDef;
+
+ // @todo: because we don't update lru upon each result, it's not
+ // truly lru; to meet strict definition, need to update stats
+ // when audio is actually used, not just when it's returned from
+ // the server; trying the simple way first, probably good enough
- var audioNode = this._speechCache[key];
- if(audioNode) {
- resultDef = new dojo.Deferred();
- resultDef.callback(audioNode);
- return resultDef;
- }
resultDef = this._speechRenderings[key];
if(resultDef) {
// return deferred result for synth already in progress on server
return resultDef;
}
- var response = this._speechFiles[key];
- if(response) {
- response = dojo.fromJson(response);
- // build a new audio node for a known speech file url
- audioNode = this._onSpeechSynthed(null, args, response);
+ fileName = this._speechCache.get(key);
+ if(fileName) {
+ console.log('known key', key);
+ // known key
+ this._speechCache.push(key, fileName);
+ audioNode = this._buildNode(fileName);
resultDef = new dojo.Deferred();
resultDef.callback(audioNode);
return resultDef;
}
// synth on server
- var speechParams = {
+ speechParams = {
format : this._ext,
utterances : {text : args.text},
properties: props
};
resultDef = new dojo.Deferred();
- var request = {
+ request = {
url : this.jsonicURI+'synth',
handleAs: 'json',
postData : dojo.toJson(speechParams),
@@ -755,19 +846,21 @@ dojo.declare('uow.audio.JSonicCache', dijit._Widget, {
_onSpeechSynthed: function(resultDef, args, response) {
delete this._speechRenderings[args.key];
+ var fileName = response.result.text;
+ var node = this._buildNode(fileName);
+ if(args.cache) {
+ // cache the speech file url and properties
+ this._speechCache.push(args.key, fileName);
+ }
+ resultDef.callback(node);
+ return node;
+ },
+
+ _buildNode: function(fileName) {
var node = {}; //dojo.create('audio');
node.autobuffer = true;
node.preload = 'auto';
- node.src = this.jsonicURI+'files/'+response.result.text+this._ext;
- // @todo: don't let caches grow unbounded
- // @todo: distinguish levels of caching
- if(args.cache) {
- // cache the audio node
- this._speechCache[args.key] = node;
- // cache the speech file url and properties for server caching
- this._speechFiles[args.key] = dojo.toJson(response);
- }
- if(resultDef) {resultDef.callback(node);}
+ node.src = this.jsonicURI+'files/'+fileName+this._ext;
return node;
}
});
@@ -1082,7 +1175,7 @@ dojo.declare('uow.audio.JSonicChannel', dijit._Widget, {
if(this._kind === 'say') {
// if speech, dump the entire local cache assuming we need a
// resynth of everything
- this.cache.resetCache(this._args);
+ this.cache.resetCache();
}
// clear everything before the callback
var cargs = this._args;
View
2  doc/changelog.rst
@@ -4,6 +4,8 @@ Changelog
Version 0.5
-----------
+* Implemeted a least-recently used cache invalidation strategy to keep the cached speech utterance store from growing without bound.
+* Added a `cacheSize` parameter for :meth:`uow.audio.initJsonic`.
* Fixed :meth:`uow.audio.JSonicDeferred` callback argument documentation for say and play commands (completion booleans, not notice objects).
* Added the :meth:`uow.audio.JSonic.stopAll` method.
* Added the :meth:`uow.audio.JSonic.wait` method.
View
9 doc/js.rst
@@ -21,6 +21,10 @@ The JSonic factory
Initializes the client API.
:param args: Object with the following properties:
+
+ cacheSize (optional)
+
+ Integer stating the maximum number of speech utterance URLs to keep in memory and localStorage on page unload. Defaults to 50.
defaultCaching (optional)
@@ -30,7 +34,10 @@ The JSonic factory
String URI pointing to the root of the JSonic REST API. Defaults to `/`.
- :type args: object
+ :type args: object
+
+ .. versionchanged:: 0.5
+ Added `cacheSize` parameter.
The JSonic interface
--------------------
View
7 tests/index.html
@@ -38,15 +38,16 @@
}
};
QUnit.moduleDone = function(name) {
- if(uow.audio.initJSonic) {
+ if(uow.audio._jsonicInstance) {
// cleanup JSonic singleton before next module runs
- var js = uow.audio.initJSonic();
- js.destroyRecursive();
+ uow.audio._jsonicInstance.destroyRecursive();
}
};
dojo.require('uow.audio.JSonic');
+ dojo.require('uow.audio.tests.lru');
dojo.require('uow.audio.tests.creation');
+ dojo.require('uow.audio.tests.persist');
dojo.require('uow.audio.tests.simple');
dojo.require('uow.audio.tests.interrupt');
dojo.require('uow.audio.tests.sequential');
View
85 tests/lru.js
@@ -0,0 +1,85 @@
+/*global dojo ok equal getModOpts module test stop start*/
+dojo.provide('uow.audio.tests.lru');
+
+(function() {
+ module('lru', {
+ setup: function() {
+ this.cache = uow.audio.LRUCache({maxSize : 5});
+ }
+ });
+
+ test('push up to max', 10, function () {
+ var rv;
+ for(var i=0; i < this.cache.maxSize; i++) {
+ rv = this.cache.push(i, i);
+ equal(rv, null);
+ equal(this.cache.size, i+1);
+ }
+ });
+
+ test('push repeat', 20, function () {
+ var rv;
+ for(var i=0; i < this.cache.maxSize*2; i++) {
+ rv = this.cache.push(1, i);
+ equal(rv, null);
+ equal(this.cache.size, 1);
+ }
+ });
+
+ test('push overflow', 25, function() {
+ var rv;
+ for(var i=0; i < this.cache.maxSize*2; i++) {
+ rv = this.cache.push(i, i);
+ if(i < 5) {
+ equal(rv, null);
+ equal(this.cache.size, i+1);
+ } else {
+ equal(rv.key, i - 5);
+ equal(rv.value, i - 5);
+ equal(this.cache.size, this.cache.maxSize);
+ }
+ }
+ });
+
+ test('push reorder', 16, function() {
+ var rv;
+ for(var i=0; i < this.cache.maxSize; i++) {
+ rv = this.cache.push(i, i);
+ equal(rv, null);
+ }
+ rv = this.cache.push(0, 100);
+ equal(rv, null);
+ for(var i=5; i < this.cache.maxSize*2 - 1; i++) {
+ rv = this.cache.push(i, i);
+ equal(rv.key, i-4);
+ equal(rv.value, i-4);
+ }
+ rv = this.cache.push(100, 100);
+ equal(rv.key, 0);
+ equal(rv.value, 100);
+ });
+
+ test('to array', 10, function() {
+ var i;
+ for(i=0; i < this.cache.maxSize; i++) {
+ this.cache.push(i, i+10);
+ }
+ var arr = this.cache.toArray();
+ for(i=0; i < this.cache.maxSize; i++) {
+ var node = arr[i];
+ equal(node[0], i);
+ equal(node[1], i+10);
+ }
+ });
+
+ test('from array', 11, function() {
+ var arr = [[0,10], [1,11], [2,12], [3,13], [4,14], [5,15], [6,16]];
+ this.cache.fromArray(arr);
+ equal(this.cache.size, this.cache.maxSize);
+ var carr = this.cache.toArray();
+ for(var i=0; i<this.cache.maxSize; i++) {
+ equal(carr[i].key, arr[i+2].key);
+ equal(carr[i].value, arr[i+2].value);
+ }
+ });
+}());
View
37 tests/persist.js
@@ -0,0 +1,37 @@
+/*global TO UT1 UT2 localStorage dojo ok equal getModOpts module test stop start uow*/
+dojo.provide('uow.audio.tests.persist');
+
+(function() {
+ module('persist', {
+ setup: function() {
+ this.js = uow.audio.initJSonic({defaultCaching : true});
+ },
+ teardown: function() {
+ if(this.js) {
+ this.js.destroyRecursive();
+ }
+ delete localStorage['jsonic.cache'];
+ }
+ });
+ test('persist cache', 4, function () {
+ stop(TO);
+ var self = this;
+ this.js.say({text : UT1}).callAfter(function() {
+ self.js.say({text : UT1}).callAfter(function() {
+ // destroy instance to force persistence of cache
+ self.js.destroy();
+ // verify cache created and its length
+ var arr = dojo.fromJson(localStorage['jsonic.cache']);
+ equal(arr.length, 2);
+ equal(arr[0][0].slice(0, UT2.length), UT2);
+ equal(arr[1][0].slice(0, UT1.length), UT1);
+ // build a new instance to read the cache
+ self.js = uow.audio.initJSonic({defaultCaching : true});
+ // whitebox: look at cache contents
+ equal(self.js._cache._speechCache.size, 2);
+ start();
+ });
+ });
+ this.js.say({text : UT2});
+ });
+}());
Please sign in to comment.
Something went wrong with that request. Please try again.