Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handshake sounds #2

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
40 changes: 40 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Logs
logs
*.log

# Runtime data
pids
*.pid
*.seed

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# node-waf configuration
.lock-wscript

# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release

# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules

# Distribution directory
dist/
lib/

### Vim ###
[._]*.s[a-w][a-z]
[._]s[a-w][a-z]
*.un~
Session.vim
.netrwhist
*~

23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# shared-views

[![Build Status](https://travis-ci.org/anyWareSculpture/shared-views.svg?branch=master)](https://travis-ci.org/anyWareSculpture/shared-views)

[![codecov.io](http://codecov.io/github/anyWareSculpture/shared-views/coverage.svg?branch=master)](http://codecov.io/github/anyWareSculpture/shared-views?branch=master)

![codecov.io](http://codecov.io/github/anyWareSculpture/shared-views/branch.svg?branch=master)

## Usage

This repository contains shared views used by multiple anyWare runtimes.

When installed (or built), modules are stored in a `lib/` directory. Thus when requiring files, make sure that you are adding that before the path of the file you are requiring. In addition, ensure that you are requiring each individual file. `require('@anyware/shared-views')` alone will not work.

Example of correct usage:

const AudioView = require('@anyware/shared-views/lib/audio-view);

This was implemented this way for a variety of reasons:

1. Requiring individual files only gets those files and their dependencies. That way it isn't necessary to include the entire library if you only need a few parts.
2. This means that we don't have to keep any `index.js` or something up to date all the time. You can access whatever you want using the `lib/` directory.

41 changes: 41 additions & 0 deletions gulpfile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
var gulp = require('gulp');

var runSequence = require('run-sequence');

var gulpUtils = require('@anyware/gulp-utils');

MINIMUM_CODE_COVERAGE = 90;

// Create shared tasks
require('@anyware/gulp-utils/tasks/test-task')(
gulp,
'test', // taskName
['src/**/*.js', '!src/index.js'], // filesToCover
'test/**/*-test.js', // testFiles
process.env.TRAVIS ? 'spec' : 'nyan', // reporter
MINIMUM_CODE_COVERAGE // minimumCodeCoverage
);
require('@anyware/gulp-utils/tasks/submit-coverage-task')(
gulp,
'submit-coverage' // taskName
);
require('@anyware/gulp-utils/tasks/lint-task')(
gulp,
'lint', // taskName
["src/**/*.js", "test/**/*.js"] // files
);
require('@anyware/gulp-utils/tasks/transpile-task')(
gulp,
'build', // taskName
'src/**/*.js', // targetFiles
'lib' // destinationDirectory
);

gulp.task('default', function(callback) {
return runSequence('lint', 'test', 'build', callback);
});

gulp.task('watch', ['build'], function watch() {
gulp.watch('src/**/*.js', ['build']);
});

34 changes: 34 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@anyware/shared-views",
"version": "1.0.0",
"description": "",
"main": "",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/anyWareSculpture/shared-views.git"
},
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/anyWareSculpture/shared-views/issues"
},
"homepage": "https://github.com/anyWareSculpture/shared-views#readme",
"dependencies": {
"@anyware/game-logic": "^11.1.0",
"promise-decode-audio-data": "^0.2.0"
},
"devDependencies": {
"@anyware/gulp-utils": "^1.3.3",
"babel": "^5.4.7",
"chai": "^2.3.0",
"eslint": "^0.24.0",
"eslint-plugin-react": "^2.6.2",
"gulp": "^3.9.0",
"rewire": "^2.3.3",
"run-sequence": "^1.1.0",
"sinon": "^1.14.1"
}
}
139 changes: 139 additions & 0 deletions src/audio-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
const assert = require('assert');
const path = require('path');
const fs = require('fs');
require('promise-decode-audio-data');

// FIXME: Defer this to window.onload() ?
const context = initContext();

let isNode = false;

export class Sound {
constructor({ url, loop = false, fadeIn = 0, fadeOut = fadeIn, rate = 1, gain = 1, name = path.basename(url, '.wav') } = {}) {

assert(url);

this.url = url;
this.params = {
loop,
fadeIn,
fadeOut,
rate,
gain
};
this.name = name;
this.gain = context.createGain();
if (!isNode) this.gain.connect(context.destination);
this.head = this.gain;
}

/**
* Returns a promise to fully load all needed assets for this sound
*/
load() {
console.log('loading ' + this.url);
// FIXME: Node support:
// if (isNode) fetch = promisify(fs.readFile)(__dirname + '/../' + this.url).then(buffer => buffer);

return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', this.url, true);
xhr.responseType = 'arraybuffer';
xhr.onload = e => {
if (xhr.status == 200) resolve(xhr.response);
else reject(xhr.response);
}
xhr.onerror = e => reject(e);
xhr.send();
})
.then(buffer => {
console.log(`loaded ${this.url} - ${buffer.byteLength} bytes`);
if (!buffer) console.log(`Buffer error: ${this.url}`);
return context.decodeAudioData(buffer);
})
.then(soundBuffer => {
console.log(`decoded ${this.url}`);
this.buffer = soundBuffer;
return this;
});
}

play() {
if (this.params.fadeIn > 0) {
this.gain.gain.setValueAtTime(0, context.currentTime);
this.gain.gain.linearRampToValueAtTime(this.params.gain, context.currentTime + this.params.fadeIn);
}

this.source = context.createBufferSource();
this.source.buffer = this.buffer;
this.source.loop = this.params.loop;
if (this.params.rate != 1) this.source.playbackRate.value = this.params.rate;
if (this.params.gain != 1) this.gain.gain.value = this.params.gain;
this.source.connect(this.head);
if (isNode) this.gain.connect(context.destination);
this.source.start(context.currentTime);
}

stop() {
if (this.params.fadeOut > 0) {
var volume = this.gain.gain.value;
this.gain.gain.cancelScheduledValues(context.currentTime);
this.gain.gain.setValueAtTime(volume, context.currentTime);
this.gain.gain.linearRampToValueAtTime(0,context.currentTime + volume*this.params.fadeOut);
this.gain.gain.setValueAtTime(1, context.currentTime + volume*this.params.fadeOut);
if (this.source) this.source.stop(context.currentTime + volume*this.params.fadeOut);
}
else {
if (this.source) this.source.stop();
}
}
}

/**
* Sound with a VCF (Voltage Controlled Filter). The VCF is currently hardcoded since we only use it once
*/
export class VCFSound extends Sound {
constructor({ url, fadeIn = 0, fadeOut = fadeIn, name = path.basename(url, '.wav') } = {}) {
super({url, loop: true, fadeIn, fadeOut, name});
}

play() {
// FIXME: If running on node.js
if (!context.createBiquadFilter) return super.play();

const lowpass = context.createBiquadFilter();
lowpass.Q.value = 2;
lowpass.frequency.value = 2200;
lowpass.type = 'lowpass';
lowpass.connect(this.head);
this.head = lowpass;

var lfogain = context.createGain();
lfogain.gain.value = 2000;

var lfo = context.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = 0.333;
lfogain.connect(lowpass.frequency);
lfo.connect(lfogain);
lfo.start(context.currentTime);

super.play();
}

stop() {
super.stop();
}
}

function initContext() {
if (typeof AudioContext !== "undefined") {
return new AudioContext();
} else if (typeof NodeAudioContext !== "undefined") {
isNode = true;
return new NodeAudioContext();
}
else {
throw new Error('AudioContext not supported. :(');
}
}
115 changes: 115 additions & 0 deletions src/audio-view.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
Game event Sound Logic

Enter alone mode Alone_Mode/Pulse_amb_loop FIXME
Handshake Alone_Mode/Hand_Shake_01 changes.handshakes (Set of bool)

Mole:
Panel activated Game_01/G01_LED_XX
Active panel touched Game_01/G01_Success_01
Non-active panel touched Game_01/G01_Negative_01

Disk:

Simon:
Panel activated during pattern animation
Game_03/G03_LED_XX
Correct panel touched
Game_03/G03_LED_XX
Wrong panel touched
Game_03/G03_Negative_01
Won level (after short delay)
Game_03/G03_Success_01
Won all levels (after short delay)
Game_03/G03_Light_Show_01

*/

const _ = require('lodash');

const SculptureStore = require('@anyware/game-logic/lib/sculpture-store');
const GAMES = require('@anyware/game-logic/lib/constants/games');
import {Sound, VCFSound} from './audio-api';

export default class AudioView {
constructor(store, config, dispatcher) {
this.store = store;
this.config = config;
}

/**
* Loads all sounds, calls callback([err]) when done
*/
load(callback) {
// Maps logical sound identifiers to filenames. We'll load these sounds next.
this.sounds = {
alone: {
ambient: new VCFSound({url: 'sounds/Alone_Mode/Pulse_Amb_Loop.wav', fadeIn: 3}),
handshake: 'sounds/Alone_Mode/Hand_Shake_01.wav'
},
mole: {
success: 'sounds/Game_01/G01_Success_01.wav',
failure: 'sounds/Game_01/G01_Negative_01.wav',
panels: [0,1,2].map(stripId => _.range(10).map(panelId => `sounds/Game_01/G01_LED_${("0"+(stripId*10+panelId+1)).slice(-2)}.wav`))
},
disk: {
ambient: new Sound({url: 'sounds/Game_02/G02_Amb_Breath_Loop_01.wav', loop: true}),
loop: new Sound({url: 'sounds/Game_02/G02_Disk_Loop_Ref_01.wav', loop: true, rate: 2, gain: 0.3, fadeIn: 10}),
distance: new Sound({url: 'sounds/Game_02/G02_Disk_Loop_01.wav', loop: true, rate: 2, gain: 0.5, fadeIn: 10}),
lighteffect: 'sounds/Game_02/G02_Lights_01.wav',
success: 'sounds/Game_02/G02_Success_01.wav',
show: 'sounds/Game_02/G02_Success_final_01.wav'
},
simon: {
panels: [0,1,2].map(stripId => _.range(10).map(panelId => `sounds/Game_03/G03_LED_${("0"+(stripId*10+panelId+1)).slice(-2)}.wav`)),
success: 'sounds/Game_03/G03_Success_01.wav',
failure: 'sounds/Game_03/G03_Negative_01.wav',
show: 'sounds/Game_03/G03_Light_Show_01.wav'
}
};

// Traverse this.sounds and replace the filenames with valid sound objects.
this._promises = [];
this._traverse(this.sounds, this._promises);

// _traverse() will create promises. We call the callback once all promises resolve
console.log(`${this._promises.length} promises`);
Promise.all(this._promises)
// Don't listen to events until we've loaded all sounds
.then(() => this.store.on(SculptureStore.EVENT_CHANGE, this._handleChanges.bind(this)))
.then(() => callback(null))
.catch(callback.bind(null));
}

/**
* Traverses sound config objects and replaces nodes with valid, loaded, sounds
* populates the given promises array with promises of loaded sounds
*/
_traverse(node, promises) {
for (let key in node) {
const value = node[key];
let sound;
if (typeof value === 'string') sound = node[key] = new Sound({url: value});
else if (value instanceof Sound) sound = value;
if (sound) promises.push(sound.load());
else this._traverse(value, promises);
}
}

_handleChanges(changes) {
if (this.store.isPlayingHandshakeGame) this._handleHandshakeGame(changes);
}

_handleHandshakeGame(changes) {
// On startup, or when Start State becomes active, play ambient sound
if (changes.currentGame === GAMES.HANDSHAKE) this.sounds.alone.ambient.play();

if (changes.handshakes) {
// Did someone shake my hand?
if (changes.handshakes[this.config.username]) {
this.sounds.alone.ambient.stop();
this.sounds.alone.handshake.play();
}
}
}
}