Browse Source

Merge pull request #2 from nypublicradio/master

Jason Cox 8 years ago
parent
commit
407f4279c1
54 changed files with 1500 additions and 588 deletions
  1. 23 0
      .travis.yml
  2. 1 2
      Dockerfile
  3. 6 24
      INSTALL.md
  4. 2 0
      README.md
  5. 1 1
      THEMES.md
  6. 33 10
      audiogram/draw-frames.js
  7. 0 35
      audiogram/duration.js
  8. 34 35
      audiogram/index.js
  9. 6 12
      audiogram/initialize-canvas.js
  10. 3 3
      audiogram/trim.js
  11. 64 37
      audiogram/waveform.js
  12. 3 0
      bin/server
  13. 11 5
      bin/worker
  14. 27 13
      client/index.js
  15. 11 6
      client/preview.js
  16. 1 0
      client/sample-wave.js
  17. 1 0
      client/waveform.js
  18. 7 4
      editor/index.html
  19. 55 0
      lib/pcm.js
  20. 43 0
      lib/probe.js
  21. 42 0
      lib/profiler.js
  22. 1 1
      lib/register-fonts.js
  23. 15 0
      lib/settings/index.js
  24. 26 0
      lib/settings/load.js
  25. 90 0
      lib/settings/validate-settings.js
  26. 49 0
      lib/settings/validate-themes.js
  27. 1 1
      lib/transports/index.js
  28. 0 6
      lib/transports/s3/fake.js
  29. 0 15
      lib/transports/s3/remote.js
  30. 3 4
      package.json
  31. 39 31
      renderer/index.js
  32. 56 15
      renderer/patterns.js
  33. 0 1
      renderer/sample-wave.js
  34. 14 14
      renderer/text-wrapper.js
  35. 1 1
      server/error.js
  36. 71 0
      server/fonts.js
  37. 11 10
      server/index.js
  38. 10 13
      server/render.js
  39. 1 2
      server/status.js
  40. 10 0
      settings/themes.json
  41. BIN
      test/data/glazed-donut-mono.mp3
  42. BIN
      test/data/glazed-donut-mono.wav
  43. BIN
      test/data/long-beeps.mp3
  44. BIN
      test/data/short.wav
  45. 0 153
      test/duration-test.js
  46. BIN
      test/font/Merriweather-Bold.ttf
  47. BIN
      test/font/Merriweather-Regular.ttf
  48. 93 0
      test/font/OFL.txt
  49. 47 79
      test/frame-test.js
  50. 0 16
      test/patch-settings.js
  51. 169 0
      test/probe-test.js
  52. 49 10
      test/server-test.js
  53. 324 0
      test/settings-test.js
  54. 46 29
      test/waveform-test.js

+ 23 - 0
.travis.yml View File

1
+language: node_js
2
+node_js:
3
+  - '6'
4
+  - '4'
5
+  - '0.12'
6
+addons:
7
+  apt:
8
+    sources:
9
+      - ubuntu-toolchain-r-test
10
+      - sourceline: 'ppa:mc3man/trusty-media'
11
+    packages:
12
+      - libcairo2-dev
13
+      - libjpeg8-dev
14
+      - libpango1.0-dev
15
+      - libgif-dev
16
+      - g++-4.9
17
+      - ffmpeg
18
+env:
19
+  - CXX=g++-4.9
20
+before_install:
21
+  - npm explore npm -g -- npm install node-gyp@latest
22
+sudo: required
23
+dist: trusty

+ 1 - 2
Dockerfile View File

3
 # Install dependencies
3
 # Install dependencies
4
 RUN apt-get update --yes && apt-get upgrade --yes
4
 RUN apt-get update --yes && apt-get upgrade --yes
5
 RUN apt-get install git nodejs npm \
5
 RUN apt-get install git nodejs npm \
6
-libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev build-essential g++ \
6
+libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev libpng-dev build-essential g++ \
7
 ffmpeg \
7
 ffmpeg \
8
-libgroove-dev zlib1g-dev libpng-dev \
9
 redis-server --yes
8
 redis-server --yes
10
 
9
 
11
 RUN ln -s `which nodejs` /usr/bin/node
10
 RUN ln -s `which nodejs` /usr/bin/node

+ 6 - 24
INSTALL.md View File

