├── README.md ├── .eslintrc ├── config.js ├── cars ├── index.html └── index.js ├── index.html ├── package.json ├── visual ├── visual-utils.js └── vu.js ├── LICENSE ├── .gitignore └── index.js /README.md: -------------------------------------------------------------------------------- 1 | # train-brake-smell 2 | 3 | ## RPI 4 | 5 | `touch $HOME/.asoundrc` 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "rules": { 4 | "indent": ["error", 4] 5 | } 6 | } -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | portals: { 3 | portal3bar: { 4 | PORT: 2342, 5 | LENGTH: 300, 6 | HOST: 'portal3.bar' 7 | }, 8 | portal2bar: { 9 | PORT: 2342, 10 | LENGTH: 300, 11 | HOST: 'portal2.bar' 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /cars/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | 17 | 18 | Laps:

0

19 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "train-brake-smell", 3 | "version": "0.2.0", 4 | "description": "open pixel control opc udp esp8266", 5 | "main": "index.js", 6 | "repository": "git@github.com:olso/train-brake-smell.git", 7 | "author": "olso ", 8 | "license": "MIT", 9 | "dependencies": { 10 | "blockchain.info": "^2.10.1", 11 | "commander": "^2.11.0", 12 | "cubic-bezier": "^0.1.2", 13 | "lodash": "^4.17.4", 14 | "mic": "git+https://github.com/lorenzoromagnoli/mic.git", 15 | "node-fetch": "^1.7.3", 16 | "opc-via-udp": "^1.0.6", 17 | "redux": "^3.7.2", 18 | "through2": "^2.0.3", 19 | "tinycolor2": "^1.4.1", 20 | "tinygradient": "^0.3.1", 21 | "vu-meter": "^0.1.0", 22 | "ws": "^3.2.0" 23 | }, 24 | "devDependencies": { 25 | "eslint": "^4.9.0", 26 | "eslint-config-airbnb-base": "^12.1.0", 27 | "eslint-plugin-import": "^2.8.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /visual/visual-utils.js: -------------------------------------------------------------------------------- 1 | const tinycolor = require('tinycolor2') 2 | const tinygradient = require('tinygradient') 3 | 4 | function toRgbArray(rgbObject) { 5 | return [ rgbObject.r, rgbObject.g, rgbObject.b ] 6 | } 7 | 8 | function randomRgbColor() { 9 | return toRgbArray(tinycolor.random().toRgb()) 10 | } 11 | 12 | function makeRgbGradientArray(startColor, endColor, steps) { 13 | const colors = tinygradient([startColor, endColor]).rgb(steps) 14 | 15 | return colors.map(color => toRgbArray(color.toRgb())) 16 | } 17 | 18 | function convertHexColorToRgbArray(color) { 19 | return toRgbArray(tinycolor(color).toRgb()) 20 | } 21 | 22 | function fillPixelsWithSingleColor(stripLength = 1, color = randomRgbColor()) { 23 | return new Array(stripLength).fill(color) 24 | } 25 | 26 | function clearPixels(stripLength) { 27 | return fillPixelsWithSingleColor(stripLength, [0, 0, 0]) 28 | } 29 | 30 | module.exports = { 31 | clearPixels, 32 | toRgbArray, 33 | convertHexColorToRgbArray, 34 | fillPixelsWithSingleColor, 35 | makeRgbGradientArray, 36 | randomRgbColor, 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 ProgressBar HackerSpace 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (http://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # Typescript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { getSendPixels } = require('opc-via-udp') 2 | const WebSocket = require('ws') 3 | const { flatten } = require('lodash') 4 | const { clearPixels, fillPixelsWithSingleColor, randomRgbColor } = require('./visual/visual-utils') 5 | const { getVuMicPacketStream, virtualMeter, meter, easeIn } = require('./visual/vu') 6 | 7 | const portalConfig = require('./config').portals.portal3bar 8 | 9 | const sendPixels = getSendPixels({ 10 | port: portalConfig.PORT, 11 | length: portalConfig.LENGTH, 12 | host: portalConfig.HOST 13 | }) 14 | 15 | // easeIn(portalConfig.LENGTH, sendPixels) 16 | 17 | // const vuMicPacketStream = getVuMicPacketStream(portalConfig.LENGTH, virtualMeter) 18 | const vuMicPacketStream = getVuMicPacketStream(portalConfig.LENGTH, meter) 19 | vuMicPacketStream.on('data', pixels => sendPixels(flatten(pixels))) 20 | 21 | // const wss = new WebSocket.Server({ port: 8080 }) 22 | // wss.on('connection', (ws) => { 23 | // ws.on('message', (message) => { 24 | // const pixels = fillPixelsWithSingleColor(portalConfig.LENGTH, [0, 0, 0]) 25 | // const pixelPos = (message/100) * portalConfig.LENGTH 26 | // 27 | // for (let i = 0; i < 5; i++) { 28 | // pixels[pixelPos] = [255, 255, 255] 29 | // } 30 | // sendPixels(flatten(pixels)) 31 | // }) 32 | // }) 33 | -------------------------------------------------------------------------------- /visual/vu.js: -------------------------------------------------------------------------------- 1 | const { flatten } = require('lodash') 2 | const { createOpcPacket } = require('opc-via-udp') 3 | const { makeRgbGradientArray, fillPixelsWithSingleColor, convertHexColorToRgbArray, clearPixels, randomRgbColor } = require('./visual-utils') 4 | const VUmeter = require('vu-meter') 5 | const through2 = require('through2') 6 | const mic = require('mic') 7 | const _ = require('lodash') 8 | const bezier = require('cubic-bezier') 9 | const http = require('http') 10 | const fetch = require('node-fetch') 11 | 12 | function getVuBgPixels(stripLength) { 13 | const startColor = '#00FF00' 14 | const endColor = '#FF0000' 15 | 16 | return makeRgbGradientArray(startColor, endColor, stripLength) 17 | } 18 | 19 | function normalizeVolume(volume) { 20 | return volume > 100 ? 100 : Math.floor(volume) 21 | } 22 | 23 | function calcMicStrength(micData) { 24 | const magicNumber = 50; // 60 25 | const strength = (Math.max(micData + magicNumber, 0) / magicNumber) * 100; 26 | 27 | // return strength < 3 ? 0 : strength; 28 | return strength; 29 | } 30 | 31 | function calcNeededStripLength(stripLength, volume) { 32 | return Math.floor((stripLength)*(volume/100)) 33 | } 34 | 35 | function meter(stripLength, rawVolume) { 36 | const volume = normalizeVolume(rawVolume) 37 | const pixels = getVuBgPixels(stripLength) 38 | const neededStripLength = calcNeededStripLength(stripLength, volume) 39 | 40 | pixels.fill([0, 0 ,0], neededStripLength, stripLength) 41 | 42 | return pixels; 43 | } 44 | 45 | function virtualMeter(stripLength, rawVolume) { 46 | const virtualLength = stripLength/2 47 | const volume = normalizeVolume(rawVolume) 48 | const pixels = getVuBgPixels(virtualLength) 49 | const neededStripLength = calcNeededStripLength(stripLength, volume) 50 | 51 | pixels.fill([0, 0, 0], neededStripLength, stripLength) 52 | 53 | return [ ...pixels, ...pixels.reverse() ] 54 | } 55 | 56 | function getMicVuOpcPipe(stripLength, meter) { 57 | return through2.obj({ objectMode: true }, (data, enc, cb) => { 58 | 59 | const micStrength = calcMicStrength(data[0]) 60 | const pixels = meter(stripLength, micStrength) 61 | 62 | cb(null, pixels) 63 | }) 64 | } 65 | 66 | function getVuMicPacketStream(stripLength, meter) { 67 | const vuMeter = new VUmeter() 68 | const micVuOpcPipe = getMicVuOpcPipe(stripLength, meter) 69 | const micInstance = mic({ 70 | debug: false, 71 | rate: '32000', 72 | bitwidth: '16', 73 | buffer: 300, 74 | device: 'hw:1,0', 75 | }) 76 | const micStream = micInstance.getAudioStream() 77 | 78 | micInstance.start() 79 | 80 | return micStream.pipe(vuMeter).pipe(micVuOpcPipe) 81 | } 82 | 83 | function easeIn(stripLength, sendPixels) { 84 | // const easeIn = bezier(0.42, 0, 1.0, 1.0, 1000) 85 | // const easeIn = bezier(0.455, 0, 0.515, 0.955) 86 | const easeIn = bezier(0, 0, 1, 1, 1000) 87 | for (let t = 0; t <= 1; t += 0.001) { 88 | const pixels = clearPixels(stripLength) 89 | const timing = Math.round(easeIn(t) * 100) 90 | const position = (stripLength / 100) * timing 91 | console.log(position) 92 | pixels[position] = [255, 255, 0]; 93 | sendPixels(flatten(pixels)) 94 | } 95 | } 96 | 97 | 98 | module.exports = { 99 | meter, 100 | virtualMeter, 101 | getVuMicPacketStream, 102 | easeIn 103 | } 104 | -------------------------------------------------------------------------------- /cars/index.js: -------------------------------------------------------------------------------- 1 | const { flatten, isFunction } = require('lodash') 2 | const { getSendPixels } = require('opc-via-udp') 3 | const { randomRgbColor } = require('../visual/visual-utils') 4 | const WebSocketServer = require('ws').Server 5 | const { portal3bar, portal2bar } = require('../config').portals 6 | const { EventEmitter } = require('events') 7 | 8 | const TRACK_LENGTH = portal3bar.LENGTH + portal2bar.LENGTH 9 | const IS_PLAYING_TIMEOUT = 5000 10 | 11 | const sendPortal3Pixels = getSendPixels({ 12 | port: portal3bar.PORT, 13 | length: portal3bar.LENGTH, 14 | host: portal3bar.HOST 15 | }) 16 | 17 | const sendPortal2Pixels = getSendPixels({ 18 | port: portal2bar.PORT, 19 | length: portal2bar.LENGTH, 20 | host: portal2bar.HOST 21 | }) 22 | 23 | const DANGERS = [ 24 | { 25 | start: 90, 26 | length: 20, 27 | triggerSpeed: 7, 28 | color: [255, 0, 0], 29 | }, 30 | { 31 | start: 190, 32 | length: 20, 33 | triggerSpeed: 8, 34 | color: [255, 0, 0], 35 | }, 36 | { 37 | start: 390, 38 | length: 20, 39 | triggerSpeed: 7, 40 | color: [255, 0, 0], 41 | }, 42 | { 43 | start: 440, 44 | length: 20, 45 | triggerSpeed: 8, 46 | color: [255, 0, 0], 47 | }, 48 | { 49 | start: 490, 50 | length: 20, 51 | triggerSpeed: 8, 52 | color: [255, 0, 0], 53 | } 54 | ] 55 | let cars = [] 56 | 57 | class Car extends EventEmitter { 58 | constructor(props) { 59 | super(props) 60 | const { id, color } = props 61 | 62 | this.id = id 63 | this.color = color 64 | this.health = 15 65 | this.position = 0 66 | this.lap = 0 67 | this.acceleration = 2 68 | this.speed = 0 69 | this.speedDecay = 0.98 70 | this.isBeingKilled = false 71 | this.maxSpeed = 8 72 | this.lastReceivedActionAt = Date.now() 73 | } 74 | 75 | isMoving() { 76 | return this.speed > 0 77 | } 78 | 79 | getPosition() { 80 | return Math.round(this.position) 81 | } 82 | 83 | updatePosition() { 84 | this.position += this.speed 85 | 86 | if (this.position > TRACK_LENGTH) { 87 | this.position = 0 88 | this.lap++ 89 | this.emit('lap-change') 90 | } 91 | 92 | return this 93 | } 94 | 95 | shouldBeKicked() { 96 | let should = false 97 | 98 | DANGERS.forEach((danger) => { 99 | if (this.position >= danger.start && 100 | this.position <= (danger.start + danger.length)) { 101 | if (this.speed > danger.triggerSpeed) { 102 | should = true 103 | } 104 | } 105 | }) 106 | 107 | return should 108 | } 109 | 110 | takeHealth() { 111 | this.health -= 1 112 | } 113 | 114 | killCar() { 115 | if (this.isBeingKilled) { 116 | return 117 | } 118 | 119 | this.isBeingKilled = true 120 | 121 | const stop = setInterval(() => { 122 | this.takeHealth() 123 | 124 | if (this.health < 0) { 125 | if (isFunction(stop)) { 126 | stop() 127 | console.log('Killed car') 128 | } 129 | } 130 | }, 100) 131 | } 132 | 133 | isDead() { 134 | return this.health <= 0 135 | } 136 | 137 | receivedAction() { 138 | this.lastReceivedActionAt = Date.now() 139 | } 140 | 141 | isPlaying() { 142 | return (Date.now() - this.lastReceivedActionAt) < IS_PLAYING_TIMEOUT 143 | } 144 | 145 | accelerate() { 146 | this.receivedAction() 147 | 148 | if (this.speed > this.maxSpeed) { 149 | return this 150 | } 151 | 152 | if (this.speed === 0) { 153 | this.speed = 0.5 154 | } 155 | 156 | if (this.speed > 0) { 157 | this.speed *= this.acceleration 158 | } 159 | 160 | return this 161 | } 162 | 163 | decelarate() { 164 | if (this.speed < 0.1) { 165 | this.speed = 0 166 | 167 | return this 168 | } 169 | 170 | this.speed *= this.speedDecay 171 | 172 | return this 173 | } 174 | } 175 | 176 | function getCar(id) { 177 | return cars.find(car => car.id === id) 178 | } 179 | 180 | function addCar(id, color) { 181 | const car = new Car({ id, color }) 182 | cars.push(car) 183 | return car 184 | } 185 | 186 | function removeCar(id) { 187 | cars = cars.filter(car => car.id !== id) 188 | } 189 | 190 | function step(car) { 191 | if (!car.isMoving()) { 192 | car.speed = 0 193 | } else { 194 | car.decelarate() 195 | } 196 | 197 | car.updatePosition() 198 | 199 | if (car.shouldBeKicked()) { 200 | car.takeHealth() 201 | car.position = 0 202 | } 203 | 204 | if (car.isDead()) { 205 | removeCar(car.id) 206 | } 207 | 208 | if (!car.isPlaying()) { 209 | car.killCar() 210 | } 211 | } 212 | 213 | function drawDangers(pixels) { 214 | DANGERS.forEach((danger) => { 215 | for (let i = danger.start; i <= (danger.start + danger.length); i++) { 216 | pixels[i] = danger.color 217 | } 218 | }) 219 | 220 | return pixels 221 | } 222 | 223 | function getPixels() { 224 | let pixels = new Array(TRACK_LENGTH).fill([0, 0, 0]) 225 | 226 | pixels = drawDangers(pixels) 227 | 228 | cars.forEach((car) => { 229 | const pos = car.getPosition() 230 | 231 | for (let i = 0; i < car.health; i++) { 232 | pixels[pos + i] = car.color 233 | } 234 | }) 235 | 236 | return pixels 237 | } 238 | 239 | function updateGame() { 240 | cars.forEach(car => step(car)) 241 | // console.log(cars.length) 242 | } 243 | 244 | const SV_TICK_RATE = 10 245 | setInterval(() => { 246 | updateGame() 247 | const pixels = flatten(getPixels()) 248 | const middle = pixels.length / 2 249 | const firstHalf = pixels.slice(0, middle) 250 | const secondHalf = pixels.slice(middle, pixels.length) 251 | 252 | sendPortal3Pixels(firstHalf) 253 | sendPortal2Pixels(secondHalf) 254 | }, SV_TICK_RATE) 255 | 256 | const wss = new WebSocketServer({ port: 1337 }) 257 | wss.on('connection', (ws) => { 258 | const id = ws._ultron.id 259 | 260 | const mainCar = addCar(id, randomRgbColor()) 261 | 262 | ws.on('message', (data) => { 263 | const car = getCar(id) 264 | 265 | if (car) { 266 | car.accelerate() 267 | } 268 | }) 269 | 270 | const handleLapChange = () => { 271 | const car = getCar(id) 272 | if (car) { 273 | ws.send(car.lap, (e) => { 274 | if (e) { 275 | console.error('ERR:', e.message) 276 | } 277 | }) 278 | } 279 | } 280 | mainCar.on('lap-change', handleLapChange) 281 | 282 | }) 283 | --------------------------------------------------------------------------------