├── node ├── .npmrc ├── public │ ├── sounds │ │ ├── notes │ │ │ ├── ab1.wav │ │ │ ├── ab2.wav │ │ │ ├── ab3.wav │ │ │ ├── bb1.wav │ │ │ ├── bb2.wav │ │ │ ├── c1.wav │ │ │ ├── c2.wav │ │ │ ├── db1.wav │ │ │ ├── db2.wav │ │ │ ├── eb1.wav │ │ │ ├── eb2.wav │ │ │ ├── f1.wav │ │ │ ├── f2.wav │ │ │ ├── g1.wav │ │ │ └── g2.wav │ │ ├── loops │ │ │ ├── voices.mp3 │ │ │ ├── beatbox.mp3 │ │ │ └── drum_loop.wav │ │ └── samples │ │ │ ├── clap.wav │ │ │ ├── kick.wav │ │ │ ├── snap.wav │ │ │ ├── bongo.wav │ │ │ ├── rimshot.wav │ │ │ ├── snare.wav │ │ │ ├── voice.wav │ │ │ └── woosh.wav │ ├── fonts │ │ ├── Quicksand-Bold.ttf │ │ ├── Quicksand-Light.ttf │ │ └── Quicksand-Regular.ttf │ ├── irs │ │ ├── HOA3_BRIRs-medium_01-08ch.wav │ │ ├── HOA3_BRIRs-medium_09-16ch.wav │ │ ├── HOA3_filters_virtual_01-08ch.wav │ │ ├── HOA3_filters_virtual_09-16ch.wav │ │ ├── room-medium-1-furnished-src-20-Set1_16b_01-08ch.wav │ │ └── room-medium-1-furnished-src-20-Set1_16b_09-16ch.wav │ └── stream │ │ └── readme.txt ├── .babelrc ├── sass │ ├── _overrides.scss │ ├── main.scss │ ├── _01-mixins.scss │ ├── _configuration.scss │ ├── _03-services.scss │ ├── _02-commons.scss │ └── _04-survey.scss ├── src │ ├── client │ │ ├── player │ │ │ ├── NuMain.js │ │ │ ├── index.js │ │ │ ├── NuBaseModule.js │ │ │ ├── NuTemplate.js │ │ │ ├── Nu.js │ │ │ ├── NuStream.js │ │ │ ├── NuScore.js │ │ │ ├── PlayerExperience.js │ │ │ ├── NuRoomReverb.js │ │ │ ├── NuOutput.js │ │ │ ├── NuProbe.js │ │ │ ├── NuPath.js │ │ │ ├── NuGroups.js │ │ │ ├── NuLoop.js │ │ │ ├── NuSynth.js │ │ │ └── NuDisplay.js │ │ └── controller │ │ │ ├── index.js │ │ │ └── ControllerExperience.js │ └── server │ │ ├── NuProbe.js │ │ ├── NuLoop.js │ │ ├── NuSynth.js │ │ ├── NuDisplay.js │ │ ├── NuScore.js │ │ ├── Nu.js │ │ ├── NuGrain.js │ │ ├── NuTemplate.js │ │ ├── NuMain.js │ │ ├── index.js │ │ ├── NuGroups.js │ │ ├── NuBaseModule.js │ │ ├── NuPath.js │ │ ├── config │ │ └── default.js │ │ ├── PlayerExperience.js │ │ ├── NuStream.js │ │ └── NuOutput.js ├── _install_macos.sh ├── bin │ ├── server │ ├── styles │ ├── log │ └── javascripts ├── html │ └── default.ejs └── package.json ├── .gitignore ├── utils └── openChromeArray.sh ├── LICENSE ├── README.md └── maxmsp ├── extras └── nu.error.maxpat └── patchers ├── nu.template.maxpat ├── nu.output.maxpat ├── nu.synth.maxpat └── nu.loop.maxpat /node/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /node/public/sounds/notes/ab1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/ab1.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/ab2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/ab2.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/ab3.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/ab3.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/bb1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/bb1.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/bb2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/bb2.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/c1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/c1.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/c2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/c2.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/db1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/db1.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/db2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/db2.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/eb1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/eb1.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/eb2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/eb2.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/f1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/f1.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/f2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/f2.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/g1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/g1.wav -------------------------------------------------------------------------------- /node/public/sounds/notes/g2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/notes/g2.wav -------------------------------------------------------------------------------- /node/public/sounds/loops/voices.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/loops/voices.mp3 -------------------------------------------------------------------------------- /node/public/sounds/samples/clap.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/samples/clap.wav -------------------------------------------------------------------------------- /node/public/sounds/samples/kick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/samples/kick.wav -------------------------------------------------------------------------------- /node/public/sounds/samples/snap.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/samples/snap.wav -------------------------------------------------------------------------------- /node/public/fonts/Quicksand-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/fonts/Quicksand-Bold.ttf -------------------------------------------------------------------------------- /node/public/fonts/Quicksand-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/fonts/Quicksand-Light.ttf -------------------------------------------------------------------------------- /node/public/sounds/loops/beatbox.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/loops/beatbox.mp3 -------------------------------------------------------------------------------- /node/public/sounds/loops/drum_loop.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/loops/drum_loop.wav -------------------------------------------------------------------------------- /node/public/sounds/samples/bongo.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/samples/bongo.wav -------------------------------------------------------------------------------- /node/public/sounds/samples/rimshot.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/samples/rimshot.wav -------------------------------------------------------------------------------- /node/public/sounds/samples/snare.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/samples/snare.wav -------------------------------------------------------------------------------- /node/public/sounds/samples/voice.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/samples/voice.wav -------------------------------------------------------------------------------- /node/public/sounds/samples/woosh.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/sounds/samples/woosh.wav -------------------------------------------------------------------------------- /node/public/fonts/Quicksand-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/fonts/Quicksand-Regular.ttf -------------------------------------------------------------------------------- /node/public/irs/HOA3_BRIRs-medium_01-08ch.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/irs/HOA3_BRIRs-medium_01-08ch.wav -------------------------------------------------------------------------------- /node/public/irs/HOA3_BRIRs-medium_09-16ch.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/irs/HOA3_BRIRs-medium_09-16ch.wav -------------------------------------------------------------------------------- /node/public/irs/HOA3_filters_virtual_01-08ch.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/irs/HOA3_filters_virtual_01-08ch.wav -------------------------------------------------------------------------------- /node/public/irs/HOA3_filters_virtual_09-16ch.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/irs/HOA3_filters_virtual_09-16ch.wav -------------------------------------------------------------------------------- /node/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMap": "inline", 3 | "presets": ["es2015"], 4 | "plugins": ["transform-runtime", "transform-es2015-modules-commonjs"] 5 | } 6 | -------------------------------------------------------------------------------- /node/public/stream/readme.txt: -------------------------------------------------------------------------------- 1 | This folder is used as a cache for the NuStream module. Audio files saved here will be automatically deleted by the server upon streaming, or at server start. 2 | -------------------------------------------------------------------------------- /node/public/irs/room-medium-1-furnished-src-20-Set1_16b_01-08ch.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/irs/room-medium-1-furnished-src-20-Set1_16b_01-08ch.wav -------------------------------------------------------------------------------- /node/public/irs/room-medium-1-furnished-src-20-Set1_16b_09-16ch.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ircam-cosima/soundworks-nu/HEAD/node/public/irs/room-medium-1-furnished-src-20-Set1_16b_09-16ch.wav -------------------------------------------------------------------------------- /node/sass/_overrides.scss: -------------------------------------------------------------------------------- 1 | // Entry point for application specific styles 2 | 3 | .controller-background { 4 | background-color: black; 5 | } 6 | 7 | .huge { 8 | font-size: 70px; 9 | } 10 | -------------------------------------------------------------------------------- /node/src/client/player/NuMain.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuMain: misc. config setup 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | 7 | export default class NuMain extends NuBaseModule { 8 | constructor(playerExperience) { 9 | super(playerExperience, 'nuMain'); 10 | } 11 | 12 | // reload page 13 | reload(){ 14 | window.location.reload(true); 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Transpiled files and dependencies 2 | node/node_modules 3 | node/public/js 4 | node/public/css 5 | node/dist 6 | 7 | # ignore all config except default 8 | node/src/server/config/* 9 | !node/src/server/config/default.js 10 | 11 | # Application instance specific 12 | node/logs 13 | node/db 14 | 15 | # ignore junk files 16 | npm-debug.log 17 | .DS_Store 18 | Thumbs.db 19 | 20 | # dynamic audio file list gen upon transpile 21 | node/src/client/shared/audioFiles.js -------------------------------------------------------------------------------- /node/src/server/NuProbe.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuProbe: get motion etc. info from given client in OSC 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | 7 | export default class NuProbe extends NuBaseModule { 8 | constructor(serverExperience) { 9 | super(serverExperience, 'nuProbe'); 10 | 11 | // to be saved params to send to client when connects: 12 | this.params = { 13 | touch: false, 14 | orientation: false, 15 | acceleration: false, 16 | energy: false 17 | }; 18 | } 19 | 20 | } 21 | 22 | -------------------------------------------------------------------------------- /node/src/server/NuLoop.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuLoop: Nu module sequencer-like (drum machine) 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | 7 | export default class NuLoop extends NuBaseModule { 8 | constructor(serverExperience) { 9 | super(serverExperience, 'nuLoop'); 10 | 11 | // local attributes 12 | this.params = { period: 2.0, 13 | divisions: 16, 14 | jitter: 0.0, 15 | jitterMemory: false, 16 | masterGain: 1.0 17 | }; 18 | 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /node/src/server/NuSynth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuSynth: distributed synthetizer, sending "note" information via OSC 3 | * to trigger real notes in local synthetizer 4 | **/ 5 | 6 | import NuBaseModule from './NuBaseModule' 7 | 8 | export default class NuSynth extends NuBaseModule { 9 | constructor(serverExperience) { 10 | super(serverExperience, 'nuSynth'); 11 | 12 | // local attributes 13 | this.params = { 14 | volume: 1.0, 15 | synthType: 'square', 16 | attackTime: 0.1, 17 | releaseTime: 0.1, 18 | periodicWave: [0, 0, 1, 0] 19 | }; 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /node/src/server/NuDisplay.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuDisplay: visual feedback 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | 7 | export default class NuDisplay extends NuBaseModule { 8 | constructor(serverExperience) { 9 | super(serverExperience, 'nuDisplay'); 10 | 11 | // to be saved params to send to client when connects: 12 | this.params = { 13 | 'feedbackGain': 1.0, 14 | 'enableFeedback': true, 15 | 'restColor': [0,0,0], 16 | 'activeColor': [255, 255, 255], 17 | 'text1': 'clientId', 18 | 'text2': ' ', 19 | 'text3': 'in the forest at night', 20 | }; 21 | } 22 | 23 | } 24 | 25 | -------------------------------------------------------------------------------- /node/sass/main.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Mixins 3 | */ 4 | @import '01-mixins'; 5 | 6 | /** 7 | * Defines some global layout variables (background-color, text-color, etc...) 8 | * Change values to change the general look of the application 9 | */ 10 | @import 'configuration'; 11 | 12 | /** 13 | * Class helpers (.small, .big for font sizes, .btn, etc..) 14 | */ 15 | @import '02-commons'; 16 | 17 | /** 18 | * Services styles. 19 | */ 20 | @import '03-services'; 21 | 22 | /** 23 | * Default styles for the `survey` scene. 24 | */ 25 | @import '04-survey'; 26 | 27 | /** 28 | * Entry point for application specific styling and overriding. 29 | */ 30 | @import 'overrides'; 31 | -------------------------------------------------------------------------------- /node/_install_macos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # ensure current dir is . 4 | LIB_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | cd $LIB_DIR 6 | 7 | # check for homebrew install 8 | which -s brew 9 | if [[ $? != 0 ]] ; then 10 | # Suggest to install Homebrew 11 | echo 'script aborted: install homebrew to continue (check https://brew.sh/index_fr.html)' 12 | exit 13 | else 14 | brew update 15 | fi 16 | 17 | # install node 18 | brew install node 19 | 20 | # install and transpile project 21 | npm install 22 | npm run transpile 23 | 24 | # output success 25 | echo '\n' 26 | echo '---------------------------' 27 | echo '## installation complete ##' 28 | echo '---------------------------' 29 | echo '\n' -------------------------------------------------------------------------------- /node/src/client/player/index.js: -------------------------------------------------------------------------------- 1 | // import client side soundworks and player experience 2 | import * as soundworks from 'soundworks/client'; 3 | import PlayerExperience from './PlayerExperience'; 4 | import serviceViews from '../shared/serviceViews'; 5 | 6 | function bootstrap() { 7 | // initialize the client with configuration received 8 | // from the server through the `index.html` 9 | // @see {~/src/server/index.js} 10 | // @see {~/html/default.ejs} 11 | const config = Object.assign({ appContainer: '#container' }, window.soundworksConfig); 12 | soundworks.client.init(config.clientType, config); 13 | 14 | // configure views for the services 15 | soundworks.client.setServiceInstanciationHook((id, instance) => { 16 | if (serviceViews.has(id)) 17 | instance.view = serviceViews.get(id, config); 18 | }); 19 | 20 | // create client side (player) experience and start the client 21 | const experience = new PlayerExperience(config.assetsDomain); 22 | soundworks.client.start(); 23 | } 24 | 25 | window.addEventListener('load', bootstrap); 26 | -------------------------------------------------------------------------------- /node/src/client/controller/index.js: -------------------------------------------------------------------------------- 1 | // import client side soundworks and player experience 2 | import * as soundworks from 'soundworks/client'; 3 | import ControllerExperience from './ControllerExperience'; 4 | import serviceViews from '../shared/serviceViews'; 5 | 6 | function bootstrap() { 7 | // initialize the client with configuration received 8 | // from the server through the `index.html` 9 | // @see {~/src/server/index.js} 10 | // @see {~/html/default.ejs} 11 | const config = Object.assign({ appContainer: '#container' }, window.soundworksConfig); 12 | soundworks.client.init(config.clientType, config); 13 | 14 | // configure views for the services 15 | soundworks.client.setServiceInstanciationHook((id, instance) => { 16 | if (serviceViews.has(id)) 17 | instance.view = serviceViews.get(id, config); 18 | }); 19 | 20 | // create client side (player) experience and start the client 21 | const experience = new ControllerExperience(config.assetsDomain); 22 | soundworks.client.start(); 23 | } 24 | 25 | window.addEventListener('load', bootstrap); 26 | -------------------------------------------------------------------------------- /node/src/server/NuScore.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuScore: define sequences of sounds to play 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | 7 | export default class NuScore extends NuBaseModule { 8 | constructor(serverExperience) { 9 | super(serverExperience, 'nuScore'); 10 | 11 | // to be saved params to send to client when connects: 12 | this.params = { 13 | gain: 1.0, 14 | perc: 1.0, 15 | delay: 2.0, // set delay before start score (for rendez-vous time sync. based mechanism) 16 | }; 17 | } 18 | 19 | // overwrite enterPlayer to make sure saved score will not be sent to client upon conection (saved in this.params.setScore) 20 | // got to find a better mechanism for this whole saved param spread business.. 21 | enterPlayer(client){ 22 | // send to new client information regarding current groups parameters 23 | Object.keys(this.params).forEach( (key) => { 24 | if( key != 'startScore' ){ 25 | this.e.send(client, this.moduleName, [key, this.params[key]] ); 26 | } 27 | }); 28 | } 29 | 30 | } 31 | 32 | -------------------------------------------------------------------------------- /node/src/server/Nu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Nu: Simple wrapper to export all Nu modules 3 | **/ 4 | 5 | export { default as Display } from './NuDisplay'; 6 | export { default as Output } from './NuOutput'; 7 | export { default as Groups } from './NuGroups'; 8 | export { default as RoomReverb } from './NuRoomReverb'; 9 | export { default as Path } from './NuPath'; 10 | export { default as Loop } from './NuLoop'; 11 | export { default as Template } from './NuTemplate'; 12 | export { default as Grain } from './NuGrain'; 13 | export { default as Probe } from './NuProbe'; 14 | export { default as Synth } from './NuSynth'; 15 | export { default as Stream } from './NuStream'; 16 | export { default as Main } from './NuMain'; 17 | export { default as Score } from './NuScore'; 18 | 19 | // ------------------------------------------------------------ 20 | // UTILS 21 | // ------------------------------------------------------------ 22 | 23 | // convert "stringified numbers" (e.g. '10.100') element of arayIn to Numbers 24 | Array.prototype.numberify = function() { 25 | this.forEach( (elmt, index) => { 26 | if( !isNaN(elmt) ) 27 | this[index] = Number(this[index]); 28 | }); 29 | return this; 30 | }; -------------------------------------------------------------------------------- /node/bin/server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var colors = require('colors'); 3 | var fse = require('fs-extra'); 4 | var log = require('./log'); 5 | var path = require('path'); 6 | var childProcess = require('child_process'); 7 | 8 | 'use strict'; 9 | 10 | var cwd = process.cwd(); 11 | 12 | /** 13 | * Process hosting the server 14 | */ 15 | var server = null; 16 | 17 | /** 18 | * Run the `serverIndex` in a forked process 19 | */ 20 | function start(serverIndex) { 21 | try { 22 | if (fse.statSync(serverIndex).isFile()) { 23 | if (server) 24 | stop(); 25 | 26 | log.serverStart(); 27 | server = childProcess.fork(serverIndex); 28 | } 29 | } catch(err) { 30 | log.serverError(serverIndex); 31 | } 32 | } 33 | 34 | /** 35 | * Kill the forked process hosting the server 36 | */ 37 | function stop() { 38 | if (server) { 39 | log.serverStop(); 40 | server.kill(); 41 | server = null; 42 | } 43 | } 44 | 45 | /** 46 | * 47 | */ 48 | function restart() { 49 | stop(); 50 | start(); 51 | } 52 | 53 | /** 54 | * Kill server on uncaughtException 55 | */ 56 | process.on('uncaughtException', function (err) { 57 | console.log('Uncaught Exception: '.red); 58 | console.error(err.stack); 59 | 60 | stop(); 61 | process.exit(); 62 | }); 63 | 64 | module.exports = { 65 | start: start, 66 | stop: stop, 67 | restart: restart, 68 | }; 69 | 70 | 71 | -------------------------------------------------------------------------------- /node/sass/_01-mixins.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------- 2 | // MIXINS 3 | // ----------------------------------------------------------------- 4 | 5 | @mixin font-face($family, $weight, $file) { 6 | $filepath: "../fonts/" + $file; 7 | @font-face { 8 | font-family: "#{$family}"; 9 | font-weight: $weight; 10 | src: url($filepath + ".ttf") format('truetype'); 11 | } 12 | } 13 | 14 | @mixin keyframes($animation-name) { 15 | @-webkit-keyframes #{$animation-name} { 16 | @content; 17 | } 18 | @-moz-keyframes #{$animation-name} { 19 | @content; 20 | } 21 | @-ms-keyframes #{$animation-name} { 22 | @content; 23 | } 24 | @-o-keyframes #{$animation-name} { 25 | @content; 26 | } 27 | @keyframes #{$animation-name} { 28 | @content; 29 | } 30 | } 31 | 32 | @mixin prefix($name, $value) { 33 | @each $vendor in ('-webkit-', '-moz-', '-ms-', '-o-', '') { 34 | #{$vendor}#{$name}: #{$value}; 35 | } 36 | } 37 | 38 | @mixin transition($str) { 39 | -webkit-transition: $str; 40 | -moz-transition: $str; 41 | -ms-transition: $str; 42 | -o-transition: $str; 43 | transition: $str; 44 | } 45 | 46 | @mixin animation($str) { 47 | -webkit-animation: #{$str}; 48 | -moz-animation: #{$str}; 49 | -ms-animation: #{$str}; 50 | -o-animation: #{$str}; 51 | animation: #{$str}; 52 | } 53 | 54 | -------------------------------------------------------------------------------- /node/src/server/NuGrain.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuGrain: Granular synthesis (based on soundworks-shaker). An audio track is segmented 3 | * and segments are sorted by loudness. Segments are afterwards playing in a sequencer, 4 | * the current active segment being selected based on shaking energy or OSC client sent 5 | * energy. 6 | **/ 7 | 8 | import NuBaseModule from './NuBaseModule' 9 | 10 | export default class NuGrain extends NuBaseModule { 11 | constructor(serverExperience) { 12 | super(serverExperience, 'nuGrain'); 13 | 14 | // local attributes 15 | this.params = { 16 | gain: 1.0, 17 | enable: 0, 18 | override: 1.0, 19 | energy: 0, 20 | randomVar: 1, 21 | engineParams: {} 22 | }; 23 | 24 | } 25 | 26 | /** 27 | * had to redefine the enterPlayer method here to send a "reset" message once 28 | * all initial parameters were sent, to make sure the granular engine of the 29 | * new player will indeed take into account said parameters 30 | **/ 31 | enterPlayer(client){ 32 | // send to new client information regarding current groups parameters 33 | Object.keys(this.params).forEach( (key) => { 34 | this.e.send(client, this.moduleName, [key, this.params[key]] ); 35 | }); 36 | // reset granular engine to take preset values into account 37 | this.e.send( client, this.moduleName, ['reset'] ); 38 | } 39 | 40 | } 41 | 42 | -------------------------------------------------------------------------------- /utils/openChromeArray.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | CHROME_APP=/Applications/Browsers/Google\ Chrome.app/Contents/MacOS/Google\ Chrome 4 | URL="http://127.0.0.1:8000" 5 | 6 | windowWidth=300 7 | windowHeight=300 8 | I=4 9 | J=4 10 | 11 | if [[ $1 == full ]]; then 12 | echo "full mode" 13 | I=4 14 | J=4 15 | windowHeight=100 16 | windowWidth=100 17 | fi 18 | 19 | for i in $(seq 1 $I); 20 | do 21 | posY=$(( ($i - 1) * windowWidth )) 22 | for j in $(seq 1 $J); 23 | do 24 | posX=$(( ($j - 1) * windowHeight )) 25 | # echo "window pos: $posX $posY dim: $windowWidth $windowHeight" 26 | 27 | # "${CHROME_APP}" --user-data-dir=${PROFILE} --window-size=$windowWidth,$windowHeight --window-position=$posX,$posY --app=$URL 28 | 29 | "${CHROME_APP}" --profile-directory="Default" \ 30 | --app="data:text/html, 31 | " & 38 | sleep 0.1 39 | if [[ $1 != full ]]; then 40 | sleep 0.7 # ensures order in client pos / id 41 | fi 42 | 43 | done 44 | done 45 | 46 | 47 | # if [[ $1 == full ]]; then 48 | # read -p "Press any key to continue... " 49 | # pkill -f "${CHROME_APP}" 50 | # fi 51 | 52 | # "${CHROME_APP}" 53 | 54 | # --window-size=800,600 --window-position=580,240 --app="http://127.0.0.1:8000" -------------------------------------------------------------------------------- /node/src/server/NuTemplate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuTemplate: example of how to create a Nu module 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | 7 | export default class NuTemplate extends NuBaseModule{ 8 | constructor(serverExperience) { 9 | super(serverExperience, 'nuTemplate'); 10 | 11 | // to be saved params to send to client when connects: 12 | this.params = { gain: 1.0, fileId: 0 }; 13 | 14 | // binding 15 | this.serverMethod = this.serverMethod.bind(this); 16 | } 17 | 18 | // send a global timed instruction to all players from OSC client 19 | serverMethod(args){ 20 | console.log('--', args) 21 | // extract arguments 22 | let playerId = args[0]; 23 | let delay = args[1]; 24 | 25 | // define rdv time (sec) in which to blink synchronously 26 | let rdvTime = this.e.sync.getSyncTime() + delay; 27 | console.log(rdvTime, delay, this.e.sync.getSyncTime()); 28 | 29 | // send to all a rdv time 30 | if( playerId == -1 ){ 31 | this.e.broadcast('player', null, 'nuTemplate_methodTriggeredFromServer', rdvTime ); 32 | return; 33 | } 34 | 35 | // msg is player specific: get player from server map 36 | let client = this.e.playerMap.get( playerId ); 37 | // discard if player not defined 38 | if( client === undefined ){ return; } 39 | // send player specific msg 40 | this.e.send(client, 'nuTemplate_methodTriggeredFromServer', rdvTime ); 41 | } 42 | 43 | } 44 | 45 | -------------------------------------------------------------------------------- /node/src/client/player/NuBaseModule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuBaseModule: base class extended by all Nu modules 3 | **/ 4 | 5 | import * as soundworks from 'soundworks/client'; 6 | const client = soundworks.client; 7 | const audioContext = soundworks.audioContext; 8 | 9 | export default class NuBaseModule { 10 | constructor(playerExperience, moduleName) { 11 | 12 | // local attributes 13 | this.e = playerExperience; 14 | this.moduleName = moduleName; 15 | this.params = {}; 16 | 17 | // setup receive callbacks 18 | this.e.receive(this.moduleName, (args) => { 19 | // get header 20 | let name = args.shift(); 21 | // convert singleton array if need be 22 | args = (args.length == 1) ? args[0] : args; 23 | // process msg 24 | this.paramCallback(name, args); 25 | }); 26 | 27 | // notify module is ready to receive msg 28 | this.e.send('moduleReady', this.moduleName); 29 | 30 | // binding 31 | this.paramCallback = this.paramCallback.bind(this); 32 | } 33 | 34 | /** 35 | * default callback applied to all incoming 'OSC' messages 36 | * (come from OSC at least, the protocol would however be web-socket 37 | * since message pre-processed by soundworks server 38 | **/ 39 | paramCallback(name, args){ 40 | // either route to internal function 41 | if( this[name] !== undefined ){ this[name]( args ); } 42 | // or to this.params value 43 | else{ this.params[name] = args; } 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /node/bin/styles: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var fse = require('fs-extra'); 3 | var klaw = require('klaw'); 4 | var log = require('./log'); 5 | var path = require('path'); 6 | var sass = require('node-sass'); 7 | 8 | 'use strict'; 9 | 10 | /** 11 | * Find all files recursively in `srcDirectory` that pass the allowed function 12 | * and are not prefixed by `_`. Transpile them to the `distDirectory` 13 | */ 14 | function processSass(srcDirectory, distDirectory, isAllowed, sassOptions) { 15 | klaw(srcDirectory) 16 | .on('data', function(item) { 17 | var filename = item.path; 18 | var basename = path.basename(filename); 19 | 20 | if (isAllowed(filename) && !/^_/.test(basename)) { 21 | var relFilename = path.relative(srcDirectory, filename); 22 | var outFilename = path.join(distDirectory, relFilename); 23 | outFilename = outFilename.replace(/\.scss$/, '.css'); 24 | 25 | Object.assign(sassOptions, { 26 | file: filename, 27 | outFilename: outFilename 28 | }); 29 | 30 | sass.render(sassOptions, function(err, result) { 31 | if (err) 32 | return log.sassError(err); 33 | 34 | fse.outputFile(outFilename, result.css, function(err) { 35 | if (err) 36 | return console.error(err.message); 37 | 38 | log.sassSuccess(outFilename); 39 | }); 40 | }); 41 | } 42 | }); 43 | } 44 | 45 | module.exports = { 46 | process: processSass, 47 | }; 48 | -------------------------------------------------------------------------------- /node/src/client/player/NuTemplate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuTemplate: example of how to create a Nu module 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | import * as soundworks from 'soundworks/client'; 7 | 8 | const client = soundworks.client; 9 | const audioContext = soundworks.audioContext; 10 | 11 | export default class NuTemplate extends NuBaseModule { 12 | constructor(playerExperience) { 13 | super(playerExperience, 'nuTemplate'); 14 | 15 | // local attributes 16 | this.params = { 17 | 'gain': 1.0 18 | }; 19 | 20 | // binding 21 | this.directToClientMethod = this.directToClientMethod.bind(this); 22 | this.methodTriggeredFromServer = this.methodTriggeredFromServer.bind(this); 23 | 24 | // setup receive callbacks 25 | this.e.receive('nuTemplate_methodTriggeredFromServer', this.methodTriggeredFromServer); 26 | } 27 | 28 | // trigger event directly from OSC client 29 | directToClientMethod(value){ 30 | this.e.renderer.blink([0, this.params.gain * value, 0], 0.4); 31 | console.log('blinking now!'); 32 | } 33 | 34 | // re-routed event for sync. playback: server add a rdv time to msg sent by OSC client 35 | methodTriggeredFromServer(rdvTime){ 36 | // get rel time (sec) in which I must blink 37 | let timeRemaining = rdvTime - this.e.sync.getSyncTime(); 38 | console.log('will blink in', timeRemaining, 'seconds'); 39 | setTimeout( () => { this.e.renderer.blink([0, 160, 200], 0.4); }, timeRemaining * 1000 ); 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, IRCAM – Centre Pompidou (France, Paris) 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, this 12 | list of conditions and the following disclaimer in the documentation and/or 13 | other materials provided with the distribution. 14 | 15 | * Neither the name of the IRCAM nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 26 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /node/html/default.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= data.appName %> 10 | <% if (data.clientType !== data.defaultType) { %> 11 | | <%= data.clientType %> 12 | <% } %> 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 25 | 26 | <% if (data.env === 'production') { %> 27 | 28 | <% } else { %> 29 | 30 | <% } %> 31 | 32 | <% if (data.env === 'production' && data.gaId) { %> 33 | 42 | <% } %> 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nü Soundworks 2 | 3 | Framework source code. See the [project website](https://ircam-cosima.github.io/soundworks-nu/) for more details. 4 | 5 | ## License and Credits 6 | 7 | The Nü framework is released under the [BSD 3-Clause license](https://opensource.org/licenses/BSD-3-Clause). 8 | 9 | Nü has been developped at IRCAM-CNRS within the [CoSiMa](http://cosima.ircam.fr/) research project, supported by the French National Research Agency (ANR). 10 | 11 | ## Install Node.js 12 | 13 | Node.js or "npm" is a toolbox / framework / magic wizard for javascript & web developers, required to run Nü. Check the official [Node.js installation guide](https://docs.npmjs.com/getting-started/installing-node). Nü has been developped with npm 3.9.5 and node v6.2.2. 14 | 15 | ## Install Nü (main) 16 | 17 | ```sh 18 | git clone https://github.com/ircam-cosima/soundworks-nu.git soundworks-nu 19 | cd soundworks-nu/node 20 | npm install 21 | echo '## DEV ## working with develop version of soundworks even here, requires transpile' 22 | cd node_modules/soundworks 23 | npm run transpile 24 | cd ../.. 25 | echo '## DEV ##' 26 | npm run transpile 27 | npm run start 28 | ``` 29 | 30 | ## Install Nü (for nü developers only) 31 | 32 | ```sh 33 | git clone https://github.com/ircam-cosima/soundworks-nu 34 | cd soundworks-nu 35 | git checkout develop 36 | git pull 37 | cd node 38 | npm install 39 | cd node_modules/soundworks 40 | npm run transpile 41 | cd ../.. 42 | npm run watch 43 | ``` 44 | 45 | ## How to use 46 | 47 | * Start the server (see Install) 48 | * Connect client to server (default: open your browser at 127.0.0.1:8000) 49 | * Use Max/MSP modules to control client's behavior (starting with nu.main) 50 | -------------------------------------------------------------------------------- /node/src/server/NuMain.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuMain: misc. config setup 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | 7 | export default class NuMain extends NuBaseModule { 8 | constructor(serverExperience) { 9 | super(serverExperience, 'nuMain'); 10 | 11 | setTimeout( () => { 12 | // sync. clocks 13 | const clockInterval = 0.1; // refresh interval in seconds 14 | setInterval( () => { 15 | this.e.osc.send('/nuMain/clock', this.e.sync.getSyncTime()); 16 | }, 1000 * clockInterval); 17 | }, 1000); 18 | 19 | } 20 | 21 | enterPlayer(client){ 22 | 23 | // direct forward of players message to OSC client 24 | this.e.receive(client, 'osc', (header, args) => { 25 | // append client index to msg 26 | args.unshift(client.index); 27 | // forward to OSC 28 | this.e.osc.send(header, args); 29 | }); 30 | } 31 | 32 | exitPlayer(client){ 33 | // update local attributes 34 | this.e.coordinatesMap.delete( client.index ); 35 | // update osc mapper 36 | this.e.osc.send('/nuMain/playerRemoved', client.index ); 37 | } 38 | 39 | /** 40 | * method triggered by OSC client upon connection, requiring an update on all 41 | * the "knowledge" the server already gathered about the current experiment setup 42 | **/ 43 | updateRequest(){ 44 | // send back players position at osc client request 45 | this.e.coordinatesMap.forEach((item, key)=>{ 46 | this.e.osc.send('/nuMain/playerPos', [key, item[0], item[1]] ); 47 | }); 48 | } 49 | 50 | // force all players to reload the current page 51 | reloadPlayers(){ 52 | // re-route to clients 53 | this.e.broadcast( 'player', null, 'nuMain', ['reload'] ); 54 | } 55 | 56 | } 57 | 58 | -------------------------------------------------------------------------------- /node/src/client/player/Nu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Nu: Simple wrapper to export all Nu modules 3 | **/ 4 | 5 | export { default as Output } from './NuOutput'; 6 | export { default as Groups } from './NuGroups'; 7 | export { default as RoomReverb } from './NuRoomReverb'; 8 | export { default as Path } from './NuPath'; 9 | export { default as Loop } from './NuLoop'; 10 | export { default as Template } from './NuTemplate'; 11 | export { default as Grain } from './NuGrain'; 12 | export { default as Probe } from './NuProbe'; 13 | export { default as Synth } from './NuSynth'; 14 | export { default as Stream } from './NuStream'; 15 | export { default as Main } from './NuMain'; 16 | export { default as Score } from './NuScore'; 17 | 18 | /** 19 | * the NuDisplay is a bit specific, not exposed as other modules 20 | * but rather invoked directly in PlayerExperience 21 | **/ 22 | // export { default as Display } from './NuDisplay';` 23 | 24 | 25 | // ------------------------------------------------------------ 26 | // UTILS 27 | // ------------------------------------------------------------ 28 | 29 | // fix for Safari that doesn't implement Float32Array.slice yet 30 | if (!Float32Array.prototype.slice) { 31 | Float32Array.prototype.slice = function(begin, end) { 32 | var target = new Float32Array(end - begin); 33 | 34 | for (var i = 0; i < begin + end; ++i) { 35 | target[i] = this[begin + i]; 36 | } 37 | return target; 38 | }; 39 | } 40 | 41 | // couterpart of copyToChannel, without overwrite 42 | AudioBuffer.prototype.addToChannel = function( source, channelNumber, startInChannel ){ 43 | let chData = this.getChannelData( channelNumber ); 44 | let l = source.length; 45 | 46 | for (let i = 0; i < l; i++){ 47 | chData[startInChannel + i] += source[i]; 48 | } 49 | return this; 50 | } -------------------------------------------------------------------------------- /node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soundworks-nu", 3 | "authors": [ 4 | "Jean-Philippe Lambert", 5 | "David Poirier-Quinot" 6 | ], 7 | "description": "Modules for real-time distributed rendering on cellphones", 8 | "license": "BSD-3-Clause", 9 | "version": "1.1.1", 10 | "scripts": { 11 | "minify": "node ./bin/runner --minify", 12 | "prewatch": "npm run transpile", 13 | "start": "node ./bin/runner --start", 14 | "transpile": "node ./bin/runner --transpile", 15 | "watch": "node --harmony ./bin/runner --watch" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/ircam-cosima/soundworks-nu" 20 | }, 21 | "dependencies": { 22 | "ambisonics": "0.2.3", 23 | "audio": "1.2.0", 24 | "audio-encode-wav": "0.2.0", 25 | "babel-runtime": "6.26.0", 26 | "bufferutil": "1.3.0", 27 | "debug": "2.6.9", 28 | "directory-tree": "2.2.4", 29 | "fs": "0.0.1-security", 30 | "osc": "2.2.4", 31 | "recorderjs": "mattdiamond/Recorderjs.git", 32 | "soundworks": "collective-soundworks/soundworks.git#v2.1.0", 33 | "source-map-support": "0.4.18", 34 | "typedarray-methods": "1.0.1", 35 | "utf-8-validate": "1.2.2", 36 | "waves-lfo": "wavesjs/waves-lfo#v1.0.1", 37 | "web-audio-api": "0.2.2", 38 | "webworker-threads": "0.7.17", 39 | "ws": "1.1.5" 40 | }, 41 | "devDependencies": { 42 | "babel-core": "6.26.3", 43 | "babel-plugin-transform-es2015-modules-commonjs": "6.26.2", 44 | "babel-plugin-transform-runtime": "6.23.0", 45 | "babel-preset-es2015": "6.24.1", 46 | "browserify": "14.5.0", 47 | "colors": "1.4.0", 48 | "fs-extra": "2.1.2", 49 | "klaw": "1.3.1", 50 | "node-sass": "4.14.1", 51 | "uglify-js": "3.10.2", 52 | "watch": "1.0.2", 53 | "watchify": "3.11.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /node/src/server/index.js: -------------------------------------------------------------------------------- 1 | import 'source-map-support/register'; // enable sourcemaps in node 2 | import path from 'path'; 3 | import * as soundworks from 'soundworks/server'; 4 | import PlayerExperience from './PlayerExperience'; 5 | 6 | const configName = process.env.ENV || 'default'; 7 | const configPath = path.join(__dirname, 'config', configName); 8 | let config = null; 9 | 10 | // rely on node `require` for synchronicity 11 | try { 12 | config = require(configPath).default; 13 | } catch(err) { 14 | console.error(`Invalid ENV "${configName}", file "${configPath}.js" not found`); 15 | process.exit(1); 16 | } 17 | 18 | // configure express environment ('production' enables cache systems) 19 | process.env.NODE_ENV = config.env; 20 | // initialize application with configuration options 21 | soundworks.server.init(config); 22 | 23 | // define the configuration object to be passed to the `.ejs` template 24 | soundworks.server.setClientConfigDefinition((clientType, config, httpRequest) => { 25 | return { 26 | clientType: clientType, 27 | env: config.env, 28 | appName: config.appName, 29 | websockets: config.websockets, 30 | version: config.version, 31 | defaultType: config.defaultClient, 32 | assetsDomain: config.assetsDomain, 33 | }; 34 | }); 35 | 36 | // create the experience 37 | // activities must be mapped to client types: 38 | // - the `'player'` clients (who take part in the scenario by connecting to the 39 | // server through the root url) need to communicate with the `checkin` (see 40 | // `src/server/playerExperience.js`) and the server side `playerExperience`. 41 | // - we could also map activities to additional client types (thus defining a 42 | // route (url) of the following form: `/${clientType}`) 43 | const experience = new PlayerExperience(['player', 'controller']); 44 | 45 | // start application 46 | soundworks.server.start(); 47 | -------------------------------------------------------------------------------- /node/sass/_configuration.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------- 2 | // GLOBALS 3 | // ----------------------------------------------------------------- 4 | 5 | $backgroundColor: #080808; 6 | $foregroundColor: #ffffff; 7 | $textColor: rgba(255, 255, 255, 0.9); 8 | $borderSize: 1px; 9 | $borderRadius: 2px; 10 | $mainFont: Quicksand, arial, sans-serif; 11 | $loadingProgressBarHeight: 10px; 12 | 13 | // ----------------------------------------------------------------- 14 | // IMPORTS FONTS 15 | // ----------------------------------------------------------------- 16 | 17 | @include font-face('Quicksand', 100, 'Quicksand-Light'); 18 | @include font-face('Quicksand', 400, 'Quicksand-Regular'); 19 | @include font-face('Quicksand', 700, 'Quicksand-Bold'); 20 | 21 | 22 | // ----------------------------------------------------------------- 23 | // COLOR LIST 24 | // ----------------------------------------------------------------- 25 | 26 | /** 27 | * These color swatches were inspired by the swatches 28 | * found in Flat UI, by designmodo. 29 | * http://designmodo.com/flat-free/ 30 | */ 31 | 32 | $color-list: ( 33 | turquoise #1abc9c, 34 | green-sea #16a085, 35 | emerald #2ecc71, 36 | nephritis #27ae60, 37 | peter-river #3498db, 38 | belize-hole #2980b9, 39 | amethyst #9b59b6, 40 | wisteria #8e44ad, 41 | wet-asphalt #34495e, 42 | midnight-blue #2c3e50, 43 | sun-flower #f1c40f, 44 | orange #f39c12, 45 | carrot #e67e22, 46 | pumpkin #d35400, 47 | alizarin #e74c3c, 48 | pomegranate #c0392b, 49 | clouds #ecf0f1, 50 | silver #bdc3c7, 51 | concrete #95a5a6, 52 | asbestos #7f8c8d, 53 | black #000000, 54 | white #ffffff 55 | ); 56 | 57 | $turquoise: #1abc9c; 58 | $green-sea: #16a085; 59 | $emerald: #2ecc71; 60 | $nephritis: #27ae60; 61 | $peter-river: #3498db; 62 | $belize-hole: #2980b9; 63 | $amethyst: #9b59b6; 64 | $wisteria: #8e44ad; 65 | $wet-asphalt: #34495e; 66 | $midnight-blue: #2c3e50; 67 | $sun-flower: #f1c40f; 68 | $orange: #f39c12; 69 | $carrot: #e67e22; 70 | $pumpkin: #d35400; 71 | $alizarin: #e74c3c; 72 | $pomegranate: #c0392b; 73 | $clouds: #ecf0f1; 74 | $silver: #bdc3c7; 75 | $concrete: #95a5a6; 76 | $asbestos: #7f8c8d; 77 | $black: #000000; 78 | $white: #ffffff; 79 | -------------------------------------------------------------------------------- /node/src/client/player/NuStream.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuStream: live audio stream from OSC client to players 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | import * as soundworks from 'soundworks/client'; 7 | 8 | const client = soundworks.client; 9 | const audioContext = soundworks.audioContext; 10 | 11 | export default class NuStream extends NuBaseModule { 12 | constructor(playerExperience) { 13 | super(playerExperience, 'nuStream'); 14 | 15 | // local attributes 16 | this.params = {}; 17 | 18 | // binding 19 | this.rawSocketCallback = this.rawSocketCallback.bind(this); 20 | 21 | // setup socket reveive callbacks (receiving raw audio data) 22 | this.e.rawSocket.receive('nuStream', this.rawSocketCallback ); 23 | 24 | // output gain 25 | this.out = audioContext.createGain(); 26 | this.out.connect( this.e.nuOutput.in ); 27 | } 28 | 29 | // set audio gain out 30 | gain(val){ 31 | this.out.gain.value = val; 32 | } 33 | 34 | /** 35 | * enable / disable streaming (only thing enabled / disabled here is visual feedback, 36 | * actual streaming is done automatically when receiving audio data from dedicated web-socket 37 | **/ 38 | onOff(value){ 39 | if( value ){ this.e.renderer.enable(); } 40 | else{ this.e.renderer.disable(); } 41 | } 42 | 43 | /* 44 | * callback executed when rawsocket data received from server (streamed audio data) 45 | */ 46 | rawSocketCallback(data) { 47 | 48 | // decode 49 | let packetId = Math.round( data.slice(0, 1) * 100 ) / 100; // other digit are not relevant 50 | let audioArray = new Float32Array(data.slice(1, data.length)); 51 | 52 | // get start time 53 | const now = this.e.sync.getSyncTime(); 54 | let sysTime = this.params.startTime + ( packetId ) * this.params.packetTime + this.params.delayTime; 55 | let relOffset = sysTime - now; 56 | 57 | // discard data if start time passed (packet deprecated) 58 | if( relOffset < 0 ){ 59 | this.e.renderer.blink([100, 0, 0]); 60 | return; 61 | } 62 | 63 | // create audio buffer 64 | let audioBuffer = audioContext.createBuffer(1, audioArray.length, 44100); 65 | audioBuffer.getChannelData(0).set(audioArray); 66 | 67 | // create audio source 68 | let src = audioContext.createBufferSource(); 69 | src.buffer = audioBuffer; 70 | src.connect( this.out ); 71 | 72 | // start source 73 | src.start(audioContext.currentTime + relOffset); 74 | 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /node/src/server/NuGroups.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuGroup: Nu module to assign audio tracks to groups of players 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | 7 | export default class NuGroups extends NuBaseModule { 8 | constructor(serverExperience) { 9 | super(serverExperience, 'nuGroups'); 10 | 11 | // binding 12 | this.getGroup = this.getGroup.bind(this); 13 | this.enterPlayer = this.enterPlayer.bind(this); 14 | 15 | // local attributes 16 | this.groupMap = new Map(); 17 | } 18 | 19 | /** 20 | * override default paramCallback from parent, to be able to redefine how OSC 21 | * parameters are copied locally (based on the "groupMap" object) 22 | **/ 23 | paramCallback(msg){ 24 | let playerId = msg.shift(); 25 | let name = msg.shift(); 26 | let msgStippedOfPlayerId = [name].concat(msg); 27 | 28 | // if player specific instruction 29 | if( playerId !== -1 ){ 30 | let client = this.e.playerMap.get( playerId ); 31 | if( client === undefined ){ return; } 32 | this.e.send( client, this.moduleName, msgStippedOfPlayerId ); 33 | } 34 | 35 | // if instruction concerns all the players 36 | else{ 37 | // broadcast msg 38 | this.e.broadcast( 'player', null, this.moduleName, msgStippedOfPlayerId ); 39 | // store value 40 | let groupId = msg.shift(); 41 | let value = msg.shift(); 42 | // get associated group 43 | let group = this.getGroup( groupId ); 44 | // save values 45 | group[name] = value; 46 | } 47 | } 48 | 49 | // get local group based on id 50 | getGroup(groupId) { 51 | // get already existing group 52 | if (this.groupMap.has(groupId)) 53 | return this.groupMap.get(groupId); 54 | // create new group 55 | let group = { time: 0, startTime: 0, onOff: 0, volume: 1, loop: 1 }; 56 | // store new group in local map 57 | this.groupMap.set(groupId, group); 58 | // return created group 59 | return group; 60 | } 61 | 62 | /** 63 | * override default enterPlayer from parent, to be able to redefine how OSC 64 | * parameters are bundled to connecting clients (based on the "groupMap" object) 65 | **/ 66 | enterPlayer(client){ 67 | // send to new client information regarding current groups parameters 68 | this.groupMap.forEach( (group, groupId) => { 69 | Object.keys(group).forEach( (key) => { 70 | this.e.send(client, 'nuGroups', [key, groupId, group[key]]); 71 | }); 72 | }); 73 | } 74 | 75 | } -------------------------------------------------------------------------------- /node/src/client/player/NuScore.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuScore: define sequences of sounds to play 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | import * as soundworks from 'soundworks/client'; 7 | 8 | const client = soundworks.client; 9 | const audioContext = soundworks.audioContext; 10 | 11 | export default class NuScore extends NuBaseModule { 12 | constructor(playerExperience) { 13 | super(playerExperience, 'nuScore'); 14 | 15 | // local attributes 16 | this.masterGain = audioContext.createGain(); 17 | this.masterGain.connect(this.e.nuOutput.in); 18 | this.srcMap = new Map(); 19 | } 20 | 21 | // set audio gain out 22 | gain(val){ 23 | this.masterGain.gain.value = val; 24 | } 25 | 26 | /* 27 | * message callback: play sound 28 | */ 29 | startScore( args ) { 30 | let timeSent = args.shift(); 31 | let nuNotes = args.length/2; 32 | var time, name, buffer, startTime, duration; 33 | 34 | // define times 35 | var now = this.e.sync.getSyncTime(); 36 | var syncStartTime = timeSent + this.params.delay - now; 37 | 38 | // loop over score samples, fill in output buffer with samples 39 | for (let i = 0; i < nuNotes; i++) { 40 | // get note params 41 | time = args[ 2*i ]; 42 | name = args[ 2*i + 1 ]; 43 | buffer = this.e.loader.data[name]; 44 | 45 | // skip note if undefined audio buffer 46 | if( buffer === undefined ){ continue; } 47 | 48 | // create and connect source 49 | let src = audioContext.createBufferSource(); 50 | src.buffer = buffer; 51 | src.connect( this.masterGain ); 52 | 53 | // start source 54 | startTime = syncStartTime + time; 55 | duration = this.params.perc * buffer.duration; 56 | // play in future if rendez-vous time not yet reached (should be default behavior) 57 | if (startTime > 0){ src.start(audioContext.currentTime + startTime, 0, duration); } 58 | // play with advance in buffer to keep sync. 59 | else { src.start(audioContext.currentTime, -startTime, duration); } 60 | 61 | // add src to local map for eventual reset 62 | this.srcMap.set(src, src); 63 | src.onended = () => { 64 | this.srcMap.delete(src); 65 | this.e.renderer.disable(); 66 | }; 67 | 68 | // enable visual rendering 69 | this.e.renderer.enable(); 70 | } 71 | 72 | } 73 | 74 | // kill audio 75 | reset(){ 76 | this.srcMap.forEach( (src) => { 77 | // stop source 78 | src.stop(); 79 | // remove associated visual feedback 80 | this.e.renderer.disable(); 81 | }); 82 | } 83 | 84 | } -------------------------------------------------------------------------------- /node/src/server/NuBaseModule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuBaseModule: base class extended by all Nu modules 3 | **/ 4 | 5 | export default class NuBaseModule { 6 | constructor(serverExperience, moduleName) { 7 | 8 | // local attributes 9 | this.e = serverExperience; 10 | this.moduleName = moduleName; 11 | 12 | // to be saved parameters to send to client when connects 13 | this.params = {}; 14 | 15 | // binding 16 | this.enterPlayer = this.enterPlayer.bind(this); 17 | this.exitPlayer = this.exitPlayer.bind(this); 18 | this.paramCallback = this.paramCallback.bind(this); 19 | 20 | // setup osx msg receive callback: format msg and apply this.paramCallback 21 | this.e.osc.receive( '/' + this.moduleName, (msgRaw) => { 22 | // shape msg into array of arguments 23 | let msg = msgRaw.split(' '); 24 | msg.numberify(); 25 | // pass msg to callback 26 | this.paramCallback( msg ); 27 | }); 28 | 29 | } 30 | 31 | /** 32 | * local equivalent of soundworks "enter" method 33 | * only applied for clients of type 'player' 34 | * init client's modules with all Nu parameters saved in server 35 | **/ 36 | enterPlayer(client){ 37 | // send to new client information regarding current groups parameters 38 | Object.keys(this.params).forEach( (key) => { 39 | this.e.send(client, this.moduleName, [key, this.params[key]] ); 40 | }); 41 | } 42 | 43 | /** 44 | * local equivalent of soundworks exit. 45 | * only applied for clients of type 'players'. No default behavior, 46 | * module-specific override if need be. 47 | **/ 48 | exitPlayer(client){} 49 | 50 | /** 51 | * callback that handles Nu msg from OSC client. Supposes that every msg 52 | * received contains playerId, re-route to concerned player or to internal 53 | * params/function if playerId == -1 (all concerned) 54 | **/ 55 | paramCallback(msg){ 56 | // extract data 57 | let playerId = msg.shift(); // concerned player id 58 | let name = msg.shift(); // method / argument name 59 | 60 | // eventually convert remaining array to singleton 61 | let args = (msg.length == 1) ? msg[0] : msg; 62 | 63 | // call local dedicated method if defined 64 | if( this[name] !== undefined ){ 65 | this[name]( [playerId].concat(args) ); 66 | return; 67 | } 68 | 69 | // if player specific instruction, send to player 70 | if( playerId !== -1 ){ 71 | let client = this.e.playerMap.get( playerId ); 72 | if( client === undefined ){ return; } 73 | this.e.send( client, this.moduleName, [name].concat(args) ); 74 | return 75 | } 76 | 77 | // all players are concerned: broadcast and save local 78 | // (to broadcast current config to newcomers) 79 | this.e.broadcast( 'player', null, this.moduleName, [name].concat(args) ); 80 | // store value 81 | this.params[name] = args; 82 | } 83 | 84 | } 85 | 86 | -------------------------------------------------------------------------------- /node/bin/log: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var pkg = require('../package.json'); 3 | var colors = require('colors'); 4 | var path = require('path'); 5 | 6 | 'use strict'; 7 | 8 | var prefix = '[' + pkg.name + ']\t'; 9 | var cwd = process.cwd(); 10 | 11 | function toRel(target) { 12 | return path.relative(cwd, target); 13 | } 14 | 15 | function getDeltaTime(time) { 16 | return new Date().getTime() - time; 17 | } 18 | 19 | var log = { 20 | transpileSuccess: function(src, dest, startTime) { 21 | var time = getDeltaTime(startTime); 22 | var msg = prefix + '%s: successfully transpiled to "%s" (%sms)'.green; 23 | console.log(msg, toRel(src), toRel(dest), time); 24 | }, 25 | 26 | transpileError: function(err) { 27 | var parts = err.message.split(':'); 28 | var msg = prefix + '%s'.red; 29 | 30 | console.log(msg, toRel(err.message)); 31 | console.log(err.codeFrame); 32 | }, 33 | 34 | bundleStart: function(dest) { 35 | var msg = prefix + '%s: bundle start'.yellow; 36 | console.log(msg, toRel(dest)); 37 | }, 38 | 39 | bundleSuccess: function(dest, startTime) { 40 | var time = getDeltaTime(startTime); 41 | var msg = prefix + '%s: successfully bundled (%sms)'.green; 42 | console.log(msg, toRel(dest), time); 43 | }, 44 | 45 | bundleError: function(dest, err) { 46 | var msg = prefix + '%s: bundle error'.red; 47 | console.log(msg, toRel(dest)); 48 | console.log('> ' + err.message); 49 | }, 50 | 51 | deleteFile: function(filename) { 52 | var msg = prefix + '%s: successfully removed'.yellow; 53 | console.log(msg, toRel(filename)); 54 | }, 55 | 56 | minifyStart: function(filename) { 57 | var msg = prefix + '%s: minify start'.yellow; 58 | console.log(msg, toRel(filename)); 59 | }, 60 | 61 | minifySuccess: function(filename, outFilename, startTime) { 62 | var msg = prefix + '%s: successfully minified (%sms)'.green; 63 | var time = getDeltaTime(startTime); 64 | console.log(msg, toRel(outFilename), time); 65 | }, 66 | 67 | sassSuccess: function(dest) { 68 | var msg = prefix + '%s: successfully written'.green 69 | console.log(msg, toRel(dest)); 70 | }, 71 | 72 | sassError: function(err) { 73 | var msg = prefix + ('%s (%s:%s): sass error').red; 74 | console.log(msg, toRel(err.file), err.line, err.column); 75 | console.log('> ' + err.message); 76 | }, 77 | 78 | serverStart: function() { 79 | console.log(prefix + 'server start'.cyan); 80 | }, 81 | 82 | serverStop: function() { 83 | console.log(prefix + 'server stop'.cyan); 84 | }, 85 | 86 | serverError: function(serverIndex) { 87 | var msg = prefix + '%s: not found, run `npm run transpile`'.red; 88 | console.log(msg, toRel(serverIndex)); 89 | }, 90 | 91 | watchWarning: function(directory) { 92 | var msg = prefix + '%s: is not a directory, restart the script to watch it'.yellow; 93 | console.log(msg, toRel(directory)); 94 | } 95 | }; 96 | 97 | module.exports = log; 98 | -------------------------------------------------------------------------------- /node/sass/_03-services.scss: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------ 2 | // VIEW COMPONENTS 3 | // ------------------------------------------------------------ 4 | 5 | // @todo - change to id 6 | .space { 7 | svg { 8 | overflow: hidden; 9 | background-color: #242424; 10 | } 11 | 12 | .point { 13 | fill: $foregroundColor; 14 | 15 | &.selected { 16 | fill: darken($foregroundColor, 70%); 17 | } 18 | } 19 | 20 | .line { 21 | stroke: $foregroundColor; 22 | stroke-width: 1px; 23 | 24 | &.arrow { 25 | marker-mid: url(#marker-arrow); 26 | } 27 | } 28 | 29 | // .marker-circle { 30 | // stroke: none; 31 | // fill: $foregroundColor; 32 | // } 33 | 34 | .marker-arrow { 35 | fill: $foregroundColor; 36 | } 37 | } 38 | 39 | .select { 40 | color: $backgroundColor; 41 | background-color: $foregroundColor; 42 | font-size: 1.3rem; 43 | line-height: 3rem; 44 | height: 3rem; 45 | } 46 | 47 | // ------------------------------------------------------------ 48 | // AUTH 49 | // ------------------------------------------------------------ 50 | 51 | #service-auth { 52 | input[type=password], .btn { 53 | width: 100%; 54 | } 55 | 56 | input[type=password] { 57 | font-size: 1.4rem; 58 | height: 30px; 59 | margin-bottom: 12px; 60 | text-align: center; 61 | color: #343434; 62 | } 63 | } 64 | 65 | // ------------------------------------------------------------ 66 | // CHECKIN 67 | // ------------------------------------------------------------ 68 | 69 | #service-checkin { 70 | .checkin-label { 71 | margin: 0 auto; 72 | background-color: $belize-hole; 73 | border-radius: 50%; 74 | } 75 | 76 | $portraitBoxSize: 160px; 77 | $landscapeBoxSize: 120px; 78 | 79 | &.portrait .checkin-label { 80 | flex-basis: $portraitBoxSize; 81 | height: $portraitBoxSize; 82 | 83 | p { 84 | line-height: $portraitBoxSize; 85 | } 86 | } 87 | 88 | &.landscape .checkin-label { 89 | flex-basis: $landscapeBoxSize; 90 | height: $landscapeBoxSize; 91 | 92 | p { 93 | line-height: $landscapeBoxSize; 94 | } 95 | } 96 | } 97 | 98 | // ------------------------------------------------------------ 99 | // AUDIO BUFFER MANAGER 100 | // ------------------------------------------------------------ 101 | 102 | #service-audio-buffer-manager { 103 | .progress-wrap { 104 | margin: 12px auto 0; 105 | border: solid 1px white; 106 | border-radius: 5px; 107 | height: $loadingProgressBarHeight; 108 | overflow: hidden; 109 | flex-basis: 80%; 110 | } 111 | 112 | .progress-bar { 113 | background-color: white; 114 | height: $loadingProgressBarHeight; 115 | width: 0; // updated from javascript 116 | @include transition(width 150ms); 117 | } 118 | } 119 | 120 | // ------------------------------------------------------------ 121 | // BASIC SHARED CONTROLLERS 122 | // ------------------------------------------------------------ 123 | 124 | #basic-shared-controller { 125 | margin: 0 auto; 126 | max-width: 800px; 127 | padding: 10px; 128 | box-sizing: border-box; 129 | 130 | h1 { 131 | margin: 8px 0; 132 | } 133 | } 134 | 135 | // ------------------------------------------------------------ 136 | // LANGUAGE 137 | // ------------------------------------------------------------ 138 | 139 | #service-language { 140 | .section-center { 141 | padding: 10px; 142 | } 143 | 144 | .btn { 145 | width: 100%; 146 | margin-bottom: 20px; 147 | } 148 | } 149 | 150 | -------------------------------------------------------------------------------- /node/sass/_02-commons.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------- 2 | // RESET - GENERAL LAYOUT 3 | // ----------------------------------------------------------------- 4 | 5 | /** 6 | * The following code is inspired by: 7 | * http://meyerweb.com/eric/tools/css/reset/ 8 | * v2.0 | 20110126 9 | * License: none (public domain) 10 | */ 11 | html, body, div, span, h1, h2, h3, h4, h5, h6, p, a, button, input, option, select { 12 | margin: 0; 13 | padding: 0; 14 | border: 0; 15 | font-size: 100%; 16 | font: inherit; 17 | vertical-align: baseline; 18 | } 19 | 20 | body { 21 | line-height: 1; 22 | } 23 | 24 | ol, ul { 25 | list-style: none; 26 | } 27 | 28 | html, body { 29 | width: 100%; 30 | height: 100%; 31 | background-color: $backgroundColor; 32 | } 33 | 34 | body { 35 | font-family: $mainFont; 36 | font-size: 62.5%; 37 | line-height: normal; 38 | color: $textColor; 39 | } 40 | 41 | h1, h2, h3, h4, h5, h6 { 42 | font-weight: bold; 43 | } 44 | 45 | p { 46 | margin: 0; 47 | padding: 0; 48 | } 49 | 50 | // ----------------------------------------------------------------- 51 | // LAYOUT HELPERS 52 | // ----------------------------------------------------------------- 53 | 54 | // default container of the application 55 | #container { 56 | position: relative; 57 | } 58 | 59 | .foreground { 60 | position: relative; 61 | z-index: 1; 62 | } 63 | 64 | .background { 65 | position: absolute; 66 | top: 0; 67 | left: 0; 68 | } 69 | 70 | .fit-container { 71 | display: block; 72 | width: 100%; 73 | height: 100%; 74 | } 75 | 76 | // center horizontally 77 | .flex-center { 78 | box-sizing: border-box; 79 | padding: 10px; 80 | display: flex; 81 | display: -webkit-flex; /* Safari */ 82 | 83 | & > * { 84 | text-align: center; 85 | flex-basis: 100%; 86 | -webkit-flex-basis: 100%; 87 | width: 100%; 88 | } 89 | } 90 | 91 | // center horizontally and vertically 92 | .flex-middle { 93 | @extend .flex-center; 94 | align-items: center; 95 | -webkit-align-items: center; 96 | } 97 | 98 | // create the soft blink animation from keyframe mixin 99 | @include keyframes(soft-blink) { 100 | 0% { opacity: 1.0; } 101 | 50% { opacity: 0.3; } 102 | 100% { opacity: 1.0; } 103 | } 104 | 105 | // ----------------------------------------------------------------- 106 | // TAG HELPERS 107 | // ----------------------------------------------------------------- 108 | 109 | .huge { 110 | font-size: 3rem; 111 | } 112 | 113 | .big { 114 | font-size: 2rem; 115 | } 116 | 117 | p, .normal { 118 | font-size: 1.5rem; 119 | } 120 | 121 | .small { 122 | font-size: 1rem; 123 | } 124 | 125 | .bold { 126 | font-weight: bold; 127 | } 128 | 129 | .italic { 130 | font-style: italic; 131 | } 132 | 133 | .btn { 134 | background-color: $backgroundColor; 135 | border: $borderSize solid $textColor; 136 | border-radius: $borderRadius; 137 | color: $textColor; 138 | padding: 6px 0; 139 | font-size: 1rem; 140 | display: block; 141 | letter-spacing: 0.05em; 142 | 143 | &:active, &.active { 144 | background-color: $textColor; 145 | color: $backgroundColor; 146 | } 147 | 148 | &:focus { 149 | outline: none; 150 | } 151 | 152 | &.disabled, &[disabled] { 153 | opacity: 0.5; 154 | } 155 | 156 | &.pushed { 157 | background-color: $textColor; 158 | color: $backgroundColor; 159 | } 160 | 161 | &.released { 162 | background-color: $backgroundColor; 163 | color: $textColor; 164 | } 165 | } 166 | 167 | .landscape .portrait-only { 168 | display: none; 169 | } 170 | 171 | // transform a
in ' ' in landscape mode 172 | .landscape br.portrait-only { 173 | content: ' '; 174 | display: inline; 175 | 176 | &:after { 177 | content: ' '; 178 | } 179 | } 180 | 181 | .soft-blink { 182 | @include animation('soft-blink 3.6s ease-in-out infinite'); 183 | } 184 | -------------------------------------------------------------------------------- /maxmsp/extras/nu.error.maxpat: -------------------------------------------------------------------------------- 1 | { 2 | "patcher" : { 3 | "fileversion" : 1, 4 | "appversion" : { 5 | "major" : 7, 6 | "minor" : 3, 7 | "revision" : 3, 8 | "architecture" : "x86", 9 | "modernui" : 1 10 | } 11 | , 12 | "rect" : [ 1365.0, 384.0, 275.0, 221.0 ], 13 | "bglocked" : 0, 14 | "openinpresentation" : 0, 15 | "default_fontsize" : 12.0, 16 | "default_fontface" : 0, 17 | "default_fontname" : "Arial", 18 | "gridonopen" : 1, 19 | "gridsize" : [ 15.0, 15.0 ], 20 | "gridsnaponopen" : 1, 21 | "objectsnaponopen" : 1, 22 | "statusbarvisible" : 2, 23 | "toolbarvisible" : 1, 24 | "lefttoolbarpinned" : 0, 25 | "toptoolbarpinned" : 0, 26 | "righttoolbarpinned" : 0, 27 | "bottomtoolbarpinned" : 0, 28 | "toolbars_unpinned_last_save" : 0, 29 | "tallnewobj" : 0, 30 | "boxanimatetime" : 200, 31 | "enablehscroll" : 1, 32 | "enablevscroll" : 1, 33 | "devicewidth" : 0.0, 34 | "description" : "", 35 | "digest" : "", 36 | "tags" : "", 37 | "style" : "", 38 | "subpatcher_template" : "", 39 | "boxes" : [ { 40 | "box" : { 41 | "id" : "obj-7", 42 | "linecount" : 2, 43 | "maxclass" : "comment", 44 | "numinlets" : 1, 45 | "numoutlets" : 0, 46 | "patching_rect" : [ 70.0, 26.0, 150.0, 33.0 ], 47 | "style" : "", 48 | "text" : "popup window to warn of an undefined argument" 49 | } 50 | 51 | } 52 | , { 53 | "box" : { 54 | "id" : "obj-6", 55 | "maxclass" : "newobj", 56 | "numinlets" : 1, 57 | "numoutlets" : 1, 58 | "outlettype" : [ "" ], 59 | "patching_rect" : [ 27.0, 141.0, 184.0, 22.0 ], 60 | "style" : "", 61 | "text" : "prepend (argument not defined):" 62 | } 63 | 64 | } 65 | , { 66 | "box" : { 67 | "id" : "obj-2", 68 | "maxclass" : "newobj", 69 | "numinlets" : 1, 70 | "numoutlets" : 2, 71 | "outlettype" : [ "", "bang" ], 72 | "patching_rect" : [ 27.0, 64.0, 30.0, 22.0 ], 73 | "style" : "", 74 | "text" : "t l b" 75 | } 76 | 77 | } 78 | , { 79 | "box" : { 80 | "id" : "obj-24", 81 | "maxclass" : "newobj", 82 | "numinlets" : 1, 83 | "numoutlets" : 0, 84 | "patching_rect" : [ 27.0, 171.0, 99.0, 22.0 ], 85 | "style" : "", 86 | "text" : "print Nü.Error" 87 | } 88 | 89 | } 90 | , { 91 | "box" : { 92 | "comment" : "", 93 | "id" : "obj-1", 94 | "index" : 1, 95 | "maxclass" : "inlet", 96 | "numinlets" : 0, 97 | "numoutlets" : 1, 98 | "outlettype" : [ "" ], 99 | "patching_rect" : [ 27.0, 26.0, 30.0, 30.0 ], 100 | "style" : "" 101 | } 102 | 103 | } 104 | , { 105 | "box" : { 106 | "fontname" : "Arial", 107 | "fontsize" : 13.0, 108 | "id" : "obj-5", 109 | "linecount" : 2, 110 | "maxclass" : "message", 111 | "numinlets" : 2, 112 | "numoutlets" : 1, 113 | "outlettype" : [ "" ], 114 | "patching_rect" : [ 133.0, 78.0, 109.0, 38.0 ], 115 | "style" : "", 116 | "text" : ";\rmax maxwindow" 117 | } 118 | 119 | } 120 | ], 121 | "lines" : [ { 122 | "patchline" : { 123 | "destination" : [ "obj-2", 0 ], 124 | "disabled" : 0, 125 | "hidden" : 0, 126 | "source" : [ "obj-1", 0 ] 127 | } 128 | 129 | } 130 | , { 131 | "patchline" : { 132 | "destination" : [ "obj-5", 0 ], 133 | "disabled" : 0, 134 | "hidden" : 0, 135 | "source" : [ "obj-2", 1 ] 136 | } 137 | 138 | } 139 | , { 140 | "patchline" : { 141 | "destination" : [ "obj-6", 0 ], 142 | "disabled" : 0, 143 | "hidden" : 0, 144 | "source" : [ "obj-2", 0 ] 145 | } 146 | 147 | } 148 | , { 149 | "patchline" : { 150 | "destination" : [ "obj-24", 0 ], 151 | "disabled" : 0, 152 | "hidden" : 0, 153 | "source" : [ "obj-6", 0 ] 154 | } 155 | 156 | } 157 | ] 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /node/src/client/player/PlayerExperience.js: -------------------------------------------------------------------------------- 1 | import * as soundworks from 'soundworks/client'; 2 | 3 | import NuDisplay from './NuDisplay'; 4 | import * as Nu from './Nu' 5 | import audioFiles from '../shared/audioFiles'; 6 | 7 | const audioContext = soundworks.audioContext; 8 | const client = soundworks.client; 9 | 10 | const viewTemplate = ` 11 | 12 |
13 | 14 |
15 |

16 |
17 | 18 |
19 |

20 |
21 | 22 |
23 | 24 |
25 | 26 |
27 | `; 28 | 29 | /* 30 | * The PlayerExperience script defines the behavior of default clients (of type 'player'). 31 | * Here it simply imports and instantiates all Nu modules. 32 | */ 33 | 34 | export default class PlayerExperience extends soundworks.Experience { 35 | constructor(assetsDomain) { 36 | super(); 37 | 38 | // require soundworks services 39 | this.platform = this.require('platform', { features: ['web-audio'] }); 40 | this.params = this.require('shared-params'); 41 | this.sharedConfig = this.require('shared-config'); 42 | this.sync = this.require('sync'); 43 | this.checkin = this.require('checkin', { showDialog: false }); 44 | this.scheduler = this.require('sync-scheduler', { lookahead: 0.050 }); 45 | this.rawSocket = this.require('raw-socket'); 46 | this.loader = this.require('audio-buffer-manager', { 47 | assetsDomain: assetsDomain, 48 | files: audioFiles, 49 | }); 50 | this.motionInput = this.require('motion-input', { 51 | descriptors: ['accelerationIncludingGravity', 'deviceorientation', 'energy'] 52 | }); 53 | 54 | // auto-click if #emulate found in url (for debug / laptop-based sessions) 55 | if( window.location.hash === "#emulate" ) { this.emulateClick(); } 56 | } 57 | 58 | start() { 59 | super.start(); 60 | 61 | // initialize the view 62 | this.view = new soundworks.CanvasView(viewTemplate, {}, {}, { 63 | id: this.id, 64 | preservePixelRatio: true, 65 | }); 66 | 67 | // as show can be async, we make sure that the view is actually rendered 68 | this.show().then(() => { 69 | 70 | // initialize renderer 71 | this.renderer = new NuDisplay(this); 72 | this.view.addRenderer(this.renderer); 73 | 74 | // init client position in room (AWAITING REAL LOC. SYSTEM) 75 | let coordinates = this.sharedConfig.get('setup.coordinates'); 76 | this.coordinates = coordinates[client.index]; 77 | // random coordinates if reached end of predefined coords 78 | if( this.coordinates === undefined){ this.coordinates = [ Math.random(), Math.random() ]; } 79 | this.send('coordinates', this.coordinates); 80 | 81 | // init Nu modules 82 | Object.keys(Nu).forEach( (nuClass) => { 83 | this['nu' + nuClass] = new Nu[nuClass](this); 84 | }); 85 | 86 | // disable text selection, magnifier, and screen move on swipe on ios 87 | document.getElementsByTagName("body")[0].addEventListener("touchstart", 88 | function(e) { e.returnValue = false }); 89 | }); 90 | 91 | } 92 | 93 | /** 94 | * simulate user click to skip welcome screen (used e.g. for prototyping sessions on laptop) 95 | * won't work on mobile (need a REAL user input to start audio) 96 | **/ 97 | emulateClick() { 98 | // prepare click and gui elmt on which to click 99 | const $el = document.querySelector('#service-platform'); 100 | const event = new MouseEvent('mousedown', { 'view': window, 'bubbles': true, 'cancelable': true }); 101 | // click if we've got gui elmt 102 | if( $el !== null ){ $el.dispatchEvent(event); } 103 | // re-iterate while not started (sometimes, even a click on gui will not start...) 104 | if( this.coordinates === undefined ){ 105 | setTimeout(() => { this.emulateClick(); console.log('click delayed'); }, 300) 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /node/src/server/NuPath.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuPath: Nu module to move a sound through players topology, based on 3 | * emission points. A path is composed of emission points coupled with 4 | * emission time. Each point is used as a source image to produce a tap 5 | * in player's IR. 6 | **/ 7 | 8 | import NuBaseModule from './NuBaseModule' 9 | 10 | export default class NuPath extends NuBaseModule { 11 | constructor(serverExperience) { 12 | super(serverExperience, 'nuPath'); 13 | 14 | // to be saved params to send to client when connects: 15 | this.params = { masterGain: 1.0, 16 | propagationSpeed: 100.0, 17 | propagationGain: 0.9, 18 | propagationRxMinGain: 0.01, 19 | audioFileId: "snap", 20 | perc: 1, 21 | loop: true, 22 | accSlope: 0, 23 | timeBound: 0 }; 24 | 25 | // this variable is intern to server, no need to broadcast it to clients upon connection 26 | this._rdvDelay = 2.0; 27 | 28 | // binding 29 | this.setPath = this.setPath.bind(this); 30 | this.startPath = this.startPath.bind(this); 31 | this.rdvDelay = this.rdvDelay.bind(this); 32 | } 33 | 34 | setPath(args){ 35 | args.shift(); // playerId, not used, here to keep uniform the module impl. 36 | // extract from arguments 37 | let pathId = args.shift(); 38 | 39 | // shape args from [x0 y0 t0 ... xN yN tN] to [ [t0, [x0, y0]], [tN, [xN, yN]] ] 40 | let pathArray = []; 41 | for( let i = 0; i < args.length; i+=3){ 42 | let time = args[i]; 43 | let pos = [ args[i+1], args[i+2] ]; 44 | pathArray.push( [time, pos] ); 45 | } 46 | 47 | // avoid zero propagation speed 48 | let propagationSpeed = this.params.propagationSpeed; 49 | if( Math.abs(propagationSpeed) < 0.1 ) propagationSpeed = 0.1; 50 | 51 | // create IR for each player 52 | let dist, time, gain, timeMin = 0; 53 | let irsArray = []; 54 | this.e.coordinatesMap.forEach(( clientPos, clientId) => { 55 | 56 | // init 57 | irsArray[clientId] = []; 58 | gain = 1.0; 59 | 60 | // loop over path 61 | pathArray.forEach(( item, index ) => { 62 | let pathTime = item[0]; 63 | let pathPos = item[1]; 64 | // compute IR taps 65 | dist = Math.sqrt(Math.pow(clientPos[0] - pathPos[0], 2) + Math.pow(clientPos[1] - pathPos[1], 2)); 66 | time = pathTime + ( dist / propagationSpeed ); 67 | // gain *= Math.pow( this.params.propagationGain, dist ); 68 | // the gain doesn't decrease along the path, rather it decreases as the player 69 | // gets further away from current path point: 70 | gain = Math.pow( this.params.propagationGain, dist ); 71 | // save tap if valid 72 | if (gain >= this.params.propagationRxMinGain) { 73 | // push IR in array 74 | irsArray[clientId].push(time, gain); 75 | // prepare handle neg speed 76 | if (time < timeMin) timeMin = time; 77 | } 78 | }); 79 | }); 80 | 81 | // send IRs (had to split in two (see above) because of timeMin) 82 | this.e.coordinatesMap.forEach((clientPos, clientId) => { 83 | // get IR 84 | let ir = irsArray[ clientId ]; 85 | // add init time offset (useful for negative speed) 86 | ir.unshift( timeMin ); // add time min 87 | ir.unshift( pathId ); // add path id 88 | // shape for sending 89 | let msgArray = new Float32Array( ir ); 90 | // send 91 | let client = this.e.playerMap.get( clientId ); 92 | this.e.rawSocket.send( client, this.moduleName, msgArray ); 93 | }); 94 | } 95 | 96 | // trigger path rendering in clients 97 | startPath(args){ 98 | args.shift(); // playerId, not used, here to keep uniform the module impl. 99 | let pathId = args.shift(); 100 | // set rendez-vous time in 2 seconds from now. 101 | let rdvTime = this.e.sync.getSyncTime() + this._rdvDelay; 102 | this.e.broadcast('player', null, this.moduleName, ['startPath', pathId, rdvTime] ); 103 | } 104 | 105 | // define delay before rdv time, in sec, from moment when play path msg is received 106 | // (for syync. play) 107 | rdvDelay(args){ 108 | args.shift(); // playerId, not used, here to keep uniform the module impl. 109 | this._rdvDelay = args.shift(); 110 | } 111 | 112 | // reset clients (stop all sounds) 113 | reset(){ 114 | // re-route to clients 115 | this.e.broadcast( 'player', null, this.moduleName, ['reset'] ); 116 | } 117 | 118 | } 119 | 120 | -------------------------------------------------------------------------------- /node/src/server/config/default.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | const cwd = process.cwd(); 3 | 4 | // build coordinates grid 5 | const W = 4; 6 | const H = 4; 7 | const coordinates = []; 8 | for(let j = 1; j <= H; j ++ ){ 9 | for(let i = 1; i <= W; i ++ ){ 10 | coordinates.push([i,j]); 11 | } 12 | } 13 | 14 | // Configuration of the application. 15 | // Other entries can be added (as long as their name doesn't conflict with 16 | // existing ones) to define global parameters of the application (e.g. BPM, 17 | // synth parameters) that can then be shared easily among all clients using 18 | // the `shared-config` service. 19 | export default { 20 | // name of the application, used in the `.ejs` template and by default in 21 | // the `platform` service to populate its view 22 | appName: 'Nü', 23 | 24 | // name of the environnement ('production' enable cache in express application) 25 | env: 'development', 26 | 27 | // version of application, can be used to force reload css and js files 28 | // from server (cf. `html/default.ejs`) 29 | version: '0.0.1', 30 | 31 | // name of the default client type, i.e. the client that can access the 32 | // application at its root URL 33 | defaultClient: 'player', 34 | 35 | // define from where the assets (static files) should be loaded, these value 36 | // could also refer to a separate server for scalability reasons. This value 37 | // should also be used client-side to configure the `loader` service. 38 | assetsDomain: '/', 39 | 40 | // port used to open the http server, in production this value is typically 80 41 | port: 8000, 42 | 43 | // describe the location where the experience takes places, theses values are 44 | // used by the `placer`, `checkin` and `locator` services. 45 | // if one of these service is required, this entry shouldn't be removed. 46 | setup: { 47 | area: { 48 | width: W+1, 49 | height: H+1, 50 | // path to an image to be used in the area representation 51 | background: null, 52 | }, 53 | // list of predefined labels 54 | labels: null, 55 | // list of predefined coordinates given as an array of `[x:Number, y:Number]` 56 | coordinates: coordinates, 57 | // maximum number of clients allowed in a position 58 | maxClientsPerPosition: 1, 59 | // maximum number of positions (may limit or be limited by the number of 60 | // labels and/or coordinates) 61 | capacity: Infinity, 62 | }, 63 | 64 | // socket.io configuration 65 | websockets: { 66 | url: '', 67 | transports: ['websocket'], 68 | // @note: EngineIO defaults 69 | // pingTimeout: 3000, 70 | // pingInterval: 1000, 71 | // upgradeTimeout: 10000, 72 | // maxHttpBufferSize: 10E7, 73 | }, 74 | 75 | // define if the HTTP server should be launched using secure connections. 76 | // For development purposes when set to `true` and no certificates are given 77 | // (cf. `httpsInfos`), a self-signed certificate is created. 78 | useHttps: false, 79 | 80 | // paths to the key and certificate to be used in order to launch the https 81 | // server. Both entries are required otherwise a self-signed certificate 82 | // is generated. 83 | httpsInfos: { 84 | key: null, 85 | cert: null, 86 | }, 87 | 88 | // password to be used by the `auth` service 89 | password: '', 90 | 91 | // configuration of the `osc` service 92 | osc: { 93 | // IP of the currently running node server 94 | receiveAddress: '127.0.0.1', 95 | // port listening for incomming messages 96 | receivePort: 57121, 97 | // IP of the remote application 98 | sendAddress: '127.0.0.1', 99 | // port where the remote application is listening for messages 100 | sendPort: 57120, 101 | }, 102 | 103 | // configuration of the `raw-socket` service 104 | rawSocket: { 105 | // port used for socket connection 106 | port: 8085 107 | }, 108 | 109 | // define if the server should use gzip compression for static files 110 | enableGZipCompression: true, 111 | 112 | // location of the public directory (accessible through http(s) requests) 113 | publicDirectory: path.join(cwd, 'public'), 114 | 115 | // directory where the server templating system looks for the `ejs` templates 116 | templateDirectory: path.join(cwd, 'html'), 117 | 118 | // bunyan configuration 119 | logger: { 120 | name: 'soundworks', 121 | level: 'info', 122 | streams: [{ 123 | level: 'info', 124 | stream: process.stdout, 125 | }, /* { 126 | level: 'info', 127 | path: path.join(process.cwd(), 'logs', 'soundworks.log'), 128 | } */] 129 | }, 130 | 131 | // directory where error reported from the clients are written 132 | errorReporterDirectory: path.join(cwd, 'logs', 'clients'), 133 | 134 | } 135 | -------------------------------------------------------------------------------- /node/src/server/PlayerExperience.js: -------------------------------------------------------------------------------- 1 | import * as soundworks from 'soundworks/server'; 2 | import * as Nu from './Nu'; 3 | 4 | // server-side experience. 5 | export default class PlayerExperience extends soundworks.Experience { 6 | constructor(clientType) { 7 | super(clientType); 8 | 9 | // require services 10 | this.checkin = this.require('checkin'); 11 | this.sharedConfig = this.require('shared-config'); 12 | this.sharedConfig.share('setup', 'player'); // share `setup` entry to players 13 | this.sharedConfig.share('socketIO', 'player'); // share `socketIO` entry to players 14 | this.params = this.require('shared-params'); 15 | this.audioBufferManager = this.require('audio-buffer-manager'); 16 | this.syncScheduler = this.require('sync-scheduler'); 17 | this.sync = this.require('sync'); 18 | this.osc = this.require('osc'); 19 | var protocol = [ 20 | { channel: 'nuStream', type: 'Float32' }, 21 | { channel: 'nuRoomReverb', type: 'Float32' }, 22 | { channel: 'nuPath', type: 'Float32' }, 23 | { channel: 'nuOutput', type: 'Float32' }, 24 | ]; 25 | this.rawSocket = this.require('raw-socket', { protocol: protocol }); 26 | 27 | // bind methods 28 | this.checkinController = this.checkinController.bind(this); 29 | 30 | // local attributes 31 | this.playerMap = new Map(); 32 | this.coordinatesMap = new Map(); 33 | this.controllerMap = new Map(); 34 | } 35 | 36 | start() { 37 | // init Nu modules 38 | Object.keys(Nu).forEach( (nuClass) => { 39 | this['nu' + nuClass] = new Nu[nuClass](this); 40 | }); 41 | } 42 | 43 | enter(client) { 44 | super.enter(client); 45 | 46 | switch (client.type) { 47 | case 'player': 48 | 49 | // msg callback: receive client coordinates 50 | // (could use local service, this way lets open for pos estimation in client in the future) 51 | this.receive(client, 'coordinates', (xy) => { 52 | this.coordinatesMap.set( client.index, xy ); 53 | // update client pos in osc client 54 | this.osc.send('/nuMain/playerPos', [client.index, xy[0], xy[1]] ); 55 | }); 56 | 57 | // update nu modules (when they're ready to receive) 58 | this.receive(client, 'moduleReady', (nuClassPrefixed) => { 59 | this[nuClassPrefixed].enterPlayer(client); 60 | }); 61 | 62 | // update local attributes 63 | this.playerMap.set( client.index, client ); 64 | 65 | break; 66 | 67 | case 'controller': 68 | 69 | // add controller to local map (not using checkin for controllers) 70 | let clientId = this.checkinController(client); 71 | // indicate to OSC that controller 'client.index' is present 72 | this.osc.send('/nuController', [clientId, 'enterExit', 1]); 73 | // direct forward to OSC 74 | this.receive(client, 'osc', (header, args) => { 75 | // append controller index to msg 76 | let clientId = this.controllerMap.get(client); 77 | args.unshift(clientId); 78 | // forward to OSC 79 | this.osc.send(header, args); 80 | }); 81 | 82 | break; 83 | } 84 | } 85 | 86 | exit(client) { 87 | super.exit(client); 88 | 89 | switch (client.type) { 90 | case 'player': 91 | 92 | // update local attributes 93 | this.playerMap.delete( client.index ); 94 | 95 | // update Nu modules 96 | Object.keys(Nu).forEach( (nuClass) => { 97 | this['nu' + nuClass].exitPlayer(client); 98 | }); 99 | 100 | break; 101 | 102 | case 'controller': 103 | 104 | // update osc 105 | let clientId = this.controllerMap.get(client); 106 | this.osc.send('/nuController', [clientId, 'enterExit', 0]); 107 | // update local attributes 108 | this.controllerMap.delete(client); 109 | 110 | break; 111 | } 112 | } 113 | 114 | // equivalent of the checkin service (to avoid using checkin on controllers and screwing players numbering) 115 | checkinController(client){ 116 | let clientId = this.controllerMap.get(client); 117 | // if already defined, simply return clientId 118 | if( clientId !== undefined ){ return clientId; } 119 | // get occupied IDs 120 | let indexArray = Array.from( this.controllerMap, x => x[1] ); 121 | clientId = -1; let testId = 0; 122 | while( clientId == -1 ){ 123 | if( indexArray.indexOf(testId) == -1 ) 124 | clientId = testId; 125 | testId += 1; 126 | } 127 | // store new client index 128 | this.controllerMap.set(client, clientId); 129 | // send client index to client 130 | this.send(client, 'checkinId', clientId); 131 | // return Id 132 | return clientId 133 | } 134 | 135 | } -------------------------------------------------------------------------------- /node/src/server/NuStream.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuStream: live audio stream from OSC client to players 3 | * 4 | * "streaming" from OSC client to soundworks server is based on disk writing / reading. 5 | * streaming from server to players is based on the rawsocket soundworks service 6 | **/ 7 | 8 | import NuBaseModule from './NuBaseModule' 9 | 10 | // req audio read depts 11 | const fs = require('fs'); 12 | var AudioContext = require('web-audio-api').AudioContext 13 | const audioContext = new AudioContext; 14 | const assetsPath = __dirname + '/../../public/stream/'; 15 | 16 | export default class NuStream extends NuBaseModule { 17 | constructor(serverExperience) { 18 | super(serverExperience, 'nuStream'); 19 | 20 | // local attributes 21 | this.pointerToStreamInterval = undefined; 22 | this.packetId = 0; 23 | 24 | // to be saved params to send to client when connects: 25 | this.params = { 26 | gain: 1.0, // audio gain 27 | delayTime: 4.0, // time between buffer rec / buffer played on client (for sync playback) 28 | packetTime: 2.0, // duration of a stream packet, in sec 29 | startTime: 0.0, // system time at which streaming stated (order given from OSC received) 30 | }; 31 | 32 | // binding 33 | this.streamCallback = this.streamCallback.bind(this); 34 | 35 | // clean stream directory (delete old 'stream' files) 36 | this.cleanStreamDir(); 37 | } 38 | 39 | // clean all files containing "stream" in assetsPath 40 | cleanStreamDir(){ 41 | fs.readdir(assetsPath, (err, files) => { 42 | if( err ){ console.log('at ' + __filename + ':'); throw err; } 43 | for( let file of files ){ 44 | if( file.search('stream') < 0 ){ continue; } 45 | fs.unlinkSync(assetsPath + file); 46 | } 47 | }); 48 | } 49 | 50 | // enable / disable streaming module 51 | onOff(args){ 52 | args.shift(); // playerId, not used, here to keep uniform the module impl. 53 | let value = args.shift(); 54 | if( value ){ 55 | // remove old files 56 | this.cleanStreamDir(); 57 | // reset packet id 58 | this.packetId = 0; 59 | // broadcast start time for reference 60 | this.params.startTime = this.e.sync.getSyncTime(); 61 | this.e.broadcast('player', null, 'nuStream', ['startTime', this.params.startTime] ); 62 | // start streaming callback 63 | this.pointerToStreamInterval = setInterval( () => { 64 | this.streamCallback(); 65 | }, 1000); 66 | } 67 | 68 | // remove streaming callback 69 | else{ clearInterval( this.pointerToStreamInterval ); } 70 | 71 | // notify clients of on/off status 72 | this.e.broadcast( 'player', null, this.moduleName, ['onOff', value] ); 73 | } 74 | 75 | /** 76 | * callback in charge of reading audio file from disk, 77 | * broadcasting their audio content to players, and deleting 78 | * said audio files once used 79 | **/ 80 | streamCallback(){ 81 | 82 | // read files names from disk 83 | fs.readdir(assetsPath, (err, files) => { 84 | 85 | // search for a valid 'stream' file (should be only one there) 86 | var fileName = undefined; 87 | for( let file of files ){ 88 | if( file.search('stream') >= 0 ){ 89 | fileName = file; 90 | break; 91 | } 92 | } 93 | 94 | // discard if nothing found 95 | if( fileName === undefined ){ return; } 96 | 97 | // read file otherwise 98 | fs.readFile( assetsPath + fileName, (err, buf) => { 99 | if (err) { throw err; } 100 | 101 | // decode file to audiobuffer 102 | audioContext.decodeAudioData(buf, (audioBuffer) => { 103 | // debug 104 | console.log('\nread file:', fileName, 105 | 'nCh', audioBuffer.numberOfChannels, 106 | 'sampl.', audioBuffer.sampleRate + 'Hz', 107 | 'dur.', audioBuffer.length / audioBuffer.sampleRate + 's'); 108 | 109 | // get timestamp from file name 110 | // let timeStamp = Number( fileName.slice(fileName.search('_') + 1, fileName.search('.wav') ) ); 111 | 112 | // add packet id to array (need to create new one since Float32Array is fixed size) 113 | let audioArray = audioBuffer.getChannelData(0); 114 | var dataArray = new Float32Array( audioArray.length + 1 ); 115 | dataArray[0] = this.packetId; 116 | dataArray.set(audioArray, 1); 117 | 118 | // send data to every clients 119 | this.e.clients.forEach( (client) => { 120 | if( client.type === 'player' ){ 121 | this.e.rawSocket.send( client, 'nuStream', dataArray ); 122 | } 123 | }); 124 | 125 | // delete file 126 | fs.unlinkSync(assetsPath + fileName); 127 | console.log('deleting file', fileName); 128 | 129 | // incr. packet Id 130 | this.packetId += 1; 131 | }, 132 | (err) => { throw err; }); }); 133 | 134 | }); 135 | 136 | } 137 | 138 | } 139 | 140 | -------------------------------------------------------------------------------- /node/sass/_04-survey.scss: -------------------------------------------------------------------------------- 1 | 2 | // ------------------------------------------------------------ 3 | // SURVEY 4 | // ------------------------------------------------------------ 5 | 6 | #survey { 7 | .section-center { 8 | padding: 10px; 9 | box-sizing: border-box; 10 | } 11 | 12 | .question { 13 | width: 100%; 14 | height: 100%; 15 | } 16 | 17 | // radio, checkbox 18 | .label { 19 | text-align: left; 20 | padding-bottom: 6px; 21 | font-style: italic; 22 | font-size: 1.1rem; 23 | } 24 | 25 | .answer { 26 | font-size: 1rem; 27 | position: relative; 28 | padding: 8px 0 6px 0; 29 | margin-bottom: 1px; 30 | border-radius: $borderRadius; 31 | @include transition(background-color 0.2s); 32 | 33 | &.radio, &.checkbox { 34 | padding-left: 50px; 35 | } 36 | 37 | &.selected { 38 | background-color: rgba(255, 255, 255, 0.2); 39 | } 40 | 41 | &.radio:before { 42 | content: ''; 43 | display: block; 44 | position: absolute; 45 | left: 10px; 46 | width: 16px; 47 | height: 16px; 48 | background-color: transparent; 49 | border-radius: 50%; 50 | border: 1px solid white; 51 | @include transition(background-color 0.2s); 52 | } 53 | 54 | &.radio.selected:before { 55 | background-color: white; 56 | } 57 | 58 | &.checkbox:before { 59 | content: ''; 60 | display: block; 61 | position: absolute; 62 | left: 10px; 63 | width: 16px; 64 | height: 16px; 65 | background-color: transparent; 66 | border-radius: $borderRadius; 67 | border: 1px solid white; 68 | @include transition(background-color 0.2s); 69 | } 70 | 71 | &.checkbox.selected:before { 72 | background-color: white; 73 | } 74 | } 75 | 76 | // SLIDER 77 | // reset slider styles 78 | input[type=range] { 79 | -webkit-appearance: none; /* Hides the slider so that custom slider can be made */ 80 | width: 100%; /* Specific width is required for Firefox. */ 81 | background-color: transparent; 82 | } 83 | 84 | input[type=range]::-webkit-slider-thumb { 85 | -webkit-appearance: none; 86 | } 87 | 88 | input[type=range]:focus { 89 | outline: none; /* Removes the blue border. You should probably do some kind of focus styling for accessibility reasons though. */ 90 | } 91 | 92 | input[type=range]::-ms-track { 93 | width: 100%; 94 | cursor: pointer; 95 | background: transparent; /* Hides the slider so custom styles can be added */ 96 | border-color: transparent; 97 | color: transparent; 98 | } 99 | 100 | // --------------------------- 101 | // handler 102 | // --------------------------- 103 | 104 | /* Special styling for WebKit/Blink */ 105 | input[type=range]::-webkit-slider-thumb { 106 | -webkit-appearance: none; 107 | // border: 1px solid #000000; 108 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 109 | height: 40px; 110 | width: 40px; 111 | border-radius: 50%; 112 | background: #ffffff; 113 | cursor: pointer; 114 | margin-top: -17px; /* You need to specify a margin in Chrome, but in Firefox and IE it is automatic */ 115 | // box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; /* Add cool effects to your sliders! */ 116 | } 117 | 118 | /* All the same stuff for Firefox */ 119 | input[type=range]::-moz-range-thumb { 120 | // border: 1px solid #000000; 121 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 122 | height: 40px; 123 | width: 40px; 124 | border-radius: 50%; 125 | background: #ffffff; 126 | cursor: pointer; 127 | } 128 | 129 | // --------------------------- 130 | // track 131 | // --------------------------- 132 | input[type=range]::-webkit-slider-runnable-track { 133 | height: 8px; 134 | cursor: pointer; 135 | background: rgba(255, 255, 255, 0.7); 136 | border-radius: 3px; 137 | border: 0.2px solid #010101; 138 | } 139 | 140 | input[type=range]:focus::-webkit-slider-runnable-track { 141 | background: rgba(255, 255, 255, 0.9); 142 | } 143 | 144 | input[type=range]::-moz-range-track { 145 | height: 8px; 146 | cursor: pointer; 147 | background: rgba(255, 255, 255, 0.7); 148 | border-radius: 3px; 149 | border: 0.2px solid #010101; 150 | } 151 | 152 | // slider 153 | input[type=range].slider { 154 | width: 100%; 155 | margin: 20px 0; 156 | float: left; 157 | } 158 | 159 | .textarea { 160 | color: $backgroundColor; 161 | padding: 10px; 162 | box-sizing: border-box; 163 | } 164 | 165 | .feedback { 166 | font-size: 2rem; 167 | display: block; 168 | text-align: center; 169 | } 170 | 171 | .counter { 172 | font-size: 2rem; 173 | font-style: italic; 174 | position: absolute; 175 | top: 20px; 176 | right: 20px; 177 | } 178 | 179 | .thanks { 180 | font-size: 2rem; 181 | } 182 | 183 | @media (min-width: 10px) and (orientation: portrait) { 184 | & { 185 | position: relative; 186 | } 187 | 188 | .counter { 189 | position: relative; 190 | top: 0; 191 | left: 0; 192 | right: 0; 193 | padding: 20px; 194 | text-align: right; 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /node/bin/javascripts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var babel = require('babel-core'); 3 | var browserify = require('browserify'); 4 | var fse = require('fs-extra'); 5 | var log = require('./log'); 6 | var path = require('path'); 7 | var watchify = require('watchify'); 8 | 9 | 'use strict'; 10 | 11 | /** 12 | * Create a transpiler object binded to a `srcDirectory` and a `distDirectory` 13 | */ 14 | function getTranspiler(srcDirectory, distDirectory, isAllowed, babelOptions, browserifyOptions) { 15 | 16 | /** 17 | * Add watchify to the browserify options. client only. 18 | */ 19 | if (browserifyOptions !== undefined) { 20 | Object.assign(browserifyOptions, { 21 | cache: {}, // required for watchify 22 | packageCache: {}, // required for watchify 23 | }); 24 | 25 | if (browserifyOptions.plugin) 26 | browserifyOptions.plugin.push(watchify); 27 | else 28 | browserifyOptions.plugin = [watchify]; 29 | } 30 | 31 | /** 32 | * Returns the name of the target transpiled file 33 | */ 34 | function getTarget(filename) { 35 | var relFilename = path.relative(srcDirectory, filename); 36 | var outFilename = path.join(distDirectory, relFilename); 37 | return outFilename; 38 | } 39 | 40 | /** 41 | * Returns the path transpiled `index.js` file the client folder in which resides 42 | * the given `filename` 43 | */ 44 | function getClientEntryPoint(filename) { 45 | var folderName = getClientFolderName(filename); 46 | var entryPoint = path.join(distDirectory, folderName, 'index.js'); 47 | return entryPoint; 48 | } 49 | 50 | /** 51 | * Returns the name of the 1rst level folder inside `srcDirectory` in which the 52 | * client side javascript file resides. 53 | */ 54 | function getClientFolderName(filename) { 55 | var relFilename = path.relative(srcDirectory, filename); 56 | var folderName = relFilename.split(path.sep)[0]; 57 | return folderName; 58 | } 59 | 60 | /** 61 | * returns the transpiler to be consumed. 62 | */ 63 | var transpiler = { 64 | /** 65 | * Transpile the given file from es6 to es5. If the given stack is not empty 66 | * call the method recursively till its empty. When the stack is empty, 67 | * execute the callback. 68 | */ 69 | transpile: function(filename, stack, callback) { 70 | /** 71 | * If stack is not empty transpile the next entry, else execute the 72 | * callback if any. 73 | */ 74 | function next() { 75 | if (stack && stack.length > 0) 76 | transpiler.transpile(stack.shift(), stack, callback); 77 | else if (stack.length === 0 && callback) 78 | callback(); 79 | } 80 | 81 | if (filename === undefined || !isAllowed(filename)) 82 | return next(); 83 | 84 | var outFilename = getTarget(filename); 85 | var startTime = new Date().getTime(); 86 | 87 | babel.transformFile(filename, babelOptions, function(err, result) { 88 | if (err) 89 | return log.transpileError(err); 90 | 91 | fse.outputFile(outFilename, result.code, function(err) { 92 | if (err) 93 | return console.error(err.message); 94 | 95 | log.transpileSuccess(filename, outFilename, startTime); 96 | next(); 97 | }); 98 | }); 99 | }, 100 | 101 | /** @private */ 102 | _bundlers: [], 103 | 104 | /** 105 | * Transform a given file to it's browserified version, client only. 106 | * Only clients have their browserified counterparts, each folder in `src/client` 107 | * is considered has a separate browserified client file. The `index.js` in each 108 | * folder defines the entry point of the particular client. The browserified 109 | * file is name after the name of the folder. 110 | */ 111 | bundle: function(filename, bundleDistDirectory, ensureFile, stopWhenDone) { 112 | if (filename === undefined || !isAllowed(filename, ensureFile)) 113 | return; 114 | 115 | // get the entry point of the client (`index.js`) 116 | var entryPoint = getClientEntryPoint(filename); 117 | var outBasename = getClientFolderName(filename) + '.js'; 118 | var outFilename = path.join(bundleDistDirectory, outBasename); 119 | 120 | if (!this._bundlers[entryPoint]) { 121 | var bundler = browserify(entryPoint, browserifyOptions); 122 | 123 | function rebundle() { 124 | fse.ensureFileSync(outFilename); // ensure file exists 125 | 126 | var startTime = new Date().getTime(); 127 | var writeStream = fse.createWriteStream(outFilename); 128 | 129 | bundler.bundle() 130 | .on('error', function(err) { 131 | log.bundleError(outFilename, err); 132 | }) 133 | .on('end', function() { 134 | if (stopWhenDone) { 135 | // don't remove, handle some bug of watchify when file is empty 136 | setTimeout(function() { bundler.close(); }, 100); 137 | } 138 | 139 | log.bundleSuccess(outFilename, startTime); 140 | }) 141 | .pipe(writeStream); 142 | 143 | log.bundleStart(outFilename); 144 | } 145 | 146 | if (!stopWhenDone) { 147 | bundler.on('update', rebundle); 148 | this._bundlers[entryPoint] = bundler; 149 | } 150 | 151 | rebundle(); 152 | } 153 | }, 154 | 155 | /** 156 | * Delete the transpiled file. 157 | */ 158 | delete: function(filename, callback) { 159 | var outFilename = getTarget(filename); 160 | 161 | fse.stat(outFilename, function(err, stats) { 162 | if (err) 163 | return console.log(err.message); 164 | 165 | if (stats.isFile()) { 166 | fse.remove(outFilename, function(err) { 167 | if (err) 168 | return console.log(err.message); 169 | 170 | log.deleteFile(outFilename); 171 | 172 | if (callback) 173 | callback(); 174 | }); 175 | } else { 176 | callback(); 177 | } 178 | }); 179 | }, 180 | }; 181 | 182 | return transpiler; 183 | } 184 | 185 | module.exports = { 186 | getTranspiler: getTranspiler 187 | }; 188 | -------------------------------------------------------------------------------- /node/src/client/controller/ControllerExperience.js: -------------------------------------------------------------------------------- 1 | import * as soundworks from 'soundworks/client'; 2 | 3 | const audioContext = soundworks.audioContext; 4 | const client = soundworks.client; 5 | 6 | const viewTemplate = ` 7 | 8 |
9 | 10 |
11 |

12 |
13 | 14 |
15 |

<%= checkinId %>

16 |
17 | 18 |
19 | 20 |
21 | 22 |
23 | `; 24 | 25 | 26 | /* Description: 27 | ... 28 | */ 29 | 30 | export default class PlayerExperience extends soundworks.Experience { 31 | constructor(assetsDomain) { 32 | super(); 33 | 34 | // soundworks services 35 | this.motionInput = this.require('motion-input', { 36 | descriptors: ['accelerationIncludingGravity', 'deviceorientation', 'energy'] 37 | }); 38 | 39 | // binding 40 | this.touchGestureDetect = this.touchGestureDetect.bind(this); 41 | this.touchCallback = this.touchCallback.bind(this); 42 | 43 | // local attributes 44 | this.throttle = { 45 | 'acc': [Infinity, Infinity, Infinity], 46 | 'accThreshold': 2.5, 47 | 'ori': [Infinity, Infinity, Infinity], 48 | 'oriThreshold': 3, 49 | 'energy': Infinity, 50 | 'energyThreshold': 0.1, 51 | 'touch': [Infinity, Infinity] 52 | }; 53 | this.touchDataMap = new Map(); 54 | 55 | } 56 | 57 | start() { 58 | super.start(); 59 | 60 | // receive and display controller id 61 | this.receive('checkinId', (id) => { 62 | // initialize the view 63 | this.view = new soundworks.CanvasView(viewTemplate, { subtitle: `controller`, checkinId: id }, {}, { 64 | id: this.id, 65 | preservePixelRatio: true, 66 | }); 67 | // start exp. once view showed 68 | this.show().then( this.startOnceViewShowed() ); 69 | }); 70 | 71 | } 72 | 73 | startOnceViewShowed() { 74 | 75 | // setup motion input listeners 76 | if (this.motionInput.isAvailable('accelerationIncludingGravity')) { 77 | this.motionInput.addListener('accelerationIncludingGravity', (data) => { 78 | // throttle 79 | let delta = Math.abs(this.throttle.acc[0] - data[0]) + 80 | Math.abs(this.throttle.acc[1] - data[1]) + 81 | Math.abs(this.throttle.acc[2] - data[2]); 82 | if( delta < this.throttle.accThreshold ){ return } 83 | // save new throttle values 84 | this.throttle.acc[0] = data[0]; 85 | this.throttle.acc[1] = data[1]; 86 | this.throttle.acc[2] = data[2]; 87 | // send to OSC via server 88 | this.send('osc', '/nuController', ['acceleration', data[0], data[1], data[2]] ); 89 | }); 90 | } 91 | 92 | // setup motion input listeners 93 | if (this.motionInput.isAvailable('deviceorientation')) { 94 | this.motionInput.addListener('deviceorientation', (data) => { 95 | // throttle 96 | let delta = Math.abs(this.throttle.ori[0] - data[0]) + 97 | Math.abs(this.throttle.ori[1] - data[1]) + 98 | Math.abs(this.throttle.ori[2] - data[2]); 99 | if( delta < this.throttle.oriThreshold ){ return } 100 | // save new throttle values 101 | this.throttle.ori[0] = data[0]; 102 | this.throttle.ori[1] = data[1]; 103 | this.throttle.ori[2] = data[2]; 104 | // send to OSC via server 105 | this.send('osc', '/nuController', ['orientation', data[0], data[1], data[2]] ); 106 | }); 107 | } 108 | 109 | // setup motion input listeners 110 | if (this.motionInput.isAvailable('energy')) { 111 | this.motionInput.addListener('energy', (data) => { 112 | // throttle 113 | let delta = Math.abs(this.throttle.energy - data); 114 | if( delta < this.throttle.energyThreshold ){ return } 115 | // save new throttle values 116 | this.throttle.energy = data; 117 | // send to OSC via server 118 | this.send('osc', '/nuController', ['energy', data] ); 119 | }); 120 | } 121 | 122 | 123 | // disable text selection, magnifier, and screen move on swipe on ios 124 | document.getElementsByTagName("body")[0].addEventListener("touchstart", 125 | function(e) { e.returnValue = false }); 126 | 127 | const surface = new soundworks.TouchSurface(this.view.$el); 128 | 129 | // setup touch listeners 130 | surface.addListener('touchstart', (id, normX, normY) => { 131 | // notify touch on 132 | this.send('osc', '/nuController', ['touchOn', 1] ); 133 | // reset touch memory 134 | this.touchDataMap.set(id, []); 135 | // general callback 136 | this.touchCallback(id, normX, normY); 137 | }); 138 | 139 | surface.addListener('touchmove', (id, normX, normY) => { 140 | // general callback 141 | this.touchCallback(id, normX, normY); 142 | }); 143 | 144 | surface.addListener('touchend', (id, normX, normY) => { 145 | // general callback 146 | this.touchCallback(id, normX, normY); 147 | // notify touch off 148 | this.send('osc', '/nuController', ['touchOn', 0] ); 149 | // gesture detection 150 | this.touchGestureDetect(this.touchDataMap.get(id)); 151 | }); 152 | 153 | } 154 | 155 | touchCallback(id, normX, normY){ 156 | // save touch data 157 | this.touchDataMap.get(id).push([audioContext.currentTime, normX, normY]); 158 | // send touch pos 159 | this.send('osc', '/nuController', ['touchPos', id, normX, normY]); 160 | } 161 | 162 | touchGestureDetect(data) { 163 | let N = data.length - 1; 164 | let pathVect = [data[N][1] - data[0][1], data[N][2] - data[0][2]]; 165 | let pathDuration = data[N][0] - data[0][0]; 166 | 167 | // discard slow movements 168 | if (pathDuration > 2.0) return; 169 | 170 | // swipe up 171 | if (pathVect[1] > 0.4) 172 | this.send('osc', '/nuController', ['gesture', 'swipe', 0] ); 173 | // swipe down 174 | if (pathVect[1] < -0.4) 175 | this.send('osc', '/nuController', ['gesture', 'swipe', 1] ); 176 | } 177 | 178 | } -------------------------------------------------------------------------------- /node/src/client/player/NuRoomReverb.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuRoomReverb: Nu module in charge of room reverb where players 3 | * emit bursts when the acoustical wave passes them by 4 | **/ 5 | 6 | import NuBaseModule from './NuBaseModule' 7 | import * as soundworks from 'soundworks/client'; 8 | 9 | const client = soundworks.client; 10 | const audioContext = soundworks.audioContext; 11 | 12 | export default class NuRoomReverb extends NuBaseModule { 13 | constructor(playerExperience) { 14 | super(playerExperience, 'nuRoomReverb'); 15 | 16 | // local attributes 17 | this.irMap = new Map(); 18 | this.srcSet = new Set(); 19 | this.params = {}; 20 | 21 | // binding 22 | this.rawSocketCallback = this.rawSocketCallback.bind(this); 23 | this.emitAtPos = this.emitAtPos.bind(this); 24 | this.reset = this.reset.bind(this); 25 | 26 | // setup socket reveive callbacks (receiving raw audio data) 27 | this.e.rawSocket.receive('nuRoomReverb', this.rawSocketCallback ); 28 | } 29 | 30 | /* 31 | * callback when websocket event (msg containing new IR sent by server) is received 32 | */ 33 | rawSocketCallback(interleavedIrArray) { 34 | 35 | // extract header 36 | let emitterId = interleavedIrArray[0]; 37 | let minTime = interleavedIrArray[1]; 38 | // exctract data 39 | interleavedIrArray = interleavedIrArray.slice(2, interleavedIrArray.length); 40 | 41 | // de-interleave + get max delay for IR buffer size 42 | let irTime = [], 43 | irGain = [], 44 | irDuration = 0.0; 45 | for (let i = 0; i < interleavedIrArray.length / 2; i++) { 46 | irTime[i] = interleavedIrArray[2 * i] - minTime; 47 | irGain[i] = interleavedIrArray[2 * i + 1]; 48 | irDuration = Math.max(irDuration, irTime[i]); 49 | } 50 | 51 | // store IR 52 | let ir = { times: irTime, gains: irGain, duration: irDuration }; 53 | this.irMap.set(emitterId, ir); 54 | 55 | // feedback user that IR has been loaded 56 | this.e.renderer.blink([0, 100, 0]); 57 | } 58 | 59 | 60 | /* 61 | * message callback: play sound convolved with IR 62 | */ 63 | emitAtPos(args) { 64 | let irId = args.shift(); 65 | let syncStartTime = args.shift(); 66 | 67 | // check if designated audioFile exists in loader 68 | if (this.e.loader.data[this.params.audioFileId] == undefined) { 69 | console.warn('required audio file id', this.params.audioFileId, 'not in client index, actual content:', this.e.loader.options.files); 70 | return; 71 | } 72 | 73 | // check if IR not available yet: slightly flash red otherwise 74 | if (!this.irMap.has(irId)) { 75 | this.e.renderer.blink([160, 0, 0]); 76 | console.warn('IR', irId, 'not yet defined in client, need to update propagation'); 77 | return; 78 | } 79 | 80 | // init 81 | let ir = this.irMap.get(irId); 82 | 83 | // create empty sound src 84 | let src = audioContext.createBufferSource(); 85 | let inputBuffer = this.e.loader.data[this.params.audioFileId]; 86 | let outputDuration = ir.duration + inputBuffer.duration + 1; 87 | let outputBuffer = audioContext.createBuffer(1, Math.max(outputDuration * audioContext.sampleRate, 512), audioContext.sampleRate); 88 | 89 | // fill sound source with delayed audio buffer version (tap delay line mecanism) 90 | let inputData = inputBuffer.getChannelData(0); 91 | let outputData = outputBuffer.getChannelData(0); 92 | ir.times.forEach((tapTime, index) => { 93 | 94 | // get tap time and gain 95 | let tapGain = ir.gains[index]; 96 | let tapdelayInSample = Math.floor(tapTime * audioContext.sampleRate); 97 | 98 | // get input start point based on time since propagation started 99 | let offsetTimeInSamples = Math.floor(this.params.timeBound * tapdelayInSample); 100 | if (this.params.loop) offsetTimeInSamples %= inputBuffer.length; 101 | 102 | // if end of audio input not reached yet 103 | if (offsetTimeInSamples < inputBuffer.length) { 104 | 105 | // eventually read only a chunk of input buffer 106 | let numSamplesToFill = inputBuffer.length - offsetTimeInSamples; 107 | numSamplesToFill = Math.floor(numSamplesToFill * this.params.perc); 108 | 109 | // if reading speed acc with time passed 110 | let readSpeed = 1 + this.params.accSlope * tapTime; 111 | numSamplesToFill = Math.floor(numSamplesToFill / readSpeed); 112 | 113 | // copy tap to output buffer 114 | for (let i = 0; i < numSamplesToFill; i++) 115 | outputData[tapdelayInSample + i] += (tapGain * inputData[offsetTimeInSamples + Math.round(i * readSpeed)]); 116 | } 117 | 118 | }); 119 | 120 | // normalize output buffer 121 | let maxOutputValue = 0.0; 122 | for (let i = 0; i < outputBuffer.length; i++) { 123 | maxOutputValue = Math.max(Math.abs(outputData[i]), maxOutputValue); 124 | } 125 | let normFactor = Math.max.apply(null, ir.gains) / Math.max(maxOutputValue, 1.0); 126 | // console.log('max:', maxOutputValue, 'norm:', normFactor); 127 | 128 | // replace audio source buffer with created output buffer 129 | src.buffer = outputBuffer; 130 | 131 | // create master gain (shared param, controlled from conductor) 132 | let gain = audioContext.createGain(); 133 | gain.gain.value = normFactor * this.params.masterGain; 134 | 135 | // connect graph 136 | src.connect(gain); 137 | gain.connect( this.e.nuOutput.in ); 138 | 139 | // play sound if rendez-vous time is in the future (else report bug) 140 | let now = this.e.sync.getSyncTime() 141 | if (syncStartTime > now) { 142 | let audioContextStartTime = audioContext.currentTime + syncStartTime - now; 143 | src.start(audioContextStartTime); 144 | // console.log('play scheduled in:', Math.round((syncStartTime - now) * 1000) / 1000, 'sec', 'at:', syncStartTime); 145 | } else { 146 | console.warn('no sound played, I received the instruction to play to late'); 147 | this.e.renderer.blink([250, 0, 0]); 148 | } 149 | 150 | // setup screen color = f(amplitude) callback 151 | this.e.renderer.enable(); 152 | 153 | // save source for eventual global reset 154 | this.srcSet.add(src); 155 | 156 | // timeout callback, runs when we finished playing 157 | setTimeout(() => { 158 | // disable visual feeback 159 | this.e.renderer.disable(); 160 | // remove source from set 161 | this.srcSet.delete(src); 162 | }, (syncStartTime - now + src.buffer.duration) * 1000); 163 | 164 | } 165 | 166 | // stop all audio sources 167 | reset(){ 168 | this.srcSet.forEach( (src) => { 169 | // stop source 170 | src.stop(); 171 | // remove associated visual feedback 172 | this.e.renderer.disable(); 173 | }); 174 | } 175 | 176 | } -------------------------------------------------------------------------------- /maxmsp/patchers/nu.template.maxpat: -------------------------------------------------------------------------------- 1 | { 2 | "patcher" : { 3 | "fileversion" : 1, 4 | "appversion" : { 5 | "major" : 7, 6 | "minor" : 3, 7 | "revision" : 3, 8 | "architecture" : "x86", 9 | "modernui" : 1 10 | } 11 | , 12 | "rect" : [ 173.0, 514.0, 344.0, 282.0 ], 13 | "bglocked" : 0, 14 | "openinpresentation" : 0, 15 | "default_fontsize" : 12.0, 16 | "default_fontface" : 0, 17 | "default_fontname" : "Arial", 18 | "gridonopen" : 1, 19 | "gridsize" : [ 15.0, 15.0 ], 20 | "gridsnaponopen" : 1, 21 | "objectsnaponopen" : 1, 22 | "statusbarvisible" : 2, 23 | "toolbarvisible" : 1, 24 | "lefttoolbarpinned" : 0, 25 | "toptoolbarpinned" : 0, 26 | "righttoolbarpinned" : 0, 27 | "bottomtoolbarpinned" : 0, 28 | "toolbars_unpinned_last_save" : 0, 29 | "tallnewobj" : 0, 30 | "boxanimatetime" : 200, 31 | "enablehscroll" : 1, 32 | "enablevscroll" : 1, 33 | "devicewidth" : 0.0, 34 | "description" : "", 35 | "digest" : "", 36 | "tags" : "", 37 | "style" : "", 38 | "subpatcher_template" : "", 39 | "boxes" : [ { 40 | "box" : { 41 | "id" : "obj-8", 42 | "maxclass" : "message", 43 | "numinlets" : 2, 44 | "numoutlets" : 1, 45 | "outlettype" : [ "" ], 46 | "patching_rect" : [ 15.0, 98.0, 43.0, 22.0 ], 47 | "style" : "", 48 | "text" : "set $1" 49 | } 50 | 51 | } 52 | , { 53 | "box" : { 54 | "id" : "obj-6", 55 | "maxclass" : "newobj", 56 | "numinlets" : 1, 57 | "numoutlets" : 1, 58 | "outlettype" : [ "" ], 59 | "patching_rect" : [ 15.0, 189.5, 71.0, 22.0 ], 60 | "style" : "", 61 | "text" : "prepend #1" 62 | } 63 | 64 | } 65 | , { 66 | "box" : { 67 | "id" : "obj-3", 68 | "maxclass" : "message", 69 | "numinlets" : 2, 70 | "numoutlets" : 1, 71 | "outlettype" : [ "" ], 72 | "patching_rect" : [ 228.0, 164.0, 50.0, 22.0 ], 73 | "style" : "", 74 | "text" : "gain $1" 75 | } 76 | 77 | } 78 | , { 79 | "box" : { 80 | "id" : "obj-2", 81 | "maxclass" : "message", 82 | "numinlets" : 2, 83 | "numoutlets" : 1, 84 | "outlettype" : [ "" ], 85 | "patching_rect" : [ 157.0, 130.0, 140.0, 22.0 ], 86 | "style" : "", 87 | "text" : "directToClientMethod $1" 88 | } 89 | 90 | } 91 | , { 92 | "box" : { 93 | "id" : "obj-12", 94 | "maxclass" : "newobj", 95 | "numinlets" : 1, 96 | "numoutlets" : 0, 97 | "patching_rect" : [ 15.0, 245.5, 65.0, 22.0 ], 98 | "style" : "", 99 | "text" : "s toServer" 100 | } 101 | 102 | } 103 | , { 104 | "box" : { 105 | "id" : "obj-13", 106 | "maxclass" : "newobj", 107 | "numinlets" : 1, 108 | "numoutlets" : 1, 109 | "outlettype" : [ "" ], 110 | "patching_rect" : [ 15.0, 217.5, 123.0, 22.0 ], 111 | "style" : "", 112 | "text" : "prepend /nuTemplate" 113 | } 114 | 115 | } 116 | , { 117 | "box" : { 118 | "id" : "obj-4", 119 | "maxclass" : "message", 120 | "numinlets" : 2, 121 | "numoutlets" : 1, 122 | "outlettype" : [ "" ], 123 | "patching_rect" : [ 86.0, 98.0, 101.0, 22.0 ], 124 | "style" : "", 125 | "text" : "serverMethod $1" 126 | } 127 | 128 | } 129 | , { 130 | "box" : { 131 | "id" : "obj-24", 132 | "maxclass" : "newobj", 133 | "numinlets" : 1, 134 | "numoutlets" : 0, 135 | "patching_rect" : [ 265.0, 23.0, 53.0, 22.0 ], 136 | "style" : "", 137 | "text" : "nu.error" 138 | } 139 | 140 | } 141 | , { 142 | "box" : { 143 | "id" : "obj-23", 144 | "maxclass" : "newobj", 145 | "numinlets" : 5, 146 | "numoutlets" : 5, 147 | "outlettype" : [ "", "", "", "", "" ], 148 | "patching_rect" : [ 15.0, 55.0, 303.0, 22.0 ], 149 | "style" : "", 150 | "text" : "route playerId serverMethod directToClientMethod gain" 151 | } 152 | 153 | } 154 | , { 155 | "box" : { 156 | "comment" : "", 157 | "id" : "obj-1", 158 | "index" : 0, 159 | "maxclass" : "inlet", 160 | "numinlets" : 0, 161 | "numoutlets" : 1, 162 | "outlettype" : [ "" ], 163 | "patching_rect" : [ 15.0, 9.0, 30.0, 30.0 ], 164 | "style" : "" 165 | } 166 | 167 | } 168 | ], 169 | "lines" : [ { 170 | "patchline" : { 171 | "destination" : [ "obj-23", 0 ], 172 | "disabled" : 0, 173 | "hidden" : 0, 174 | "source" : [ "obj-1", 0 ] 175 | } 176 | 177 | } 178 | , { 179 | "patchline" : { 180 | "destination" : [ "obj-12", 0 ], 181 | "disabled" : 0, 182 | "hidden" : 0, 183 | "source" : [ "obj-13", 0 ] 184 | } 185 | 186 | } 187 | , { 188 | "patchline" : { 189 | "destination" : [ "obj-6", 0 ], 190 | "disabled" : 0, 191 | "hidden" : 0, 192 | "source" : [ "obj-2", 0 ] 193 | } 194 | 195 | } 196 | , { 197 | "patchline" : { 198 | "destination" : [ "obj-2", 0 ], 199 | "disabled" : 0, 200 | "hidden" : 0, 201 | "source" : [ "obj-23", 2 ] 202 | } 203 | 204 | } 205 | , { 206 | "patchline" : { 207 | "destination" : [ "obj-24", 0 ], 208 | "disabled" : 0, 209 | "hidden" : 0, 210 | "source" : [ "obj-23", 4 ] 211 | } 212 | 213 | } 214 | , { 215 | "patchline" : { 216 | "destination" : [ "obj-3", 0 ], 217 | "disabled" : 0, 218 | "hidden" : 0, 219 | "source" : [ "obj-23", 3 ] 220 | } 221 | 222 | } 223 | , { 224 | "patchline" : { 225 | "destination" : [ "obj-4", 0 ], 226 | "disabled" : 0, 227 | "hidden" : 0, 228 | "source" : [ "obj-23", 1 ] 229 | } 230 | 231 | } 232 | , { 233 | "patchline" : { 234 | "destination" : [ "obj-8", 0 ], 235 | "disabled" : 0, 236 | "hidden" : 0, 237 | "source" : [ "obj-23", 0 ] 238 | } 239 | 240 | } 241 | , { 242 | "patchline" : { 243 | "destination" : [ "obj-6", 0 ], 244 | "disabled" : 0, 245 | "hidden" : 0, 246 | "source" : [ "obj-3", 0 ] 247 | } 248 | 249 | } 250 | , { 251 | "patchline" : { 252 | "destination" : [ "obj-6", 0 ], 253 | "disabled" : 0, 254 | "hidden" : 0, 255 | "source" : [ "obj-4", 0 ] 256 | } 257 | 258 | } 259 | , { 260 | "patchline" : { 261 | "destination" : [ "obj-13", 0 ], 262 | "disabled" : 0, 263 | "hidden" : 0, 264 | "source" : [ "obj-6", 0 ] 265 | } 266 | 267 | } 268 | , { 269 | "patchline" : { 270 | "destination" : [ "obj-6", 0 ], 271 | "disabled" : 0, 272 | "hidden" : 0, 273 | "source" : [ "obj-8", 0 ] 274 | } 275 | 276 | } 277 | ], 278 | "dependency_cache" : [ { 279 | "name" : "nu.error.maxpat", 280 | "bootpath" : "~/Projects/Cosima/devs/Nu/soundworks-nu/maxmsp/extras", 281 | "type" : "JSON", 282 | "implicit" : 1 283 | } 284 | ], 285 | "autosave" : 0 286 | } 287 | 288 | } 289 | -------------------------------------------------------------------------------- /node/src/client/player/NuOutput.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuOutput: render output, either directly to audioContext.destination or 3 | * to spatialization engine for debug sessions (i.e. to get a feel of the final 4 | * result while players are emulated on server's laptop). Spatialization is based 5 | * Ambisonic encoding plugged in binaural decoding. 6 | **/ 7 | 8 | import NuBaseModule from './NuBaseModule' 9 | import * as soundworks from 'soundworks/client'; 10 | import * as ambisonics from 'ambisonics'; 11 | import Recorder from 'recorderjs'; 12 | 13 | const client = soundworks.client; 14 | const audioContext = soundworks.audioContext; 15 | 16 | /** 17 | * convert Cartesian to spherical coordinates 18 | * (azimuth is in xy. elev at zero is in xy, positive up) 19 | **/ 20 | const cart2sph = function(xyz){ 21 | let r2d = 180 / Math.PI; 22 | let d = Math.sqrt( Math.pow(xyz[0], 2) + Math.pow(xyz[1], 2) + Math.pow(xyz[2], 2) ); 23 | let a = r2d * Math.atan2(xyz[0], xyz[1]); 24 | let e = 0; 25 | if( d !== 0 ){ e = r2d * Math.asin(xyz[2] / d); } 26 | return [a,e,d]; 27 | } 28 | 29 | export default class NuOutput extends NuBaseModule { 30 | constructor(playerExperience) { 31 | super(playerExperience, 'nuOutput'); 32 | 33 | // local attributes 34 | this.params = { userPos: [0, 0, 0] }; 35 | 36 | /** 37 | * input gain (connected to analyzer for visual feedback) 38 | * (had to do it the other around since Safari's analyser would 39 | * remain frozen this.in was the gain connected to the analyser) 40 | **/ 41 | this.in = this.e.renderer.audioAnalyser.in; 42 | this.masterGain = audioContext.createGain(); 43 | this.out = audioContext.createGain(); 44 | 45 | // create Ambisonic encoder / decoder 46 | this.maxOrder = 3; 47 | this.ambiOrderValue = 3; 48 | this.encoder = new ambisonics.monoEncoder(audioContext, this.ambiOrderValue); 49 | this.limiter = new ambisonics.orderLimiter(audioContext, this.maxOrder, this.maxOrder); 50 | this.decoder = new ambisonics.binDecoder(audioContext, this.ambiOrderValue); 51 | 52 | // create additional gain to compensate for badly norm. room IR 53 | this.ambiGain = audioContext.createGain(); 54 | 55 | // create audio recorder, a too large bufferLen here will produce unsync 56 | // recordings when server will add client's buffers together (the javascript 57 | // node "starting" to record samples in indpt worker will fire in between 58 | // "start record" and in bufferLen samples, and that "randomly" for each client) 59 | this.recorder = new Recorder( this.out, {bufferLen: 512} ); 60 | this.startRecTime = 0.0; 61 | 62 | // init coordinates 63 | let coordXY = this.e.coordinates; 64 | this.coordXYZ = [ coordXY[0], coordXY[1], 0]; 65 | 66 | // connect graph 67 | this.in.connect( this.masterGain ); 68 | this.out.connect( audioContext.destination ); 69 | this.ambiGain.connect( this.encoder.in ) 70 | this.encoder.out.connect( this.limiter.in ); 71 | this.limiter.out.connect( this.decoder.in ); 72 | } 73 | 74 | // trigger session recording to disk 75 | record(val) { 76 | // start recording 77 | if (val) { 78 | // start recording 79 | this.recorder.clear(); 80 | this.recorder.record(); 81 | this.startRecTime = this.e.sync.getSyncTime(); 82 | } 83 | // stop recording 84 | else { 85 | // stop recorder 86 | this.recorder.stop(); 87 | // get recorder buffer and send audio data to server 88 | this.recorder.getBuffer( (buffers) => { 89 | 90 | // create empty buffer for interleaving before send 91 | let headerLength = 3; 92 | var interleavedBuffer = new Float32Array( 2*buffers[0].length + headerLength); 93 | 94 | // add header 95 | interleavedBuffer[0] = client.index; 96 | interleavedBuffer[1] = this.startRecTime; 97 | interleavedBuffer[2] = audioContext.sampleRate; 98 | 99 | // fill interleaved buffer 100 | for( let i = 0; i < buffers[0].length; i++ ){ 101 | interleavedBuffer[ headerLength + 2*i ] = buffers[0][i]; 102 | interleavedBuffer[ headerLength + 2*i + 1] = buffers[1][i]; 103 | } 104 | 105 | // send audio data 106 | this.e.rawSocket.send( this.moduleName, interleavedBuffer ); 107 | }); 108 | 109 | } 110 | } 111 | 112 | // set audio gain out 113 | gain(val){ 114 | this.masterGain.gain.value = val; 115 | } 116 | 117 | // enable / disable spatialization of player based on its position in the room 118 | enableSpat(val){ 119 | if(val){ 120 | try{ this.masterGain.disconnect( this.out ); } 121 | catch(e){ if( e.name !== 'InvalidAccessError'){ console.error(e); } } 122 | this.masterGain.connect( this.ambiGain ); 123 | this.decoder.out.connect( this.out ); 124 | } 125 | else{ 126 | try{ 127 | this.decoder.out.disconnect( this.out ); 128 | this.masterGain.disconnect( this.ambiGain ); 129 | } 130 | catch(e){ if( e.name !== 'InvalidAccessError'){ console.error(e); } } 131 | this.masterGain.connect( this.out ); 132 | } 133 | } 134 | 135 | // set encoding Ambisonic order 136 | ambiOrder(val){ 137 | // filter order in 138 | if( val > 3 || val < 1 ){ return; } 139 | this.limiter.updateOrder( val ); 140 | this.limiter.out.connect( this.decoder.in ); 141 | } 142 | 143 | /** 144 | * enable spatialized room reverberation 145 | * (replace dry Ambisonic IR with Room Ambisonic IR) 146 | **/ 147 | enableRoom(val){ 148 | let irUrl = ''; 149 | if( val ){ 150 | // different IR for reverb (+ gain adjust for iso-loudness) 151 | irUrl = 'irs/HOA3_BRIRs-medium.wav'; 152 | this.ambiGain.gain.value = 0.5; 153 | } 154 | else{ 155 | irUrl = 'irs/HOA3_filters_virtual.wav'; 156 | this.ambiGain.gain.value = 1.0; 157 | } 158 | // load HOA to bianural filters in decoder 159 | var loader_filters = new ambisonics.HOAloader(audioContext, this.maxOrder, irUrl, (buffer) => { this.decoder.updateFilters(buffer); } ); 160 | loader_filters.load(); 161 | } 162 | 163 | /** define fake user position (the user here is the composer / debugger, 164 | * sitting in front of browsers simulating the players, deciding where 165 | * he/she wants to be in the room during the composition / debug session) 166 | **/ 167 | userPos(args){ 168 | this.params.userPos[0] = args[0]; 169 | this.params.userPos[1] = args[1]; 170 | this.setPos(); 171 | } 172 | 173 | // update player position 174 | setPos(){ 175 | // get rel. pos from user (debug listener) 176 | let relXYZ = []; 177 | for( let i = 0; i < 3; i++ ){ 178 | relXYZ.push( this.params.userPos[i] - this.coordXYZ[i] ); 179 | } 180 | let coordSph = cart2sph( relXYZ ); 181 | // update encoder parameters 182 | this.encoder.azim = coordSph[0]; 183 | this.encoder.elev = coordSph[1]; 184 | this.encoder.updateGains(); 185 | } 186 | 187 | } 188 | -------------------------------------------------------------------------------- /node/src/client/player/NuProbe.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuProbe: get motion etc. info from given client in OSC 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | import * as soundworks from 'soundworks/client'; 7 | 8 | const client = soundworks.client; 9 | const audioContext = soundworks.audioContext; 10 | 11 | export default class NuProbe extends NuBaseModule { 12 | constructor(playerExperience) { 13 | super(playerExperience, 'nuProbe'); 14 | 15 | // local attributes 16 | this.params = {}; 17 | this.throttle = { 18 | 'acc': [Infinity, Infinity, Infinity], 19 | 'accThreshold': 2.5, 20 | 'ori': [Infinity, Infinity, Infinity], 21 | 'oriThreshold': 3, 22 | 'energy': Infinity, 23 | 'energyThreshold': 0.1, 24 | 'touch': [Infinity, Infinity] 25 | }; 26 | this.callBackStatus = { 27 | ori: false, 28 | acc: false, 29 | energy: false, 30 | touch: false 31 | }; 32 | this.surface = new soundworks.TouchSurface(this.e.view.$el); 33 | 34 | // binding 35 | this.orientationCallback = this.orientationCallback.bind(this); 36 | this.accelerationCallback = this.accelerationCallback.bind(this); 37 | this.energyCallback = this.energyCallback.bind(this); 38 | this.touchStartCallback = this.touchStartCallback.bind(this); 39 | this.touchMoveCallback = this.touchMoveCallback.bind(this); 40 | this.touchEndCallback = this.touchEndCallback.bind(this); 41 | } 42 | 43 | // Note: all callbacks in aftewards section are enabled / disabled with the methods 44 | // at scripts' end (via OSC msg) 45 | 46 | orientationCallback(data){ 47 | // for computers, otherwise they'll send [null,null,null] at startup 48 | if( data[0] === null ){ return; } 49 | // throttle 50 | let delta = Math.abs(this.throttle.ori[0] - data[0]) + 51 | Math.abs(this.throttle.ori[1] - data[1]) + 52 | Math.abs(this.throttle.ori[2] - data[2]); 53 | if( delta < this.throttle.oriThreshold ){ return } 54 | // save new throttle values 55 | this.throttle.ori[0] = data[0]; 56 | this.throttle.ori[1] = data[1]; 57 | this.throttle.ori[2] = data[2]; 58 | // send to OSC via server 59 | this.e.send('osc', '/' + this.moduleName, ['orientation', data[0], data[1], data[2]] ); 60 | } 61 | 62 | accelerationCallback(data){ 63 | // throttle 64 | let delta = Math.abs(this.throttle.acc[0] - data[0]) + 65 | Math.abs(this.throttle.acc[1] - data[1]) + 66 | Math.abs(this.throttle.acc[2] - data[2]); 67 | if( delta < this.throttle.accThreshold ){ return } 68 | // save new throttle values 69 | this.throttle.acc[0] = data[0]; 70 | this.throttle.acc[1] = data[1]; 71 | this.throttle.acc[2] = data[2]; 72 | // send to OSC via server 73 | this.e.send('osc', '/' + this.moduleName, ['acceleration', data[0], data[1], data[2]] ); 74 | } 75 | 76 | energyCallback(data){ 77 | // throttle 78 | let delta = Math.abs(this.throttle.energy - data); 79 | if( delta < this.throttle.energyThreshold ){ return } 80 | // save new throttle values 81 | this.throttle.energy = data; 82 | // send to OSC via server 83 | this.e.send('osc', '/' + this.moduleName, ['energy', data] ); 84 | } 85 | 86 | touchStartCallback(id, normX, normY){ 87 | // notify touch on 88 | this.e.send('osc', '/' + this.moduleName, ['touchOn', 1] ); 89 | // common touch callback 90 | this.touchCommonCallback(id, normX, normY); 91 | } 92 | 93 | touchMoveCallback(id, normX, normY){ 94 | // common touch callback 95 | this.touchCommonCallback(id, normX, normY); 96 | } 97 | 98 | touchEndCallback(id, normX, normY){ 99 | // notify touch off 100 | this.e.send('osc', '/' + this.moduleName, ['touchOn', 0] ); 101 | // common touch callback 102 | this.touchCommonCallback(id, normX, normY); 103 | } 104 | 105 | touchCommonCallback(id, normX, normY){ 106 | // ATTEMPT AT CROSSMODULE POSTING: FUNCTIONAL BUT ORIGINAL USE NO LONGER CONSIDERED: TODELETE WHEN CONFIRMED 107 | // window.postMessage(['nuRenderer', 'touch', id, normX, normY], location.origin); 108 | // ---------- 109 | // send touch pos 110 | this.e.send('osc', '/' + this.moduleName, ['touchPos', id, normX, normY]); 111 | } 112 | 113 | // Note: hereafter are the OSC triggered functions used to enable / disable 114 | // hereabove callbacks 115 | 116 | touch(onOff){ 117 | // enable if not already enabled 118 | if( onOff && !this.callBackStatus.touch ){ 119 | this.surface.addListener('touchstart', this.touchStartCallback); 120 | this.surface.addListener('touchmove', this.touchMoveCallback); 121 | this.surface.addListener('touchend', this.touchEndCallback); 122 | this.callBackStatus.touch = true; 123 | } 124 | // disable if not already disabled 125 | if( !onOff && this.callBackStatus.touch ){ 126 | this.surface.removeListener('touchstart', this.touchStartCallback); 127 | this.surface.removeListener('touchmove', this.touchMoveCallback); 128 | this.surface.removeListener('touchend', this.touchEndCallback); 129 | this.callBackStatus.touch = false; 130 | } 131 | } 132 | 133 | orientation(onOff){ 134 | // discard instruction if motionInput not available 135 | if (!this.e.motionInput.isAvailable('deviceorientation')){ return; } 136 | // enable if not already enabled 137 | if( onOff && !this.callBackStatus.ori ){ 138 | this.e.motionInput.addListener('deviceorientation', this.orientationCallback); 139 | this.callBackStatus.ori = true; 140 | } 141 | // disable if not already disabled 142 | if( !onOff && this.callBackStatus.ori ){ 143 | this.e.motionInput.removeListener('deviceorientation', this.orientationCallback); 144 | this.callBackStatus.ori = false; 145 | } 146 | } 147 | 148 | acceleration(onOff){ 149 | // discard instruction if motionInput not available 150 | if (!this.e.motionInput.isAvailable('accelerationIncludingGravity')){ return; } 151 | // enable if not already enabled 152 | if( onOff && !this.callBackStatus.acc ){ 153 | this.e.motionInput.addListener('accelerationIncludingGravity', this.accelerationCallback); 154 | this.callBackStatus.acc = true; 155 | } 156 | // disable if not already disabled 157 | if( !onOff && this.callBackStatus.acc ){ 158 | this.e.motionInput.removeListener('accelerationIncludingGravity', this.accelerationCallback); 159 | this.callBackStatus.acc = false; 160 | } 161 | } 162 | 163 | energy(onOff){ 164 | // discard instruction if motionInput not available 165 | if (!this.e.motionInput.isAvailable('energy')){ return; } 166 | // enable if not already enabled 167 | if( onOff && !this.callBackStatus.energy ){ 168 | this.e.motionInput.addListener('energy', this.energyCallback); 169 | this.callBackStatus.energy = true; 170 | } 171 | // disable if not already disabled 172 | if( !onOff && this.callBackStatus.energy ){ 173 | this.e.motionInput.removeListener('energy', this.energyCallback); 174 | this.callBackStatus.energy = false; 175 | } 176 | } 177 | 178 | } -------------------------------------------------------------------------------- /node/src/client/player/NuPath.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuPath: Nu module to move a sound through players topology, based on 3 | * emission points. A path is composed of emission points coupled with 4 | * emission time. Each point is used as a source image to produce a tap 5 | * in player's IR. 6 | **/ 7 | 8 | import NuBaseModule from './NuBaseModule' 9 | import * as soundworks from 'soundworks/client'; 10 | 11 | const client = soundworks.client; 12 | const audioContext = soundworks.audioContext; 13 | 14 | export default class NuPath extends NuBaseModule { 15 | constructor(playerExperience) { 16 | super(playerExperience, 'nuPath'); 17 | 18 | // local attributes 19 | this.irMap = new Map(); 20 | this.srcSet = new Set(); 21 | this.params = {}; 22 | 23 | // binding 24 | this.rawSocketCallback = this.rawSocketCallback.bind(this); 25 | this.startPath = this.startPath.bind(this); 26 | this.reset = this.reset.bind(this); 27 | 28 | // setup socket reveive callbacks (receiving raw audio data) 29 | this.e.rawSocket.receive(this.moduleName, this.rawSocketCallback ); 30 | } 31 | 32 | /* 33 | * callback when websocket event (msg containing new IR sent by server) is received 34 | */ 35 | rawSocketCallback(interleavedIrArray) { 36 | 37 | // extract header 38 | let pathId = interleavedIrArray[0]; 39 | let minTime = interleavedIrArray[1]; 40 | // exctract data 41 | interleavedIrArray = interleavedIrArray.slice(2, interleavedIrArray.length); 42 | // console.log('pathId', pathId, 'minTime', minTime, 'ir', interleavedIrArray); 43 | 44 | // de-interleave + get max delay for IR buffer size 45 | let irTime = [0.0], // init at zero to handle scenarii where IR array is empty 46 | irGain = [0.0], 47 | irDuration = 0.0; 48 | for (let i = 0; i < interleavedIrArray.length / 2; i++) { 49 | irTime[i] = interleavedIrArray[2 * i] - minTime; 50 | irGain[i] = interleavedIrArray[2 * i + 1]; 51 | irDuration = Math.max(irDuration, irTime[i]); 52 | } 53 | 54 | // store ir 55 | let ir = { times: irTime, gains: irGain, duration: irDuration }; 56 | this.irMap.set(pathId, ir); 57 | 58 | // feedback user that IR has been loaded 59 | this.e.renderer.blink([0, 100, 0]); 60 | } 61 | 62 | /* 63 | * message callback: play sound 64 | */ 65 | startPath( args ) { 66 | // get parameters 67 | let irId = args.shift(); 68 | let syncStartTime = args.shift(); 69 | 70 | // check if designated audioFile exists in loader 71 | if (this.e.loader.data[this.params.audioFileId] == undefined) { 72 | console.warn('required audio file id', this.params.audioFileId, 'not in client index, actual content:', this.e.loader.options.files); 73 | return; 74 | } 75 | 76 | // check if IR not available yet: slightly flash red otherwise 77 | if (!this.irMap.has(irId)) { 78 | this.e.renderer.blink([160, 0, 0]); 79 | console.warn('IR', irId, 'not yet defined in client, need to update propagation'); 80 | return; 81 | } 82 | 83 | // init 84 | let ir = this.irMap.get(irId); 85 | 86 | // create empty sound src 87 | let src = audioContext.createBufferSource(); 88 | let inputBuffer = this.e.loader.data[this.params.audioFileId]; 89 | let outputDuration = ir.duration + inputBuffer.duration + 1; 90 | let outputBuffer = audioContext.createBuffer(1, Math.max(outputDuration * audioContext.sampleRate, 512), audioContext.sampleRate); 91 | 92 | // this.controlParams = {audioFileId: 0, segment: {perc: 1, loop: true, accSlope: 0, timeBound: 0} }; 93 | 94 | 95 | // fill sound source with delayed audio buffer version (tap delay line mecanism) 96 | let inputData = inputBuffer.getChannelData(0); 97 | let outputData = outputBuffer.getChannelData(0); 98 | ir.times.forEach((tapTime, index) => { 99 | 100 | // get tap time and gain 101 | let tapGain = ir.gains[index]; 102 | let tapdelayInSample = Math.floor(tapTime * audioContext.sampleRate); 103 | 104 | // get input start point based on time since propagation started 105 | let offsetTimeInSamples = Math.floor(this.params.timeBound * tapdelayInSample); 106 | if (this.params.loop) offsetTimeInSamples %= inputBuffer.length; 107 | 108 | // if end of audio input not reached yet 109 | if (offsetTimeInSamples < inputBuffer.length) { 110 | 111 | // eventually read only a chunk of input buffer 112 | let numSamplesToFill = inputBuffer.length - offsetTimeInSamples; 113 | numSamplesToFill = Math.floor(numSamplesToFill * this.params.perc); 114 | 115 | // if reading speed acc with time passed 116 | let readSpeed = 1 + this.params.accSlope * tapTime; 117 | numSamplesToFill = Math.floor(numSamplesToFill / readSpeed); 118 | 119 | // copy tap to output buffer 120 | for (let i = 0; i < numSamplesToFill; i++) 121 | outputData[tapdelayInSample + i] += (tapGain * inputData[offsetTimeInSamples + Math.round(i * readSpeed)]); 122 | } 123 | 124 | }); 125 | 126 | // normalize output buffer 127 | let maxOutputValue = 0.0; 128 | for (let i = 0; i < outputBuffer.length; i++) { 129 | maxOutputValue = Math.max(Math.abs(outputData[i]), maxOutputValue); 130 | } 131 | let normFactor = Math.max.apply(null, ir.gains) / Math.max(maxOutputValue, 1.0); 132 | // console.log('max:', maxOutputValue, 'norm:', normFactor); 133 | 134 | // replace audio source buffer with created output buffer 135 | src.buffer = outputBuffer; 136 | 137 | // create master gain (shared param, controlled from conductor) 138 | let gain = audioContext.createGain(); 139 | gain.gain.value = normFactor * this.params.masterGain; 140 | 141 | // connect graph 142 | src.connect(gain); 143 | gain.connect( this.e.nuOutput.in ); 144 | 145 | // play sound if rendez-vous time is in the future (else advance in buffer) 146 | let now = this.e.sync.getSyncTime(); 147 | if (syncStartTime > now) { 148 | let audioContextStartTime = audioContext.currentTime + syncStartTime - now; 149 | src.start(audioContextStartTime); 150 | // console.log('play scheduled in:', Math.round((syncStartTime - now) * 1000) / 1000, 'sec', 'at:', syncStartTime); 151 | } else { 152 | console.warn('no sound played, I received the instruction to play too late'); 153 | // this.e.renderer.blink([250, 0, 0]); 154 | let inBufferTime = now - syncStartTime; 155 | if( inBufferTime < src.buffer.duration ){ 156 | src.start(audioContext.currentTime, inBufferTime); 157 | } 158 | } 159 | 160 | // setup screen color = f(amplitude) callback 161 | this.e.renderer.enable(); 162 | 163 | // save source for eventual global reset 164 | this.srcSet.add(src); 165 | 166 | // timeout callback, runs when we finished playing 167 | setTimeout(() => { 168 | this.e.renderer.disable(); 169 | // remove source from set 170 | this.srcSet.delete(src); 171 | }, (syncStartTime - now + src.buffer.duration) * 1000); 172 | 173 | } 174 | 175 | // kill audio 176 | reset(){ 177 | this.srcSet.forEach( (src) => { 178 | // stop source 179 | src.stop(); 180 | // remove associated visual feedback 181 | this.e.renderer.disable(); 182 | }); 183 | } 184 | 185 | } -------------------------------------------------------------------------------- /node/src/client/player/NuGroups.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuGroup: Nu module to assign audio tracks to groups of players. 3 | * the term "group" hereafter is to be intepreted as "track" more often than not 4 | **/ 5 | 6 | import NuBaseModule from './NuBaseModule' 7 | import * as soundworks from 'soundworks/client'; 8 | 9 | const client = soundworks.client; 10 | const audioContext = soundworks.audioContext; 11 | 12 | export default class NuGroups extends NuBaseModule { 13 | constructor(playerExperience) { 14 | super(playerExperience, 'nuGroups'); 15 | 16 | // local attributes 17 | this.groupMap = new Map(); 18 | this.localGain = audioContext.createGain(); 19 | this.localGain.gain.value = 1.0; 20 | this.localGain.connect( this.e.nuOutput.in ); 21 | 22 | // binding 23 | this.onOff = this.onOff.bind(this); 24 | this.volume = this.volume.bind(this); 25 | this.localVolume = this.localVolume.bind(this); 26 | this.linkPlayerToGroup = this.linkPlayerToGroup.bind(this); 27 | this.loop = this.loop.bind(this); 28 | this.getGroup = this.getGroup.bind(this); 29 | } 30 | 31 | paramCallback(name, args){ 32 | // either route to internal function 33 | if( this[name] !== undefined ) 34 | if( args.length == 2 ) this[name](args[0], args[1]); 35 | else this[name](args); 36 | // or to this.params value 37 | else 38 | this.params[name] = args; 39 | } 40 | 41 | onOff(groupId, value) { 42 | 43 | // get group 44 | let group = this.getGroup( groupId ); 45 | 46 | // stop group (src) 47 | if( value === 0 ){ 48 | // stop source 49 | group.src.stop(0); 50 | // notify renderer we don't need it anymore 51 | this.e.renderer.disable(); 52 | } 53 | 54 | // start group (src) 55 | else{ 56 | // get time delay since order to start has been given 57 | let timeOffset = this.e.scheduler.syncTime - value; 58 | // modulo buffer length for slow / late connected players 59 | timeOffset %= group.src.buffer.duration; 60 | // make sure timeOffset is positive (if e.g. player not yet perfectly sync.) 61 | timeOffset = Math.max(timeOffset, 0.0); 62 | // start source at group time 63 | group.src.start(audioContext.currentTime, timeOffset); 64 | // remember start time 65 | group.startTime = value; 66 | // enable render 67 | this.e.renderer.enable(); 68 | } 69 | } 70 | 71 | // TODO: a player not in a group shouldn't play its sound as happends now with above on/off 72 | // function. Rather, only when both on/off and linked are ok should player start to play. 73 | // this would require a sync. mechanism with groups already started when linked to player. 74 | 75 | // set player to group (track) volume 76 | linkPlayerToGroup(groupId, value){ 77 | // get group 78 | let group = this.getGroup( groupId ); 79 | // apply value 80 | group.linkGain.gain.value = value; 81 | } 82 | 83 | // set group volume 84 | volume(groupId, value){ 85 | // get group 86 | let group = this.getGroup( groupId ); 87 | // set group value 88 | group.gain.gain.value = value; 89 | } 90 | 91 | // set player volume (for all its tracks) groupId is dummy here, for uniform inputs wrt other methods 92 | localVolume(groupId, value){ 93 | // set local value 94 | this.localGain.gain.value = value; 95 | } 96 | 97 | // stop all currently playing groups 98 | clear(){ 99 | // loop over groups 100 | this.groupMap.forEach( (group) => { 101 | // stop source 102 | group.src.stop(0); 103 | // notify renderer we don't need it anymore 104 | this.e.renderer.disable(); 105 | }); 106 | // reset local map 107 | this.groupMap = new Map(); 108 | } 109 | 110 | // set group time 111 | time(groupId, value){ 112 | console.log('time function not implemented yet (in NuGroup.js)'); 113 | } 114 | 115 | // enable / disable group loop 116 | loop(groupId, value){ 117 | // get group 118 | let group = this.getGroup( groupId ); 119 | // set group value 120 | group.src.loop = value; 121 | } 122 | 123 | // get group based on id, create if need be 124 | getGroup(groupId) { 125 | // get already existing group 126 | if( this.groupMap.has(groupId) ) 127 | return this.groupMap.get(groupId); 128 | 129 | // check if audio buffer associated to group exists 130 | let buffer = this.e.loader.data[groupId]; 131 | if (buffer === undefined) { 132 | console.warn('required audio file id', groupId, 'not in client index, actual content:', this.e.loader.options.files, '-> initializing empty audio source..'); 133 | buffer = audioContext.createBuffer(1, 22050, 44100); 134 | } 135 | 136 | // create new group 137 | let group = { time: 0, startTime: 0 }; 138 | 139 | // create new audio source 140 | group.src = new AudioSourceNode(buffer); 141 | 142 | // create group gain 143 | group.gain = audioContext.createGain(); 144 | group.gain.gain.value = 1.0; 145 | 146 | // create group-player link gain 147 | group.linkGain = audioContext.createGain(); 148 | group.linkGain.gain.value = 1.0; 149 | 150 | // connect graph 151 | group.src.out.connect(group.gain); 152 | group.gain.connect(group.linkGain); 153 | group.linkGain.connect(this.localGain); 154 | 155 | // store new group in local map 156 | this.groupMap.set(groupId, group); 157 | 158 | // return created group 159 | return group; 160 | } 161 | 162 | // ramp gain node to "targetValue" in fadeTime secs 163 | fadeGainTo(gainNode, targetValue, fadeTime){ 164 | // reset eventual planned changes 165 | gainNode.gain.cancelScheduledValues(audioContext.currentTime); 166 | if( fadeTime > 0 ){ 167 | // let currentValue = gainNode.gain.value; 168 | gainNode.gain.setValueAtTime(gainNode.gain.value, audioContext.currentTime); 169 | gainNode.gain.linearRampToValueAtTime(targetValue, audioContext.currentTime + fadeTime); 170 | // console.log('fade in / out from', gainNode.gain.value, 'to', targetValue, 'in', fadeTime, 'sec'); 171 | } 172 | else{ 173 | gainNode.gain.setValueAtTime(targetValue, audioContext.currentTime); 174 | } 175 | } 176 | 177 | } 178 | 179 | // "surcharged" audio source node class 180 | class AudioSourceNode { 181 | constructor(buffer){ 182 | // local gain 183 | this.out = audioContext.createGain(); 184 | this.out.gain.value = 1.0; 185 | // locals 186 | this.buffer = buffer; 187 | this.src = this.getNewSource(); 188 | this._loop = 0; 189 | 190 | } 191 | // start audio source at time, with time offset 192 | start(time = 0, offset = 0){ 193 | // stop eventual old source 194 | this.stop(0); 195 | // create new source 196 | this.src = this.getNewSource(); 197 | // start source 198 | this.src.start(time, offset); 199 | } 200 | 201 | // stop source (doesn't crash if source already stopped) 202 | stop(time = 0){ 203 | try{ 204 | this.src.stop(time); 205 | } 206 | catch(e){ 207 | if( e.name !== 'InvalidStateError'){ console.error(e); } 208 | } 209 | } 210 | 211 | // set source loop 212 | set loop(value){ 213 | this._loop = value; 214 | this.src.loop = value; 215 | } 216 | 217 | // ... 218 | get loop(){ 219 | return this._loop; 220 | } 221 | 222 | // create new audio source node 223 | getNewSource(){ 224 | // create source 225 | let src = audioContext.createBufferSource(); 226 | // fill in buffer 227 | src.buffer = this.buffer; 228 | // set src attributes 229 | src.loop = this._loop; 230 | // connect graph 231 | src.connect(this.out); 232 | return src; 233 | } 234 | } 235 | 236 | 237 | 238 | 239 | -------------------------------------------------------------------------------- /node/src/server/NuOutput.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuOutput: render output, either directly to audioContext.destination or 3 | * to spatialization engine for debug sessions (i.e. to get a feel of the final 4 | * result while players are emulated on server's laptop) 5 | **/ 6 | 7 | import NuBaseModule from './NuBaseModule' 8 | import * as soundworks from 'soundworks/server'; 9 | 10 | // read audio init 11 | const fs = require('fs'); 12 | var encodeWav = require('audio-encode-wav'); 13 | import Audio from 'audio'; 14 | var AudioContext = require('web-audio-api').AudioContext 15 | const audioContext = new AudioContext; 16 | const savePath = __dirname + '/../../../'; 17 | 18 | export default class NuOutput extends NuBaseModule { 19 | constructor(serverExperience) { 20 | super(serverExperience, 'nuOutput'); 21 | 22 | // to be saved parameters to send to client when connects: 23 | this.params = { 24 | gain: 1.0, 25 | enableSpat: false, 26 | ambiOrder: 3, 27 | enableRoom: false, 28 | userPos: [0, 0, 0], 29 | }; 30 | 31 | // locals 32 | this.logClientRec = []; 33 | this.storedAudioBuffer = undefined; 34 | this.startRecTime = 0.0; 35 | this.discardUnsync = false; 36 | 37 | // binding 38 | this.rawSocketCallback = this.rawSocketCallback.bind(this); 39 | this.checkIfAllRecordArrived = this.checkIfAllRecordArrived.bind(this); 40 | this.writeAudioToDisk = this.writeAudioToDisk.bind(this); 41 | } 42 | 43 | enterPlayer(client){ 44 | // send to new client information regarding current groups parameters 45 | Object.keys(this.params).forEach( (key) => { 46 | this.e.send(client, this.moduleName, [key, this.params[key]] ); 47 | }); 48 | 49 | // setup socket receive callbacks (receiving raw audio data) 50 | this.e.rawSocket.receive(client, this.moduleName, this.rawSocketCallback ); 51 | } 52 | 53 | /** 54 | * start / stop recording: will receive audio data from all clients, 55 | * and concatenate them into output file written to disk 56 | **/ 57 | record(args){ 58 | let playerId = args.shift(); 59 | // playerId not used at the moment, only for uniformity with other modules 60 | let val = args.shift(); 61 | 62 | // save recording start time 63 | if( val === 1 ){ 64 | this.startRecTime = this.e.sync.getSyncTime(); 65 | } 66 | 67 | // forward msg to players 68 | this.e.broadcast( 'player', null, this.moduleName, ['record', val] ); 69 | } 70 | 71 | // record callback, called once per client sending audio data at recording's end 72 | rawSocketCallback( interleavedBuffer ) { 73 | // skip empty sockets (at startup) 74 | if( interleavedBuffer.length === 0 ){ return; } 75 | 76 | // create buffer to store audio data if not already created 77 | // WARNING: here defining final size based on first client's packet length 78 | // (not wrong, but will skip data from larger packet if e.g. another client started 79 | // recording a few ms earlier) 80 | let headerLength = 3; 81 | if( this.storedAudioBuffer === undefined ){ 82 | this.storedAudioBuffer = new Float32Array( interleavedBuffer.length - headerLength ); 83 | } 84 | 85 | // extract header (client index) 86 | let clientId = interleavedBuffer[0]; 87 | let startTime = interleavedBuffer[1]; 88 | let sampleRate = interleavedBuffer[2]; 89 | interleavedBuffer = interleavedBuffer.slice(headerLength, interleavedBuffer.length); 90 | 91 | // get write offset (for sync. write across clients) 92 | let startOffset = Math.floor( (startTime - this.startRecTime) * sampleRate ); 93 | 94 | // check if client's clock is not yet correctly sync. 95 | if( startOffset < 0 ){ 96 | console.warn('\n### client', clientId, 'data in advance of', this.startRecTime - startTime, 'sec (expect weird sync.)\n'); 97 | if( this.discardUnsync ){ startOffset = -1; } 98 | else{ startOffset = 0; } 99 | } 100 | 101 | // proceed to writing audio values to local buffer 102 | if( startOffset >= 0 ){ 103 | var index = 0; 104 | for( let i = 0; i < interleavedBuffer.length; i++ ){ 105 | index = startOffset + i; 106 | if( index >= this.storedAudioBuffer.length ){ break; } 107 | this.storedAudioBuffer[ index ] += interleavedBuffer[i]; 108 | } 109 | } 110 | 111 | // if all client's sent their data.. 112 | if( this.checkIfAllRecordArrived( clientId ) ){ 113 | // .. write audio to disk 114 | this.writeAudioToDisk( sampleRate ); 115 | // clear locals for next rec session 116 | this.logClientRec = []; 117 | this.storedAudioBuffer = undefined; 118 | this.startRecTime = 0.0; 119 | } 120 | 121 | } 122 | 123 | /** 124 | * add clientId to list of client who already sent audio data, 125 | * return true if all clients connected to soundworks server already sent 126 | * their data to trigger record to disk method in raw socket callback 127 | **/ 128 | checkIfAllRecordArrived( clientId ){ 129 | // check if client already sent recording 130 | if( this.logClientRec.indexOf( clientId ) >= 0 ){  131 | console.error( this.moduleName, 'received twice data from client', clientId ); 132 | } 133 | 134 | // flag client as having sent recording 135 | this.logClientRec.push( clientId ); 136 | 137 | // check if all clients sent recordings 138 | // TODO: find a way to break out of forEach loop asa one is not in it 139 | let allReceived = true; 140 | this.e.playerMap.forEach( (client, id) => { 141 | if( this.logClientRec.indexOf( id ) < 0 ){ 142 | allReceived = false; 143 | } 144 | }); 145 | // return flag 146 | return allReceived; 147 | } 148 | 149 | // write audio data to disk 150 | writeAudioToDisk( sampleRate ){ 151 | // create new audio container 152 | let audioContainer = new Audio({ length: 2*this.storedAudioBuffer.length, sampleRate: sampleRate }); 153 | 154 | // setup fade-in / out of signal 155 | let fadeTime = 0.01; // in sec 156 | let fadeDurInSamp = fadeTime * sampleRate; 157 | 158 | // get max audio data value for normalization 159 | let maxVal = 0.1; 160 | for( let i = 0; i < this.storedAudioBuffer.length; i++ ){ 161 | maxVal = Math.max( Math.abs(this.storedAudioBuffer[i]), maxVal); 162 | } 163 | 164 | // fill audio container 165 | var val; 166 | let indexStartFadeOut = (this.storedAudioBuffer.length / 2) - fadeDurInSamp 167 | for( let i = 0; i < this.storedAudioBuffer.length / 2; i++ ){ 168 | for( let j = 0; j < 2; j++ ){ 169 | // mapping 170 | val = this.storedAudioBuffer[2*i + j] / maxVal; // norm 171 | val = (val + 1.0) / 2.0; // from -1 1 to 0 1 172 | val *= 30000; 173 | 174 | // fade in 175 | if( i < fadeDurInSamp ){  val *= i / (fadeDurInSamp -1 ); } 176 | // fade out 177 | if( i >= indexStartFadeOut ){  178 | val *= 1 - (i - indexStartFadeOut) / ( fadeDurInSamp - 1 ); 179 | } 180 | 181 | // write to container 182 | audioContainer.write(val, 2*i+j); 183 | } 184 | } 185 | 186 | // encode audio container to wav 187 | let wav = encodeWav(audioContainer); 188 | 189 | // get output file name / path 190 | let date = new Date(); 191 | let fileName = savePath + 'nu-rec-' 192 | + date.getFullYear() + '-' 193 | + date.getMonth() + '-' 194 | + date.getDate() + '-' 195 | + date.getHours() + '-' 196 | + date.getMinutes() + '-' 197 | + date.getSeconds() 198 | + '.wav'; 199 | 200 | // write to disk 201 | fs.writeFileSync( fileName, wav); 202 | console.log('saved audio file to disk: \n', fileName); 203 | } 204 | 205 | } 206 | 207 | -------------------------------------------------------------------------------- /maxmsp/patchers/nu.output.maxpat: -------------------------------------------------------------------------------- 1 | { 2 | "patcher" : { 3 | "fileversion" : 1, 4 | "appversion" : { 5 | "major" : 7, 6 | "minor" : 3, 7 | "revision" : 3, 8 | "architecture" : "x86", 9 | "modernui" : 1 10 | } 11 | , 12 | "rect" : [ 193.0, 926.0, 392.0, 350.0 ], 13 | "bglocked" : 0, 14 | "openinpresentation" : 0, 15 | "default_fontsize" : 12.0, 16 | "default_fontface" : 0, 17 | "default_fontname" : "Arial", 18 | "gridonopen" : 1, 19 | "gridsize" : [ 15.0, 15.0 ], 20 | "gridsnaponopen" : 1, 21 | "objectsnaponopen" : 1, 22 | "statusbarvisible" : 2, 23 | "toolbarvisible" : 1, 24 | "lefttoolbarpinned" : 0, 25 | "toptoolbarpinned" : 0, 26 | "righttoolbarpinned" : 0, 27 | "bottomtoolbarpinned" : 0, 28 | "toolbars_unpinned_last_save" : 0, 29 | "tallnewobj" : 0, 30 | "boxanimatetime" : 200, 31 | "enablehscroll" : 1, 32 | "enablevscroll" : 1, 33 | "devicewidth" : 0.0, 34 | "description" : "", 35 | "digest" : "", 36 | "tags" : "", 37 | "style" : "", 38 | "subpatcher_template" : "", 39 | "boxes" : [ { 40 | "box" : { 41 | "id" : "obj-3", 42 | "maxclass" : "newobj", 43 | "numinlets" : 1, 44 | "numoutlets" : 1, 45 | "outlettype" : [ "" ], 46 | "patching_rect" : [ 15.0, 260.5, 69.0, 22.0 ], 47 | "style" : "", 48 | "text" : "prepend -1" 49 | } 50 | 51 | } 52 | , { 53 | "box" : { 54 | "id" : "obj-9", 55 | "maxclass" : "newobj", 56 | "numinlets" : 1, 57 | "numoutlets" : 1, 58 | "outlettype" : [ "" ], 59 | "patching_rect" : [ 285.0, 255.5, 81.0, 22.0 ], 60 | "style" : "", 61 | "text" : "prepend gain" 62 | } 63 | 64 | } 65 | , { 66 | "box" : { 67 | "id" : "obj-8", 68 | "maxclass" : "newobj", 69 | "numinlets" : 1, 70 | "numoutlets" : 1, 71 | "outlettype" : [ "" ], 72 | "patching_rect" : [ 231.0, 221.5, 102.0, 22.0 ], 73 | "style" : "", 74 | "text" : "prepend userPos" 75 | } 76 | 77 | } 78 | , { 79 | "box" : { 80 | "id" : "obj-7", 81 | "maxclass" : "newobj", 82 | "numinlets" : 1, 83 | "numoutlets" : 1, 84 | "outlettype" : [ "" ], 85 | "patching_rect" : [ 177.0, 187.5, 92.0, 22.0 ], 86 | "style" : "", 87 | "text" : "prepend record" 88 | } 89 | 90 | } 91 | , { 92 | "box" : { 93 | "id" : "obj-6", 94 | "maxclass" : "newobj", 95 | "numinlets" : 1, 96 | "numoutlets" : 1, 97 | "outlettype" : [ "" ], 98 | "patching_rect" : [ 123.0, 153.5, 115.0, 22.0 ], 99 | "style" : "", 100 | "text" : "prepend ambiOrder" 101 | } 102 | 103 | } 104 | , { 105 | "box" : { 106 | "id" : "obj-2", 107 | "maxclass" : "newobj", 108 | "numinlets" : 1, 109 | "numoutlets" : 1, 110 | "outlettype" : [ "" ], 111 | "patching_rect" : [ 69.0, 119.5, 126.0, 22.0 ], 112 | "style" : "", 113 | "text" : "prepend enableRoom" 114 | } 115 | 116 | } 117 | , { 118 | "box" : { 119 | "id" : "obj-158", 120 | "maxclass" : "newobj", 121 | "numinlets" : 1, 122 | "numoutlets" : 0, 123 | "patching_rect" : [ 15.0, 320.0, 65.0, 22.0 ], 124 | "style" : "", 125 | "text" : "s toServer" 126 | } 127 | 128 | } 129 | , { 130 | "box" : { 131 | "id" : "obj-56", 132 | "maxclass" : "newobj", 133 | "numinlets" : 1, 134 | "numoutlets" : 1, 135 | "outlettype" : [ "" ], 136 | "patching_rect" : [ 15.0, 290.0, 111.0, 22.0 ], 137 | "style" : "", 138 | "text" : "prepend /nuOutput" 139 | } 140 | 141 | } 142 | , { 143 | "box" : { 144 | "id" : "obj-30", 145 | "maxclass" : "newobj", 146 | "numinlets" : 1, 147 | "numoutlets" : 1, 148 | "outlettype" : [ "" ], 149 | "patching_rect" : [ 15.0, 85.5, 119.0, 22.0 ], 150 | "style" : "", 151 | "text" : "prepend enableSpat" 152 | } 153 | 154 | } 155 | , { 156 | "box" : { 157 | "id" : "obj-24", 158 | "maxclass" : "newobj", 159 | "numinlets" : 1, 160 | "numoutlets" : 0, 161 | "patching_rect" : [ 306.0, 24.0, 53.0, 22.0 ], 162 | "style" : "", 163 | "text" : "nu.error" 164 | } 165 | 166 | } 167 | , { 168 | "box" : { 169 | "id" : "obj-23", 170 | "maxclass" : "newobj", 171 | "numinlets" : 7, 172 | "numoutlets" : 7, 173 | "outlettype" : [ "", "", "", "", "", "", "" ], 174 | "patching_rect" : [ 15.0, 53.0, 344.0, 22.0 ], 175 | "style" : "", 176 | "text" : "route enableSpat enableRoom ambiOrder record userPos gain" 177 | } 178 | 179 | } 180 | , { 181 | "box" : { 182 | "comment" : "", 183 | "id" : "obj-1", 184 | "index" : 0, 185 | "maxclass" : "inlet", 186 | "numinlets" : 0, 187 | "numoutlets" : 1, 188 | "outlettype" : [ "" ], 189 | "patching_rect" : [ 15.0, 9.0, 30.0, 30.0 ], 190 | "style" : "" 191 | } 192 | 193 | } 194 | ], 195 | "lines" : [ { 196 | "patchline" : { 197 | "destination" : [ "obj-23", 0 ], 198 | "disabled" : 0, 199 | "hidden" : 0, 200 | "source" : [ "obj-1", 0 ] 201 | } 202 | 203 | } 204 | , { 205 | "patchline" : { 206 | "destination" : [ "obj-3", 0 ], 207 | "disabled" : 0, 208 | "hidden" : 0, 209 | "source" : [ "obj-2", 0 ] 210 | } 211 | 212 | } 213 | , { 214 | "patchline" : { 215 | "destination" : [ "obj-2", 0 ], 216 | "disabled" : 0, 217 | "hidden" : 0, 218 | "source" : [ "obj-23", 1 ] 219 | } 220 | 221 | } 222 | , { 223 | "patchline" : { 224 | "destination" : [ "obj-24", 0 ], 225 | "disabled" : 0, 226 | "hidden" : 0, 227 | "source" : [ "obj-23", 6 ] 228 | } 229 | 230 | } 231 | , { 232 | "patchline" : { 233 | "destination" : [ "obj-30", 0 ], 234 | "disabled" : 0, 235 | "hidden" : 0, 236 | "source" : [ "obj-23", 0 ] 237 | } 238 | 239 | } 240 | , { 241 | "patchline" : { 242 | "destination" : [ "obj-6", 0 ], 243 | "disabled" : 0, 244 | "hidden" : 0, 245 | "source" : [ "obj-23", 2 ] 246 | } 247 | 248 | } 249 | , { 250 | "patchline" : { 251 | "destination" : [ "obj-7", 0 ], 252 | "disabled" : 0, 253 | "hidden" : 0, 254 | "source" : [ "obj-23", 3 ] 255 | } 256 | 257 | } 258 | , { 259 | "patchline" : { 260 | "destination" : [ "obj-8", 0 ], 261 | "disabled" : 0, 262 | "hidden" : 0, 263 | "source" : [ "obj-23", 4 ] 264 | } 265 | 266 | } 267 | , { 268 | "patchline" : { 269 | "destination" : [ "obj-9", 0 ], 270 | "disabled" : 0, 271 | "hidden" : 0, 272 | "source" : [ "obj-23", 5 ] 273 | } 274 | 275 | } 276 | , { 277 | "patchline" : { 278 | "destination" : [ "obj-56", 0 ], 279 | "disabled" : 0, 280 | "hidden" : 0, 281 | "source" : [ "obj-3", 0 ] 282 | } 283 | 284 | } 285 | , { 286 | "patchline" : { 287 | "destination" : [ "obj-3", 0 ], 288 | "disabled" : 0, 289 | "hidden" : 0, 290 | "source" : [ "obj-30", 0 ] 291 | } 292 | 293 | } 294 | , { 295 | "patchline" : { 296 | "destination" : [ "obj-158", 0 ], 297 | "disabled" : 0, 298 | "hidden" : 0, 299 | "source" : [ "obj-56", 0 ] 300 | } 301 | 302 | } 303 | , { 304 | "patchline" : { 305 | "destination" : [ "obj-3", 0 ], 306 | "disabled" : 0, 307 | "hidden" : 0, 308 | "source" : [ "obj-6", 0 ] 309 | } 310 | 311 | } 312 | , { 313 | "patchline" : { 314 | "destination" : [ "obj-3", 0 ], 315 | "disabled" : 0, 316 | "hidden" : 0, 317 | "source" : [ "obj-7", 0 ] 318 | } 319 | 320 | } 321 | , { 322 | "patchline" : { 323 | "destination" : [ "obj-3", 0 ], 324 | "disabled" : 0, 325 | "hidden" : 0, 326 | "source" : [ "obj-8", 0 ] 327 | } 328 | 329 | } 330 | , { 331 | "patchline" : { 332 | "destination" : [ "obj-3", 0 ], 333 | "disabled" : 0, 334 | "hidden" : 0, 335 | "source" : [ "obj-9", 0 ] 336 | } 337 | 338 | } 339 | ], 340 | "dependency_cache" : [ { 341 | "name" : "nu.error.maxpat", 342 | "bootpath" : "~/Projects/Cosima/devs/Nu/soundworks-nu/maxmsp/extras", 343 | "type" : "JSON", 344 | "implicit" : 1 345 | } 346 | ], 347 | "autosave" : 0 348 | } 349 | 350 | } 351 | -------------------------------------------------------------------------------- /node/src/client/player/NuLoop.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuLoop: Nu module sequencer-like (drum machine) 3 | **/ 4 | 5 | import NuBaseModule from './NuBaseModule' 6 | import * as soundworks from 'soundworks/client'; 7 | import audioFiles from '../shared/audioFiles'; 8 | 9 | const client = soundworks.client; 10 | const audioContext = soundworks.audioContext; 11 | 12 | // previous impl. was based on audio buffer calls view integer. 13 | // adapt: from name to integar to avoid changing whole code here. 14 | const audioFileNameToId = new Map(); 15 | const audioFileIdToName = new Map(); 16 | var count = 0; 17 | for (var key in audioFiles) { 18 | if (audioFiles.hasOwnProperty(key)) { 19 | audioFileNameToId.set(key, count); 20 | audioFileIdToName.set(count, key); 21 | count += 1; 22 | } 23 | } 24 | 25 | export default class NuLoop extends NuBaseModule { 26 | constructor(playerExperience) { 27 | super(playerExperience, 'nuLoop'); 28 | 29 | // local attributes 30 | this.params = {}; 31 | let audioBuffers = this.e.loader.data; 32 | this.synth = new SampleSynth(audioBuffers, this.e.nuOutput.in); 33 | this.loops = new Matrix(audioBuffers.length, this.params.divisions); 34 | 35 | // binding 36 | this.updateNumDivisions = this.updateNumDivisions.bind(this); 37 | this.setTrackSlot = this.setTrackSlot.bind(this); 38 | this.start = this.start.bind(this); 39 | this.remove = this.remove.bind(this); 40 | this.reset = this.reset.bind(this); 41 | this.updateNumDivisions = this.updateNumDivisions.bind(this); 42 | this.getSlotTime = this.getSlotTime.bind(this); 43 | this.divisions = this.divisions.bind(this); 44 | } 45 | 46 | // set number of divisions in the loop 47 | divisions(value){ 48 | this.params.divisions = value; 49 | this.updateNumDivisions(); 50 | } 51 | 52 | // update loop maps size 53 | updateNumDivisions(){ 54 | // shut down all loops 55 | this.reset(); 56 | // resize loop map 57 | let numTracks = audioFileNameToId.size; 58 | this.loops = new Matrix(numTracks, this.params.divisions); 59 | } 60 | 61 | // set loop period 62 | period(value){ 63 | this.params.period = Math.round(value * 10) / 10; 64 | } 65 | 66 | // set general output volume 67 | masterGain(value){ 68 | this.synth.output.gain.value = value; 69 | } 70 | 71 | // enable / disable a slot in the loop (a "beat") 72 | setTrackSlot(args){ 73 | 74 | // extract parameters from args array 75 | let trackName = args.shift(); 76 | let slotId = args.shift(); 77 | let onOff = args.shift(); 78 | 79 | // check valid trackName / trackId 80 | let trackId = audioFileNameToId.get(trackName); 81 | if( trackId === undefined) { 82 | console.warn('required track ', trackName, 'not available, actual content:', this.e.loader.options.files); 83 | return; 84 | } 85 | // check valid slotId 86 | if( slotId >= this.params.divisions || slotId < 0) { 87 | console.warn('required slot id', slotId, 'is not available in current setup (should be in [ 0,', this.params.divisions - 1, ']'); 88 | return; 89 | } 90 | 91 | // add event (sound) in loop 92 | if( onOff ){ 93 | // discard start already started source 94 | if( this.loops.mat[trackId][slotId] !== undefined ) { return; } 95 | // start new loop event 96 | let slotTime = this.getSlotTime(this.e.scheduler.syncTime, slotId); 97 | this.start(slotTime, {trackId: trackId, slotId: slotId}, true); 98 | // enable visual feedback 99 | this.e.renderer.enable(); 100 | } 101 | 102 | // remove event from loop 103 | else{ 104 | // this.looper.stop(time, soundParams, true); 105 | this.remove(trackId, slotId); 106 | // disable visual feedback 107 | this.e.renderer.disable(); 108 | } 109 | 110 | } 111 | 112 | // compute event time in loop 113 | getSlotTime(currentTime, slotId){ 114 | let currentTimeInMeasure = currentTime % this.params.period; 115 | let measureStartTime = currentTime - currentTimeInMeasure; 116 | let slotTime = slotId * ( this.params.period / this.params.divisions ); 117 | // console.log('current time', currentTime, 'measure start time', measureStartTime, 'slot time', measureStartTime + slotTime, 'slot id', slotId, this.params); 118 | return measureStartTime + slotTime; 119 | } 120 | 121 | // start new loop 122 | start(time, soundParams) { 123 | // create new loop 124 | const loop = new Loop(this, soundParams); 125 | // add loop to set 126 | this.loops.mat[soundParams.trackId][soundParams.slotId] = loop; 127 | // add loop to scheduler 128 | this.e.scheduler.add(loop, time); 129 | } 130 | 131 | // callback: called at each loop (in scheduler) 132 | advanceLoop(time, loop) { 133 | const soundParams = loop.soundParams; 134 | const params = this.params; 135 | // trigger sound 136 | const duration = this.synth.trigger(this.e.scheduler.audioTime, soundParams); 137 | // add jitter (randomness to beat exact time) 138 | let jitter = this.params.jitter * // jitter gain in [0:1] 139 | Math.random() * // random value in [0:1[ 140 | ( this.params.period / this.params.divisions); // normalization (jitter never goes beyond other time slots) 141 | // get absolute time for current loop if not required to keep track of old jitter injected previously 142 | // (otherwise, keep jittered time offset for current loop) 143 | if( !this.params.jitterMemory ){ 144 | time = this.getSlotTime(time, loop.soundParams.slotId); 145 | } 146 | // console.log('time', time, 'period', this.params.period, 'jitter', jitter, 'next time:', time + this.params.period + jitter); 147 | // return next time (it's how the advanceLoop works) 148 | return time + this.params.period + jitter; 149 | } 150 | 151 | // remove loop by index (both track index and time slot index) 152 | remove(trackId, slotId) { 153 | // get corresponding loop 154 | let loop = this.loops.mat[trackId][slotId]; 155 | // check if loop is defined 156 | if( loop === undefined ) { return; } 157 | // remove loop from scheduler 158 | this.e.scheduler.remove(loop); 159 | // delete loop from set 160 | this.loops.mat[trackId][slotId] = undefined; 161 | } 162 | 163 | // remove all loops from scheduler 164 | reset() { 165 | let loop; 166 | for( let i = 0; i < this.loops.i; i++ ){ 167 | for( let j = 0; j < this.loops.j; j++ ){ 168 | loop = this.loops.mat[i][j]; 169 | if( loop !== undefined ){ 170 | // remove loop 171 | this.e.scheduler.remove(loop); 172 | // disable renderer 173 | this.e.renderer.disable(); 174 | } 175 | } 176 | } 177 | this.loops.clear(); // clear set 178 | } 179 | 180 | } 181 | 182 | // loop corresponding to a single audio sample 183 | class Loop extends soundworks.audio.AudioTimeEngine { 184 | constructor(looper, soundParams) { 185 | super(); 186 | this.looper = looper; 187 | this.soundParams = soundParams; // drop parameters 188 | } 189 | 190 | advanceTime(time) { 191 | return this.looper.advanceLoop(time, this); // just call daddy 192 | } 193 | } 194 | 195 | // rough i.j matrix like array class 196 | class Matrix{ 197 | constructor(i, j){ 198 | this.i = i; 199 | this.j = j; 200 | this.mat = []; 201 | this.init(); 202 | } 203 | 204 | clear(){ 205 | this.init(); 206 | } 207 | 208 | init(){ 209 | for (let ii = 0; ii < this.i; ii++) { 210 | this.mat[ii] = []; 211 | for (let jj = 0; jj < this.j; jj++) { 212 | this.mat[ii][jj] = undefined; 213 | } 214 | } 215 | } 216 | 217 | } 218 | 219 | // in charge of playing the final sample 220 | class SampleSynth { 221 | constructor(audioBuffers, output) { 222 | this.audioBuffers = audioBuffers; 223 | this.output = audioContext.createGain(); 224 | this.output.connect( output ); 225 | this.output.gain.value = 1; 226 | } 227 | 228 | trigger(time, params) { 229 | let duration = 0; 230 | 231 | let trackName = audioFileIdToName.get(params.trackId); 232 | if (this.audioBuffers[trackName] === undefined) { return duration; } 233 | 234 | const b1 = this.audioBuffers[trackName]; 235 | 236 | duration += b1.duration; 237 | 238 | const s1 = audioContext.createBufferSource(); 239 | s1.buffer = b1; 240 | s1.connect(this.output); 241 | s1.start(time); 242 | 243 | return duration; 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /node/src/client/player/NuSynth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuSynth: distributed synthetizer, sending "note" information via OSC 3 | * to trigger real notes in local synthetizer 4 | **/ 5 | 6 | import NuBaseModule from './NuBaseModule' 7 | import * as soundworks from 'soundworks/client'; 8 | 9 | const client = soundworks.client; 10 | const audioContext = soundworks.audioContext; 11 | 12 | export default class NuSynth extends NuBaseModule { 13 | constructor(playerExperience) { 14 | super(playerExperience, 'nuSynth'); 15 | // create synth 16 | this.audioSynth = new AudioSynth(this.e); 17 | // create master gain 18 | this.localGain = audioContext.createGain(); 19 | // connect graph 20 | this.audioSynth.out.connect(this.localGain); 21 | this.localGain.connect(this.e.nuOutput.in); 22 | // binding 23 | this.noteOnOff = this.noteOnOff.bind(this); 24 | this.volume = this.volume.bind(this); 25 | } 26 | 27 | // set note on / off (args = [noteId, onOff status]) 28 | noteOnOff(args){ 29 | let noteId = args.shift(); 30 | let status = args.shift(); 31 | this.audioSynth.playNote(noteId, status); 32 | } 33 | 34 | // stop all playing notes 35 | clear(){ 36 | this.audioSynth.noteMap.forEach( (note, noteId) => { 37 | this.audioSynth.playNote(noteId, 0); 38 | }); 39 | } 40 | 41 | // set local volume 42 | volume(value){ 43 | this.localGain.gain.value = value; 44 | } 45 | 46 | /** 47 | * set noteId volume, "linking" the note to the player 48 | * (e.g. to attribute a unique note to each player) 49 | **/ 50 | linkPlayerToNote(args){ 51 | let noteId = args.shift(); 52 | let volume = args.shift(); 53 | this.audioSynth.setNoteVolume(noteId, volume); 54 | } 55 | 56 | // define local synthetizer used for audio rendering 57 | synthType(type){ 58 | this.audioSynth.setType(type); 59 | } 60 | 61 | // define synth. attack time 62 | attackTime(value){ 63 | this.audioSynth.attackTime = value; 64 | } 65 | 66 | // define synth. release time 67 | releaseTime(value){ 68 | this.audioSynth.releaseTime = value; 69 | } 70 | 71 | // define synth waveform 72 | periodicWave(args){ 73 | let halfLength = Math.floor(args.length/2); 74 | var real = new Float32Array(halfLength); 75 | var imag = new Float32Array(halfLength); 76 | for (let i = 0; i < args.length/2; i++) { 77 | real[i] = args[2*i]; 78 | imag[i] = args[2*i+1]; 79 | } 80 | this.audioSynth.setPeriodicWave(audioContext.createPeriodicWave(real, imag)); 81 | } 82 | 83 | } 84 | 85 | // a (really) basic audio synthetizer, based on the WebAudio Oscillator node 86 | class AudioSynth { 87 | constructor(soundworksClient){ 88 | this.e = soundworksClient; 89 | 90 | // local attributes 91 | this.noteMap = new Map(); 92 | this.type = 'square'; 93 | this.isPlaying = false; 94 | this.numNotesPlayed = 0; 95 | this.attackTime = 0.1; // in sec 96 | this.releaseTime = 0.1; // in sec 97 | // create custom periodic wave 98 | var real = new Float32Array(2); 99 | var imag = new Float32Array(2); 100 | real[0] = 0; imag[0] = 0; real[1] = 1; imag[1] = 0; 101 | this.periodicWave = audioContext.createPeriodicWave(real, imag); 102 | // output gain 103 | this.out = audioContext.createGain(); 104 | this.out.gain.value = 1.0; 105 | // notes ferquencies 106 | let noteFreqTable = [ 107 | 130.813, 108 | 138.591, 109 | 146.832, 110 | 155.563, 111 | 164.814, 112 | 174.614, 113 | 184.997, 114 | 195.998, 115 | 207.652, 116 | 220, 117 | 233.082, 118 | 246.942, 119 | ]; 120 | 121 | // add octaves (not just? true) 122 | let numOctaves = 4; 123 | const initTableLength = noteFreqTable.length; 124 | for( let i = 1; i < numOctaves; i++ ){ 125 | for( let j = 0; j < initTableLength; j++ ){ 126 | noteFreqTable.push( noteFreqTable[j] * Math.pow(2, i) ); 127 | } 128 | } 129 | 130 | // create notes 131 | for (let i = 0; i < noteFreqTable.length; i++) { 132 | // create note gain (note volume) 133 | let gain = audioContext.createGain(); 134 | gain.gain.value = 1.0; 135 | // create note envelope gain 136 | let envelopeGain = audioContext.createGain(); 137 | envelopeGain.gain.value = 0.0; 138 | envelopeGain.connect(this.out); 139 | // connect graph 140 | gain.connect(envelopeGain); 141 | envelopeGain.connect(this.out); 142 | // store note 143 | this.noteMap.set(i, { 144 | freq: noteFreqTable[i], 145 | gain: gain, 146 | envelopeGain: envelopeGain, 147 | osc: undefined, 148 | timeout: undefined, 149 | isPlaying: false, 150 | }); 151 | } 152 | } 153 | 154 | // set master volume 155 | setVolume(value){ 156 | this.out.gain.value = value; 157 | } 158 | 159 | // set note specific volume 160 | setNoteVolume(noteId, value){ 161 | // get note 162 | let note = this.noteMap.get(noteId); 163 | // discard if note does't exist 164 | if( note === undefined ){ return; } 165 | // set note gain 166 | note.gain.gain.value = value; 167 | } 168 | 169 | // synth type (waveform) 170 | setType(value){ 171 | this.type = value; 172 | } 173 | 174 | // define synth. waveform 175 | setPeriodicWave(wave){ 176 | this.periodicWave = wave; 177 | } 178 | 179 | // play a note on the synth. 180 | playNote(noteId, status){ 181 | // get note based on id 182 | let note = this.noteMap.get(noteId); 183 | // discard if note undefined 184 | if( note === undefined ){ 185 | this.e.renderer.blink([200, 0, 0]); 186 | console.warn('note', noteId, 'not defined'); 187 | return; 188 | } 189 | // note ON 190 | if( status ){ 191 | // discard if note already playing 192 | if(note.isPlaying){ return; } 193 | // create osc. 194 | let osc = audioContext.createOscillator(); 195 | // setup osc 196 | if( this.type === 'custom' ) 197 | osc.setPeriodicWave(this.periodicWave); 198 | else 199 | osc.type = this.type; 200 | osc.frequency.value = note.freq; // value in hertz 201 | // connect graph 202 | osc.connect(note.gain); 203 | // handle envelope 204 | let now = audioContext.currentTime; 205 | note.envelopeGain.gain.cancelScheduledValues(now); 206 | note.envelopeGain.gain.setValueAtTime(note.envelopeGain.gain.value, now); 207 | note.envelopeGain.gain.linearRampToValueAtTime(1, now + this.attackTime); 208 | // note.envelopeGain.gain.setTargetAtTime(1, now, 1/this.attackTime); 209 | // add to note 210 | note.osc = osc; 211 | // cancell eventual previous timeout (to avoid stoopping new note in old timeout) 212 | if( note.timeout !== undefined ){ 213 | clearTimeout(note.timeout); 214 | note.timeout = undefined; 215 | } 216 | else{ 217 | // update counter (for render) 218 | this.numNotesPlayed += 1; 219 | } 220 | // start 221 | osc.start(); 222 | note.isPlaying = true; 223 | } 224 | // note OFF 225 | else{ 226 | // discard if note never set to on 227 | if(note.osc === undefined){ return; } 228 | // handle enveloppe 229 | let now = audioContext.currentTime; 230 | note.envelopeGain.gain.cancelScheduledValues(now); 231 | note.envelopeGain.gain.setValueAtTime(note.envelopeGain.gain.value, now); 232 | note.envelopeGain.gain.linearRampToValueAtTime(0, now + this.releaseTime); 233 | // note.envelopeGain.gain.setTargetAtTime(0, now, 1/this.attackTime); 234 | // stop oscillator 235 | note.osc.stop(now + this.releaseTime); 236 | // schedule oscillator kill 237 | note.timeout = setTimeout(() => { 238 | // decrement renderer counter 239 | this.numNotesPlayed -= 1; 240 | this.updateRendererStatus(); 241 | // kill osc if re-started since (discard if osc already killed) 242 | if(note.osc === undefined){ return; } 243 | try{ // weird Safari behavior... 244 | note.osc.stop(); 245 | } 246 | catch(e){ 247 | if( e.name !== 'InvalidStateError'){ console.error(e); } 248 | } 249 | note.isPlaying = false 250 | note.osc = undefined; 251 | // delete timeout reference 252 | note.timeout = undefined; 253 | }, this.releaseTime*1000); 254 | } 255 | 256 | this.updateRendererStatus(); 257 | } 258 | 259 | // enable / disable visualization (enable as long as at least one note plays) 260 | updateRendererStatus(){ 261 | if( this.numNotesPlayed === 1 && !this.isPlaying){ 262 | this.e.renderer.enable(); 263 | this.isPlaying = true; 264 | } 265 | else if( this.numNotesPlayed === 0 ){ 266 | this.e.renderer.disable(); 267 | this.isPlaying = false; 268 | } 269 | } 270 | 271 | } -------------------------------------------------------------------------------- /node/src/client/player/NuDisplay.js: -------------------------------------------------------------------------------- 1 | /** 2 | * NuDisplay: nu module in charge of visual feedback 3 | **/ 4 | 5 | import * as soundworks from 'soundworks/client'; 6 | const client = soundworks.client; 7 | const audioContext = soundworks.audioContext; 8 | 9 | export default class NuDisplay extends soundworks.Canvas2dRenderer { 10 | constructor(playerExperience) { 11 | super(1/24); // update rate = 0: synchronize updates to frame rate 12 | this.moduleName = 'nuDisplay'; 13 | 14 | // local attributes 15 | this.e = playerExperience; 16 | this.params = { 17 | 'feedbackGain': 1.0, 18 | 'enableFeedback': true 19 | }; 20 | 21 | this.colors = { 22 | 'rest': [0,0,0], 23 | 'active': [255, 255, 255], 24 | 'current': [0,0,0] 25 | }; 26 | 27 | this.audioAnalyser = new AudioAnalyser(); 28 | this.bkgChangeColor = false; 29 | this.numOfElmtInNeedOfMe = 0; 30 | 31 | // this.bkgColorArray = [0,0,0]; 32 | this.blinkStatus = { isBlinking: false, savedBkgColor: [0,0,0] }; 33 | 34 | // binding 35 | this.analyserCallback = this.analyserCallback.bind(this); 36 | 37 | // setup receive callbacks 38 | this.e.receive(this.moduleName, (args) => { 39 | // get header 40 | let name = args.shift(); 41 | // convert singleton array if need be 42 | args = (args.length == 1) ? args[0] : args; 43 | if( this.params[name] !== undefined ) 44 | this.params[name] = args; // parameter set 45 | else 46 | this[name](args); // function call 47 | }); 48 | 49 | // notify module is ready to receive msg 50 | this.e.send('moduleReady', this.moduleName); 51 | 52 | // ATTEMPT AT CROSSMODULE POSTING: FUNCTIONAL BUT ORIGINAL USE NO LONGER CONSIDERED: TODELETE WHEN CONFIRMED 53 | // setup internal callback 54 | // console.log('setup event listener') 55 | // window.addEventListener("message", (event) => { 56 | // console.log('received msg', event); 57 | // if( event.origin !== location.origin || event.data[0] !== 'nuDisplay' ) 58 | // return; 59 | // console.log(event.data[4]); 60 | // this.restColor([255*event.data[4], 0, 0]); 61 | // }, false); 62 | // ---------- 63 | 64 | } 65 | 66 | // define rest color: the screen color when no sound is playing 67 | restColor(rgb){ 68 | this.colors.rest = rgb; 69 | // update background only if analyser not active 70 | if( this.numOfElmtInNeedOfMe == 0 ){ 71 | this.setCurrentColorAmpl(0); 72 | this.overrideForceRender = true; 73 | } 74 | } 75 | 76 | // define active color: the screen color when sound is playing 77 | activeColor(rgb){ 78 | this.colors.active = rgb; 79 | } 80 | 81 | /** 82 | * Initialize rederer state. 83 | * @param {Number} dt - time since last update in seconds. 84 | */ 85 | init() {} 86 | 87 | /** 88 | * Update rederer state. 89 | * @param {Number} dt - time since last update in seconds. 90 | */ 91 | update(dt) {} 92 | 93 | /** 94 | * Draw into canvas. 95 | * Method is called by animation frame loop in current frame rate. 96 | * @param {CanvasRenderingContext2D} ctx - canvas 2D rendering context 97 | */ 98 | render(ctx) { 99 | if ( this.bkgChangeColor && this.params.enableFeedback || this.overrideForceRender ) { 100 | // console.log(this.bkgColor); 101 | // ctx.save(); 102 | ctx.globalAlpha = 1; 103 | ctx.fillStyle = 'rgb(' 104 | + Math.round(this.colors.current[0]) + ',' 105 | + Math.round(this.colors.current[1]) + ',' 106 | + Math.round(this.colors.current[2]) + ')'; 107 | ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); 108 | this.overrideForceRender = false; 109 | this.bkgChangeColor = false 110 | } 111 | } 112 | 113 | // enable display, i.e. add +1 to its stack of "I need you display" clients 114 | enable(){ 115 | this.numOfElmtInNeedOfMe += 1; 116 | // if need to be triggered on for the first time: 117 | if( this.numOfElmtInNeedOfMe == 1 ){ 118 | requestAnimationFrame(this.analyserCallback); 119 | } 120 | } 121 | 122 | // disable display, i.e. remove 1 from its stack of "I need you display" clients 123 | disable(){ 124 | // decrement status 125 | this.numOfElmtInNeedOfMe = Math.max(this.numOfElmtInNeedOfMe-1, 0); 126 | // reset background color 127 | if( this.numOfElmtInNeedOfMe == 0 ){ 128 | this.bkgChangeColor = true; 129 | } 130 | } 131 | 132 | /* 133 | * Change GUI background color based on current amplitude of sound being played 134 | */ 135 | analyserCallback() { 136 | if( this.numOfElmtInNeedOfMe >= 1 || !this.blinkStatus.isBlinking ) { 137 | // call me once, I'll call myself over and over 138 | requestAnimationFrame(this.analyserCallback); 139 | // change background color based on current amplitude 140 | let amp = this.audioAnalyser.getAmplitude(); 141 | amp *= this.params.feedbackGain; 142 | this.setCurrentColorAmpl(amp); 143 | // notify to change color at next animation frame 144 | this.bkgChangeColor = true; 145 | } 146 | } 147 | 148 | // amplitude to color converter 149 | setCurrentColorAmpl(amp){ 150 | for (let i = 0; i < this.colors.current.length; i++) { 151 | this.colors.current[i] = this.colors.rest[i] + 152 | amp * ( this.colors.active[i] - this.colors.rest[i] ); 153 | } 154 | } 155 | 156 | // change screen color to 'color' for 'time' duration (in sec) 157 | blink(color, time = 0.4){ 158 | // discard if already blinking 159 | if( this.blinkStatus.isBlinking ){ return; } 160 | this.blinkStatus.isBlinking = true; 161 | // save current background color 162 | for (let i = 0; i < 3; i++) 163 | this.blinkStatus.savedBkgColor[i] = this.colors.current[i]; 164 | // change bkg color 165 | this.colors.current = color; 166 | this.bkgChangeColor = true; 167 | setTimeout(() => { 168 | for (let i = 0; i < 3; i++) 169 | this.colors.current[i] = this.blinkStatus.savedBkgColor[i]; 170 | this.blinkStatus.isBlinking = false 171 | this.bkgChangeColor = true; 172 | }, time * 1000); 173 | } 174 | 175 | // defined text (on top of the player's screen) from OSC client (header) 176 | text1(args){ 177 | let str = this.formatText(args); 178 | document.getElementById('text1').innerHTML = str; 179 | } 180 | 181 | // defined text (on middle of the player's screen) from OSC client (instructions) 182 | text2(args){ 183 | let str = this.formatText(args); 184 | document.getElementById('text2').innerHTML = str; 185 | } 186 | 187 | // defined text (on bottom of the player's screen) from OSC client (sub-instructions) 188 | text3(args){ 189 | let str = this.formatText(args); 190 | document.getElementById('text3').innerHTML = str; 191 | } 192 | 193 | // convert array of elements to string 194 | formatText(args){ 195 | let str = ''; 196 | // simple string 197 | if( typeof args === 'string' ){ str = args; } 198 | // array of strings 199 | else{ args.forEach( (elmt) => { str += ' ' + elmt; }); } 200 | // replace "cliendId" with actual client index and other conventional naming 201 | str = str.replace("clientId", client.index); 202 | str = str.replace("None", ''); 203 | // return formatted string 204 | return str; 205 | } 206 | 207 | // set analyzer min audio dB range (clip) 208 | dBmin(value){ 209 | if( value > -100 && value < 0 && value < this.audioAnalyser.in.maxDecibels ) 210 | this.audioAnalyser.in.minDecibels = value; 211 | } 212 | 213 | // set analyzer max audio dB range (clip) 214 | dBmax(value){ 215 | if( value > -100 && value < 0 && value < this.audioAnalyser.in.minDecibels ) 216 | this.audioAnalyser.in.maxDecibels = value; 217 | } 218 | 219 | // set visualizer smoothing time constant (to avoid epileptic prone behaviors from player's devices) 220 | smoothingTimeConstant(value){ 221 | if( value >= 0 && value <= 1 ) 222 | this.audioAnalyser.in.smoothingTimeConstant = value; 223 | } 224 | 225 | // set min frequency considered by the analyzer 226 | freqMin(value){ 227 | if( value > 0 && value < this.audioAnalyser.maxFreq ){ 228 | this.audioAnalyser.minFreq = value; 229 | this.audioAnalyser.updateBinNorm(); 230 | } 231 | } 232 | 233 | // set max frequency considered by the analyzer 234 | freqMax(value){ 235 | if( value < 20000 && value > this.audioAnalyser.minFreq ){ 236 | this.audioAnalyser.maxFreq = value; 237 | this.audioAnalyser.updateBinNorm(); 238 | } 239 | } 240 | 241 | } 242 | 243 | /** 244 | * Audio analyzer for visual feedback of sound amplitude on screen 245 | */ 246 | 247 | class AudioAnalyser { 248 | constructor() { 249 | // input node 250 | this.in = audioContext.createAnalyser(); 251 | this.in.smoothingTimeConstant = 0.2; 252 | this.in.fftSize = 32; 253 | // compression 254 | this.in.minDecibels = -100; 255 | this.in.maxDecibels = -50; 256 | // limit analyser spectrum 257 | this.minFreq = 200; // in Hz 258 | this.maxFreq = 8000; // in Hz 259 | this.updateBinNorm(); 260 | // pre-allocation of freqs ampl. array 261 | this.magnitudes = new Uint8Array(this.in.frequencyBinCount); 262 | } 263 | 264 | // update normalization parameters 265 | updateBinNorm(){ 266 | let norm = this.in.fftSize / audioContext.sampleRate; 267 | this.minBin = Math.round(this.minFreq * norm); 268 | this.maxBin = Math.round(this.maxFreq * norm); 269 | this.binsNormalisation = 1 / (this.maxBin - this.minBin + 1); 270 | } 271 | 272 | // return current analyzer amplitude (no freq. specific) between 0 and 1 273 | getAmplitude() { 274 | // extract data from analyzer 275 | this.in.getByteFrequencyData(this.magnitudes); 276 | // get average amplitude value 277 | let amplitude = 0.0; 278 | for( let i = this.minBin; i <= this.maxBin; ++i ) { 279 | amplitude += this.magnitudes[i]; 280 | } 281 | amplitude *= this.binsNormalisation / 250; 282 | // let norm = this.in.frequencyBinCount * 100; // arbitrary value, to be cleaned 283 | return amplitude; 284 | } 285 | 286 | } -------------------------------------------------------------------------------- /maxmsp/patchers/nu.synth.maxpat: -------------------------------------------------------------------------------- 1 | { 2 | "patcher" : { 3 | "fileversion" : 1, 4 | "appversion" : { 5 | "major" : 7, 6 | "minor" : 3, 7 | "revision" : 3, 8 | "architecture" : "x86", 9 | "modernui" : 1 10 | } 11 | , 12 | "rect" : [ 391.0, 237.0, 625.0, 387.0 ], 13 | "bglocked" : 0, 14 | "openinpresentation" : 0, 15 | "default_fontsize" : 12.0, 16 | "default_fontface" : 0, 17 | "default_fontname" : "Arial", 18 | "gridonopen" : 1, 19 | "gridsize" : [ 15.0, 15.0 ], 20 | "gridsnaponopen" : 1, 21 | "objectsnaponopen" : 1, 22 | "statusbarvisible" : 2, 23 | "toolbarvisible" : 1, 24 | "lefttoolbarpinned" : 0, 25 | "toptoolbarpinned" : 0, 26 | "righttoolbarpinned" : 0, 27 | "bottomtoolbarpinned" : 0, 28 | "toolbars_unpinned_last_save" : 0, 29 | "tallnewobj" : 0, 30 | "boxanimatetime" : 200, 31 | "enablehscroll" : 1, 32 | "enablevscroll" : 1, 33 | "devicewidth" : 0.0, 34 | "description" : "", 35 | "digest" : "", 36 | "tags" : "", 37 | "style" : "", 38 | "subpatcher_template" : "", 39 | "boxes" : [ { 40 | "box" : { 41 | "id" : "obj-4", 42 | "maxclass" : "message", 43 | "numinlets" : 2, 44 | "numoutlets" : 1, 45 | "outlettype" : [ "" ], 46 | "patching_rect" : [ 505.666656, 182.666672, 37.0, 22.0 ], 47 | "style" : "", 48 | "text" : "clear" 49 | } 50 | 51 | } 52 | , { 53 | "box" : { 54 | "id" : "obj-24", 55 | "maxclass" : "newobj", 56 | "numinlets" : 1, 57 | "numoutlets" : 0, 58 | "patching_rect" : [ 533.0, 23.0, 53.0, 22.0 ], 59 | "style" : "", 60 | "text" : "nu.error" 61 | } 62 | 63 | } 64 | , { 65 | "box" : { 66 | "id" : "obj-43", 67 | "maxclass" : "newobj", 68 | "numinlets" : 1, 69 | "numoutlets" : 1, 70 | "outlettype" : [ "" ], 71 | "patching_rect" : [ 444.0, 153.0, 123.0, 22.0 ], 72 | "style" : "", 73 | "text" : "prepend releaseTime" 74 | } 75 | 76 | } 77 | , { 78 | "box" : { 79 | "id" : "obj-41", 80 | "maxclass" : "newobj", 81 | "numinlets" : 1, 82 | "numoutlets" : 1, 83 | "outlettype" : [ "" ], 84 | "patching_rect" : [ 383.0, 121.0, 116.0, 22.0 ], 85 | "style" : "", 86 | "text" : "prepend attackTime" 87 | } 88 | 89 | } 90 | , { 91 | "box" : { 92 | "id" : "obj-39", 93 | "maxclass" : "newobj", 94 | "numinlets" : 1, 95 | "numoutlets" : 1, 96 | "outlettype" : [ "" ], 97 | "patching_rect" : [ 321.666656, 89.0, 130.0, 22.0 ], 98 | "style" : "", 99 | "text" : "prepend periodicWave" 100 | } 101 | 102 | } 103 | , { 104 | "box" : { 105 | "id" : "obj-37", 106 | "maxclass" : "newobj", 107 | "numinlets" : 1, 108 | "numoutlets" : 1, 109 | "outlettype" : [ "" ], 110 | "patching_rect" : [ 260.0, 182.666672, 113.0, 22.0 ], 111 | "style" : "", 112 | "text" : "prepend synthType" 113 | } 114 | 115 | } 116 | , { 117 | "box" : { 118 | "id" : "obj-35", 119 | "maxclass" : "newobj", 120 | "numinlets" : 1, 121 | "numoutlets" : 1, 122 | "outlettype" : [ "" ], 123 | "patching_rect" : [ 199.0, 151.666672, 148.0, 22.0 ], 124 | "style" : "", 125 | "text" : "prepend linkPlayerToNote" 126 | } 127 | 128 | } 129 | , { 130 | "box" : { 131 | "id" : "obj-33", 132 | "maxclass" : "newobj", 133 | "numinlets" : 1, 134 | "numoutlets" : 1, 135 | "outlettype" : [ "" ], 136 | "patching_rect" : [ 137.0, 120.666664, 113.0, 22.0 ], 137 | "style" : "", 138 | "text" : "prepend noteOnOff" 139 | } 140 | 141 | } 142 | , { 143 | "box" : { 144 | "id" : "obj-26", 145 | "maxclass" : "newobj", 146 | "numinlets" : 1, 147 | "numoutlets" : 1, 148 | "outlettype" : [ "" ], 149 | "patching_rect" : [ 76.333336, 89.0, 97.0, 22.0 ], 150 | "style" : "", 151 | "text" : "prepend volume" 152 | } 153 | 154 | } 155 | , { 156 | "box" : { 157 | "id" : "obj-3", 158 | "maxclass" : "message", 159 | "numinlets" : 2, 160 | "numoutlets" : 1, 161 | "outlettype" : [ "" ], 162 | "patching_rect" : [ 15.0, 89.0, 43.0, 22.0 ], 163 | "style" : "", 164 | "text" : "set $1" 165 | } 166 | 167 | } 168 | , { 169 | "box" : { 170 | "id" : "obj-11", 171 | "maxclass" : "newobj", 172 | "numinlets" : 1, 173 | "numoutlets" : 1, 174 | "outlettype" : [ "" ], 175 | "patching_rect" : [ 15.0, 269.0, 113.0, 22.0 ], 176 | "style" : "", 177 | "text" : "prepend #1" 178 | } 179 | 180 | } 181 | , { 182 | "box" : { 183 | "id" : "obj-158", 184 | "maxclass" : "newobj", 185 | "numinlets" : 1, 186 | "numoutlets" : 0, 187 | "patching_rect" : [ 15.0, 333.5, 65.0, 22.0 ], 188 | "style" : "", 189 | "text" : "s toServer" 190 | } 191 | 192 | } 193 | , { 194 | "box" : { 195 | "id" : "obj-56", 196 | "maxclass" : "newobj", 197 | "numinlets" : 1, 198 | "numoutlets" : 1, 199 | "outlettype" : [ "" ], 200 | "patching_rect" : [ 15.0, 304.5, 105.0, 22.0 ], 201 | "style" : "", 202 | "text" : "prepend /nuSynth" 203 | } 204 | 205 | } 206 | , { 207 | "box" : { 208 | "id" : "obj-23", 209 | "maxclass" : "newobj", 210 | "numinlets" : 10, 211 | "numoutlets" : 10, 212 | "outlettype" : [ "", "", "", "", "", "", "", "", "", "" ], 213 | "patching_rect" : [ 15.0, 52.0, 571.0, 22.0 ], 214 | "style" : "", 215 | "text" : "route playerId volume noteOnOff linkPlayerToNote synthType periodicWave attackTime releaseTime clear" 216 | } 217 | 218 | } 219 | , { 220 | "box" : { 221 | "comment" : "", 222 | "id" : "obj-1", 223 | "index" : 0, 224 | "maxclass" : "inlet", 225 | "numinlets" : 0, 226 | "numoutlets" : 1, 227 | "outlettype" : [ "" ], 228 | "patching_rect" : [ 15.0, 9.0, 30.0, 30.0 ], 229 | "style" : "" 230 | } 231 | 232 | } 233 | ], 234 | "lines" : [ { 235 | "patchline" : { 236 | "destination" : [ "obj-23", 0 ], 237 | "disabled" : 0, 238 | "hidden" : 0, 239 | "source" : [ "obj-1", 0 ] 240 | } 241 | 242 | } 243 | , { 244 | "patchline" : { 245 | "destination" : [ "obj-56", 0 ], 246 | "disabled" : 0, 247 | "hidden" : 0, 248 | "source" : [ "obj-11", 0 ] 249 | } 250 | 251 | } 252 | , { 253 | "patchline" : { 254 | "destination" : [ "obj-24", 0 ], 255 | "disabled" : 0, 256 | "hidden" : 0, 257 | "source" : [ "obj-23", 9 ] 258 | } 259 | 260 | } 261 | , { 262 | "patchline" : { 263 | "destination" : [ "obj-26", 0 ], 264 | "disabled" : 0, 265 | "hidden" : 0, 266 | "source" : [ "obj-23", 1 ] 267 | } 268 | 269 | } 270 | , { 271 | "patchline" : { 272 | "destination" : [ "obj-3", 0 ], 273 | "disabled" : 0, 274 | "hidden" : 0, 275 | "source" : [ "obj-23", 0 ] 276 | } 277 | 278 | } 279 | , { 280 | "patchline" : { 281 | "destination" : [ "obj-33", 0 ], 282 | "disabled" : 0, 283 | "hidden" : 0, 284 | "source" : [ "obj-23", 2 ] 285 | } 286 | 287 | } 288 | , { 289 | "patchline" : { 290 | "destination" : [ "obj-35", 0 ], 291 | "disabled" : 0, 292 | "hidden" : 0, 293 | "source" : [ "obj-23", 3 ] 294 | } 295 | 296 | } 297 | , { 298 | "patchline" : { 299 | "destination" : [ "obj-37", 0 ], 300 | "disabled" : 0, 301 | "hidden" : 0, 302 | "source" : [ "obj-23", 4 ] 303 | } 304 | 305 | } 306 | , { 307 | "patchline" : { 308 | "destination" : [ "obj-39", 0 ], 309 | "disabled" : 0, 310 | "hidden" : 0, 311 | "source" : [ "obj-23", 5 ] 312 | } 313 | 314 | } 315 | , { 316 | "patchline" : { 317 | "destination" : [ "obj-4", 0 ], 318 | "disabled" : 0, 319 | "hidden" : 0, 320 | "source" : [ "obj-23", 8 ] 321 | } 322 | 323 | } 324 | , { 325 | "patchline" : { 326 | "destination" : [ "obj-41", 0 ], 327 | "disabled" : 0, 328 | "hidden" : 0, 329 | "source" : [ "obj-23", 6 ] 330 | } 331 | 332 | } 333 | , { 334 | "patchline" : { 335 | "destination" : [ "obj-43", 0 ], 336 | "disabled" : 0, 337 | "hidden" : 0, 338 | "source" : [ "obj-23", 7 ] 339 | } 340 | 341 | } 342 | , { 343 | "patchline" : { 344 | "destination" : [ "obj-11", 0 ], 345 | "disabled" : 0, 346 | "hidden" : 0, 347 | "source" : [ "obj-26", 0 ] 348 | } 349 | 350 | } 351 | , { 352 | "patchline" : { 353 | "destination" : [ "obj-11", 0 ], 354 | "disabled" : 0, 355 | "hidden" : 0, 356 | "source" : [ "obj-3", 0 ] 357 | } 358 | 359 | } 360 | , { 361 | "patchline" : { 362 | "destination" : [ "obj-11", 0 ], 363 | "disabled" : 0, 364 | "hidden" : 0, 365 | "source" : [ "obj-33", 0 ] 366 | } 367 | 368 | } 369 | , { 370 | "patchline" : { 371 | "destination" : [ "obj-11", 0 ], 372 | "disabled" : 0, 373 | "hidden" : 0, 374 | "source" : [ "obj-35", 0 ] 375 | } 376 | 377 | } 378 | , { 379 | "patchline" : { 380 | "destination" : [ "obj-11", 0 ], 381 | "disabled" : 0, 382 | "hidden" : 0, 383 | "source" : [ "obj-39", 0 ] 384 | } 385 | 386 | } 387 | , { 388 | "patchline" : { 389 | "destination" : [ "obj-11", 0 ], 390 | "disabled" : 0, 391 | "hidden" : 0, 392 | "source" : [ "obj-4", 0 ] 393 | } 394 | 395 | } 396 | , { 397 | "patchline" : { 398 | "destination" : [ "obj-11", 0 ], 399 | "disabled" : 0, 400 | "hidden" : 0, 401 | "source" : [ "obj-41", 0 ] 402 | } 403 | 404 | } 405 | , { 406 | "patchline" : { 407 | "destination" : [ "obj-11", 0 ], 408 | "disabled" : 0, 409 | "hidden" : 0, 410 | "source" : [ "obj-43", 0 ] 411 | } 412 | 413 | } 414 | , { 415 | "patchline" : { 416 | "destination" : [ "obj-158", 0 ], 417 | "disabled" : 0, 418 | "hidden" : 0, 419 | "source" : [ "obj-56", 0 ] 420 | } 421 | 422 | } 423 | ], 424 | "dependency_cache" : [ { 425 | "name" : "nu.error.maxpat", 426 | "bootpath" : "~/Projects/Cosima/devs/Nu/soundworks-nu/maxmsp/extras", 427 | "type" : "JSON", 428 | "implicit" : 1 429 | } 430 | ], 431 | "autosave" : 0 432 | } 433 | 434 | } 435 | -------------------------------------------------------------------------------- /maxmsp/patchers/nu.loop.maxpat: -------------------------------------------------------------------------------- 1 | { 2 | "patcher" : { 3 | "fileversion" : 1, 4 | "appversion" : { 5 | "major" : 7, 6 | "minor" : 3, 7 | "revision" : 3, 8 | "architecture" : "x86", 9 | "modernui" : 1 10 | } 11 | , 12 | "rect" : [ 597.0, 363.0, 541.0, 397.0 ], 13 | "bglocked" : 0, 14 | "openinpresentation" : 0, 15 | "default_fontsize" : 12.0, 16 | "default_fontface" : 0, 17 | "default_fontname" : "Arial", 18 | "gridonopen" : 1, 19 | "gridsize" : [ 15.0, 15.0 ], 20 | "gridsnaponopen" : 1, 21 | "objectsnaponopen" : 1, 22 | "statusbarvisible" : 2, 23 | "toolbarvisible" : 1, 24 | "lefttoolbarpinned" : 0, 25 | "toptoolbarpinned" : 0, 26 | "righttoolbarpinned" : 0, 27 | "bottomtoolbarpinned" : 0, 28 | "toolbars_unpinned_last_save" : 0, 29 | "tallnewobj" : 0, 30 | "boxanimatetime" : 200, 31 | "enablehscroll" : 1, 32 | "enablevscroll" : 1, 33 | "devicewidth" : 0.0, 34 | "description" : "", 35 | "digest" : "", 36 | "tags" : "", 37 | "style" : "", 38 | "subpatcher_template" : "", 39 | "boxes" : [ { 40 | "box" : { 41 | "id" : "obj-27", 42 | "maxclass" : "message", 43 | "numinlets" : 2, 44 | "numoutlets" : 1, 45 | "outlettype" : [ "" ], 46 | "patching_rect" : [ 435.444458, 219.0, 37.0, 22.0 ], 47 | "style" : "", 48 | "text" : "reset" 49 | } 50 | 51 | } 52 | , { 53 | "box" : { 54 | "id" : "obj-19", 55 | "maxclass" : "message", 56 | "numinlets" : 2, 57 | "numoutlets" : 1, 58 | "outlettype" : [ "" ], 59 | "patching_rect" : [ 382.888885, 271.0, 43.0, 22.0 ], 60 | "style" : "", 61 | "text" : "set $1" 62 | } 63 | 64 | } 65 | , { 66 | "box" : { 67 | "id" : "obj-17", 68 | "maxclass" : "newobj", 69 | "numinlets" : 1, 70 | "numoutlets" : 1, 71 | "outlettype" : [ "" ], 72 | "patching_rect" : [ 330.333344, 307.5, 71.0, 22.0 ], 73 | "style" : "", 74 | "text" : "prepend #2" 75 | } 76 | 77 | } 78 | , { 79 | "box" : { 80 | "id" : "obj-11", 81 | "maxclass" : "newobj", 82 | "numinlets" : 1, 83 | "numoutlets" : 1, 84 | "outlettype" : [ "" ], 85 | "patching_rect" : [ 15.0, 290.0, 113.0, 22.0 ], 86 | "style" : "", 87 | "text" : "prepend #1" 88 | } 89 | 90 | } 91 | , { 92 | "box" : { 93 | "id" : "obj-12", 94 | "maxclass" : "newobj", 95 | "numinlets" : 1, 96 | "numoutlets" : 0, 97 | "patching_rect" : [ 15.0, 342.5, 65.0, 22.0 ], 98 | "style" : "", 99 | "text" : "s toServer" 100 | } 101 | 102 | } 103 | , { 104 | "box" : { 105 | "id" : "obj-13", 106 | "maxclass" : "newobj", 107 | "numinlets" : 1, 108 | "numoutlets" : 1, 109 | "outlettype" : [ "" ], 110 | "patching_rect" : [ 15.0, 316.5, 101.0, 22.0 ], 111 | "style" : "", 112 | "text" : "prepend /nuLoop" 113 | } 114 | 115 | } 116 | , { 117 | "box" : { 118 | "id" : "obj-10", 119 | "maxclass" : "newobj", 120 | "numinlets" : 1, 121 | "numoutlets" : 1, 122 | "outlettype" : [ "" ], 123 | "patching_rect" : [ 330.333344, 336.5, 124.0, 22.0 ], 124 | "style" : "", 125 | "text" : "prepend setTrackSlot" 126 | } 127 | 128 | } 129 | , { 130 | "box" : { 131 | "id" : "obj-4", 132 | "maxclass" : "message", 133 | "numinlets" : 2, 134 | "numoutlets" : 1, 135 | "outlettype" : [ "" ], 136 | "patching_rect" : [ 15.0, 89.0, 43.0, 22.0 ], 137 | "style" : "", 138 | "text" : "set $1" 139 | } 140 | 141 | } 142 | , { 143 | "box" : { 144 | "id" : "obj-9", 145 | "maxclass" : "newobj", 146 | "numinlets" : 1, 147 | "numoutlets" : 1, 148 | "outlettype" : [ "" ], 149 | "patching_rect" : [ 277.0, 225.5, 81.0, 22.0 ], 150 | "style" : "", 151 | "text" : "prepend jitter" 152 | } 153 | 154 | } 155 | , { 156 | "box" : { 157 | "id" : "obj-8", 158 | "maxclass" : "newobj", 159 | "numinlets" : 1, 160 | "numoutlets" : 1, 161 | "outlettype" : [ "" ], 162 | "patching_rect" : [ 225.0, 191.5, 124.0, 22.0 ], 163 | "style" : "", 164 | "text" : "prepend jitterMemory" 165 | } 166 | 167 | } 168 | , { 169 | "box" : { 170 | "id" : "obj-7", 171 | "maxclass" : "newobj", 172 | "numinlets" : 1, 173 | "numoutlets" : 1, 174 | "outlettype" : [ "" ], 175 | "patching_rect" : [ 172.0, 157.5, 120.0, 22.0 ], 176 | "style" : "", 177 | "text" : "prepend masterGain" 178 | } 179 | 180 | } 181 | , { 182 | "box" : { 183 | "id" : "obj-6", 184 | "maxclass" : "newobj", 185 | "numinlets" : 1, 186 | "numoutlets" : 1, 187 | "outlettype" : [ "" ], 188 | "patching_rect" : [ 120.0, 123.5, 91.0, 22.0 ], 189 | "style" : "", 190 | "text" : "prepend period" 191 | } 192 | 193 | } 194 | , { 195 | "box" : { 196 | "id" : "obj-2", 197 | "maxclass" : "newobj", 198 | "numinlets" : 1, 199 | "numoutlets" : 1, 200 | "outlettype" : [ "" ], 201 | "patching_rect" : [ 67.555557, 89.0, 98.0, 22.0 ], 202 | "style" : "", 203 | "text" : "prepend division" 204 | } 205 | 206 | } 207 | , { 208 | "box" : { 209 | "id" : "obj-24", 210 | "maxclass" : "newobj", 211 | "numinlets" : 1, 212 | "numoutlets" : 0, 213 | "patching_rect" : [ 454.0, 23.0, 53.0, 22.0 ], 214 | "style" : "", 215 | "text" : "nu.error" 216 | } 217 | 218 | } 219 | , { 220 | "box" : { 221 | "id" : "obj-23", 222 | "maxclass" : "newobj", 223 | "numinlets" : 10, 224 | "numoutlets" : 10, 225 | "outlettype" : [ "", "", "", "", "", "", "", "", "", "" ], 226 | "patching_rect" : [ 15.0, 55.0, 492.0, 22.0 ], 227 | "style" : "", 228 | "text" : "route playerId divisions period masterGain jitterMemory jitter setTrackSlot trackName reset" 229 | } 230 | 231 | } 232 | , { 233 | "box" : { 234 | "comment" : "", 235 | "id" : "obj-1", 236 | "index" : 0, 237 | "maxclass" : "inlet", 238 | "numinlets" : 0, 239 | "numoutlets" : 1, 240 | "outlettype" : [ "" ], 241 | "patching_rect" : [ 15.0, 9.0, 30.0, 30.0 ], 242 | "style" : "" 243 | } 244 | 245 | } 246 | ], 247 | "lines" : [ { 248 | "patchline" : { 249 | "destination" : [ "obj-23", 0 ], 250 | "disabled" : 0, 251 | "hidden" : 0, 252 | "source" : [ "obj-1", 0 ] 253 | } 254 | 255 | } 256 | , { 257 | "patchline" : { 258 | "destination" : [ "obj-11", 0 ], 259 | "disabled" : 0, 260 | "hidden" : 0, 261 | "source" : [ "obj-10", 0 ] 262 | } 263 | 264 | } 265 | , { 266 | "patchline" : { 267 | "destination" : [ "obj-13", 0 ], 268 | "disabled" : 0, 269 | "hidden" : 0, 270 | "source" : [ "obj-11", 0 ] 271 | } 272 | 273 | } 274 | , { 275 | "patchline" : { 276 | "destination" : [ "obj-12", 0 ], 277 | "disabled" : 0, 278 | "hidden" : 0, 279 | "source" : [ "obj-13", 0 ] 280 | } 281 | 282 | } 283 | , { 284 | "patchline" : { 285 | "destination" : [ "obj-10", 0 ], 286 | "disabled" : 0, 287 | "hidden" : 0, 288 | "source" : [ "obj-17", 0 ] 289 | } 290 | 291 | } 292 | , { 293 | "patchline" : { 294 | "destination" : [ "obj-17", 0 ], 295 | "disabled" : 0, 296 | "hidden" : 0, 297 | "source" : [ "obj-19", 0 ] 298 | } 299 | 300 | } 301 | , { 302 | "patchline" : { 303 | "destination" : [ "obj-11", 0 ], 304 | "disabled" : 0, 305 | "hidden" : 0, 306 | "source" : [ "obj-2", 0 ] 307 | } 308 | 309 | } 310 | , { 311 | "patchline" : { 312 | "destination" : [ "obj-17", 0 ], 313 | "disabled" : 0, 314 | "hidden" : 0, 315 | "source" : [ "obj-23", 6 ] 316 | } 317 | 318 | } 319 | , { 320 | "patchline" : { 321 | "destination" : [ "obj-19", 0 ], 322 | "disabled" : 0, 323 | "hidden" : 0, 324 | "source" : [ "obj-23", 7 ] 325 | } 326 | 327 | } 328 | , { 329 | "patchline" : { 330 | "destination" : [ "obj-2", 0 ], 331 | "disabled" : 0, 332 | "hidden" : 0, 333 | "source" : [ "obj-23", 1 ] 334 | } 335 | 336 | } 337 | , { 338 | "patchline" : { 339 | "destination" : [ "obj-24", 0 ], 340 | "disabled" : 0, 341 | "hidden" : 0, 342 | "source" : [ "obj-23", 9 ] 343 | } 344 | 345 | } 346 | , { 347 | "patchline" : { 348 | "destination" : [ "obj-27", 0 ], 349 | "disabled" : 0, 350 | "hidden" : 0, 351 | "source" : [ "obj-23", 8 ] 352 | } 353 | 354 | } 355 | , { 356 | "patchline" : { 357 | "destination" : [ "obj-4", 0 ], 358 | "disabled" : 0, 359 | "hidden" : 0, 360 | "source" : [ "obj-23", 0 ] 361 | } 362 | 363 | } 364 | , { 365 | "patchline" : { 366 | "destination" : [ "obj-6", 0 ], 367 | "disabled" : 0, 368 | "hidden" : 0, 369 | "source" : [ "obj-23", 2 ] 370 | } 371 | 372 | } 373 | , { 374 | "patchline" : { 375 | "destination" : [ "obj-7", 0 ], 376 | "disabled" : 0, 377 | "hidden" : 0, 378 | "source" : [ "obj-23", 3 ] 379 | } 380 | 381 | } 382 | , { 383 | "patchline" : { 384 | "destination" : [ "obj-8", 0 ], 385 | "disabled" : 0, 386 | "hidden" : 0, 387 | "source" : [ "obj-23", 4 ] 388 | } 389 | 390 | } 391 | , { 392 | "patchline" : { 393 | "destination" : [ "obj-9", 0 ], 394 | "disabled" : 0, 395 | "hidden" : 0, 396 | "source" : [ "obj-23", 5 ] 397 | } 398 | 399 | } 400 | , { 401 | "patchline" : { 402 | "destination" : [ "obj-11", 0 ], 403 | "disabled" : 0, 404 | "hidden" : 0, 405 | "source" : [ "obj-27", 0 ] 406 | } 407 | 408 | } 409 | , { 410 | "patchline" : { 411 | "destination" : [ "obj-11", 0 ], 412 | "disabled" : 0, 413 | "hidden" : 0, 414 | "source" : [ "obj-4", 0 ] 415 | } 416 | 417 | } 418 | , { 419 | "patchline" : { 420 | "destination" : [ "obj-11", 0 ], 421 | "disabled" : 0, 422 | "hidden" : 0, 423 | "source" : [ "obj-6", 0 ] 424 | } 425 | 426 | } 427 | , { 428 | "patchline" : { 429 | "destination" : [ "obj-11", 0 ], 430 | "disabled" : 0, 431 | "hidden" : 0, 432 | "source" : [ "obj-7", 0 ] 433 | } 434 | 435 | } 436 | , { 437 | "patchline" : { 438 | "destination" : [ "obj-11", 0 ], 439 | "disabled" : 0, 440 | "hidden" : 0, 441 | "source" : [ "obj-8", 0 ] 442 | } 443 | 444 | } 445 | , { 446 | "patchline" : { 447 | "destination" : [ "obj-11", 0 ], 448 | "disabled" : 0, 449 | "hidden" : 0, 450 | "source" : [ "obj-9", 0 ] 451 | } 452 | 453 | } 454 | ], 455 | "dependency_cache" : [ { 456 | "name" : "nu.error.maxpat", 457 | "bootpath" : "~/Projects/Cosima/devs/Nu/soundworks-nu/maxmsp/extras", 458 | "type" : "JSON", 459 | "implicit" : 1 460 | } 461 | ], 462 | "autosave" : 0 463 | } 464 | 465 | } 466 | --------------------------------------------------------------------------------