5
 * [Node.js/NPM](https://nodejs.org/) v0.11.2 or greater
5
 * [Node.js/NPM](https://nodejs.org/) v0.11.2 or greater
6
 * [node-canvas dependencies](https://github.com/Automattic/node-canvas#installation)
6
 * [node-canvas dependencies](https://github.com/Automattic/node-canvas#installation)
7
 * [FFmpeg](https://www.ffmpeg.org/)
7
 * [FFmpeg](https://www.ffmpeg.org/)
8
-* [libgroove](https://github.com/andrewrk/libgroove)
9
 
8
 
10
 If you're using a particularly fancy distributed setup you'll also need to install [Redis](http://redis.io/).
9
 If you're using a particularly fancy distributed setup you'll also need to install [Redis](http://redis.io/).
11
 
10
 
22
 An example bootstrap script for installing Audiogram on Ubuntu looks like this:
21
 An example bootstrap script for installing Audiogram on Ubuntu looks like this:
23
 
22
 
24
 ```sh
23
 ```sh
25
-# 14.04 only: add PPAs for FFmpeg and Libgroove
24
+# 14.04 only: add PPA for FFmpeg
26
 # Not required for 15.04+
25
 # Not required for 15.04+
27
 sudo add-apt-repository ppa:mc3man/trusty-media --yes
26
 sudo add-apt-repository ppa:mc3man/trusty-media --yes
28
-sudo apt-add-repository ppa:andrewrk/libgroove --yes
29
 
27
 
30
 # Update/upgrade
28
 # Update/upgrade
31
 sudo apt-get update --yes && sudo apt-get upgrade --yes
29
 sudo apt-get update --yes && sudo apt-get upgrade --yes
35
 # Git
33
 # Git
36
 # node-canvas dependencies (Cairo, Pango, libgif, libjpeg)
34
 # node-canvas dependencies (Cairo, Pango, libgif, libjpeg)
37
 # FFmpeg
35
 # FFmpeg
38
-# node-waveform dependencies (libgroove, zlib, libpng)
39
 sudo apt-get install git nodejs npm \
36
 sudo apt-get install git nodejs npm \
40
-libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev build-essential g++ \
37
+libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev libpng-dev build-essential g++ \
41
 ffmpeg \
38
 ffmpeg \
42
-libgroove-dev zlib1g-dev libpng-dev \
43
 --yes
39
 --yes
44
 
40
 
45
 # Install Redis if you plan to use it to share rendering among multiple processes/servers
41
 # Install Redis if you plan to use it to share rendering among multiple processes/servers
82
 
78
 
83
 1. [Node.js/NPM](https://nodejs.org/)
79
 1. [Node.js/NPM](https://nodejs.org/)
84
 2. [node-canvas dependencies](https://github.com/Automattic/node-canvas#installation)
80
 2. [node-canvas dependencies](https://github.com/Automattic/node-canvas#installation)
85
-4. [libgroove](https://github.com/andrewrk/libgroove)
86
-
87
-You probably don't need to install FFmpeg separately because libgroove depends on it.
81
+4. [FFmpeg](https://www.ffmpeg.org/)
88
 
82
 
89
 You can install Node.js by [downloading it from the website](https://nodejs.org/).
83
 You can install Node.js by [downloading it from the website](https://nodejs.org/).
90
 
84
 
91
-Installation of node-canvas dependencies and libgroove might look like the following with [Homebrew](http://brew.sh/) (you'll want to make sure [XCode](https://developer.apple.com/xcode/) is installed and up-to-date too):
85
+Installation of node-canvas dependencies and FFmpeg might look like the following with [Homebrew](http://brew.sh/) (you'll want to make sure [XCode](https://developer.apple.com/xcode/) is installed and up-to-date too):
92
 
86
 
93
 ```sh
87
 ```sh
94
 # Install Git if you haven't already
88
 # Install Git if you haven't already
95
 brew install git
89
 brew install git
96
 
90
 
97
-# Install Cairo, Pango, libgif, libjpeg, libgroove, and FFmpeg
91
+# Install Cairo, Pango, libgif, libjpeg, libpng, and FFmpeg
98
 # You may not need to install zlib
92
 # You may not need to install zlib
99
-brew install pkg-config cairo pango libpng jpeg giflib libgroove ffmpeg
93
+brew install pkg-config cairo pango libpng jpeg giflib ffmpeg
100
 
94
 
101
 # Go to the directory where you want the audiogram directory
95
 # Go to the directory where you want the audiogram directory
102
 cd /where/to/put/this/
96
 cd /where/to/put/this/
147
 
141
 
148
 Follow the [Homebrew troubleshooting guide](https://github.com/Homebrew/brew/blob/master/share/doc/homebrew/Troubleshooting.md#troubleshooting), particularly making sure that XCode is up to date.
142
 Follow the [Homebrew troubleshooting guide](https://github.com/Homebrew/brew/blob/master/share/doc/homebrew/Troubleshooting.md#troubleshooting), particularly making sure that XCode is up to date.
149
 
143
 
150
-### Installing libgroove manually
151
-
152
-If you're on a Mac and installing [libgroove](https://github.com/andrewrk/libgroove) with Homebrew failed for some reason, you can try following the instructions in the repo to install it more manually.
153
-
154
-### Installing XCode command line tools
155
-
156
-Audiogram uses [waveform](https://github.com/andrewrk/waveform), which requires the development version of `zlib`. Your Mac might already have this, but if it doesn't, installing XCode command line tools might help:
157
-
158
-```sh
159
-xcode-select --install
160
-```
161
-
162
 ### Updating node-gyp
144
 ### Updating node-gyp
163
 
145
 
164
 Updating node-gyp to a current version with:
146
 Updating node-gyp to a current version with:

+ 2 - 0
README.md View File

1
 # Audiogram
1
 # Audiogram
2
 
2
 
3
+[![Build Status](https://travis-ci.org/nypublicradio/audiogram.svg?branch=alpha)](https://travis-ci.org/nypublicradio/audiogram)
4
+
3
 🔊 -> 🎥
5
 🔊 -> 🎥
4
 
6
 
5
 Audiogram is a library for generating shareable videos from audio clips.
7
 Audiogram is a library for generating shareable videos from audio clips.

+ 1 - 1
THEMES.md View File

60
 
60
 
61
 ### Wave options
61
 ### Wave options
62
 
62
 
63
-* `pattern` - What waveform shape to draw. Current options are `wave`, `bars`, `roundBars`, `pixel`, `bricks`, and `equalizer` (default: `wave`)
63
+* `pattern` - What waveform shape to draw. Current options are `wave`, `bars`, `line`, `curve`, `roundBars`, `pixel`, `bricks`, and `equalizer` (default: `wave`)
64
 * `waveTop` - How many pixels from the top edge to start the waveform (default: `0`)
64
 * `waveTop` - How many pixels from the top edge to start the waveform (default: `0`)
65
 * `waveBottom` - How many pixels from the top edge to end the waveform (default: same as `height`)
65
 * `waveBottom` - How many pixels from the top edge to end the waveform (default: same as `height`)
66
 * `waveLeft` - How many pixels from the left edge to start the waveform (default: `0`)
66
 * `waveLeft` - How many pixels from the left edge to start the waveform (default: `0`)

+ 33 - 10
audiogram/draw-frames.js View File

1
 var fs = require("fs"),
1
 var fs = require("fs"),
2
     path = require("path"),
2
     path = require("path"),
3
+    Canvas = require("canvas"),
3
     queue = require("d3").queue;
4
     queue = require("d3").queue;
4
 
5
 
5
 function drawFrames(renderer, options, cb) {
6
 function drawFrames(renderer, options, cb) {
6
 
7
 
7
-  var frameQueue = queue(10);
8
+  var frameQueue = queue(10),
9
+      canvases = [];
8
 
10
 
9
-  for (var i = 0; i < options.numFrames; i++) {
11
+  for (var i = 0; i < 10; i++) {
12
+    canvases.push(new Canvas(options.width, options.height));
13
+  }
10
 
14
 
15
+  for (var i = 0; i < options.numFrames; i++) {
11
     frameQueue.defer(drawFrame, i);
16
     frameQueue.defer(drawFrame, i);
12
-
13
   }
17
   }
14
 
18
 
15
   frameQueue.awaitAll(cb);
19
   frameQueue.awaitAll(cb);
16
 
20
 
17
   function drawFrame(frameNumber, frameCallback) {
21
   function drawFrame(frameNumber, frameCallback) {
18
 
22
 
19
-    renderer.drawFrame(frameNumber);
20
-    fs.writeFile(path.join(options.frameDir, zeropad(frameNumber + 1, 6) + ".png"), renderer.context.canvas.toBuffer(), function(err) {
23
+    var canvas = canvases.pop(),
24
+        context = canvas.getContext("2d");
25
+
26
+    renderer.drawFrame(context, {
27
+      caption: options.caption,
28
+      waveform: options.waveform[frameNumber],
29
+      frame: frameNumber
30
+    });
31
+
32
+    canvas.toBuffer(function(err, buf){
33
+
21
       if (err) {
34
       if (err) {
22
-        return frameCallback(err);
35
+        return cb(err);
23
       }
36
       }
24
 
37
 
25
-      if (options.tick) {
26
-        options.tick();
27
-      }
38
+      fs.writeFile(path.join(options.frameDir, zeropad(frameNumber + 1, 6) + ".png"), buf, function(writeErr) {
39
+
40
+        if (writeErr) {
41
+          return frameCallback(writeErr);
42
+        }
43
+
44
+        if (options.tick) {
45
+          options.tick();
46
+        }
47
+
48
+        canvases.push(canvas);
49
+
50
+        return frameCallback(null);
28
 
51
 
29
-      return frameCallback(null);
52
+      });
30
 
53
 
31
     });
54
     });
32
 
55
 

+ 0 - 35
audiogram/duration.js View File

1
-var probe = require("node-ffprobe");
2
-
3
-module.exports = function(filename, cb){
4
-
5
-  probe(filename, function(err, probeData) {
6
-
7
-    if (err) {
8
-      return cb(err);
9
-    }
10
-
11
-    var duration = getDuration(probeData);
12
-
13
-    if (!duration || duration === "N/A" || !(duration > 0)) {
14
-      return cb("Couldn't probe audio duration.");
15
-    }
16
-
17
-    return cb(null, duration);
18
-
19
-  });
20
-
21
-};
22
-
23
-function getDuration(probeData) {
24
-
25
-  if (probeData.format && probeData.format.duration) {
26
-    return probeData.format.duration;
27
-  }
28
-
29
-  if (Array.isArray(probeData.streams) && probeData.streams.length && probeData.streams[0].duration) {
30
-    return probeData.streams[0].duration;
31
-  }
32
-
33
-  return null;
34
-
35
-}

+ 34 - 35
audiogram/index.js View File

2
     queue = require("d3").queue,
2
     queue = require("d3").queue,
3
     mkdirp = require("mkdirp"),
3
     mkdirp = require("mkdirp"),
4
     rimraf = require("rimraf"),
4
     rimraf = require("rimraf"),
5
-    serverSettings = require("../settings/"),
5
+    serverSettings = require("../lib/settings/"),
6
     transports = require("../lib/transports/"),
6
     transports = require("../lib/transports/"),
7
     logger = require("../lib/logger/"),
7
     logger = require("../lib/logger/"),
8
-    getDuration = require("./duration.js"),
8
+    Profiler = require("../lib/profiler.js"),
9
+    probe = require("../lib/probe.js"),
9
     getWaveform = require("./waveform.js"),
10
     getWaveform = require("./waveform.js"),
10
     initializeCanvas = require("./initialize-canvas.js"),
11
     initializeCanvas = require("./initialize-canvas.js"),
11
     drawFrames = require("./draw-frames.js"),
12
     drawFrames = require("./draw-frames.js"),
12
     combineFrames = require("./combine-frames.js"),
13
     combineFrames = require("./combine-frames.js"),
13
     trimAudio = require("./trim.js");
14
     trimAudio = require("./trim.js");
14
 
15
 
15
-function Audiogram(settings) {
16
+function Audiogram(id) {
16
 
17
 
17
   // Unique audiogram ID
18
   // Unique audiogram ID
18
-  this.id = settings.id;
19
-
20
-  this.settings = settings;
19
+  this.id = id;
21
 
20
 
22
   // File locations to use
21
   // File locations to use
23
   this.dir = path.join(serverSettings.workingDirectory, this.id);
22
   this.dir = path.join(serverSettings.workingDirectory, this.id);
23
+
24
   this.audioPath = path.join(this.dir, "audio");
24
   this.audioPath = path.join(this.dir, "audio");
25
   this.videoPath = path.join(this.dir, "video.mp4");
25
   this.videoPath = path.join(this.dir, "video.mp4");
26
   this.frameDir = path.join(this.dir, "frames");
26
   this.frameDir = path.join(this.dir, "frames");
27
 
27
 
28
+  this.profiler = new Profiler();
29
+
28
   return this;
30
   return this;
29
 
31
 
30
 }
32
 }
31
 
33
 
32
-// Probe an audio file for its duration, compute the number of frames required
33
-Audiogram.prototype.getDuration = function(cb) {
34
+// Get the waveform data from the audio file, split into frames
35
+Audiogram.prototype.getWaveform = function(cb) {
34
 
36
 
35
   var self = this;
37
   var self = this;
36
 
38
 
37
-  this.status("duration");
39
+  this.status("probing");
38
 
40
 
39
-  getDuration(this.audioPath, function(err, duration){
41
+  probe(this.audioPath, function(err, data){
40
 
42
 
41
     if (err) {
43
     if (err) {
42
       return cb(err);
44
       return cb(err);
43
     }
45
     }
44
 
46
 
45
-    if (self.settings.maxDuration && self.settings.maxDuration < duration) {
46
-      cb("Exceeds max duration of " + self.settings.maxDuration + "s");
47
+    if (self.settings.theme.maxDuration && self.settings.theme.maxDuration < data.duration) {
48
+      return cb("Exceeds max duration of " + self.settings.theme.maxDuration + "s");
47
     }
49
     }
48
 
50
 
49
-    self.set("numFrames", self.numFrames = Math.floor(duration * self.settings.framesPerSecond));
51
+    self.profiler.size(data.duration);
52
+    self.set("numFrames", self.numFrames = Math.floor(data.duration * self.settings.theme.framesPerSecond));
53
+    self.status("waveform");
50
 
54
 
51
-    cb(null);
52
-
53
-  });
54
-
55
-};
56
-
57
-// Get the waveform data from the audio file, split into frames
58
-Audiogram.prototype.getWaveform = function(cb) {
59
-
60
-  var self = this;
55
+    getWaveform(self.audioPath, {
56
+      numFrames: self.numFrames,
57
+      samplesPerFrame: self.settings.theme.samplesPerFrame,
58
+      channels: data.channels
59
+    }, function(waveformErr, waveform){
61
 
60
 
62
-  this.status("waveform");
61
+      return cb(waveformErr, self.waveform = waveform);
63
 
62
 
64
-  getWaveform(this.audioPath, {
65
-    numFrames: this.numFrames,
66
-    samplesPerFrame: this.settings.samplesPerFrame
67
-  }, function(err, waveform){
63
+    });
68
 
64
 
69
-    return cb(err, self.settings.waveform = waveform);
70
 
65
 
71
   });
66
   });
72
 
67
 
106
 
101
 
107
   this.status("renderer");
102
   this.status("renderer");
108
 
103
 
109
-  initializeCanvas(this.settings, function(err, renderer){
104
+  initializeCanvas(this.settings.theme, function(err, renderer){
110
 
105
 
111
     if (err) {
106
     if (err) {
112
       return cb(err);
107
       return cb(err);
115
     self.status("frames");
110
     self.status("frames");
116
 
111
 
117
     drawFrames(renderer, {
112
     drawFrames(renderer, {
113
+      width: self.settings.theme.width,
114
+      height: self.settings.theme.height,
118
       numFrames: self.numFrames,
115
       numFrames: self.numFrames,
119
       frameDir: self.frameDir,
116
       frameDir: self.frameDir,
117
+      caption: self.settings.caption,
118
+      waveform: self.waveform,
120
       tick: function() {
119
       tick: function() {
121
         transports.incrementField(self.id, "framesComplete");
120
         transports.incrementField(self.id, "framesComplete");
122
       }
121
       }
135
     framePath: path.join(this.frameDir, "%06d.png"),
134
     framePath: path.join(this.frameDir, "%06d.png"),
136
     audioPath: this.audioPath,
135
     audioPath: this.audioPath,
137
     videoPath: this.videoPath,
136
     videoPath: this.videoPath,
138
-    framesPerSecond: this.settings.framesPerSecond
137
+    framesPerSecond: this.settings.theme.framesPerSecond
139
   }, cb);
138
   }, cb);
140
 
139
 
141
 };
140
 };
156
 
155
 
157
   // If the audio needs to be clipped, clip it first and update the path
156
   // If the audio needs to be clipped, clip it first and update the path
158
   if (this.settings.start || this.settings.end) {
157
   if (this.settings.start || this.settings.end) {
159
-    q.defer(this.trimAudio.bind(this), this.settings.start || 0, this.settings.end);
158
+    q.defer(this.trimAudio.bind(this), this.settings.start || 0, this.settings.end || null);
160
   }
159
   }
161
 
160
 
162
-  // Get the audio's duration for computing number of frames
163
-  q.defer(this.getDuration.bind(this));
164
-
165
   // Get the audio waveform data
161
   // Get the audio waveform data
166
   q.defer(this.getWaveform.bind(this));
162
   q.defer(this.getWaveform.bind(this));
167
 
163
 
184
       self.set("url", transports.getURL(self.id));
180
       self.set("url", transports.getURL(self.id));
185
     }
181
     }
186
 
182
 
183
+    logger.debug(self.profiler.print());
184
+
187
     return cb(err);
185
     return cb(err);
188
 
186
 
189
   });
187
   });
200
 
198
 
201
 // Convenience method for .set("status")
199
 // Convenience method for .set("status")
202
 Audiogram.prototype.status = function(value) {
200
 Audiogram.prototype.status = function(value) {
201
+  this.profiler.start(value);
203
   return this.set("status", value);
202
   return this.set("status", value);
204
 };
203
 };
205
 
204
 

+ 6 - 12
audiogram/initialize-canvas.js View File

1
 var fs = require("fs"),
1
 var fs = require("fs"),
2
     path = require("path"),
2
     path = require("path"),
3
     Canvas = require("canvas"),
3
     Canvas = require("canvas"),
4
-    getRenderer = require("../renderer/"),
5
-    serverSettings = require("../settings/");
4
+    getRenderer = require("../renderer/");
6
 
5
 
7
-function initializeCanvas(options, cb) {
6
+function initializeCanvas(theme, cb) {
8
 
7
 
9
   // Fonts pre-registered in bin/worker
8
   // Fonts pre-registered in bin/worker
9
+  var renderer = getRenderer(theme);
10
 
10
 
11
-  var canvas = new Canvas(options.width, options.height),
12
-      context = canvas.getContext("2d"),
13
-      renderer = getRenderer(context).update(options);
14
-
15
-  renderer.caption = options.caption;
16
-
17
-  if (!options.backgroundImage) {
11
+  if (!theme.backgroundImage) {
18
     return cb(null, renderer);
12
     return cb(null, renderer);
19
   }
13
   }
20
 
14
 
21
   // Load background image from file (done separately so renderer code can work in browser too)
15
   // Load background image from file (done separately so renderer code can work in browser too)
22
-  fs.readFile(path.join(__dirname, "..", "settings", "backgrounds", options.backgroundImage), function(err, raw){
16
+  fs.readFile(path.join(__dirname, "..", "settings", "backgrounds", theme.backgroundImage), function(err, raw){
23
 
17
 
24
     if (err) {
18
     if (err) {
25
       return cb(err);
19
       return cb(err);
27
 
21
 
28
     var bg = new Canvas.Image;
22
     var bg = new Canvas.Image;
29
     bg.src = raw;
23
     bg.src = raw;
30
-    renderer.backgroundImage = bg;
24
+    renderer.backgroundImage(bg);
31
 
25
 
32
     return cb(null, renderer);
26
     return cb(null, renderer);
33
 
27
 

+ 3 - 3
audiogram/trim.js View File

1
 var exec = require("child_process").exec,
1
 var exec = require("child_process").exec,
2
-    getDuration = require("./duration.js");
2
+    probe = require("../lib/probe.js");
3
 
3
 
4
 function trimAudio(options, cb) {
4
 function trimAudio(options, cb) {
5
 
5
 
6
   if (!options.endTime) {
6
   if (!options.endTime) {
7
 
7
 
8
-    return getDuration(options.origin, function(err, duration){
8
+    return probe(options.origin, function(err, data){
9
       if (err) {
9
       if (err) {
10
         return cb(err);
10
         return cb(err);
11
       }
11
       }
12
 
12
 
13
-      options.endTime = duration;
13
+      options.endTime = data.duration;
14
       trimAudio(options, cb);
14
       trimAudio(options, cb);
15
 
15
 
16
     });
16
     });

+ 64 - 37
audiogram/waveform.js View File

1
-var waveform = require("waveform"),
2
-    d3 = require("d3");
1
+var probe = require("../lib/probe.js"),
2
+    d3 = require("d3"),
3
+    pcmStream = require("../lib/pcm.js");
3
 
4
 
4
 function getWaveform(filename, options, cb) {
5
 function getWaveform(filename, options, cb) {
5
 
6
 
6
-  var numSamples = options.numFrames * options.samplesPerFrame;
7
+  var stream = pcmStream(filename, {
8
+        channels: options.channels
9
+      }),
10
+      samples = [];
7
 
11
 
8
-  var waveformOptions = {
9
-    "scan": false,
10
-    "waveformjs": "-",
11
-    "wjs-width": numSamples,
12
-    "wjs-precision": 2,
13
-    "wjs-plain": true,
14
-    "encoding": "utf8"
15
-  };
12
+  stream.on("data",function(sample, channel){
16
 
13
 
17
-  waveform(filename, waveformOptions, function(err, buf) {
18
-
19
-    if (err) {
20
-      return cb(err);
14
+    // Average multiple channels
15
+    if (channel > 0) {
16
+      samples[samples.length - 1] = ((samples[samples.length - 1] * channel) + sample) / (channel + 1);
17
+    } else {
18
+      samples.push(sample);
21
     }
19
     }
22
 
20
 
23
-    cb(null, processWaveform(JSON.parse(buf)));
21
+  });
22
+
23
+  stream.on("error", cb);
24
 
24
 
25
+  stream.on("end", function(output){
26
+    var processed = processSamples(samples, options.numFrames, options.samplesPerFrame);
27
+    return cb(null, processed);
25
   });
28
   });
26
 
29
 
27
-  // Slice one-dimensional waveform data into array of arrays, one array per frame
28
-  function processWaveform(waveformData) {
30
+}
31
+
32
+function processSamples(samples, numFrames, samplesPerFrame) {
29
 
33
 
30
-    var max = -Infinity,
31
-        maxFrame;
34
+  // TODO spread out slop across frames
35
+  var perFrame = Math.floor(samples.length / numFrames),
36
+      perPoint = Math.floor(perFrame / samplesPerFrame),
37
+      range = d3.range(samplesPerFrame),
38
+      maxFrame,
39
+      maxRms = maxMid = 0;
32
 
40
 
33
-    waveformData.forEach(function(d, i){
34
-      if (d > max) {
35
-        max = d;
36
-        maxFrame = Math.floor(i / options.samplesPerFrame);
37
-      }
38
-    });
41
+  var unadjusted = d3.range(numFrames).map(function(frame){
39
 
42
 
40
-    // Scale peaks to 1
41
-    var scaled = d3.scaleLinear()
42
-      .domain([0, max])
43
-      .range([0, 1]);
43
+    var frameSamples = samples.slice(frame * perFrame, (frame + 1) * perFrame),
44
+        points = range.map(function(point){
44
 
45
 
45
-    var waveformFrames = d3.range(options.numFrames).map(function getFrame(frameNumber) {
46
+          var pointSamples = frameSamples.slice(point * perPoint, (point + 1) * perPoint),
47
+              midpoint = pointSamples[Math.floor(pointSamples.length / 2)];
46
 
48
 
47
-      return waveformData.slice(options.samplesPerFrame * frameNumber, options.samplesPerFrame * (frameNumber + 1)).map(scaled);
49
+          var rms = Math.sqrt(d3.sum(pointSamples.map(function(d){
50
+            return d * d;
51
+          })) / perPoint);
48
 
52
 
49
-    });
53
+          if (rms > maxRms) {
54
+            maxRms = rms;
55
+            maxFrame = frame;
56
+          }
57
+
58
+          if (Math.abs(midpoint) > maxMid) {
59
+            maxMid = Math.abs(midpoint);
60
+          }
50
 
61
 
51
-    // Set the first and last frame's waveforms to something peak-y for better thumbnails
52
-    waveformFrames[0] = waveformFrames[waveformFrames.length - 1] = waveformFrames[maxFrame];
62
+          // Min value, max value, and midpoint value
63
+          return [rms, midpoint];
64
+
65
+        });
66
+
67
+    return points;
68
+
69
+  });
70
+
71
+  var adjusted = unadjusted.map(function(frame){
72
+    return frame.map(function(point){
73
+      return [
74
+        point[0] / maxRms,
75
+        point[1] / maxMid
76
+      ];
77
+    });
78
+  });
53
 
79
 
54
-    return waveformFrames;
80
+  // Make first and last frame peaky
81
+  adjusted[0] = adjusted[numFrames - 1] = adjusted[maxFrame];
55
 
82
 
56
-  }
83
+  return adjusted;
57
 
84
 
58
 }
85
 }
59
 
86
 

+ 3 - 0
bin/server View File

1
 #!/usr/bin/env node
1
 #!/usr/bin/env node
2
 
2
 
3
+// Require settings first, for validation
4
+require("../lib/settings/");
5
+
3
 var dotenv = require("dotenv").config({silent: true}),
6
 var dotenv = require("dotenv").config({silent: true}),
4
     logger = require("../lib/logger/"),
7
     logger = require("../lib/logger/"),
5
     server = require("../server/");
8
     server = require("../server/");

+ 11 - 5
bin/worker View File

1
 #!/usr/bin/env node
1
 #!/usr/bin/env node
2
+
3
+// Require settings first, for validation
4
+require("../lib/settings/");
5
+
2
 var dotenv = require("dotenv").config({silent: true}),
6
 var dotenv = require("dotenv").config({silent: true}),
3
     Audiogram = require("../audiogram/"),
7
     Audiogram = require("../audiogram/"),
4
     transports = require("../lib/transports/");
8
     transports = require("../lib/transports/");
10
 
14
 
11
 function pluckJob(){
15
 function pluckJob(){
12
 
16
 
13
-  transports.getJob(function(err, settings){
17
+  transports.getJob(function(err, job){
14
 
18
 
15
     if (err) {
19
     if (err) {
16
       throw err;
20
       throw err;
17
     }
21
     }
18
 
22
 
19
-    if (settings) {
23
+    if (job) {
20
 
24
 
21
-      return render(settings);
25
+      return render(job);
22
 
26
 
23
     }
27
     }
24
 
28
 
28
 
32
 
29
 }
33
 }
30
 
34
 
31
-function render(settings) {
35
+function render(job) {
36
+
37
+  var audiogram = new Audiogram(job.id);
32
 
38
 
33
-  var audiogram = new Audiogram(settings);
39
+  audiogram.settings = job;
34
 
40
 
35
   audiogram.render(function(err){
41
   audiogram.render(function(err){
36
 
42
 

+ 27 - 13
client/index.js View File

6
 
6
 
7
 d3.json("/settings/themes.json", function(err, themes){
7
 d3.json("/settings/themes.json", function(err, themes){
8
 
8
 
9
-  if (err) {
10
-    throw err;
9
+  var errorMessage;
10
+
11
+  // Themes are missing or invalid
12
+  if (err || !d3.keys(themes).filter(function(d){ return d !== "default"; }).length) {
13
+    if (err instanceof SyntaxError) {
14
+      errorMessage = "Error in settings/themes.json:<br/><code>" + err.toString() + "</code>";
15
+    } else if (err instanceof ProgressEvent) {
16
+      errorMessage = "Error: no settings/themes.json.";
17
+    } else if (err) {
18
+      errorMessage = "Error: couldn't load settings/themes.json.";
19
+    } else {
20
+      errorMessage = "No themes found in settings/themes.json.";
21
+    }
22
+    d3.select("#loading-bars").remove();
23
+    d3.select("#loading-message").html(errorMessage);
24
+    if (err) {
25
+      throw err;
26
+    }
27
+    return;
11
   }
28
   }
12
 
29
 
13
   for (var key in themes) {
30
   for (var key in themes) {
45
 
62
 
46
   var formData = new FormData();
63
   var formData = new FormData();
47
 
64
 
48
-  var settings = $.extend({}, theme, {
49
-    caption: caption,
50
-    start: selection.start,
51
-    end: selection.end
52
-  });
53
-
54
-  delete settings.backgroundImageFile;
55
-
56
   formData.append("audio", file);
65
   formData.append("audio", file);
57
-  formData.append("settings", JSON.stringify(settings));
66
+  if (selection.start || selection.end) {
67
+    formData.append("start", selection.start);
68
+    formData.append("end", selection.end);
69
+  }
70
+  formData.append("theme", JSON.stringify($.extend({}, theme, { backgroundImageFile: null })));
71
+  formData.append("caption", caption);
58
 
72
 
59
   setClass("loading");
73
   setClass("loading");
60
   d3.select("#loading-message").text("Uploading audio...");
74
   d3.select("#loading-message").text("Uploading audio...");
266
       return "Downloading audio for processing";
280
       return "Downloading audio for processing";
267
     case "trim":
281
     case "trim":
268
       return "Trimming audio";
282
       return "Trimming audio";
269
-    case "duration":
270
-      return "Checking duration";
283
+    case "probing":
284
+      return "Probing audio file";
271
     case "waveform":
285
     case "waveform":
272
       return "Analyzing waveform";
286
       return "Analyzing waveform";
273
     case "renderer":
287
     case "renderer":

+ 11 - 6
client/preview.js View File

2
     audio = require("./audio.js"),
2
     audio = require("./audio.js"),
3
     video = require("./video.js"),
3
     video = require("./video.js"),
4
     minimap = require("./minimap.js"),
4
     minimap = require("./minimap.js"),
5
+    sampleWave = require("./sample-wave.js"),
6
+    getRenderer = require("../renderer/"),
5
     getWaveform = require("./waveform.js");
7
     getWaveform = require("./waveform.js");
6
 
8
 
7
 var context = d3.select("canvas").node().getContext("2d");
9
 var context = d3.select("canvas").node().getContext("2d");
8
 
10
 
9
-var renderer = require("../renderer/")(context);
10
-
11
 var theme,
11
 var theme,
12
     caption,
12
     caption,
13
     file,
13
     file,
74
 
74
 
75
   video.kill();
75
   video.kill();
76
 
76
 
77
-  renderer.update(theme);
78
-  renderer.caption = caption;
79
-  renderer.backgroundImage = theme.backgroundImageFile || null;
80
-  renderer.drawFrame(0);
77
+  var renderer = getRenderer(theme);
78
+
79
+  renderer.backgroundImage(theme.backgroundImageFile || null);
80
+
81
+  renderer.drawFrame(context, {
82
+    caption: caption,
83
+    waveform: sampleWave,
84
+    frame: 0
85
+  });
81
 
86
 
82
 }
87
 }
83
 
88
 

File diff suppressed because it is too large
+ 1 - 0
client/sample-wave.js


+ 1 - 0
client/waveform.js View File

38
   var fileReader = new FileReader();
38
   var fileReader = new FileReader();
39
 
39
 
40
   var close = function(err, data) {
40
   var close = function(err, data) {
41
+    console.warn(err);
41
     ctx.close();
42
     ctx.close();
42
     cb(err, data);
43
     cb(err, data);
43
   };
44
   };

+ 7 - 4
editor/index.html View File

4
     <title>Audiogram</title>
4
     <title>Audiogram</title>
5
     <meta charset="utf-8" />
5
     <meta charset="utf-8" />
6
     <meta name="viewport" content="width=device-width, initial-scale=1">
6
     <meta name="viewport" content="width=device-width, initial-scale=1">
7
-    <link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,300,600" rel="stylesheet" type="text/css">
8
     <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css">
7
     <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css">
9
-    <link href="css/base.css" rel="stylesheet" type="text/css">
10
-    <link href="css/editor.css" rel="stylesheet" type="text/css">
8
+    <link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,300,600" rel="stylesheet" type="text/css">
9
+    <link href="/css/base.css" rel="stylesheet" type="text/css">
10
+    <link href="/css/editor.css" rel="stylesheet" type="text/css">
11
+    <link href="/fonts/fonts.css" rel="stylesheet" type="text/css">
11
   </head>
12
   </head>
12
   <body class="loading">
13
   <body class="loading">
13
     <div class="container">
14
     <div class="container">
89
         </div>
90
         </div>
90
       </div><!-- #loaded -->
91
       </div><!-- #loaded -->
91
     </div><!-- .container -->
92
     </div><!-- .container -->
92
-    <script src="js/bundle.js"></script>
93
+    <script src="/js/bundle.js"></script>
94
+    <!-- Force load custom fonts -->
95
+    <script src="/fonts/fonts.js"></script>
93
   </body>
96
   </body>
94
 </html>
97
 </html>

+ 55 - 0
lib/pcm.js View File

1
+var spawn = require("child_process").spawn,
2
+    stream = require("stream");
3
+
4
+// Based on https://github.com/jhurliman/node-pcm
5
+// Modified to respect channels, use fewer vars, and return a stream
6
+module.exports = function(filename, options) {
7
+  var sampleStream = new stream.Stream(),
8
+      channels = 2,
9
+      output = "",
10
+      channel = 0,
11
+      oddByte;
12
+
13
+  sampleStream.readable = true;
14
+
15
+  if (options && options.channels) channels = options.channels;
16
+
17
+  var args = ["-i", filename, "-f", "s16le", "-ac", channels, "-acodec", "pcm_s16le"];
18
+
19
+  if (options && options.sampleRate) {
20
+    args.push("-ar", options.sampleRate);
21
+  }
22
+
23
+  args.push("-y", "pipe:1");
24
+
25
+  var spawned = spawn("ffmpeg", args);
26
+
27
+  spawned.stdout.on("data", function(data) {
28
+
29
+    var len = data.length;
30
+
31
+    if (oddByte != null) {
32
+      sampleStream.emit("data", ((data.readInt8(i++, true) << 8) | oddByte) / 32767.0, channel);
33
+      channel = ++channel % channels;
34
+    }
35
+
36
+    for (var i = 0; i < len; i += 2) {
37
+      sampleStream.emit("data", data.readInt16LE(i, true) / 32767.0, channel);
38
+      channel = ++channel % channels;
39
+    }
40
+
41
+    oddByte = (i < len) ? data.readUInt8(i, true) : null;
42
+
43
+  });
44
+
45
+  spawned.stderr.on("data", function(data) {
46
+    output += data.toString();
47
+  });
48
+
49
+  spawned.stderr.on("end", function() {
50
+    sampleStream.emit(oddByte !== undefined ? "end" : "error", output);
51
+  });
52
+
53
+  return sampleStream;
54
+
55
+};

+ 43 - 0
lib/probe.js View File

1
+var probe = require("node-ffprobe");
2
+
3
+module.exports = function(filename, cb){
4
+
5
+  probe(filename, function(err, probeData) {
6
+
7
+    if (err) {
8
+      return cb(err);
9
+    }
10
+
11
+    var duration = getProperty(probeData, "duration"),
12
+        channels = getProperty(probeData, "channels");
13
+
14
+    if (!duration || duration === "N/A" || !(duration > 0)) {
15
+      return cb("Couldn't probe audio duration.");
16
+    }
17
+
18
+    if (typeof channels !== "number" || channels < 1 || channels > 2) {
19
+      return cb("Couldn't detect mono/stereo channels");
20
+    }
21
+
22
+    return cb(null, {
23
+      duration: duration,
24
+      channels: channels
25
+    });
26
+
27
+  });
28
+
29
+};
30
+
31
+function getProperty(probeData, property) {
32
+
33
+  if (probeData.format && probeData.format[property]) {
34
+    return probeData.format[property];
35
+  }
36
+
37
+  if (Array.isArray(probeData.streams) && probeData.streams.length && probeData.streams[0][property]) {
38
+    return probeData.streams[0][property];
39
+  }
40
+
41
+  return null;
42
+
43
+}

+ 42 - 0
lib/profiler.js View File

1
+function Profiler() {
2
+  this._times = {};
3
+  return this;
4
+};
5
+
6
+Profiler.prototype.start = function(key) {
7
+  this.end(this._current);
8
+  this._current = key;
9
+  this._times[this._current] = { start: Date.now() };
10
+  return this;
11
+};
12
+
13
+Profiler.prototype.size = function(size) {
14
+  if (!arguments.length) return this._size;
15
+  this._size = size;
16
+  return this;
17
+};
18
+
19
+Profiler.prototype.end = function(key) {
20
+  if (key in this._times) this._times[key].end = Date.now();
21
+  return this;
22
+};
23
+
24
+Profiler.prototype.print = function(size) {
25
+  var rows = [],
26
+      row;
27
+
28
+  this.end(this._current);
29
+
30
+  for (var key in this._times) {
31
+    row = { key: key, time: this._times[key].end - this._times[key].start };
32
+    if (this._size) row.per = row.time / this._size;
33
+    rows.push(row);
34
+  }
35
+
36
+  return rows.map(function(row){
37
+    return row.key + ": " + Math.round(row.time) + "ms total" + (row.per ? ", " + Math.round(row.per) + "ms per" : "");
38
+  }).join("\n");
39
+
40
+};
41
+
42
+module.exports = Profiler;

+ 1 - 1
lib/register-fonts.js View File

1
-var fonts = require("../settings/").fonts,
1
+var fonts = require("./settings/").fonts,
2
     _ = require("underscore"),
2
     _ = require("underscore"),
3
     Canvas = require("canvas");
3
     Canvas = require("canvas");
4
 
4
 

+ 15 - 0
lib/settings/index.js View File

1
+var _ = require("underscore"),
2
+    path = require("path"),
3
+    fs = require("fs"),
4
+    load = require("./load.js");
5
+
6
+var settings = load("settings/index.js"),
7
+    themes = load("settings/themes.json");
8
+
9
+// Validate settings
10
+settings = require("./validate-settings.js")(settings);
11
+
12
+// Validate themes
13
+themes = require("./validate-themes.js")(themes);
14
+
15
+module.exports = settings;

+ 26 - 0
lib/settings/load.js View File

1
+var path = require("path");
2
+
3
+// Try to load module
4
+module.exports = function(filename) {
5
+
6
+  var loaded;
7
+
8
+  try {
9
+    loaded = require(path.join(__dirname, "..", "..", filename));
10
+    if (!loaded) {
11
+      throw new Error("Couldn't load contents of " + filename + ".");
12
+    }
13
+  } catch(e) {
14
+    if (e.code === "MODULE_NOT_FOUND") {
15
+      throw new Error("No " + filename + " file found.");
16
+    } else if (e instanceof SyntaxError) {
17
+      console.warn("Error parsing " + filename + ".");
18
+      throw e;
19
+    } else {
20
+      throw e;
21
+    }
22
+  }
23
+
24
+  return loaded;
25
+
26
+}

+ 90 - 0
lib/settings/validate-settings.js View File

1
+var fs = require("fs"),
2
+    path = require("path"),
3
+    mkdirp = require("mkdirp"),
4
+    _ = require("underscore");
5
+
6
+module.exports = function(settings) {
7
+
8
+  // Check paths
9
+  if (typeof settings.workingDirectory !== "string") {
10
+    throw new Error("settings.workingDirectory is required. See https://github.com/nypublicradio/audiogram/blob/master/SERVER.md#all-settings for details.");
11
+  }
12
+
13
+  if (typeof settings.storagePath !== "string" && typeof settings.s3Bucket !== "string") {
14
+    throw new Error("settings.storagePath or settings.s3Bucket is required. See https://github.com/nypublicradio/audiogram/blob/master/SERVER.md#all-settings for details.");
15
+  }
16
+
17
+  // TODO normalize workingDirectory, s3Bucket, and storagePath
18
+  settings.workingDirectory = normalize(settings.workingDirectory);
19
+  tryToCreate(settings.workingDirectory, "workingDirectory");
20
+
21
+  if (typeof settings.s3Bucket === "string") {
22
+
23
+    // Remove s3:// and trailing slash, bucket name only
24
+    settings.s3Bucket = settings.s3Bucket.replace(/^(s3[:]\/\/)|\/$/g, "");
25
+
26
+    if (typeof settings.storagePath === "string") {
27
+
28
+      // No leading slash, one trailing slash
29
+      settings.storagePath = settings.storagePath.replace(/^\/|\/$/g, "");
30
+
31
+      if (settings.storagePath) {
32
+        settings.storagePath += "/";
33
+      }
34
+
35
+    } else {
36
+      settings.storagePath = "";
37
+    }
38
+
39
+  } else {
40
+    settings.storagePath = normalize(settings.storagePath);
41
+    tryToCreate(settings.storagePath, "storagePath");
42
+  }
43
+
44
+  // Check maxUploadSize
45
+  if ("maxUploadSize" in settings && typeof settings.maxUploadSize !== "number") {
46
+    throw new TypeError("settings.maxUploadSize must be an integer. See https://github.com/nypublicradio/audiogram/blob/master/SERVER.md#all-settings for details.");
47
+  }
48
+
49
+  // Check fonts
50
+  if ("fonts" in settings) {
51
+    if (!Array.isArray(settings.fonts)) {
52
+      throw new TypeError("settings.fonts must be an array of fonts. See https://github.com/nypublicradio/audiogram/blob/master/THEMES.md#a-note-about-fonts for details.")
53
+    }
54
+
55
+    settings.fonts.forEach(function(font){
56
+
57
+      if (!font || typeof font.family !== "string" || typeof font.file !== "string") {
58
+        return console.warn("Custom font in settings.fonts missing a 'family' or 'file'. See https://github.com/nypublicradio/audiogram/blob/master/THEMES.md#a-note-about-fonts for details.");
59
+      }
60
+
61
+      font.file = normalize(font.file);
62
+
63
+      try {
64
+        fs.accessSync(font.file);
65
+      } catch(e) {
66
+        return console.warn("Font file " + font.file + " does not exist or is not readable.");
67
+      }
68
+
69
+    });
70
+  }
71
+
72
+  return settings;
73
+
74
+};
75
+
76
+function tryToCreate(dir, key) {
77
+  try {
78
+    mkdirp.sync(dir);
79
+    fs.accessSync(dir);
80
+  } catch(e) {
81
+    throw new Error("Could not create/access settings." + key + " at " + dir + "");
82
+  }
83
+}
84
+
85
+function normalize(p) {
86
+  if (path.isAbsolute(p)) {
87
+    return p;
88
+  }
89
+  return path.join(__dirname, "..", "..", p);
90
+}

+ 49 - 0
lib/settings/validate-themes.js View File

1
+var path = require("path"),
2
+    _ = require("underscore"),
3
+    fs = require("fs");
4
+
5
+module.exports = function(themes) {
6
+
7
+  if (!themes || !_.keys(_.omit(themes, "default")).length) {
8
+    return console.warn("No themes found in settings/themes.json. See https://github.com/nypublicradio/audiogram/blob/master/THEMES.md for details.");
9
+  }
10
+
11
+  for (var key in themes) {
12
+    if (key !== "default") {
13
+      validateTheme(key, _.extend({}, themes.default || {}, themes[key]));
14
+    }
15
+  }
16
+
17
+  return themes;
18
+
19
+};
20
+
21
+function validateTheme(name, options) {
22
+
23
+  var fullBackgroundImagePath;
24
+
25
+  if (!options || !_.keys(options).length) {
26
+    return console.warn("Theme '" + name + "' is not defined.");
27
+  }
28
+
29
+  ["width", "height", "framesPerSecond", "samplesPerFrame"].forEach(function(key){
30
+    if (typeof options[key] !== "number") {
31
+      console.warn("The required property '" + key +"' is missing from theme '" + name + "' or invalid.");
32
+    }
33
+  });
34
+
35
+  if (typeof options.backgroundImage === "string") {
36
+
37
+    fullBackgroundImagePath = options.backgroundImage;
38
+
39
+    if (!path.isAbsolute(options.backgroundImage)) {
40
+      fullBackgroundImagePath = path.join(__dirname, "..", "..", "settings/backgrounds/", options.backgroundImage);
41
+    }
42
+
43
+    try {
44
+      fs.accessSync(fullBackgroundImagePath);
45
+    } catch(e) {
46
+      console.warn("Background image for theme '" + name + "' (" + fullBackgroundImagePath + ") does not exist or is not readable.");
47
+    }
48
+  }
49
+}

+ 1 - 1
lib/transports/index.js View File

1
 var extend = require("underscore").extend,
1
 var extend = require("underscore").extend,
2
-    serverSettings = require("../../settings/"),
2
+    serverSettings = require("../settings/"),
3
     s3 = require("./s3")(serverSettings),
3
     s3 = require("./s3")(serverSettings),
4
     redis = require("./redis")(serverSettings);
4
     redis = require("./redis")(serverSettings);
5
 
5
 

+ 0 - 6
lib/transports/s3/fake.js View File

5
 
5
 
6
 module.exports = function(storagePath) {
6
 module.exports = function(storagePath) {
7
 
7
 
8
-  storagePath = storagePath || "";
9
-
10
-  if (!path.isAbsolute(storagePath)) {
11
-    storagePath = path.join(__dirname, "..", "..", "..", storagePath);
12
-  }
13
-
14
   function copy(source, destination, cb) {
8
   function copy(source, destination, cb) {
15
 
9
 
16
     if (!path.isAbsolute(source)) {
10
     if (!path.isAbsolute(source)) {

+ 0 - 15
lib/transports/s3/remote.js View File

3
 
3
 
4
 module.exports = function(bucket, storagePath) {
4
 module.exports = function(bucket, storagePath) {
5
 
5
 
6
-  storagePath = storagePath || "";
7
-
8
-  // Normalize slashes in path
9
-  if (storagePath.length) {
10
-    storagePath = storagePath.replace(/^\/|\/$/g, "") + "/";
11
-  }
12
-
13
-  // Catch single slash path
14
-  if (storagePath === "/") {
15
-    storagePath = "";
16
-  }
17
-
18
-  // Remove s3:// just in case
19
-  bucket = bucket.replace(/^(s3[:]\/\/)|\/$/g, "");
20
-
21
   var s3 = new AWS.S3({ params: { Bucket: bucket } });
6
   var s3 = new AWS.S3({ params: { Bucket: bucket } });
22
 
7
 
23
   // Test credentials
8
   // Test credentials

+ 3 - 4
package.json View File

1
 {
1
 {
2
   "name": "audiogram",
2
   "name": "audiogram",
3
-  "version": "0.9.4",
3
+  "version": "0.9.5",
4
   "description": "Turn audio into a shareable video.",
4
   "description": "Turn audio into a shareable video.",
5
   "main": "index.js",
5
   "main": "index.js",
6
   "scripts": {
6
   "scripts": {
10
     "rebuild": "npm run postinstall",
10
     "rebuild": "npm run postinstall",
11
     "watch": "mkdir -p editor/js && watchify client/index.js -o editor/js/bundle.js",
11
     "watch": "mkdir -p editor/js && watchify client/index.js -o editor/js/bundle.js",
12
     "debug": "npm run postinstall && DEBUG=1 bin/server",
12
     "debug": "npm run postinstall && DEBUG=1 bin/server",
13
-    "test": "tape 'test/**/*-test.js'"
13
+    "test": "rm -rf test/tmp && tape 'test/**/*-test.js'"
14
   },
14
   },
15
   "repository": {
15
   "repository": {
16
     "type": "git",
16
     "type": "git",
25
   "dependencies": {
25
   "dependencies": {
26
     "aws-sdk": "^2.2.39",
26
     "aws-sdk": "^2.2.39",
27
     "browserify": "^13.0.0",
27
     "browserify": "^13.0.0",
28
+    "canvas": "git+https://github.com/chearon/node-canvas.git#12971f64a66b",
28
     "compression": "^1.6.1",
29
     "compression": "^1.6.1",
29
     "d3": "^4.1.1",
30
     "d3": "^4.1.1",
30
     "dotenv": "^2.0.0",
31
     "dotenv": "^2.0.0",
39
     "rimraf": "^2.5.0",
40
     "rimraf": "^2.5.0",
40
     "smartquotes": "^1.0.0",
41
     "smartquotes": "^1.0.0",
41
     "underscore": "^1.8.3",
42
     "underscore": "^1.8.3",
42
-    "canvas": "git+https://github.com/chearon/node-canvas.git#b62dd3a9fa",
43
-    "waveform": "git+https://github.com/veltman/node-waveform.git#8b1a22f109",
44
     "webaudio-peaks": "0.0.5",
43
     "webaudio-peaks": "0.0.5",
45
     "winston": "^2.2.0"
44
     "winston": "^2.2.0"
46
   },
45
   },

+ 39 - 31
renderer/index.js View File

1
 var d3 = require("d3"),
1
 var d3 = require("d3"),
2
     patterns = require("./patterns.js"),
2
     patterns = require("./patterns.js"),
3
-    sample = require("./sample-wave.js"),
4
     textWrapper = require("./text-wrapper.js");
3
     textWrapper = require("./text-wrapper.js");
5
 
4
 
6
-module.exports = function(context) {
5
+module.exports = function(t) {
7
 
6
 
8
-  context.patternQuality = "best";
7
+  var renderer = {},
8
+      backgroundImage,
9
+      wrapText,
10
+      theme;
9
 
11
 
10
-  var renderer = {};
12
+  renderer.backgroundImage = function(_) {
13
+    if (!arguments.length) return backgroundImage;
14
+    backgroundImage = _;
15
+    return this;
16
+  };
11
 
17
 
12
-  renderer.context = context;
18
+  renderer.theme = function(_) {
19
+    if (!arguments.length) return theme;
13
 
20
 
14
-  renderer.update = function(options) {
21
+    theme = _;
15
 
22
 
16
-    // TODO cleaner defaults
17
-    options.backgroundColor = options.backgroundColor || "#fff";
18
-    options.waveColor = options.waveColor || options.foregroundColor || "#000";
19
-    options.captionColor = options.captionColor || options.foregroundColor || "#000";
23
+    // Default colors
24
+    theme.backgroundColor = theme.backgroundColor || "#fff";
25
+    theme.waveColor = theme.waveColor || theme.foregroundColor || "#000";
26
+    theme.captionColor = theme.captionColor || theme.foregroundColor || "#000";
20
 
27
 
21
-    if (typeof options.waveTop !== "number") options.waveTop = 0;
22
-    if (typeof options.waveBottom !== "number") options.waveBottom = options.height;
23
-    if (typeof options.waveLeft !== "number") options.waveLeft = 0;
24
-    if (typeof options.waveRight !== "number") options.waveRight = options.width;
28
+    // Default wave dimensions
29
+    if (typeof theme.waveTop !== "number") theme.waveTop = 0;
30
+    if (typeof theme.waveBottom !== "number") theme.waveBottom = theme.height;
31
+    if (typeof theme.waveLeft !== "number") theme.waveLeft = 0;
32
+    if (typeof theme.waveRight !== "number") theme.waveRight = theme.width;
25
 
33
 
26
-    this.wrapText = textWrapper(context, options);
27
-    this.options = options;
28
-    this.waveform = options.waveform || [sample.slice(0, options.samplesPerFrame)];
29
-    return this;
30
-  };
34
+    wrapText = textWrapper(theme);
31
 
35
 
32
-  // Get the waveform data for this frame
33
-  renderer.getWaveform = function(frameNumber) {
34
-    return this.waveform[Math.min(this.waveform.length - 1, frameNumber)];
36
+    return this;
35
   };
37
   };
36
 
38
 
37
   // Draw the frame
39
   // Draw the frame
38
-  renderer.drawFrame = function(frameNumber) {
40
+  renderer.drawFrame = function(context, options){
41
+
42
+    context.patternQuality = "best";
39
 
43
 
40
     // Draw the background image and/or background color
44
     // Draw the background image and/or background color
41
-    context.clearRect(0, 0, this.options.width, this.options.height);
45
+    context.clearRect(0, 0, theme.width, theme.height);
42
 
46
 
43
-    context.fillStyle = this.options.backgroundColor;
44
-    context.fillRect(0, 0, this.options.width, this.options.height);
47
+    context.fillStyle = theme.backgroundColor;
48
+    context.fillRect(0, 0, theme.width, theme.height);
45
 
49
 
46
-    if (this.backgroundImage) {
47
-      context.drawImage(this.backgroundImage, 0, 0, this.options.width, this.options.height);
50
+    if (backgroundImage) {
51
+      context.drawImage(backgroundImage, 0, 0, theme.width, theme.height);
48
     }
52
     }
49
 
53
 
50
-    patterns[this.options.pattern || "wave"](context, this.getWaveform(frameNumber), this.options);
54
+    patterns[theme.pattern || "wave"](context, options.waveform, theme);
51
 
55
 
52
     // Write the caption
56
     // Write the caption
53
-    if (this.caption) {
54
-      this.wrapText(this.caption);
57
+    if (options.caption) {
58
+      wrapText(context, options.caption);
55
     }
59
     }
56
 
60
 
57
     return this;
61
     return this;
58
 
62
 
59
   };
63
   };
60
 
64
 
65
+  if (t) {
66
+    renderer.theme(t);
67
+  }
68
+
61
   return renderer;
69
   return renderer;
62
 
70
 
63
 }
71
 }

+ 56 - 15
renderer/patterns.js View File

1
 var d3 = require("d3");
1
 var d3 = require("d3");
2
 
2
 
3
 module.exports = {
3
 module.exports = {
4
-  wave: curve(d3.curveCardinal.tension(0.1)),
5
-  pixel: curve(d3.curveStep),
4
+  wave: filledPath(d3.curveCardinal.tension(0.1)),
5
+  pixel: filledPath(d3.curveStep),
6
   roundBars: bars(true),
6
   roundBars: bars(true),
7
   bars: bars(),
7
   bars: bars(),
8
   bricks: bricks(),
8
   bricks: bricks(),
9
-  equalizer: bricks(true)
9
+  equalizer: bricks(true),
10
+  line: strokedPath(),
11
+  curve: strokedPath(d3.curveCardinal.tension(0.1))
10
 };
12
 };
11
 
13
 
12
-function curve(interpolator) {
14
+function filledPath(interpolator) {
13
 
15
 
14
   return function drawCurve(context, data, options) {
16
   return function drawCurve(context, data, options) {
15
 
17
 
18
     context.lineWidth = 3;
20
     context.lineWidth = 3;
19
 
21
 
20
     var line = d3.line()
22
     var line = d3.line()
21
-      .curve(interpolator)
22
       .context(context);
23
       .context(context);
23
 
24
 
25
+    if (interpolator) {
26
+      line.curve(interpolator);
27
+    }
28
+
24
     var waveHeight = options.waveBottom - options.waveTop;
29
     var waveHeight = options.waveBottom - options.waveTop;
25
 
30
 
26
     var baseline = options.waveTop + waveHeight / 2;
31
     var baseline = options.waveTop + waveHeight / 2;
36
 
41
 
37
     var top = data.map(function(d,i){
42
     var top = data.map(function(d,i){
38
 
43
 
39
-      return [x(i), baseline - height(d)];
44
+      return [x(i), baseline - height(d[0])];
40
 
45
 
41
     });
46
     });
42
 
47
 
43
     var bottom = data.map(function(d,i){
48
     var bottom = data.map(function(d,i){
44
 
49
 
45
-      return [x(i), baseline + height(d)];
50
+      return [x(i), baseline + height(d[0])];
46
 
51
 
47
     }).reverse();
52
     }).reverse();
48
 
53
 
90
 
95
 
91
     data.forEach(function(val, i){
96
     data.forEach(function(val, i){
92
 
97
 
93
-      var h = height(val),
94
-          x = barX(i);
98
+      var h = height(val[0]) * 2,
99
+          x = barX(i),
100
+          y = baseline - height(val[0]);
95
 
101
 
96
-      context.fillRect(x, baseline - h, barWidth, h * 2);
102
+      context.fillRect(x, y, barWidth, h);
97
 
103
 
98
       if (round) {
104
       if (round) {
99
         context.beginPath();
105
         context.beginPath();
100
-        context.arc(x + barWidth / 2, baseline - h, barWidth / 2, 0, 2 * Math.PI);
101
-        context.moveTo(x + barWidth / 2, baseline + h);
102
-        context.arc(x + barWidth / 2, baseline + h, barWidth / 2, 0, 2 * Math.PI);
106
+        context.arc(x + barWidth / 2, y, barWidth / 2, 0, 2 * Math.PI);
107
+        context.moveTo(x + barWidth / 2, y + h);
108
+        context.arc(x + barWidth / 2, y + h, barWidth / 2, 0, 2 * Math.PI);
103
         context.fill();
109
         context.fill();
104
       }
110
       }
105
 
111
 
109
 }
115
 }
110
 
116
 
111
 function bricks(rainbow) {
117
 function bricks(rainbow) {
112
-  return function (context, data, options) {
118
+  return function(context, data, options) {
113
 
119
 
114
     context.fillStyle = options.waveColor;
120
     context.fillStyle = options.waveColor;
115
 
121
 
132
 
138
 
133
     data.forEach(function(val, i){
139
     data.forEach(function(val, i){
134
 
140
 
135
-      var bricks = Math.max(1, Math.floor(height(val) / (brickHeight + brickGap))),
141
+      var bricks = Math.max(1, Math.floor(height(val[0]) / (brickHeight + brickGap))),
136
           x = barX(i);
142
           x = barX(i);
137
 
143
 
138
       d3.range(bricks).forEach(function(b){
144
       d3.range(bricks).forEach(function(b){
146
 
152
 
147
   };
153
   };
148
 }
154
 }
155
+
156
+function strokedPath(interpolator) {
157
+  return function(context, data, options) {
158
+
159
+    context.fillStyle = options.waveColor;
160
+    context.strokeStyle = options.waveColor;
161
+    context.lineWidth = 5;
162
+
163
+    var line = d3.line()
164
+      .context(context);
165
+
166
+    if (interpolator) {
167
+      line.curve(interpolator);
168
+    }
169
+
170
+    var x = d3.scalePoint()
171
+      .padding(0.1)
172
+      .domain(d3.range(data.length))
173
+      .range([options.waveLeft, options.waveRight]);
174
+
175
+    var y = d3.scaleLinear()
176
+      .domain([-1, 1])
177
+      .range([options.waveBottom, options.waveTop]);
178
+
179
+    var points = data.map(function(d, i){
180
+      return [x(i), y(d[1])];
181
+    });
182
+
183
+    // Fill waveform
184
+    context.beginPath();
185
+    line(points);
186
+    context.stroke();
187
+
188
+  }
189
+}

File diff suppressed because it is too large
+ 0 - 1
renderer/sample-wave.js


+ 14 - 14
renderer/text-wrapper.js View File

1
 var smartquotes = require("smartquotes").string;
1
 var smartquotes = require("smartquotes").string;
2
 
2
 
3
-module.exports = function(context, options) {
4
-
5
-  context.font = options.captionFont;
6
-  context.textBaseline = "top";
7
-  context.textAlign = options.captionAlign || "center";
3
+module.exports = function(theme) {
8
 
4
 
9
   // Do some typechecking
5
   // Do some typechecking
10
-  var left = ifNumeric(options.captionLeft, 0),
11
-      right = ifNumeric(options.captionRight, options.width),
12
-      bottom = ifNumeric(options.captionBottom, null),
13
-      top = ifNumeric(options.captionTop, null);
6
+  var left = ifNumeric(theme.captionLeft, 0),
7
+      right = ifNumeric(theme.captionRight, theme.width),
8
+      bottom = ifNumeric(theme.captionBottom, null),
9
+      top = ifNumeric(theme.captionTop, null);
14
 
10
 
15
   if (bottom === null && top === null) {
11
   if (bottom === null && top === null) {
16
     top = 0;
12
     top = 0;
18
 
14
 
19
   var captionWidth = right - left;
15
   var captionWidth = right - left;
20
 
16
 
21
-  return function(caption) {
17
+  return function(context, caption) {
22
 
18
 
23
     if (!caption) {
19
     if (!caption) {
24
       return;
20
       return;
28
         maxWidth = 0,
24
         maxWidth = 0,
29
         words = smartquotes(caption + "").trim().replace(/\s\s+/g, " \n").split(/ /g);
25
         words = smartquotes(caption + "").trim().replace(/\s\s+/g, " \n").split(/ /g);
30
 
26
 
27
+    context.font = theme.captionFont;
28
+    context.textBaseline = "top";
29
+    context.textAlign = theme.captionAlign || "center";
30
+
31
     // Check whether each word exceeds the width limit
31
     // Check whether each word exceeds the width limit
32
     // Wrap onto next line as needed
32
     // Wrap onto next line as needed
33
     words.forEach(function(word,i){
33
     words.forEach(function(word,i){
50
 
50
 
51
     });
51
     });
52
 
52
 
53
-    var totalHeight = lines.length * options.captionLineHeight + (lines.length - 1) * options.captionLineSpacing;
53
+    var totalHeight = lines.length * theme.captionLineHeight + (lines.length - 1) * theme.captionLineSpacing;
54
 
54
 
55
     // horizontal alignment
55
     // horizontal alignment
56
-    var x = options.captionAlign === "left" ? left : options.captionAlign === "right" ? right : (left + right) / 2;
56
+    var x = theme.captionAlign === "left" ? left : theme.captionAlign === "right" ? right : (left + right) / 2;
57
 
57
 
58
     // Vertical alignment
58
     // Vertical alignment
59
     var y;
59
     var y;
69
       y = top;
69
       y = top;
70
     }
70
     }
71
 
71
 
72
-    context.fillStyle = options.captionColor;
72
+    context.fillStyle = theme.captionColor;
73
     lines.forEach(function(line, i){
73
     lines.forEach(function(line, i){
74
-      context.fillText(line.join(" "), x, y + i * (options.captionLineHeight + options.captionLineSpacing));
74
+      context.fillText(line.join(" "), x, y + i * (theme.captionLineHeight + theme.captionLineSpacing));
75
     });
75
     });
76
 
76
 
77
  };
77
  };

+ 1 - 1
server/error.js View File

1
-var serverSettings = require("../settings/");
1
+var serverSettings = require("../lib/settings/");
2
 
2
 
3
 module.exports = function(err, req, res, next) {
3
 module.exports = function(err, req, res, next) {
4
 
4
 

+ 71 - 0
server/fonts.js View File

1
+var fonts = require("../lib/settings/").fonts || [];
2
+
3
+var bySlug = {};
4
+
5
+fonts.forEach(function(font, i){
6
+
7
+  var extension = "",
8
+      split = font.file.split(".");
9
+
10
+  // Use existing file extension if there is one
11
+  if (split.length > 1){
12
+    extension = "." + split.pop();
13
+  }
14
+
15
+  bySlug[font.slug = "custom-" + i + extension] = font;
16
+
17
+});
18
+
19
+// Send a stylesheet with custom fonts
20
+function sendCSS(req, res) {
21
+  res.set("Content-Type", "text/css")
22
+    .send(fonts.map(declaration).join("\n\n"));
23
+}
24
+
25
+// Send JS that forces all custom fonts to download upfront
26
+function sendJS(req, res) {
27
+  res.set("Content-Type", "application/javascript")
28
+    .send("(function(){\n\n" + fonts.map(shim).join("\n\n") + "\n\n})();");
29
+}
30
+
31
+// Send custom file by its slug
32
+function sendFont(req, res) {
33
+
34
+  var font = bySlug[req.params.font];
35
+
36
+  if (font && font.file) {
37
+    return res.sendFile(font.file);
38
+  }
39
+
40
+  res.status(404).send("Cannot GET " + req.baseUrl);
41
+
42
+}
43
+
44
+function declaration(font, i) {
45
+  return [
46
+    "@font-face {",
47
+    "  font-family: '" + font.family + "';",
48
+    "  src: url('/fonts/" + font.slug + "');",
49
+    font.weight ? "  font-weight: " + font.weight + ";" : "",
50
+    font.style ? "  font-style: " + font.style + ";" : "",
51
+    "}"
52
+  ].filter(function(d){ return d; }).join("\n");
53
+}
54
+
55
+function shim(font, i) {
56
+  return [
57
+    "  var font" + i + " = document.createElement(\"div\");",
58
+    "  font" + i + ".innerHTML = '.';",
59
+    "  font" + i + ".style.fontFamily = \"" + font.family + "\";",
60
+    font.weight ? "  font" + i + ".style.fontWeight = \"" + font.weight + "\";" : "",
61
+    font.style ? "  font" + i + ".style.fontStyle = \"" + font.style + "\";" : "",
62
+    "  document.body.appendChild(font" + i + ");",
63
+    "  setTimeout(function(){ font" + i + ".remove(); }, 0);"
64
+  ].filter(function(d){ return d; }).join("\n");
65
+}
66
+
67
+module.exports = {
68
+  css: sendCSS,
69
+  js: sendJS,
70
+  font: sendFont
71
+};

+ 11 - 10
server/index.js View File

10
 var logger = require("../lib/logger/"),
10
 var logger = require("../lib/logger/"),
11
     render = require("./render.js"),
11
     render = require("./render.js"),
12
     status = require("./status.js"),
12
     status = require("./status.js"),
13
+    fonts = require("./fonts.js"),
13
     errorHandlers = require("./error.js");
14
     errorHandlers = require("./error.js");
14
 
15
 
15
 // Settings
16
 // Settings
16
-var serverSettings = require("../settings/");
17
+var serverSettings = require("../lib/settings/");
17
 
18
 
18
 var app = express();
19
 var app = express();
19
 
20
 
43
   };
44
   };
44
 }
45
 }
45
 
46
 
46
-if (typeof serverSettings.workingDirectory !== "string") {
47
-  throw new TypeError("No workingDirectory set in settings/index.js");
48
-}
49
-
50
 // On submission, check upload, validate input, and start generating a video
47
 // On submission, check upload, validate input, and start generating a video
51
 app.post("/submit/", [multer(fileOptions).single("audio"), render.validate, render.route]);
48
 app.post("/submit/", [multer(fileOptions).single("audio"), render.validate, render.route]);
52
 
49
 
53
 // If not using S3, serve videos locally
50
 // If not using S3, serve videos locally
54
 if (!serverSettings.s3Bucket) {
51
 if (!serverSettings.s3Bucket) {
55
-  if (typeof serverSettings.storagePath !== "string") {
56
-    throw new TypeError("No storagePath set in settings/index.js");
57
-  }
58
-  var storagePath =  path.isAbsolute(serverSettings.storagePath) ? serverSettings.storagePath : path.join(__dirname, "..", serverSettings.storagePath);
59
-  app.use("/video/", express.static(path.join(storagePath, "video")));
52
+  app.use("/video/", express.static(path.join(serverSettings.storagePath, "video")));
53
+}
54
+
55
+// Serve custom fonts
56
+app.get("/fonts/fonts.css", fonts.css);
57
+app.get("/fonts/fonts.js", fonts.js);
58
+
59
+if (serverSettings.fonts) {
60
+  app.get("/fonts/:font", fonts.font);
60
 }
61
 }
61
 
62
 
62
 // Check the status of a current video
63
 // Check the status of a current video

+ 10 - 13
server/render.js View File

1
-var serverSettings = require("../settings/"),
1
+var serverSettings = require("../lib/settings/"),
2
     spawn = require("child_process").spawn,
2
     spawn = require("child_process").spawn,
3
     path = require("path"),
3
     path = require("path"),
4
     _ = require("underscore"),
4
     _ = require("underscore"),
5
-    Audiogram = require("../audiogram/"),
6
     logger = require("../lib/logger"),
5
     logger = require("../lib/logger"),
7
     transports = require("../lib/transports");
6
     transports = require("../lib/transports");
8
 
7
 
10
 
9
 
11
   try {
10
   try {
12
 
11
 
13
-    req.body.settings = JSON.parse(req.body.settings);
12
+    req.body.theme = JSON.parse(req.body.theme);
14
 
13
 
15
   } catch(e) {
14
   } catch(e) {
16
 
15
 
22
     return res.status(500).send("No valid audio received.");
21
     return res.status(500).send("No valid audio received.");
23
   }
22
   }
24
 
23
 
25
-  req.body.settings.id = req.file.destination.split(path.sep).pop();
26
-
27
   // Start at the beginning, or specified time
24
   // Start at the beginning, or specified time
28
-  if (req.body.settings.start) {
29
-    req.body.settings.start = +req.body.settings.start;
25
+  if (req.body.start) {
26
+    req.body.start = +req.body.start;
30
   }
27
   }
31
 
28
 
32
-  if (req.body.settings.end) {
33
-    req.body.settings.end = +req.body.settings.end;
29
+  if (req.body.end) {
30
+    req.body.end = +req.body.end;
34
   }
31
   }
35
 
32
 
36
   return next();
33
   return next();
39
 
36
 
40
 function route(req, res) {
37
 function route(req, res) {
41
 
38
 
42
-  var audiogram = new Audiogram(req.body.settings);
39
+  var id = req.file.destination.split(path.sep).pop();
43
 
40
 
44
-  transports.uploadAudio(audiogram.audioPath, "audio/" + audiogram.id,function(err) {
41
+  transports.uploadAudio(path.join(req.file.destination, "audio"), "audio/" + id,function(err) {
45
 
42
 
46
     if (err) {
43
     if (err) {
47
       throw err;
44
       throw err;
48
     }
45
     }
49
 
46
 
50
     // Queue up the job with a timestamp
47
     // Queue up the job with a timestamp
51
-    transports.addJob(_.extend({ created: (new Date()).getTime() }, req.body.settings));
48
+    transports.addJob(_.extend({ id: id, created: (new Date()).getTime() }, req.body));
52
 
49
 
53
-    res.json({ id: req.body.settings.id });
50
+    res.json({ id: id });
54
 
51
 
55
     // If there's no separate worker, spawn one right away
52
     // If there's no separate worker, spawn one right away
56
     if (!serverSettings.worker) {
53
     if (!serverSettings.worker) {

+ 1 - 2
server/status.js View File

9
     .await(function(err, jobs, hash) {
9
     .await(function(err, jobs, hash) {
10
 
10
 
11
       if (err) {
11
       if (err) {
12
-        res.status(500).json("Unknown error.");
13
         throw err;
12
         throw err;
14
       }
13
       }
15
 
14
 
30
         hash = { status: "unknown" };
29
         hash = { status: "unknown" };
31
       }
30
       }
32
 
31
 
33
-      ["duration","numFrames","framesComplete"].forEach(function(key) {
32
+      ["numFrames", "framesComplete"].forEach(function(key) {
34
         if (key in hash) {
33
         if (key in hash) {
35
           hash[key] = +hash[key];
34
           hash[key] = +hash[key];
36
         }
35
         }

+ 10 - 0
settings/themes.json View File

65
     "captionBottom": 660,
65
     "captionBottom": 660,
66
     "captionTop": null
66
     "captionTop": null
67
   },
67
   },
68
+  "Line": {
69
+    "backgroundColor": "#edc951",
70
+    "foregroundColor": "#00a0b0",
71
+    "pattern": "line"
72
+  },
73
+  "Curve": {
74
+    "backgroundColor": "#00a0b0",
75
+    "foregroundColor": "#edc951",
76
+    "pattern": "curve"
77
+  },
68
   "Bricks": {
78
   "Bricks": {
69
     "backgroundColor": "#8771aa",
79
     "backgroundColor": "#8771aa",
70
     "foregroundColor": "#fff",
80
     "foregroundColor": "#fff",

BIN
test/data/glazed-donut-mono.mp3 View File


BIN
test/data/glazed-donut-mono.wav View File


BIN
test/data/long-beeps.mp3 View File


BIN
test/data/short.wav View File


+ 0 - 153
test/duration-test.js View File

1
-var tape = require("tape"),
2
-    path = require("path"),
3
-    fs = require("fs"),
4
-    queue = require("d3").queue;
5
-
6
-require("mkdirp").sync(path.join(__dirname, "tmp"));
7
-
8
-var getDuration = require("../audiogram/duration.js"),
9
-    trimAudio = require("../audiogram/trim.js");
10
-
11
-tape("MP3 duration", function(test) {
12
-
13
-  getDuration(path.join(__dirname, "data/glazed-donut.mp3"), function(err, duration){
14
-
15
-    test.error(err);
16
-    test.equal(typeof duration, "number");
17
-    test.assert(Math.abs(duration - 26.67) < 0.1);
18
-    test.end();
19
-
20
-  });
21
-
22
-});
23
-
24
-tape("WAV duration", function(test) {
25
-
26
-  getDuration(path.join(__dirname, "data/glazed-donut.wav"), function(err, duration){
27
-
28
-    test.error(err);
29
-    test.equal(typeof duration, "number");
30
-    test.assert(Math.abs(duration - 1.83) < 0.1);
31
-    test.end();
32
-
33
-  });
34
-
35
-});
36
-
37
-tape("Duration error", function(test) {
38
-
39
-  getDuration(path.join(__dirname, "..", "README.md"), function(err){
40
-
41
-    test.ok(err);
42
-    test.end();
43
-
44
-  });
45
-
46
-});
47
-
48
-tape("Trim start", function(test) {
49
-
50
-  var options = {
51
-    origin: path.join(__dirname, "data/glazed-donut.mp3"),
52
-    destination: path.join(__dirname, "tmp/trim-start.mp3"),
53
-    startTime: 6.67
54
-  };
55
-
56
-  queue(1)
57
-    .defer(trimAudio, options)
58
-    .defer(getDuration, options.destination)
59
-    .await(function(err, _, duration){
60
-
61
-      test.error(err);
62
-      test.equal(typeof duration, "number");
63
-      test.assert(Math.abs(duration - 20) < 0.1);
64
-      test.end();
65
-
66
-    });
67
-
68
-});
69
-
70
-tape("Trim end", function(test) {
71
-
72
-  var options = {
73
-    origin: path.join(__dirname, "data/glazed-donut.mp3"),
74
-    destination: path.join(__dirname, "tmp/trim-end.mp3"),
75
-    startTime: 6.67
76
-  };
77
-
78
-  queue(1)
79
-    .defer(trimAudio, options)
80
-    .defer(getDuration, options.destination)
81
-    .await(function(err, _, duration){
82
-
83
-      test.error(err);
84
-      test.equal(typeof duration, "number");
85
-      test.assert(Math.abs(duration - 20) < 0.1);
86
-      test.end();
87
-
88
-    });
89
-
90
-});
91
-
92
-tape("Trim start & end", function(test) {
93
-
94
-  var options = {
95
-    origin: path.join(__dirname, "data/glazed-donut.mp3"),
96
-    destination: path.join(__dirname, "tmp/trim-start-end.mp3"),
97
-    startTime: 5,
98
-    endTime: 10
99
-  };
100
-
101
-  queue(1)
102
-    .defer(trimAudio, options)
103
-    .defer(getDuration, options.destination)
104
-    .await(function(err, _, duration){
105
-
106
-      test.error(err);
107
-      test.equal(typeof duration, "number");
108
-      test.assert(Math.abs(duration - 5) < 0.1);
109
-      test.end();
110
-
111
-    });
112
-
113
-});
114
-
115
-
116
-tape("Trim invalid", function(test) {
117
-
118
-  var options = {
119
-    origin: path.join(__dirname, "data/glazed-donut.mp3"),
120
-    destination: path.join(__dirname, "tmp/trim-invalid.mp3"),
121
-    startTime: 5,
122
-    endTime: 4
123
-  };
124
-
125
-  queue(1)
126
-    .defer(trimAudio, options)
127
-    .defer(getDuration, options.destination)
128
-    .await(function(err, _, duration){
129
-
130
-      test.ok(err);
131
-      test.end();
132
-
133
-    });
134
-
135
-});
136
-
137
-// Cleanup
138
-tape.onFinish(function(){
139
-  require("rimraf")(path.join(__dirname, "tmp"), function(err){
140
-    if (err) {
141
-      throw err;
142
-    }
143
-  });
144
-});
145
-
146
-// Cleanup
147
-tape.onFinish(function(){
148
-  require("rimraf")(path.join(__dirname, "tmp"), function(err){
149
-    if (err) {
150
-      throw err;
151
-    }
152
-  });
153
-});

BIN
test/font/Merriweather-Bold.ttf View File


BIN
test/font/Merriweather-Regular.ttf View File


+ 93 - 0
test/font/OFL.txt View File

1
+Copyright (c) 2010-2016, Sorkin Type Co (www.sorkintype.com) with Reserved Font Name 'Merriweather'. Merriweather is a trademark of Sorkin Type Co.
2
+
3
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+This license is copied below, and is also available with a FAQ at:
5
+http://scripts.sil.org/OFL
6
+
7
+
8
+-----------------------------------------------------------
9
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+-----------------------------------------------------------
11
+
12
+PREAMBLE
13
+The goals of the Open Font License (OFL) are to stimulate worldwide
14
+development of collaborative font projects, to support the font creation
15
+efforts of academic and linguistic communities, and to provide a free and
16
+open framework in which fonts may be shared and improved in partnership
17
+with others.
18
+
19
+The OFL allows the licensed fonts to be used, studied, modified and
20
+redistributed freely as long as they are not sold by themselves. The
21
+fonts, including any derivative works, can be bundled, embedded, 
22
+redistributed and/or sold with any software provided that any reserved
23
+names are not used by derivative works. The fonts and derivatives,
24
+however, cannot be released under any other type of license. The
25
+requirement for fonts to remain under this license does not apply
26
+to any document created using the fonts or their derivatives.
27
+
28
+DEFINITIONS
29
+"Font Software" refers to the set of files released by the Copyright
30
+Holder(s) under this license and clearly marked as such. This may
31
+include source files, build scripts and documentation.
32
+
33
+"Reserved Font Name" refers to any names specified as such after the
34
+copyright statement(s).
35
+
36
+"Original Version" refers to the collection of Font Software components as
37
+distributed by the Copyright Holder(s).
38
+
39
+"Modified Version" refers to any derivative made by adding to, deleting,
40
+or substituting -- in part or in whole -- any of the components of the
41
+Original Version, by changing formats or by porting the Font Software to a
42
+new environment.
43
+
44
+"Author" refers to any designer, engineer, programmer, technical
45
+writer or other person who contributed to the Font Software.
46
+
47
+PERMISSION & CONDITIONS
48
+Permission is hereby granted, free of charge, to any person obtaining
49
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+redistribute, and sell modified and unmodified copies of the Font
51
+Software, subject to the following conditions:
52
+
53
+1) Neither the Font Software nor any of its individual components,
54
+in Original or Modified Versions, may be sold by itself.
55
+
56
+2) Original or Modified Versions of the Font Software may be bundled,
57
+redistributed and/or sold with any software, provided that each copy
58
+contains the above copyright notice and this license. These can be
59
+included either as stand-alone text files, human-readable headers or
60
+in the appropriate machine-readable metadata fields within text or
61
+binary files as long as those fields can be easily viewed by the user.
62
+
63
+3) No Modified Version of the Font Software may use the Reserved Font
64
+Name(s) unless explicit written permission is granted by the corresponding
65
+Copyright Holder. This restriction only applies to the primary font name as
66
+presented to the users.
67
+
68
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+Software shall not be used to promote, endorse or advertise any
70
+Modified Version, except to acknowledge the contribution(s) of the
71
+Copyright Holder(s) and the Author(s) or with their explicit written
72
+permission.
73
+
74
+5) The Font Software, modified or unmodified, in part or in whole,
75
+must be distributed entirely under this license, and must not be
76
+distributed under any other license. The requirement for fonts to
77
+remain under this license does not apply to any document created
78
+using the Font Software.
79
+
80
+TERMINATION
81
+This license becomes null and void if any of the above conditions are
82
+not met.
83
+
84
+DISCLAIMER
85
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+OTHER DEALINGS IN THE FONT SOFTWARE.

+ 47 - 79
test/frame-test.js View File

10
 
10
 
11
 var frameDir = path.join(__dirname, "tmp", "frames");
11
 var frameDir = path.join(__dirname, "tmp", "frames");
12
 
12
 
13
-tape.test("Draw frame", function(test){
14
-
15
-  var options = {
16
-    width: 1280,
17
-    height: 720,
18
-    backgroundColor: "#f00",
19
-    foregroundColor: "#fff",
20
-    waveTop: 340,
21
-    waveBottom: 380,
22
-    waveform: [[0, 1, 0], [1, 0.1, 1]]
23
-  };
24
-
25
-  initializeCanvas(options, function(err, renderer){
13
+var waveform = [
14
+  [
15
+    [0, 0], [1, 1], [0, 0]
16
+  ],
17
+  [
18
+    [1, 1], [0.1, 0.1], [1, 1]
19
+  ]
20
+];
26
 
21
 
27
-    test.error(err);
28
-    test.assert(renderer.context.canvas instanceof Canvas);
29
-    test.assert(renderer.context.canvas.width === options.width);
30
-    test.assert(renderer.context.canvas.height === options.height);
22
+function tester(options) {
31
 
23
 
32
-    drawFrames(renderer, {
33
-      numFrames: 2,
34
-      frameDir: frameDir
35
-    }, function(err){
36
-      test.error(err);
37
-      checkFrame(test, options);
38
-    });
24
+  return function(test) {
39
 
25
 
40
-  });
41
-
42
-});
26
+    initializeCanvas(options, function(err, renderer){
43
 
27
 
44
-tape.test("Default colors", function(test){
45
-
46
-  var options = {
47
-    width: 1280,
48
-    height: 720,
49
-    waveTop: 340,
50
-    waveBottom: 380,
51
-    waveform: [[0, 1, 0], [1, 0.1, 1]]
52
-  };
53
-
54
-  initializeCanvas(options, function(err, renderer){
55
-
56
-    test.error(err);
57
-    test.assert(renderer.context.canvas instanceof Canvas);
58
-    test.assert(renderer.context.canvas.width === options.width);
59
-    test.assert(renderer.context.canvas.height === options.height);
60
-
61
-    drawFrames(renderer, {
62
-      numFrames: 2,
63
-      frameDir: frameDir
64
-    }, function(err){
65
       test.error(err);
28
       test.error(err);
66
-      checkFrame(test, options);
67
-    });
68
 
29
 
69
-  });
70
-
71
-});
30
+      drawFrames(renderer, {
31
+        numFrames: waveform.length,
32
+        frameDir: frameDir,
33
+        width: options.width,
34
+        height: options.height,
35
+        waveform: waveform
36
+      }, function(err){
37
+        test.error(err);
38
+        checkFrame(test, options);
39
+      });
72
 
40
 
73
-tape.test("Square frame", function(test){
41
+    });
74
 
42
 
75
-  var options = {
76
-    width: 720,
77
-    height: 720,
78
-    backgroundColor: "#fc0",
79
-    foregroundColor: "#fff",
80
-    waveTop: 340,
81
-    waveBottom: 380,
82
-    waveform: [[0, 1, 0], [1, 0.1, 1]]
83
   };
43
   };
84
 
44
 
85
-  initializeCanvas(options, function(err, renderer){
86
-
87
-    test.error(err);
88
-    test.assert(renderer.context.canvas instanceof Canvas);
89
-    test.assert(renderer.context.canvas.width === options.width);
90
-    test.assert(renderer.context.canvas.height === options.height);
91
-
92
-    drawFrames(renderer, {
93
-      numFrames: 2,
94
-      frameDir: frameDir
95
-    }, function(err){
96
-      test.error(err);
97
-      checkFrame(test, options);
98
-    });
99
-
100
-  });
45
+}
101
 
46
 
102
-});
47
+tape.test("Draw frame", tester({
48
+  width: 1280,
49
+  height: 720,
50
+  backgroundColor: "#f00",
51
+  foregroundColor: "#fff",
52
+  waveTop: 340,
53
+  waveBottom: 380
54
+}));
55
+
56
+tape.test("Default colors", tester({
57
+  width: 1280,
58
+  height: 720,
59
+  waveTop: 340,
60
+  waveBottom: 380
61
+}));
62
+
63
+tape.test("Square frame", tester({
64
+  width: 720,
65
+  height: 720,
66
+  backgroundColor: "#f00",
67
+  foregroundColor: "#fff",
68
+  waveTop: 340,
69
+  waveBottom: 380
70
+}));
103
 
71
 
104
 function checkFrame(test, options) {
72
 function checkFrame(test, options) {
105
 
73
 

+ 0 - 16
test/patch-settings.js View File

1
-var serverSettings = require("../settings/");
2
-
3
-module.exports = function(newSettings) {
4
-  for (var key in serverSettings) {
5
-    if (!(key in newSettings)) {
6
-      delete serverSettings[key];
7
-    }
8
-  }
9
-
10
-  for (var key in newSettings) {
11
-    serverSettings[key] = newSettings[key];
12
-  }
13
-
14
-  return serverSettings;
15
-
16
-};

+ 169 - 0
test/probe-test.js View File

1
+var tape = require("tape"),
2
+    path = require("path"),
3
+    fs = require("fs"),
4
+    queue = require("d3").queue;
5
+
6
+require("mkdirp").sync(path.join(__dirname, "tmp"));
7
+
8
+var probe = require("../lib/probe.js"),
9
+    trimAudio = require("../audiogram/trim.js");
10
+
11
+tape("MP3 probe", function(test) {
12
+
13
+  probe(path.join(__dirname, "data/glazed-donut.mp3"), function(err, data){
14
+
15
+    test.error(err);
16
+    test.equal(typeof data.duration, "number");
17
+    test.equal(data.channels, 2);
18
+    test.assert(Math.abs(data.duration - 26.67) < 0.1);
19
+    test.end();
20
+
21
+  });
22
+
23
+});
24
+
25
+tape("Mono probe", function(test) {
26
+
27
+  probe(path.join(__dirname, "data/glazed-donut-mono.mp3"), function(err, data){
28
+
29
+    test.error(err);
30
+    test.equal(typeof data.duration, "number");
31
+    test.equal(data.channels, 1);
32
+    test.assert(Math.abs(data.duration - 26.67) < 0.1);
33
+    test.end();
34
+
35
+  });
36
+
37
+});
38
+
39
+tape("WAV probe", function(test) {
40
+
41
+  probe(path.join(__dirname, "data/glazed-donut.wav"), function(err, data){
42
+
43
+    test.error(err);
44
+    test.equal(typeof data.duration, "number");
45
+    test.equal(data.channels, 2);
46
+    test.assert(Math.abs(data.duration - 1.83) < 0.1);
47
+    test.end();
48
+
49
+  });
50
+
51
+});
52
+
53
+tape("Mono probe", function(test) {
54
+
55
+  probe(path.join(__dirname, "data/short.wav"), function(err, data){
56
+
57
+    test.error(err);
58
+    test.equal(typeof data.duration, "number");
59
+    test.equal(data.channels, 1);
60
+    test.assert(Math.abs(data.duration - 0.01) < 0.01);
61
+    test.end();
62
+
63
+  });
64
+
65
+});
66
+
67
+tape("Probe error", function(test) {
68
+
69
+  probe(path.join(__dirname, "..", "README.md"), function(err){
70
+
71
+    test.ok(err);
72
+    test.end();
73
+
74
+  });
75
+
76
+});
77
+
78
+tape("Trim start", function(test) {
79
+
80
+  var options = {
81
+    origin: path.join(__dirname, "data/glazed-donut.mp3"),
82
+    destination: path.join(__dirname, "tmp/trim-start.mp3"),
83
+    startTime: 6.67
84
+  };
85
+
86
+  queue(1)
87
+    .defer(trimAudio, options)
88
+    .defer(probe, options.destination)
89
+    .await(function(err, _, data){
90
+
91
+      test.error(err);
92
+      test.equal(typeof data.duration, "number");
93
+      test.assert(Math.abs(data.duration - 20) < 0.1);
94
+      test.end();
95
+
96
+    });
97
+
98
+});
99
+
100
+tape("Trim end", function(test) {
101
+
102
+  var options = {
103
+    origin: path.join(__dirname, "data/glazed-donut.mp3"),
104
+    destination: path.join(__dirname, "tmp/trim-end.mp3"),
105
+    startTime: 6.67
106
+  };
107
+
108
+  queue(1)
109
+    .defer(trimAudio, options)
110
+    .defer(probe, options.destination)
111
+    .await(function(err, _, data){
112
+
113
+      test.error(err);
114
+      test.equal(typeof data.duration, "number");
115
+      test.assert(Math.abs(data.duration - 20) < 0.1);
116
+      test.end();
117
+
118
+    });
119
+
120
+});
121
+
122
+tape("Trim start & end", function(test) {
123
+
124
+  var options = {
125
+    origin: path.join(__dirname, "data/glazed-donut.mp3"),
126
+    destination: path.join(__dirname, "tmp/trim-start-end.mp3"),
127
+    startTime: 5,
128
+    endTime: 10
129
+  };
130
+
131
+  queue(1)
132
+    .defer(trimAudio, options)
133
+    .defer(probe, options.destination)
134
+    .await(function(err, _, data){
135
+
136
+      test.error(err);
137
+      test.equal(typeof data.duration, "number");
138
+      test.assert(Math.abs(data.duration - 5) < 0.1);
139
+      test.end();
140
+
141
+    });
142
+
143
+});
144
+
145
+
146
+tape("Trim invalid", function(test) {
147
+
148
+  var options = {
149
+    origin: path.join(__dirname, "data/glazed-donut.mp3"),
150
+    destination: path.join(__dirname, "tmp/trim-invalid.mp3"),
151
+    startTime: 5,
152
+    endTime: 4
153
+  };
154
+
155
+  trimAudio(options, function(err){
156
+    test.ok(err);
157
+    test.end();
158
+  });
159
+
160
+});
161
+
162
+// Cleanup
163
+tape.onFinish(function(){
164
+  require("rimraf")(path.join(__dirname, "tmp"), function(err){
165
+    if (err) {
166
+      throw err;
167
+    }
168
+  });
169
+});

+ 49 - 10
test/server-test.js View File

4
     queue = require("d3").queue,
4
     queue = require("d3").queue,
5
     request = require("supertest");
5
     request = require("supertest");
6
 
6
 
7
-var serverSettings = require("./patch-settings")({
8
-  workingDirectory: path.join(__dirname, "tmp", "working"),
9
-  maxUploadSize: 100000,
10
-  storagePath: path.join(__dirname, "tmp", "storage"),
11
-  worker: true
12
-});
7
+var serverSettings = require("../lib/settings/");
8
+
9
+serverSettings.workingDirectory = path.join(__dirname, "tmp", "working");
10
+serverSettings.storagePath = path.join(__dirname, "tmp", "storage");
11
+serverSettings.maxUploadSize = 100000;
12
+serverSettings.worker = true;
13
 
13
 
14
 var server = require("../server");
14
 var server = require("../server");
15
 
15
 
66
 
66
 
67
 });
67
 });
68
 
68
 
69
+tape("404 3", function(test) {
70
+
71
+  request(server)
72
+    .get("/fonts/something")
73
+    .expect(404)
74
+    .end(function(err, res){
75
+      test.error(err);
76
+      test.end();
77
+    });
78
+
79
+});
80
+
81
+tape("Font stylesheet", function(test) {
82
+
83
+  request(server)
84
+    .get("/fonts/fonts.css")
85
+    .expect(200)
86
+    .expect(/font-face/)
87
+    .expect("Content-Type", /css/)
88
+    .end(function(err, res){
89
+      test.error(err);
90
+      test.end();
91
+    });
92
+
93
+});
94
+
95
+tape("Font file", function(test) {
96
+
97
+  request(server)
98
+    .get("/fonts/custom-0.ttf")
99
+    .expect(200)
100
+    .expect("Content-Type", /ttf/)
101
+    .end(function(err, res){
102
+      test.error(err);
103
+      test.end();
104
+    });
105
+
106
+});
107
+
69
 tape("Server static background", function(test) {
108
 tape("Server static background", function(test) {
70
 
109
 
71
   request(server)
110
   request(server)
84
   request(server)
123
   request(server)
85
     .post("/submit/")
124
     .post("/submit/")
86
     .attach("audio", longSample)
125
     .attach("audio", longSample)
87
-    .field("settings", "{}")
126
+    .field("theme", "{}")
88
     .expect(500)
127
     .expect(500)
89
     .end(function(err, res){
128
     .end(function(err, res){
90
       test.assert(res.text.match(/uploads are limited/i));
129
       test.assert(res.text.match(/uploads are limited/i));
98
   request(server)
137
   request(server)
99
     .post("/submit/")
138
     .post("/submit/")
100
     .type("json")
139
     .type("json")
101
-    .field("settings", "{}")
140
+    .field("theme", "{}")
102
     .expect(500)
141
     .expect(500)
103
     .end(function(err, res){
142
     .end(function(err, res){
104
       test.assert(res.text.match(/audio/i));
143
       test.assert(res.text.match(/audio/i));
112
   request(server)
151
   request(server)
113
     .post("/submit/")
152
     .post("/submit/")
114
     .type("multipart/form-data")
153
     .type("multipart/form-data")
115
-    .field("settings", "a")
154
+    .field("theme", "a")
116
     .expect(500)
155
     .expect(500)
117
     .end(function(err, res){
156
     .end(function(err, res){
118
       test.assert(res.text.match(/settings/i));
157
       test.assert(res.text.match(/settings/i));
128
   request(server)
167
   request(server)
129
     .post("/submit/")
168
     .post("/submit/")
130
     .attach("audio", shortSample)
169
     .attach("audio", shortSample)
131
-    .field("settings", JSON.stringify({ test: true }))
170
+    .field("theme", JSON.stringify({ test: true }))
132
     .expect(200)
171
     .expect(200)
133
     .end(function(err, res){
172
     .end(function(err, res){
134
 
173
 

+ 324 - 0
test/settings-test.js View File

1
+var tape = require("tape"),
2
+    path = require("path"),
3
+    validateSettings = require("../lib/settings/validate-settings.js"),
4
+    validateThemes = require("../lib/settings/validate-themes.js"),
5
+    load = require("../lib/settings/load.js");
6
+
7
+tape("Load test", function(test) {
8
+
9
+  test.throws(function(){
10
+    load("settings/blah.js");
11
+  }, /No .+ file found/);
12
+
13
+  test.throws(function(){
14
+    load("README.md");
15
+  }, /SyntaxError/);
16
+
17
+  test.doesNotThrow(function(){
18
+    load("settings/themes.json");
19
+  });
20
+
21
+  test.doesNotThrow(function(){
22
+    load("settings/index.js");
23
+  });
24
+
25
+  test.end();
26
+
27
+});
28
+
29
+tape("Required fields", function(test) {
30
+
31
+  test.throws(function(){
32
+    validateSettings({});
33
+  }, /settings.workingDirectory is required/);
34
+
35
+  test.throws(function(){
36
+    validateSettings({ workingDirectory: 1 });
37
+  }, /settings.workingDirectory is required/);
38
+
39
+  test.throws(function(){
40
+    validateSettings({ workingDirectory: "" });
41
+  }, /settings.storagePath/);
42
+
43
+  test.throws(function(){
44
+    validateSettings({ workingDirectory: "", storagePath: 1 });
45
+  }, /settings.storagePath/);
46
+
47
+  test.doesNotThrow(function(){
48
+    validateSettings({ workingDirectory: path.join(__dirname), storagePath: path.join(__dirname) });
49
+  }, /settings.storagePath/);
50
+
51
+  test.doesNotThrow(function(){
52
+    validateSettings({ workingDirectory: path.join(__dirname), s3Bucket: "bucket" });
53
+  }, /settings.storagePath/);
54
+
55
+  test.end();
56
+
57
+});
58
+
59
+tape("Max upload size", function(test) {
60
+
61
+  test.throws(function(){
62
+    validateSettings({ workingDirectory: path.join(__dirname), storagePath: path.join(__dirname), maxUploadSize: "a lot" });
63
+  }, /settings.maxUploadSize/);
64
+
65
+  test.end();
66
+
67
+});
68
+
69
+tape("Normalizing paths", function(test) {
70
+
71
+  var relative = validateSettings({
72
+    storagePath: "test/",
73
+    workingDirectory: "test/"
74
+  });
75
+
76
+  var absolute = validateSettings({
77
+    storagePath: path.join(__dirname),
78
+    workingDirectory: path.join(__dirname)
79
+  });
80
+
81
+  var relative2 = validateSettings({
82
+    storagePath: "test",
83
+    workingDirectory: "test"
84
+  });
85
+
86
+  test.equal(path.relative(relative.storagePath, absolute.storagePath), "");
87
+  test.equal(path.relative(relative.workingDirectory, absolute.workingDirectory), "");
88
+  test.equal(path.relative(relative2.storagePath, absolute.storagePath), "");
89
+  test.equal(path.relative(relative2.workingDirectory, absolute.workingDirectory), "");
90
+
91
+  test.end();
92
+
93
+});
94
+
95
+tape("Normalize S3 bucket", function(test) {
96
+
97
+  var settings = validateSettings({
98
+    s3Bucket: "bucket",
99
+    workingDirectory: "test/"
100
+  });
101
+
102
+  test.equal(settings.s3Bucket, "bucket");
103
+  test.equal(settings.storagePath, "");
104
+
105
+  var settings = validateSettings({
106
+    s3Bucket: "s3://bucket",
107
+    workingDirectory: "test/"
108
+  });
109
+
110
+  test.equal(settings.s3Bucket, "bucket");
111
+  test.equal(settings.storagePath, "");
112
+
113
+  settings = validateSettings({
114
+    s3Bucket: "s3://bucket/",
115
+    workingDirectory: "test/"
116
+  });
117
+
118
+  test.equal(settings.s3Bucket, "bucket");
119
+  test.equal(settings.storagePath, "");
120
+
121
+  settings = validateSettings({
122
+    s3Bucket: "s3://bucket/",
123
+    storagePath: "/",
124
+    workingDirectory: "test/"
125
+  });
126
+
127
+  test.equal(settings.s3Bucket, "bucket");
128
+  test.equal(settings.storagePath, "");
129
+
130
+  settings = validateSettings({
131
+    s3Bucket: "s3://bucket/",
132
+    storagePath: "dir",
133
+    workingDirectory: "test/"
134
+  });
135
+
136
+  test.equal(settings.s3Bucket, "bucket");
137
+  test.equal(settings.storagePath, "dir/");
138
+
139
+  settings = validateSettings({
140
+    s3Bucket: "s3://bucket/",
141
+    storagePath: "dir/",
142
+    workingDirectory: "test/"
143
+  });
144
+
145
+  test.equal(settings.s3Bucket, "bucket");
146
+  test.equal(settings.storagePath, "dir/");
147
+
148
+  settings = validateSettings({
149
+    s3Bucket: "s3://bucket/",
150
+    storagePath: "/dir/",
151
+    workingDirectory: "test/"
152
+  });
153
+
154
+  test.equal(settings.s3Bucket, "bucket");
155
+  test.equal(settings.storagePath, "dir/");
156
+
157
+  test.end();
158
+
159
+});
160
+
161
+tape("Fonts", function(test) {
162
+
163
+  test.throws(function(){
164
+    validateSettings({
165
+      s3Bucket: "bucket",
166
+      workingDirectory: "test/",
167
+      fonts: 1
168
+    });
169
+  }, /settings.fonts/);
170
+
171
+  test.throws(function(){
172
+    validateSettings({
173
+      s3Bucket: "bucket",
174
+      workingDirectory: "test/",
175
+      fonts: {}
176
+    });
177
+  }, /settings.fonts/);
178
+
179
+  doesWarn(test, function(){
180
+    validateSettings({
181
+      s3Bucket: "bucket",
182
+      workingDirectory: "test/",
183
+      fonts: [
184
+        {}
185
+      ]
186
+    });
187
+  }, /settings.fonts.+missing/);
188
+
189
+  doesWarn(test, function(){
190
+    validateSettings({
191
+      s3Bucket: "bucket",
192
+      workingDirectory: "test/",
193
+      fonts: [
194
+        { family: "" }
195
+      ]
196
+    });
197
+  }, /settings.fonts.+missing/);
198
+
199
+  doesWarn(test, function(){
200
+    validateSettings({
201
+      s3Bucket: "bucket",
202
+      workingDirectory: "test/",
203
+      fonts: [
204
+        { file: "" }
205
+      ]
206
+    });
207
+  }, /settings.fonts.+missing/);
208
+
209
+  doesWarn(test, function(){
210
+    validateSettings({
211
+      s3Bucket: "bucket",
212
+      workingDirectory: "test/",
213
+      fonts: [
214
+        { file: "notarealfont.ttf", family: "fake" }
215
+      ]
216
+    });
217
+  }, /Font file.+does not exist/);
218
+
219
+  doesNotWarn(test, function(){
220
+    validateSettings({
221
+      s3Bucket: "bucket",
222
+      workingDirectory: "test/",
223
+      fonts: [
224
+        { file: "settings/fonts/SourceSansPro-Light.ttf", family: "Source Sans Pro" },
225
+        { file: path.join(__dirname, "..", "settings/fonts/SourceSansPro-Bold.ttf"), family: "Source Sans Pro", weight: "bold" }
226
+      ]
227
+    });
228
+  });
229
+
230
+  test.end();
231
+
232
+});
233
+
234
+tape("Themes", function(test) {
235
+
236
+  doesWarn(test, function(){
237
+    validateThemes({});
238
+  }, /No themes/);
239
+
240
+  doesWarn(test, function(){
241
+    validateThemes([]);
242
+  }, /No themes/);
243
+
244
+  doesWarn(test, function(){
245
+    validateThemes({ "default": {} });
246
+  }, /No themes/);
247
+
248
+  doesNotWarn(test, function(){
249
+    validateThemes({ "default": { width: 0, height: 0, framesPerSecond: 0, samplesPerFrame: 0 }, "theme": {} });
250
+  });
251
+
252
+  doesNotWarn(test, function(){
253
+    validateThemes({ "default": { framesPerSecond: 0, samplesPerFrame: 0 }, "theme": { width: 0, height: 0 } });
254
+  });
255
+
256
+  doesWarn(test, function(){
257
+    validateThemes({ "default": { height: 0, framesPerSecond: 0, samplesPerFrame: 0 }, "theme": {} });
258
+  }, /required property 'width'/);
259
+
260
+  doesWarn(test, function(){
261
+    validateThemes({ "default": { width: 0, framesPerSecond: 0, samplesPerFrame: 0 }, "theme": {} });
262
+  }, /required property 'height'/);
263
+
264
+  doesWarn(test, function(){
265
+    validateThemes({ "default": { width: 0, height: 0, samplesPerFrame: 0 }, "theme": {} });
266
+  }, /required property 'framesPerSecond'/);
267
+
268
+  doesWarn(test, function(){
269
+    validateThemes({ "default": { width: 0, height: 0, framesPerSecond: 0 }, "theme": {} });
270
+  }, /required property 'samplesPerFrame'/);
271
+
272
+  doesWarn(test, function(){
273
+    validateThemes({ "default": { width: 0, height: 0, framesPerSecond: 0, samplesPerFrame: 0 }, "theme": { backgroundImage: "doesnotexist.jpg" } });
274
+  }, /Background image.+does not exist/);
275
+
276
+  doesNotWarn(test, function(){
277
+    validateThemes({ "default": { width: 0, height: 0, framesPerSecond: 0, samplesPerFrame: 0 }, "theme": { backgroundImage: "subway.jpg" } });
278
+  });
279
+
280
+  doesNotWarn(test, function(){
281
+    validateThemes({ "default": { width: 0, height: 0, framesPerSecond: 0, samplesPerFrame: 0 }, "theme": { backgroundImage: path.join(__dirname, "..", "settings/backgrounds/subway.jpg") } });
282
+  });
283
+
284
+  test.end();
285
+
286
+});
287
+
288
+function doesNotWarn(test, fn) {
289
+  doesWarn(test, fn, "");
290
+}
291
+
292
+function doesWarn(test, fn, regexp) {
293
+
294
+  var output = capture();
295
+
296
+  fn();
297
+
298
+  if (arguments.length > 2) {
299
+    if (typeof regexp === "string") {
300
+      test.equal(output(), regexp);
301
+    } else {
302
+      test.assert(regexp.test(output()));
303
+    }
304
+  } else {
305
+    test.assert(output().length);
306
+  }
307
+
308
+}
309
+
310
+function capture(){
311
+
312
+  var write = process.stderr.write,
313
+      out = "";
314
+
315
+  process.stderr.write = function(str) {
316
+    out += str;
317
+  };
318
+
319
+  return function(){
320
+    process.stderr.write = write;
321
+    return out;
322
+  }
323
+
324
+}

+ 46 - 29
test/waveform-test.js View File

1
 var tape = require("tape"),
1
 var tape = require("tape"),
2
     path = require("path");
2
     path = require("path");
3
 
3
 
4
-var getWaveform = require("../audiogram/waveform.js");
4
+var Audiogram = require("../audiogram/"),
5
+    getWaveform = require("../audiogram/waveform.js"),
6
+    probe = require("../lib/probe.js");
5
 
7
 
6
 var sample = path.join(__dirname, "data/glazed-donut.mp3");
8
 var sample = path.join(__dirname, "data/glazed-donut.mp3");
7
 
9
 
8
 tape("Waveform", function(test) {
10
 tape("Waveform", function(test) {
9
 
11
 
10
   var options = {
12
   var options = {
11
-    numFrames: 500,
13
+    framesPerSecond: 20,
12
     samplesPerFrame: 10
14
     samplesPerFrame: 10
13
   };
15
   };
14
 
16
 
15
-  getWaveform(sample, options, function(err, waveform){
17
+  probe(sample, function(e1, data){
16
 
18
 
17
-    test.error(err);
18
-    test.assert(Array.isArray(waveform) && waveform.length === options.numFrames);
19
+    test.error(e1);
19
 
20
 
20
-    var firstMax = Math.max.apply(null, waveform[0]);
21
+    options.channels = data.channels;
22
+    options.numFrames = Math.floor(data.duration * options.framesPerSecond);
21
 
23
 
22
-    test.assert(firstMax <= 1);
24
+    getWaveform(sample, options, function(e2, waveform){
23
 
25
 
24
-    test.assert(waveform.every(function(frame){
25
-      return frame.length === options.samplesPerFrame;
26
-    }));
26
+      test.error(e2);
27
+      test.assert(Array.isArray(waveform) && waveform.length === options.numFrames);
27
 
28
 
28
-    test.assert(waveform.every(function(frame){
29
-      return frame.every(function(val){
30
-        return typeof val === "number" && val >= 0 && val <= firstMax;
31
-      });
32
-    }));
29
+      test.assert(waveform.every(function(frame){
30
+        return frame.length === options.samplesPerFrame && frame.every(function(f){
31
+          return f.length === 2 && f.every(function(d){ return typeof d === "number"; }) && f[0] >= 0 && f[0] <= 1 && f[1] >= -1 && f[1] <= 1;
32
+        });
33
+      }));
33
 
34
 
34
-    test.end();
35
+      test.end();
36
+
37
+    });
35
 
38
 
36
   });
39
   });
37
 
40
 
38
 });
41
 });
39
 
42
 
40
-tape("Waveform missing numFrames", function(test) {
43
+tape("Max Duration Error", function(test) {
41
 
44
 
42
-  var options = {
43
-    samplesPerFrame: 10
45
+  var audiogram = new Audiogram("xyz");
46
+  audiogram.audioPath = sample;
47
+  audiogram.settings = {
48
+    theme: {
49
+      maxDuration: 20
50
+    }
44
   };
51
   };
45
 
52
 
46
-  getWaveform(sample, options, function(err, waveform){
47
-
48
-    test.ok(err);
53
+  audiogram.getWaveform(function(err, waveform){
54
+    test.assert(err);
55
+    test.assert(err.toString().match(/Exceeds max duration/));
49
     test.end();
56
     test.end();
50
-
51
   });
57
   });
52
 
58
 
53
 });
59
 });
54
 
60
 
55
-tape("Waveform missing samplesPerFrame", function(test) {
61
+tape("Max Duration OK", function(test) {
56
 
62
 
57
-  var options = {
58
-    numFrames: 500,
63
+  var audiogram = new Audiogram("xyz");
64
+  audiogram.audioPath = sample;
65
+  audiogram.settings = {
66
+    theme: {
67
+      samplesPerFrame: 10,
68
+      framesPerSecond: 20
69
+    }
59
   };
70
   };
60
 
71
 
61
-  getWaveform(sample, options, function(err, waveform){
72
+  probe(sample, function(err, data){
73
+    test.deepEqual(Math.round(data.duration), 27);
74
+    test.deepEqual(data.channels, 2);
62
 
75
 
63
-    test.ok(err);
64
-    test.end();
76
+    audiogram.getWaveform(function(waveformErr, waveform){
77
+      test.error(waveformErr);
78
+      test.assert(Array.isArray(waveform));
79
+      test.deepEqual(waveform.length, Math.floor(data.duration * audiogram.settings.theme.framesPerSecond));
80
+      test.end();
81
+    });
65
 
82
 
66
   });
83
   });
67
 
84