Browse Source

Everything

Noah 7 years ago
parent
commit
81ea325ff8
69 changed files with 4142 additions and 0 deletions
  1. 9 0
      .gitignore
  2. 99 0
      DEVELOPERS.md
  3. 146 0
      INSTALL.md
  4. 7 0
      LICENSE.md
  5. 100 0
      README.md
  6. 158 0
      SERVER.md
  7. 105 0
      THEMES.md
  8. 13 0
      audiogram/combine-frames.js
  9. 49 0
      audiogram/draw-frames.js
  10. 35 0
      audiogram/duration.js
  11. 206 0
      audiogram/index.js
  12. 38 0
      audiogram/initialize-canvas.js
  13. 26 0
      audiogram/trim.js
  14. 60 0
      audiogram/waveform.js
  15. 11 0
      bin/server
  16. 57 0
      bin/worker
  17. 124 0
      client/audio.js
  18. 270 0
      client/index.js
  19. 98 0
      client/minimap.js
  20. 113 0
      client/preview.js
  21. 36 0
      client/video.js
  22. 70 0
      client/waveform.js
  23. 138 0
      editor/css/base.css
  24. 303 0
      editor/css/editor.css
  25. 94 0
      editor/index.html
  26. 38 0
      lib/logger/index.js
  27. 10 0
      lib/register-fonts.js
  28. 6 0
      lib/transports/index.js
  29. 124 0
      lib/transports/redis/fake.js
  30. 9 0
      lib/transports/redis/index.js
  31. 75 0
      lib/transports/redis/remote.js
  32. 48 0
      lib/transports/s3/fake.js
  33. 13 0
      lib/transports/s3/index.js
  34. 99 0
      lib/transports/s3/remote.js
  35. 52 0
      package.json
  36. 55 0
      renderer/index.js
  37. 148 0
      renderer/patterns.js
  38. 1 0
      renderer/sample-wave.js
  39. 84 0
      renderer/text-wrapper.js
  40. 30 0
      server/error.js
  41. 71 0
      server/index.js
  42. 76 0
      server/render.js
  43. 43 0
      server/status.js
  44. BIN
      settings/backgrounds/nyc.png
  45. BIN
      settings/backgrounds/subway.jpg
  46. 93 0
      settings/fonts/LICENSE.txt
  47. BIN
      settings/fonts/Source Sans Pro.ttf
  48. BIN
      settings/fonts/SourceSansPro-Black.ttf
  49. BIN
      settings/fonts/SourceSansPro-BlackItalic.ttf
  50. BIN
      settings/fonts/SourceSansPro-Bold.ttf
  51. BIN
      settings/fonts/SourceSansPro-BoldItalic.ttf
  52. BIN
      settings/fonts/SourceSansPro-ExtraLight.ttf
  53. BIN
      settings/fonts/SourceSansPro-ExtraLightItalic.ttf
  54. BIN
      settings/fonts/SourceSansPro-Italic.ttf
  55. BIN
      settings/fonts/SourceSansPro-Light.ttf
  56. BIN
      settings/fonts/SourceSansPro-LightItalic.ttf
  57. BIN
      settings/fonts/SourceSansPro-Regular.ttf
  58. BIN
      settings/fonts/SourceSansPro-Semibold.ttf
  59. BIN
      settings/fonts/SourceSansPro-SemiboldItalic.ttf
  60. 32 0
      settings/index.js
  61. 90 0
      settings/themes.json
  62. BIN
      test/data/glazed-donut.mp3
  63. BIN
      test/data/glazed-donut.wav
  64. BIN
      test/data/short.mp3
  65. 153 0
      test/duration-test.js
  66. 161 0
      test/frame-test.js
  67. 16 0
      test/patch-settings.js
  68. 182 0
      test/server-test.js
  69. 68 0
      test/waveform-test.js

+ 9 - 0
.gitignore View File

@@ -0,0 +1,9 @@
1
+.DS_Store
2
+node_modules/
3
+.env
4
+tmp/
5
+editor/js/*
6
+junk/
7
+media/
8
+*.log
9
+.jobs

File diff suppressed because it is too large
+ 99 - 0
DEVELOPERS.md


+ 146 - 0
INSTALL.md View File

@@ -0,0 +1,146 @@
1
+# Audiogram installation
2
+
3
+Audiogram has a number of dependencies:
4
+
5
+* [Node.js/NPM](https://nodejs.org/) v0.11.2 or greater
6
+* [node-canvas dependencies](https://github.com/Automattic/node-canvas#installation)
7
+* [FFmpeg](https://www.ffmpeg.org/)
8
+* [libgroove](https://github.com/andrewrk/libgroove)
9
+
10
+If you're using a particularly fancy distributed setup you'll also need to install [Redis](http://redis.io/).
11
+
12
+Installation has been tested on Ubuntu 14.04 and 15.04. It has also been tested on various Mac OS X environments, with various degrees of Homebrew Hell involved.
13
+
14
+This would theoretically work on Windows, but it hasn't been tested.
15
+
16
+## Ubuntu 14.04+ installation
17
+
18
+An example bootstrap script for installing Audiogram on Ubuntu looks like this:
19
+
20
+```sh
21
+# 14.04 only: add PPAs for FFmpeg and Libgroove
22
+# Not required for 15.04
23
+sudo add-apt-repository ppa:mc3man/trusty-media --yes
24
+sudo apt-add-repository ppa:andrewrk/libgroove --yes
25
+
26
+# Update/upgrade
27
+sudo apt-get update --yes && sudo apt-get upgrade --yes
28
+
29
+# Install:
30
+# Node/NPM
31
+# Git
32
+# node-canvas dependencies (Cairo, Pango, libgif, libjpeg)
33
+# FFmpeg
34
+# node-waveform dependencies (libgroove, zlib, libpng)
35
+sudo apt-get install git nodejs npm \
36
+libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev build-essential g++ \
37
+ffmpeg \
38
+libgroove-dev zlib1g-dev libpng-dev \
39
+--yes
40
+
41
+# Install Redis if you plan to use it to share rendering among multiple processes/servers
42
+# If you don't need to handle multiple users, you can skip this step
43
+sudo apt-get install redis-server --yes
44
+
45
+# Fix nodejs/node legacy binary nonsense
46
+sudo ln -s `which nodejs` /usr/bin/node
47
+
48
+# Upgrade node to the latest stable version
49
+# Or use a different method of your choosing (e.g. nvm)
50
+# Any node version later than v0.11.2 should work
51
+sudo npm install -g n
52
+sudo n stable
53
+
54
+# You'll probably need to reconnect to see the new Node version reflected
55
+
56
+# Clone the audiogram repo
57
+git clone https://github.com/nypublicradio/audiogram.git
58
+cd audiogram
59
+
60
+# Install local modules from NPM
61
+npm install
62
+
63
+# If this worked, you're done
64
+# If you get an error about `make` failing,
65
+# you may need to ensure that node-gyp is up-to-date
66
+# You may even need to run this command twice, because computers
67
+sudo npm install -g node-gyp
68
+
69
+# If you had to update node-gyp, try again
70
+npm install
71
+```
72
+
73
+## Mac OS X installation
74
+
75
+Installing on a Mac can get a little rocky. Essentially, you need to install three things:
76
+
77
+1. [Node.js/NPM](https://nodejs.org/)
78
+2. [node-canvas dependencies](https://github.com/Automattic/node-canvas#installation)
79
+4. [libgroove](https://github.com/andrewrk/libgroove)
80
+
81
+You probably don't need to install FFmpeg separately because libgroove depends on it.
82
+
83
+You can install Node.js by [downloading it from the website](https://nodejs.org/).
84
+
85
+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):
86
+
87
+```sh
88
+# Install Git if you haven't already
89
+brew install git
90
+
91
+# Install Cairo, Pango, libgif, libjpeg, libgroove, and FFmpeg
92
+# You may not need to install zlib
93
+brew install pkg-config cairo pango libpng jpeg giflib libgroove ffmpeg
94
+
95
+# Go to the directory where you want the audiogram directory
96
+cd /where/to/put/this/
97
+
98
+# Clone the repo
99
+git clone https://github.com/nypublicradio/audiogram.git
100
+cd audiogram
101
+
102
+# Install from NPM
103
+npm install
104
+```
105
+
106
+## Mac troubleshooting
107
+
108
+If things aren't working on a Mac, there are a few fixes you can try.
109
+
110
+### Brew troubleshooting
111
+
112
+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.
113
+
114
+### Installing libgroove manually
115
+
116
+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.
117
+
118
+### Installing XCode command line tools
119
+
120
+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:
121
+
122
+```sh
123
+xcode-select --install
124
+```
125
+
126
+### Updating node-gyp
127
+
128
+Updating node-gyp to a current version with:
129
+
130
+```sh
131
+npm install -g node-gyp
132
+```
133
+
134
+may help with `npm install` errors.
135
+
136
+### Updating Node.js
137
+
138
+If you get an error about `path.isAbsolute` not being a function, you're running a pretty old version of [Node.js/NPM](https://nodejs.org/). Upgrading to anything later than v0.11.2 should help.
139
+
140
+### Installing FFmpeg with the compilation guide
141
+
142
+If FFmpeg installation is failing, you can try following the [compilation guide](https://trac.ffmpeg.org/wiki/CompilationGuide).
143
+
144
+### Installing node-canvas dependencies manually
145
+
146
+You can try installing the node-canvas dependencies with their detailed [Installation instructions](https://github.com/Automattic/node-canvas/wiki/_pages).  You don't need to install `node-canvas` itself, just everything up to that point.

+ 7 - 0
LICENSE.md View File

@@ -0,0 +1,7 @@
1
+Copyright (c) 2016 New York Public Radio
2
+
3
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

File diff suppressed because it is too large
+ 100 - 0
README.md


File diff suppressed because it is too large
+ 158 - 0
SERVER.md


+ 105 - 0
THEMES.md View File

@@ -0,0 +1,105 @@
1
+## Audiogram themes
2
+
3
+Themes are defined as one big JSON dictionary in `settings/themes.json`.
4
+
5
+All themes inherit the settings of the `default` theme, and setting an option for another theme will extend/override the same property.
6
+
7
+Each theme should be a unique name as the key and then a set of options.  For example:
8
+
9
+```
10
+  "My Theme Name": {
11
+    "width": 320,
12
+    "height": 320
13
+  }
14
+```
15
+
16
+The best way to get a feel for this is probably to look at the included `themes.json` and compare it to how the themes look in the editor.
17
+
18
+### Required options
19
+
20
+The following options are required (they aren't required for every theme, as long as they're present in `default`):
21
+
22
+* `width` - desired video width in pixels (e.g. `1280`)
23
+* `height` - desired video height in pixels (e.g. `720`)
24
+* `framesPerSecond` - desired video framerate (e.g. `20`)
25
+* `samplesPerFrame` - How many data points to use for the waveform. More points = a more detailed wave. (e.g. `128`)
26
+
27
+To see what specs different social media platforms want, see the [Developer notes](DEVELOPERS.md).
28
+
29
+### Background options
30
+
31
+Background options:
32
+
33
+* `backgroundImage` - What image to put in the background of every frame, it should be a PNG image in `settings/backgrounds/`
34
+* `backgroundColor` - A CSS color to fill the background of every frame (e.g. `pink` or `#ff00ff`). The default is white.
35
+
36
+If a `backgroundImage` is defined, its dimensions should match the theme's width and height and the file should be in `settings/backgrounds/`. So for example, you could add:
37
+
38
+```js
39
+  "tmnt": {
40
+    "name": "Teenage Mutant Ninja Turtles",
41
+    "foregroundColor": "green",
42
+    "backgroundImage": "tmnt-bg.png"
43
+  }
44
+```
45
+
46
+and save a background image as `settings/backgrounds/tmnt-bg.png`.
47
+
48
+You can set both a `backgroundColor` and a `backgroundImage`, in which case the image will be drawn on top of the color.
49
+
50
+### Caption options
51
+
52
+* `captionColor` - A CSS color, what color the text should be (e.g. `red` or `#ffcc00`). The default is black.
53
+* `captionAlign` - Text alignment of the caption: `left`, `right`, or `center` (default: `center`)
54
+* `captionFont` - A full CSS font definition to use for the caption (see _A note about fonts_ below)
55
+* `captionLineHeight` - How tall each caption line is in pixels. You'll want to adjust this for whatever font and font size you're using.
56
+* `captionLineSpacing` - How many extra pixels to put between caption lines. You'll want to adjust this for whatever font and font size you're using.
57
+* `captionLeft` - How many pixels from the left edge to place the caption
58
+* `captionRight` - How many pixels from the right edge to place the caption
59
+* `captionBottom` or `captionTop` - How many pixels from the bottom or top edge to place the caption. Determines whether the caption text will be top- or bottom-aligned. If both are set, the caption will be roughly vertically centered between them, though this [isn't perfect](https://github.com/nypublicradio/audiogram/issues/20).
60
+
61
+### Wave options
62
+
63
+* `pattern` - What waveform shape to draw. Current options are `wave`, `bars`, `roundBars`, `pixel`, `bricks`, and `equalizer` (default: `wave`)
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`)
66
+* `waveColor` - A CSS color, what color the wave should be. The default is black.
67
+
68
+### Additional options
69
+
70
+* `name` - What name to show in the dropdown menu in the editor (the default is the key)
71
+* `foregroundColor` - A convenience option for setting `waveColor` and `captionColor` to the same thing.
72
+* `maxDuration` - Maximum duration of an audiogram, in seconds (e.g. set this to `30` to enforce the 30-second limit for Twitter)
73
+
74
+### A note about layout
75
+
76
+When designing your own themes, keep in mind that web browsers and social apps put a variety of overlays on videos when they're paused or playing, like a progress bar at the bottom or a fat play button in the middle. Try to space things out so that important parts of your design aren't obscured.
77
+
78
+### A note about fonts
79
+
80
+By default, Audiogram will already have access to fonts on your system.  This might be fine for local use, but it will become a problem on a server without the fonts you're used to, or if you want to use a specific font across lots of installations.
81
+
82
+The good news is that you can load custom fonts directly with the `fonts` list in `settings/index.js`. Each font in the array is an object with `name` (the font family name in `captionFont`) and `file`, the absolute path to the font file.  For example:
83
+
84
+```js
85
+fonts: [
86
+  { name: "Gotham", file: "/where/to/find/Gotham.ttf" }
87
+]
88
+```
89
+
90
+Now you can specify a caption font like `32px Gotham` and it should work.  You can also specify a `style` and/or `weight` if you want to use multiple variations in the same font family and your caption font definitions include styles and weights (e.g. `bold 32px Gotham`):
91
+
92
+```js
93
+fonts: [
94
+  { name: "Gotham", file: "/where/to/find/Gotham-Bold.ttf", weight: "bold" },
95
+  { name: "Gotham", file: "/where/to/find/Gotham-Italic.ttf", style: "italic" }
96
+]
97
+```
98
+
99
+The bad news is that the font handling in the library Audiogram relies on has a lot of quirks.  Because of that, Audiogram relies on a [specific patched branch of node-canvas](https://github.com/Automattic/node-canvas/pull/715), so _hopefully_ you won't have any problems. If you do run into problems where the font you're trying to use doesn't show up in the videos, here are a few things you can try:
100
+
101
+1. Install the fonts as system fonts on the relevant computers. This at least includes the computers people are using to create Audiograms (i.e. their desktops). If you're hosting Audiogram on a remote server somewhere, install it there too.
102
+
103
+2. Ensure that the font name defined in the font file's metadata matches the font name you're using (e.g. if your font definition says "32px Gotham", make sure that when you open your font file in something like Font Forge and look at the metadata, the font name is also "Gotham" and not some variant.
104
+
105
+3. Use TrueType fonts (.ttf) rather than other formats.

+ 13 - 0
audiogram/combine-frames.js View File

@@ -0,0 +1,13 @@
1
+var exec = require("child_process").exec;
2
+
3
+function combineFrames(options, cb) {
4
+
5
+  // Raw ffmpeg command with standard mp4 setup
6
+  // Some old versions of ffmpeg require -strict for the aac codec
7
+  var cmd = "ffmpeg -r " + options.framesPerSecond + " -i " + options.framePath + " -i " + options.audioPath + " -c:v libx264 -c:a aac -strict experimental -shortest -pix_fmt yuv420p " + options.videoPath;
8
+
9
+  exec(cmd, cb);
10
+
11
+}
12
+
13
+module.exports = combineFrames;

+ 49 - 0
audiogram/draw-frames.js View File

@@ -0,0 +1,49 @@
1
+var fs = require("fs"),
2
+    path = require("path"),
3
+    queue = require("d3").queue;
4
+
5
+function drawFrames(renderer, options, cb) {
6
+
7
+  var frameQueue = queue(10);
8
+
9
+  for (var i = 0; i < options.numFrames; i++) {
10
+
11
+    frameQueue.defer(drawFrame, i);
12
+
13
+  }
14
+
15
+  frameQueue.awaitAll(cb);
16
+
17
+  function drawFrame(frameNumber, frameCallback) {
18
+
19
+    renderer.drawFrame(frameNumber);
20
+    fs.writeFile(path.join(options.frameDir, zeropad(frameNumber + 1, 6) + ".png"), renderer.context.canvas.toBuffer(), function(err) {
21
+      if (err) {
22
+        return frameCallback(err);
23
+      }
24
+
25
+      if (options.tick) {
26
+        options.tick();
27
+      }
28
+
29
+      return frameCallback(null);
30
+
31
+    });
32
+
33
+  }
34
+
35
+}
36
+
37
+function zeropad(str, len) {
38
+
39
+  str = str.toString();
40
+
41
+  while (str.length < len) {
42
+    str = "0" + str;
43
+  }
44
+
45
+  return str;
46
+
47
+}
48
+
49
+module.exports = drawFrames;

+ 35 - 0
audiogram/duration.js View File

@@ -0,0 +1,35 @@
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
+}

+ 206 - 0
audiogram/index.js View File

@@ -0,0 +1,206 @@
1
+var path = require("path"),
2
+    queue = require("d3").queue,
3
+    mkdirp = require("mkdirp"),
4
+    rimraf = require("rimraf"),
5
+    serverSettings = require("../settings/"),
6
+    transports = require("../lib/transports/"),
7
+    logger = require("../lib/logger/"),
8
+    getDuration = require("./duration.js"),
9
+    getWaveform = require("./waveform.js"),
10
+    initializeCanvas = require("./initialize-canvas.js"),
11
+    drawFrames = require("./draw-frames.js"),
12
+    combineFrames = require("./combine-frames.js"),
13
+    trimAudio = require("./trim.js");
14
+
15
+function Audiogram(settings) {
16
+
17
+  // Unique audiogram ID
18
+  this.id = settings.id;
19
+
20
+  this.settings = settings;
21
+
22
+  // File locations to use
23
+  this.dir = path.join(serverSettings.workingDirectory, this.id);
24
+  this.audioPath = path.join(this.dir, "audio");
25
+  this.videoPath = path.join(this.dir, "video.mp4");
26
+  this.frameDir = path.join(this.dir, "frames");
27
+
28
+  return this;
29
+
30
+}
31
+
32
+// Probe an audio file for its duration, compute the number of frames required
33
+Audiogram.prototype.getDuration = function(cb) {
34
+
35
+  var self = this;
36
+
37
+  this.status("duration");
38
+
39
+  getDuration(this.audioPath, function(err, duration){
40
+
41
+    if (err) {
42
+      return cb(err);
43
+    }
44
+
45
+    if (self.settings.maxDuration && self.settings.maxDuration < duration) {
46
+      cb("Exceeds max duration of " + self.settings.maxDuration + "s");
47
+    }
48
+
49
+    self.set("numFrames", self.numFrames = Math.floor(duration * self.settings.framesPerSecond));
50
+
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;
61
+
62
+  this.status("waveform");
63
+
64
+  getWaveform(this.audioPath, {
65
+    numFrames: this.numFrames,
66
+    samplesPerFrame: this.settings.samplesPerFrame
67
+  }, function(err, waveform){
68
+
69
+    return cb(err, self.settings.waveform = waveform);
70
+
71
+  });
72
+
73
+};
74
+
75
+// Trim the audio by the start and end time specified
76
+Audiogram.prototype.trimAudio = function(start, end, cb) {
77
+
78
+  var self = this;
79
+
80
+  this.status("trim");
81
+
82
+  // FFmpeg needs an extension to sniff
83
+  var trimmedPath = this.audioPath + "-trimmed.mp3";
84
+
85
+  trimAudio({
86
+    origin: this.audioPath,
87
+    destination: trimmedPath,
88
+    startTime: start,
89
+    endTime: end
90
+  }, function(err){
91
+    if (err) {
92
+      return cb(err);
93
+    }
94
+
95
+    self.audioPath = trimmedPath;
96
+
97
+    return cb(null);
98
+  });
99
+
100
+};
101
+
102
+// Initialize the canvas and draw all the frames
103
+Audiogram.prototype.drawFrames = function(cb) {
104
+
105
+  var self = this;
106
+
107
+  this.status("renderer");
108
+
109
+  initializeCanvas(this.settings, function(err, renderer){
110
+
111
+    if (err) {
112
+      return cb(err);
113
+    }
114
+
115
+    self.status("frames");
116
+
117
+    drawFrames(renderer, {
118
+      numFrames: self.numFrames,
119
+      frameDir: self.frameDir,
120
+      tick: function() {
121
+        transports.incrementField(self.id, "framesComplete");
122
+      }
123
+    }, cb);
124
+
125
+  });
126
+
127
+};
128
+
129
+// Combine the frames and audio into the final video with FFmpeg
130
+Audiogram.prototype.combineFrames = function(cb) {
131
+
132
+  this.status("combine");
133
+
134
+  combineFrames({
135
+    framePath: path.join(this.frameDir, "%06d.png"),
136
+    audioPath: this.audioPath,
137
+    videoPath: this.videoPath,
138
+    framesPerSecond: this.settings.framesPerSecond
139
+  }, cb);
140
+
141
+};
142
+
143
+// Master render function, queue up steps in order
144
+Audiogram.prototype.render = function(cb) {
145
+
146
+  var self = this,
147
+      q = queue(1);
148
+
149
+  this.status("audio-download");
150
+
151
+  // Set up tmp directory
152
+  q.defer(mkdirp, this.frameDir);
153
+
154
+  // Download the stored audio file
155
+  q.defer(transports.downloadAudio, "audio/" + this.id, this.audioPath);
156
+
157
+  // If the audio needs to be clipped, clip it first and update the path
158
+  if (this.settings.start || this.settings.end) {
159
+    q.defer(this.trimAudio.bind(this), this.settings.start || 0, this.settings.end);
160
+  }
161
+
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
166
+  q.defer(this.getWaveform.bind(this));
167
+
168
+  // Draw all the frames
169
+  q.defer(this.drawFrames.bind(this));
170
+
171
+  // Combine audio and frames together with ffmpeg
172
+  q.defer(this.combineFrames.bind(this));
173
+
174
+  // Upload video to S3 or move to local storage
175
+  q.defer(transports.uploadVideo, this.videoPath, "video/" + this.id + ".mp4");
176
+
177
+  // Delete working directory
178
+  q.defer(rimraf, this.dir);
179
+
180
+  // Final callback, results in a URL where the finished video is accessible
181
+  q.await(function(err){
182
+
183
+    if (!err) {
184
+      self.set("url", transports.getURL(self.id));
185
+    }
186
+
187
+    return cb(err);
188
+
189
+  });
190
+
191
+  return this;
192
+
193
+};
194
+
195
+Audiogram.prototype.set = function(field, value) {
196
+  logger.debug(field + "=" + value);
197
+  transports.setField(this.id, field, value);
198
+  return this;
199
+};
200
+
201
+// Convenience method for .set("status")
202
+Audiogram.prototype.status = function(value) {
203
+  return this.set("status", value);
204
+};
205
+
206
+module.exports = Audiogram;

+ 38 - 0
audiogram/initialize-canvas.js View File

@@ -0,0 +1,38 @@
1
+var fs = require("fs"),
2
+    path = require("path"),
3
+    Canvas = require("canvas"),
4
+    getRenderer = require("../renderer/"),
5
+    serverSettings = require("../settings/");
6
+
7
+function initializeCanvas(options, cb) {
8
+
9
+  // Fonts pre-registered in bin/worker
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) {
18
+    return cb(null, renderer);
19
+  }
20
+
21
+  // 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){
23
+
24
+    if (err) {
25
+      return cb(err);
26
+    }
27
+
28
+    var bg = new Canvas.Image;
29
+    bg.src = raw;
30
+    renderer.backgroundImage = bg;
31
+
32
+    return cb(null, renderer);
33
+
34
+  });
35
+
36
+}
37
+
38
+module.exports = initializeCanvas;

+ 26 - 0
audiogram/trim.js View File

@@ -0,0 +1,26 @@
1
+var exec = require("child_process").exec,
2
+    getDuration = require("./duration.js");
3
+
4
+function trimAudio(options, cb) {
5
+
6
+  if (!options.endTime) {
7
+
8
+    return getDuration(options.origin, function(err, duration){
9
+      if (err) {
10
+        return cb(err);
11
+      }
12
+
13
+      options.endTime = duration;
14
+      trimAudio(options, cb);
15
+
16
+    });
17
+
18
+  }
19
+
20
+  var cmd = "ffmpeg -i " + options.origin + " -ss " + (options.startTime || 0) + " -t " + (options.endTime - options.startTime) + " -acodec libmp3lame -b:a 128k " + options.destination;
21
+
22
+  exec(cmd, cb);
23
+
24
+}
25
+
26
+module.exports = trimAudio;

+ 60 - 0
audiogram/waveform.js View File

@@ -0,0 +1,60 @@
1
+var waveform = require("waveform"),
2
+    d3 = require("d3");
3
+
4
+function getWaveform(filename, options, cb) {
5
+
6
+  var numSamples = options.numFrames * options.samplesPerFrame;
7
+
8
+  var waveformOptions = {
9
+    "scan": false,
10
+    "waveformjs": "-",
11
+    "wjs-width": numSamples,
12
+    "wjs-precision": 2,
13
+    "wjs-plain": true,
14
+    "encoding": "utf8"
15
+  };
16
+
17
+  waveform(filename, waveformOptions, function(err, buf) {
18
+
19
+    if (err) {
20
+      return cb(err);
21
+    }
22
+
23
+    cb(null, processWaveform(JSON.parse(buf)));
24
+
25
+  });
26
+
27
+  // Slice one-dimensional waveform data into array of arrays, one array per frame
28
+  function processWaveform(waveformData) {
29
+
30
+    var max = -Infinity,
31
+        maxFrame;
32
+
33
+    waveformData.forEach(function(d, i){
34
+      if (d > max) {
35
+        max = d;
36
+        maxFrame = Math.floor(i / options.samplesPerFrame);
37
+      }
38
+    });
39
+
40
+    // Scale peaks to 1
41
+    var scaled = d3.scaleLinear()
42
+      .domain([0, max])
43
+      .range([0, 1]);
44
+
45
+    var waveformFrames = d3.range(options.numFrames).map(function getFrame(frameNumber) {
46
+
47
+      return waveformData.slice(options.samplesPerFrame * frameNumber, options.samplesPerFrame * (frameNumber + 1)).map(scaled);
48
+
49
+    });
50
+
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];
53
+
54
+    return waveformFrames;
55
+
56
+  }
57
+
58
+}
59
+
60
+module.exports = getWaveform;

+ 11 - 0
bin/server View File

@@ -0,0 +1,11 @@
1
+#!/usr/bin/env node
2
+
3
+var dotenv = require("dotenv").config({silent: true}),
4
+    logger = require("../lib/logger/"),
5
+    server = require("../server/");
6
+
7
+var port = +process.argv[2] || 8888;
8
+
9
+server.listen(port);
10
+
11
+logger.log("Listening on " + port);

+ 57 - 0
bin/worker View File

@@ -0,0 +1,57 @@
1
+#!/usr/bin/env node
2
+var dotenv = require("dotenv").config({silent: true}),
3
+    Audiogram = require("../audiogram/"),
4
+    transports = require("../lib/transports/");
5
+
6
+// Can only register fonts once, double-registering throws an error
7
+require("../lib/register-fonts.js");
8
+
9
+pluckJob();
10
+
11
+function pluckJob(){
12
+
13
+  transports.getJob(function(err, settings){
14
+
15
+    if (err) {
16
+      throw err;
17
+    }
18
+
19
+    if (settings) {
20
+
21
+      return render(settings);
22
+
23
+    }
24
+
25
+    setTimeout(pluckJob, delay());
26
+
27
+  });
28
+
29
+}
30
+
31
+function render(settings) {
32
+
33
+  var audiogram = new Audiogram(settings);
34
+
35
+  audiogram.render(function(err){
36
+
37
+    if (err) {
38
+      audiogram.status("error");
39
+      audiogram.set("error", err);
40
+      throw err;
41
+    }
42
+
43
+    audiogram.status("ready");
44
+
45
+    if (process.env.SPAWNED) {
46
+      return transports.quit();
47
+    }
48
+
49
+    setTimeout(pluckJob, delay());
50
+
51
+  });
52
+
53
+}
54
+
55
+function delay() {
56
+   return transports.workerDelay ? transports.workerDelay() : 0;
57
+}

+ 124 - 0
client/audio.js View File

@@ -0,0 +1,124 @@
1
+var minimap = require("./minimap.js"),
2
+    d3 = require("d3");
3
+
4
+var audio = document.querySelector("audio"),
5
+    extent = [0, 1];
6
+
7
+// timeupdate is too low-res
8
+d3.timer(update);
9
+
10
+d3.select(audio).on("play", toggled)
11
+  .on("pause", function(){ toggled(true); });
12
+
13
+minimap.onBrushEnd(_extent);
14
+
15
+function pause(time) {
16
+
17
+  if (arguments.length) {
18
+    audio.currentTime = time;
19
+  }
20
+
21
+  if (isPlaying()) {
22
+    audio.pause();
23
+  }
24
+
25
+  toggled(true);
26
+
27
+}
28
+
29
+function play(time) {
30
+
31
+  if (arguments.length) {
32
+    audio.currentTime = time;
33
+  }
34
+
35
+  audio.play();
36
+
37
+  toggled();
38
+
39
+}
40
+
41
+function restart() {
42
+  play(extent[0] * audio.duration);
43
+}
44
+
45
+function update() {
46
+
47
+  if (audio.duration) {
48
+
49
+    var pos = audio.currentTime / audio.duration;
50
+
51
+    // Need some allowance at the beginning because of frame imprecision (esp. FF)
52
+    if (audio.ended || pos >= extent[1] || audio.duration * extent[0] - audio.currentTime > 0.2) {
53
+      pause(extent[0] * audio.duration);
54
+    }
55
+
56
+    minimap.time(pos);
57
+
58
+  }
59
+
60
+}
61
+
62
+function toggled(paused) {
63
+  d3.select("#pause").classed("hidden", paused);
64
+  d3.select("#play").classed("hidden", !paused);
65
+}
66
+
67
+function toggle() {
68
+  if (isPlaying()) {
69
+    pause();
70
+  } else {
71
+    play();
72
+  }
73
+}
74
+
75
+function _extent(_) {
76
+
77
+  if (arguments.length) {
78
+
79
+    extent = _;
80
+
81
+    var pos = audio.currentTime / audio.duration;
82
+
83
+    if (pos > extent[1] || audio.duration * extent[0] - audio.currentTime > 0.2 || !isPlaying()) {
84
+      pause(extent[0] * audio.duration);
85
+    }
86
+
87
+    minimap.time(pos);
88
+
89
+  } else {
90
+    return extent;
91
+  }
92
+}
93
+
94
+function src(file, cb) {
95
+
96
+  d3.select("audio")
97
+    .on("canplaythrough", cb)
98
+    .on("error", function(){
99
+      cb(d3.event.target.error);
100
+    })
101
+    .select("source")
102
+      .attr("type", file.type)
103
+      .attr("src", URL.createObjectURL(file));
104
+
105
+  audio.load();
106
+
107
+}
108
+
109
+function isPlaying() {
110
+  return audio.duration && !audio.paused && !audio.ended && 0 < audio.currentTime;
111
+}
112
+
113
+function _duration() {
114
+  return audio.duration;
115
+}
116
+
117
+module.exports = {
118
+  play: play,
119
+  pause: pause,
120
+  toggle: toggle,
121
+  src: src,
122
+  restart: restart,
123
+  duration: _duration
124
+};

+ 270 - 0
client/index.js View File

@@ -0,0 +1,270 @@
1
+var d3 = require("d3"),
2
+    $ = require("jquery"),
3
+    themes = require("../settings/themes.json"),
4
+    preview = require("./preview.js"),
5
+    video = require("./video.js"),
6
+    audio = require("./audio.js");
7
+
8
+preloadImages();
9
+
10
+function submitted() {
11
+
12
+  d3.event.preventDefault();
13
+
14
+  var theme = preview.theme(),
15
+      caption = preview.caption(),
16
+      selection = preview.selection(),
17
+      file = preview.file();
18
+
19
+  if (!file) {
20
+    d3.select("#row-audio").classed("error", true);
21
+    return setClass("error", "No audio file selected.");
22
+  }
23
+
24
+  if (theme.maxDuration && selection.duration > theme.maxDuration) {
25
+    return setClass("error", "Your Audiogram must be under " + theme.maxDuration + " seconds.");
26
+  }
27
+
28
+  if (!theme || !theme.width || !theme.height) {
29
+    return setClass("error", "No valid theme detected.");
30
+  }
31
+
32
+  video.kill();
33
+  audio.pause();
34
+
35
+  var formData = new FormData();
36
+
37
+  var settings = $.extend({}, theme, {
38
+    caption: caption,
39
+    start: selection.start,
40
+    end: selection.end
41
+  });
42
+
43
+  delete settings.backgroundImageFile;
44
+
45
+  formData.append("audio", file);
46
+  formData.append("settings", JSON.stringify(settings));
47
+
48
+  setClass("loading");
49
+  d3.select("#loading-message").text("Uploading audio...");
50
+
51
+	$.ajax({
52
+		url: "/submit/",
53
+		type: "POST",
54
+		data: formData,
55
+		contentType: false,
56
+    dataType: "json",
57
+		cache: false,
58
+		processData: false,
59
+		success: function(data){
60
+      poll(data.id, 0);
61
+		},
62
+    error: error
63
+
64
+  });
65
+
66
+}
67
+
68
+function poll(id) {
69
+
70
+  setTimeout(function(){
71
+    $.ajax({
72
+      url: "/status/" + id + "/",
73
+      error: error,
74
+      dataType: "json",
75
+      success: function(result){
76
+        if (result && result.status && result.status === "ready" && result.url) {
77
+          video.update(result.url, preview.theme().name);
78
+          setClass("rendered");
79
+        } else {
80
+          d3.select("#loading-message").text(statusMessage(result));
81
+          poll(id);
82
+        }
83
+      }
84
+    });
85
+
86
+  }, 2500);
87
+
88
+}
89
+
90
+function error(msg) {
91
+
92
+  if (msg.responseText) {
93
+    msg = msg.responseText;
94
+  }
95
+
96
+  if (typeof msg !== "string") {
97
+    msg = JSON.stringify(msg);
98
+  }
99
+
100
+  d3.select("#loading-message").text("Loading...");
101
+  setClass("error", msg);
102
+
103
+}
104
+
105
+// Once images are downloaded, set up listeners
106
+function initialize(err, themesWithImages) {
107
+
108
+  // Populate dropdown menu
109
+  d3.select("#input-theme")
110
+    .on("change", updateTheme)
111
+    .selectAll("option")
112
+    .data(themesWithImages)
113
+    .enter()
114
+    .append("option")
115
+      .text(function(d){
116
+        return d.name;
117
+      });
118
+
119
+  // Get initial theme
120
+  d3.select("#input-theme").each(updateTheme);
121
+
122
+  // Get initial caption (e.g. back button)
123
+  d3.select("#input-caption").on("change keyup", updateCaption).each(updateCaption);
124
+
125
+  // Space bar listener for audio play/pause
126
+  d3.select(document).on("keypress", function(){
127
+    if (!d3.select("body").classed("rendered") && d3.event.key === " " && !d3.matcher("input, textarea, button, select").call(d3.event.target)) {
128
+      audio.toggle();
129
+    }
130
+  });
131
+
132
+  // Button listeners
133
+  d3.selectAll("#play, #pause").on("click", function(){
134
+    d3.event.preventDefault();
135
+    audio.toggle();
136
+  });
137
+
138
+  d3.select("#restart").on("click", function(){
139
+    d3.event.preventDefault();
140
+    audio.restart();
141
+  });
142
+
143
+  // If there's an initial piece of audio (e.g. back button) load it
144
+  d3.select("#input-audio").on("change", updateAudioFile).each(updateAudioFile);
145
+
146
+  d3.select("#return").on("click", function(){
147
+    d3.event.preventDefault();
148
+    video.kill();
149
+    setClass(null);
150
+  });
151
+
152
+  d3.select("#submit").on("click", submitted);
153
+
154
+}
155
+
156
+function updateAudioFile() {
157
+
158
+  d3.select("#row-audio").classed("error", false);
159
+
160
+  // Skip if empty
161
+  if (!this.files || !this.files[0]) {
162
+    d3.select("#minimap").classed("hidden", true);
163
+    preview.file(null);
164
+    setClass(null);
165
+    return true;
166
+  }
167
+
168
+  d3.select("#loading-message").text("Analyzing...");
169
+
170
+  setClass("loading");
171
+
172
+  preview.loadAudio(this.files[0], function(err){
173
+
174
+    if (err) {
175
+      d3.select("#row-audio").classed("error", true);
176
+      setClass("error", "Error decoding audio file");
177
+    } else {
178
+      setClass(null);
179
+    }
180
+
181
+    d3.selectAll("#minimap, #submit").classed("hidden", !!err);
182
+
183
+  });
184
+
185
+}
186
+
187
+function updateCaption() {
188
+  preview.caption(this.value);
189
+}
190
+
191
+function updateTheme() {
192
+  var extended = $.extend({}, themes.default, d3.select(this.options[this.selectedIndex]).datum());
193
+  preview.theme(extended);
194
+}
195
+
196
+function preloadImages() {
197
+
198
+  // preload images
199
+  var imageQueue = d3.queue();
200
+
201
+  d3.entries(themes).forEach(function(theme){
202
+
203
+    if (!theme.value.name) {
204
+      theme.value.name = theme.key;
205
+    }
206
+
207
+    if (theme.key !== "default") {
208
+      imageQueue.defer(getImage, theme.value);
209
+    }
210
+
211
+  });
212
+
213
+  imageQueue.awaitAll(initialize);
214
+
215
+  function getImage(theme,cb) {
216
+
217
+    if (!theme.backgroundImage) {
218
+      return cb(null,theme);
219
+    }
220
+
221
+    theme.backgroundImageFile = new Image();
222
+    theme.backgroundImageFile.onload = function(){
223
+      return cb(null,theme);
224
+    };
225
+    theme.backgroundImageFile.onerror = function(e){
226
+      console.warn(e);
227
+      return cb(null,theme);
228
+    };
229
+
230
+    theme.backgroundImageFile.src = "img/" + theme.backgroundImage;
231
+
232
+  }
233
+
234
+}
235
+
236
+function setClass(cl, msg) {
237
+  d3.select("body").attr("class", cl || null);
238
+  d3.select("#error").text(msg || "");
239
+}
240
+
241
+function statusMessage(result) {
242
+
243
+  switch (result.status) {
244
+    case "queued":
245
+      return "Waiting for other jobs to finish, #" + (result.position + 1) + " in queue";
246
+    case "audio-download":
247
+      return "Downloading audio for processing";
248
+    case "trim":
249
+      return "Trimming audio";
250
+    case "duration":
251
+      return "Checking duration";
252
+    case "waveform":
253
+      return "Analyzing waveform";
254
+    case "renderer":
255
+      return "Initializing renderer";
256
+    case "frames":
257
+      var msg = "Generating frames";
258
+      if (result.numFrames) {
259
+        msg += ", " + Math.round(100 * (result.framesComplete || 0) / result.numFrames) + "% complete";
260
+      }
261
+      return msg;
262
+    case "combine":
263
+      return "Combining frames with audio";
264
+    case "ready":
265
+      return "Cleaning up";
266
+    default:
267
+      return JSON.stringify(result);
268
+  }
269
+
270
+}

+ 98 - 0
client/minimap.js View File

@@ -0,0 +1,98 @@
1
+var d3 = require("d3");
2
+
3
+var minimap = d3.select("#minimap"),
4
+    svg = minimap.select("svg"),
5
+    onBrush = onBrushEnd = function(){};
6
+
7
+var t = d3.scaleLinear()
8
+  .domain([0, 640])
9
+  .range([0,1])
10
+  .clamp(true);
11
+
12
+var y = d3.scaleLinear()
13
+  .domain([0, 1])
14
+  .range([40, 0]);
15
+
16
+var line = d3.line();
17
+
18
+var brush = d3.brushX()
19
+  .on("brush end", brushed)
20
+
21
+minimap.select(".brush").call(brush)
22
+  .selectAll("rect")
23
+  .attr("height", 80);
24
+
25
+minimap.selectAll(".brush .resize")
26
+  .append("line")
27
+  .attr("x1",0)
28
+  .attr("x2",0)
29
+  .attr("y1",0)
30
+  .attr("y2", 80);
31
+
32
+function redraw(data) {
33
+
34
+  brush.move(d3.select(".brush"), [0, 0]);
35
+
36
+  var top = data.map(function(d,i){
37
+    return [i, y(d)];
38
+  });
39
+
40
+  var bottom = top.map(function(d){
41
+    return [d[0], 80 - d[1]];
42
+  }).reverse();
43
+
44
+  d3.selectAll("g.waveform path")
45
+    .attr("d",line(top.concat(bottom)));
46
+
47
+  time(0);
48
+
49
+}
50
+
51
+function time(t) {
52
+  d3.select("g.time")
53
+    .attr("transform","translate(" + (t * 640) + ")");
54
+}
55
+
56
+function brushed() {
57
+
58
+  var start = d3.event.selection ? t(d3.event.selection[0]) : 0,
59
+      end = d3.event.selection ? t(d3.event.selection[1]) : 1;
60
+
61
+  if (start === end) {
62
+    start = 0;
63
+    end = 1;
64
+  } else {
65
+    if (start <= 0.01) {
66
+      start = 0;
67
+    }
68
+    if (end >= 0.99) {
69
+      end = 1;
70
+    }
71
+  }
72
+
73
+  d3.select("clipPath rect")
74
+      .attr("x", t.invert(start))
75
+      .attr("width", t.invert(end - start));
76
+
77
+  onBrush([start, end]);
78
+
79
+  if (d3.event.type === "end") {
80
+    onBrushEnd([start, end]);
81
+  }
82
+
83
+}
84
+
85
+function _onBrush(_) {
86
+  onBrush = _;
87
+}
88
+
89
+function _onBrushEnd(_) {
90
+  onBrushEnd = _;
91
+}
92
+
93
+module.exports = {
94
+  time: time,
95
+  redraw: redraw,
96
+  onBrush: _onBrush,
97
+  onBrushEnd: _onBrushEnd
98
+};

+ 113 - 0
client/preview.js View File

@@ -0,0 +1,113 @@
1
+var d3 = require("d3"),
2
+    audio = require("./audio.js"),
3
+    video = require("./video.js"),
4
+    minimap = require("./minimap.js"),
5
+    getWaveform = require("./waveform.js");
6
+
7
+var context = d3.select("canvas").node().getContext("2d");
8
+
9
+var renderer = require("../renderer/")(context);
10
+
11
+var theme,
12
+    caption,
13
+    file,
14
+    selection;
15
+
16
+function _file(_) {
17
+  return arguments.length ? (file = _) : file;
18
+}
19
+
20
+function _theme(_) {
21
+  return arguments.length ? (theme = _, redraw()) : theme;
22
+}
23
+
24
+function _caption(_) {
25
+  return arguments.length ? (caption = _, redraw()) : caption;
26
+}
27
+
28
+function _selection(_) {
29
+  return arguments.length ? (selection = _) : selection;
30
+}
31
+
32
+minimap.onBrush(function(extent){
33
+
34
+  var duration = audio.duration();
35
+
36
+  selection = {
37
+    duration: duration * (extent[1] - extent[0]),
38
+    start: extent[0] ? extent[0] * duration : null,
39
+    end: extent[1] < 1 ? extent[1] * duration : null
40
+  };
41
+
42
+  d3.select("#duration strong").text(Math.round(10 * selection.duration) / 10)
43
+    .classed("red", theme && theme.maxDuration && theme.maxDuration < selection.duration);
44
+
45
+});
46
+
47
+// Resize video and preview canvas to maintain aspect ratio
48
+function resize(width, height) {
49
+
50
+  var widthFactor = 640 / width,
51
+      heightFactor = 360 / height,
52
+      factor = Math.min(widthFactor, heightFactor);
53
+
54
+  d3.select("canvas")
55
+    .attr("width", factor * width)
56
+    .attr("height", factor * height);
57
+
58
+  d3.select("#canvas")
59
+    .style("width", (factor * width) + "px");
60
+
61
+  d3.select("video")
62
+    .attr("height", widthFactor * height);
63
+
64
+  d3.select("#video")
65
+    .attr("height", (widthFactor * height) + "px");
66
+
67
+  context.setTransform(factor, 0, 0, factor, 0, 0);
68
+
69
+}
70
+
71
+function redraw() {
72
+
73
+  resize(theme.width, theme.height);
74
+
75
+  video.kill();
76
+
77
+  renderer.update(theme);
78
+  renderer.caption = caption;
79
+  renderer.backgroundImage = theme.backgroundImageFile || null;
80
+  renderer.drawFrame(0);
81
+
82
+}
83
+
84
+function loadAudio(f, cb) {
85
+
86
+  audio.pause();
87
+  video.kill();
88
+
89
+  d3.queue()
90
+    .defer(getWaveform, f)
91
+    .defer(audio.src, f)
92
+    .await(function(err, data){
93
+
94
+      if (err) {
95
+        return cb(err);
96
+      }
97
+
98
+      file = f;
99
+      minimap.redraw(data.peaks);
100
+
101
+      cb(err);
102
+
103
+    });
104
+
105
+}
106
+
107
+module.exports = {
108
+  caption: _caption,
109
+  theme: _theme,
110
+  file: _file,
111
+  selection: _selection,
112
+  loadAudio: loadAudio
113
+};

+ 36 - 0
client/video.js View File

@@ -0,0 +1,36 @@
1
+var d3 = require("d3");
2
+
3
+var video = document.querySelector("video");
4
+
5
+function kill() {
6
+
7
+  // Pause the video if it's playing
8
+  if (!video.paused && !video.ended && 0 < video.currentTime) {
9
+    video.pause();
10
+  }
11
+
12
+  d3.select("body").classed("rendered", false);
13
+
14
+}
15
+
16
+function update(url, name) {
17
+
18
+  var timestamp = d3.timeFormat(" - %Y-%m-%d at %-I.%M%p")(new Date).toLowerCase(),
19
+      filename = (name || "Audiogram") + timestamp + ".mp4";
20
+
21
+  d3.select("#download")
22
+    .attr("download", filename)
23
+    .attr("href", url);
24
+
25
+  d3.select(video).select("source")
26
+    .attr("src", url);
27
+
28
+  video.load();
29
+  video.play();
30
+
31
+}
32
+
33
+module.exports = {
34
+  kill: kill,
35
+  update: update
36
+}

+ 70 - 0
client/waveform.js View File

@@ -0,0 +1,70 @@
1
+var extractPeaks = require("webaudio-peaks"),
2
+    d3 = require("d3");
3
+
4
+var width = 640;
5
+
6
+function decoded(cb) {
7
+
8
+  return function(decodedData) {
9
+
10
+    var duration = decodedData.duration;
11
+
12
+    var samplesPerPixel = Math.floor(decodedData.length / width);
13
+
14
+    var peaks = extractPeaks(decodedData, samplesPerPixel, true);
15
+
16
+    // FF and Chrome support Int8Array.filter, Safari doesn't, that's fun
17
+    var positive = Array.prototype.filter.call(peaks.data[0], function(d,i){
18
+      return i % 2;
19
+    });
20
+
21
+    var scale = d3.scaleLinear()
22
+      .domain([0, getMax(positive)])
23
+      .range([0, 1])
24
+      .clamp(true);
25
+
26
+    positive = Array.prototype.slice.call(positive).map(scale);
27
+
28
+    cb(null,{ duration: duration, peaks: positive });
29
+
30
+  };
31
+
32
+}
33
+
34
+module.exports = function(file, cb) {
35
+
36
+  var ctx = new (window.AudioContext || window.webkitAudioContext)();
37
+
38
+  var fileReader = new FileReader();
39
+
40
+  var close = function(err, data) {
41
+    ctx.close();
42
+    cb(err, data);
43
+  };
44
+
45
+  fileReader.onerror = cb;
46
+
47
+  fileReader.onload = function(){
48
+
49
+    ctx.decodeAudioData(this.result, decoded(close), function(err){ close(err || "Error decoding audio."); });
50
+
51
+  };
52
+
53
+  fileReader.readAsArrayBuffer(file);
54
+
55
+}
56
+
57
+// Faster
58
+function getMax(arr) {
59
+
60
+  var max = -Infinity;
61
+
62
+  for (var i = 0, l = arr.length; i < l; i++) {
63
+    if (arr[i] > max) {
64
+      max = arr[i];
65
+    }
66
+  }
67
+
68
+  return max;
69
+
70
+}

+ 138 - 0
editor/css/base.css View File

@@ -0,0 +1,138 @@
1
+html,
2
+body,
3
+div,
4
+span,
5
+object,
6
+iframe,
7
+h1,
8
+h2,
9
+h3,
10
+h4,
11
+h5,
12
+h6,
13
+p,
14
+blockquote,
15
+pre,
16
+a,
17
+abbr,
18
+address,
19
+cite,
20
+code,
21
+del,
22
+dfn,
23
+em,
24
+img,
25
+ins,
26
+q,
27
+small,
28
+strong,
29
+sub,
30
+sup,
31
+dl,
32
+dt,
33
+dd,
34
+ol,
35
+ul,
36
+li,
37
+fieldset,
38
+form,
39
+label,
40
+legend,
41
+table,
42
+caption,
43
+tbody,
44
+tfoot,
45
+thead,
46
+tr,
47
+th,
48
+td {
49
+  border: 0;
50
+  margin: 0;
51
+  padding: 0;
52
+}
53
+
54
+article,
55
+aside,
56
+figure,
57
+figure img,
58
+figcaption,
59
+hgroup,
60
+footer,
61
+header,
62
+nav,
63
+section,
64
+video,
65
+object,
66
+h1,
67
+h2,
68
+h3,
69
+h4,
70
+h5 {
71
+  display: block;
72
+}
73
+
74
+a img {
75
+  border: 0;
76
+}
77
+
78
+figure {
79
+  position: relative;
80
+}
81
+
82
+figure img {
83
+  width: 100%;
84
+}
85
+
86
+ul {
87
+  list-style: none;
88
+}
89
+
90
+* {
91
+  box-sizing: border-box;
92
+  -moz-box-sizing: border-box;
93
+  -webkit-box-sizing: border-box;
94
+}
95
+
96
+body {
97
+  background-color: #fff;
98
+  color: #000000;
99
+  font-size: 16px;
100
+  font-family: 'Source Sans Pro', Helvetica, Arial, sans-serif;
101
+}
102
+
103
+select,
104
+input,
105
+text,
106
+option,
107
+button {
108
+  font-size: 16px;
109
+  font-family: 'Source Sans Pro', Helvetica, Arial, sans-serif;
110
+}
111
+
112
+a {
113
+  text-decoration: none;
114
+}
115
+
116
+.clear {
117
+  clear: both;
118
+}
119
+
120
+div.container {
121
+  margin: 1rem auto 0 auto;
122
+}
123
+
124
+canvas,audio,svg {
125
+  vertical-align: top;
126
+}
127
+
128
+.left {
129
+  float: left;
130
+}
131
+
132
+.right {
133
+  float: right;
134
+}
135
+
136
+.hidden {
137
+  display: none;
138
+}

+ 303 - 0
editor/css/editor.css View File

@@ -0,0 +1,303 @@
1
+div.container {
2
+  width: 640px;
3
+  margin-bottom: 2rem;
4
+}
5
+
6
+h1 {
7
+  font-weight: 500;
8
+  margin-top: 2rem;
9
+  font-size: 48px;
10
+}
11
+
12
+.red {
13
+  color: #c00;
14
+}
15
+
16
+/* Buttons/controls */
17
+button,
18
+.button {
19
+  display: inline-block;
20
+  padding: 6px 12px;
21
+  margin-bottom: 0;
22
+  font-weight: 400;
23
+  text-align: center;
24
+  white-space: nowrap;
25
+  vertical-align: middle;
26
+  -ms-touch-action: manipulation;
27
+  touch-action: manipulation;
28
+  cursor: pointer;
29
+  -webkit-user-select: none;
30
+  -moz-user-select: none;
31
+  -ms-user-select: none;
32
+  user-select: none;
33
+  background-image: none;
34
+  border: 1px solid #ccc;
35
+  border-radius: 4px;
36
+  color: #333;
37
+  background-color: #fff;
38
+  margin: 3px;
39
+  outline: 0;
40
+  font-size: 16px;
41
+}
42
+
43
+#download {
44
+  padding: 9px 12px 6px 12px;
45
+  color: #fff;
46
+  background-color: #5bc0de;
47
+  border-color: #46b8da;
48
+}
49
+
50
+#download:hover {
51
+  background-color: #31b0d5;
52
+  border-color: #269abc;
53
+}
54
+
55
+button:hover,
56
+input[type='submit']:hover {
57
+  color: #333;
58
+  background-color: #e6e6e6;
59
+  border-color: #adadad;
60
+}
61
+
62
+button:active,
63
+#download:active,
64
+input[type='submit']:active {
65
+  box-shadow: inset 0 3px 5px rgba(0,0,0,.125);
66
+}
67
+
68
+#tip {
69
+  color: #333;
70
+  font-size: 13px;
71
+  text-align: left;
72
+  float: left;
73
+  margin-top: 3px;
74
+}
75
+
76
+#duration {
77
+  display: inline-block;
78
+  padding-top: 6px;
79
+  margin-bottom: 0;
80
+  font-weight: 400;
81
+  vertical-align: middle;
82
+  color: #333;
83
+  font-size: 13px;
84
+  margin-right: 3px;
85
+}
86
+
87
+.note {
88
+  color: #999;
89
+  font-size: 12px;
90
+}
91
+
92
+#play {
93
+  width: 66px;
94
+}
95
+
96
+#controls {
97
+  text-align: right;
98
+}
99
+
100
+#controls i.fa {
101
+  margin-right: 4px;
102
+}
103
+
104
+i.fa {
105
+  margin-right: 6px;
106
+}
107
+
108
+i.fa-play {
109
+    position: relative;
110
+    bottom: 1px;
111
+}
112
+
113
+#controls button {
114
+  padding: 3px 6px;
115
+  font-size: 13px;
116
+}
117
+
118
+#error {
119
+  color: #c00;
120
+  font-weight: 600;
121
+}
122
+
123
+.row.error input,
124
+.row.error label {
125
+  color: #c00;
126
+}
127
+
128
+/* Form */
129
+.row {
130
+  margin: 1rem 0;
131
+}
132
+
133
+label {
134
+  display: block;
135
+  margin-bottom: 0.25rem;
136
+  font-weight: 500;
137
+}
138
+
139
+select {
140
+  margin-top: 6px;
141
+  padding: 6px;
142
+}
143
+
144
+input[type='text'] {
145
+  width: 100%;
146
+  padding: 6px;
147
+  color: #666;
148
+  font-size: 16px;
149
+  border: 1px solid #ddd;
150
+  border-radius: 4px;
151
+  box-shadow: 0 0 0 0;
152
+}
153
+
154
+/* Preview canvas */
155
+#canvas {
156
+  position: relative;
157
+}
158
+
159
+#preview-label {
160
+  position: absolute;
161
+  top: 0;
162
+  left: 0;
163
+  padding: 0.5rem;
164
+  text-transform: uppercase;
165
+  opacity: 0.2;
166
+  font-size: 28px;
167
+}
168
+
169
+#canvas,
170
+#video {
171
+  margin-left: auto;
172
+  margin-right: auto;
173
+}
174
+
175
+/* Minimap */
176
+svg {
177
+  border-top: 1px solid #444;
178
+  background-color: #dedede;
179
+}
180
+
181
+g.background path {
182
+  fill: #999;
183
+}
184
+
185
+g.background line {
186
+  stroke: #999;
187
+}
188
+
189
+g.foreground path {
190
+  fill: #de1e3d;
191
+}
192
+
193
+g.foreground line {
194
+  stroke: #de1e3d;
195
+}
196
+
197
+g.brush .extent {
198
+  fill: none;
199
+  stroke: none;
200
+}
201
+
202
+g.brush line {
203
+  stroke-width: 3px;
204
+  stroke: #de1e3d;
205
+}
206
+
207
+g.time {
208
+  pointer-events: none;
209
+}
210
+
211
+g.time line {
212
+  stroke-width: 1px;
213
+  stroke: #0eb8ba;
214
+}
215
+
216
+/* UI states */
217
+#audio {
218
+  display: none;
219
+}
220
+
221
+#loading,
222
+#error,
223
+#video,
224
+#download,
225
+#return,
226
+.loading #loaded,
227
+.rendered #preview,
228
+.rendered #submit,
229
+.rendered .form-row {
230
+  display: none;
231
+}
232
+
233
+.loading #loading,
234
+.error #error,
235
+.rendered #video {
236
+  display: block;
237
+}
238
+
239
+.rendered #download,
240
+.rendered #return {
241
+  display: inline-block;
242
+}
243
+
244
+/* Loading styles */
245
+#loading-message {
246
+  font-size: 24px;
247
+  text-align: center;
248
+  color: #de1e3d;
249
+}
250
+
251
+#loading-bars {
252
+  margin: 60px auto 40px auto;
253
+  width: 200px;
254
+  height: 120px;
255
+  text-align: center;
256
+  font-size: 10px;
257
+}
258
+
259
+#loading-bars > div {
260
+  background-color: #de1e3d;
261
+  height: 100%;
262
+  width: 16px;
263
+  margin-right: 3px;
264
+  display: inline-block;
265
+  -webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out;
266
+  animation: sk-stretchdelay 1.2s infinite ease-in-out;
267
+}
268
+
269
+#loading-bars .r2 {
270
+  -webkit-animation-delay: -1.1s;
271
+  animation-delay: -1.1s;
272
+}
273
+
274
+#loading-bars .r3 {
275
+  -webkit-animation-delay: -1.0s;
276
+  animation-delay: -1.0s;
277
+}
278
+
279
+#loading-bars .r4 {
280
+  -webkit-animation-delay: -0.9s;
281
+  animation-delay: -0.9s;
282
+}
283
+
284
+#loading-bars .r5 {
285
+  -webkit-animation-delay: -0.8s;
286
+  animation-delay: -0.8s;
287
+  margin-right: 0;
288
+}
289
+
290
+@-webkit-keyframes sk-stretchdelay {
291
+  0%, 40%, 100% { -webkit-transform: scaleY(0.4) }
292
+  20% { -webkit-transform: scaleY(1.0) }
293
+}
294
+
295
+@keyframes sk-stretchdelay {
296
+  0%, 40%, 100% {
297
+    transform: scaleY(0.4);
298
+    -webkit-transform: scaleY(0.4);
299
+  }  20% {
300
+    transform: scaleY(1.0);
301
+    -webkit-transform: scaleY(1.0);
302
+  }
303
+}

+ 94 - 0
editor/index.html View File

@@ -0,0 +1,94 @@
1
+<!DOCTYPE html>
2
+<html lang="en">
3
+  <head>
4
+    <title>Audiogram</title>
5
+    <meta charset="utf-8" />
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">
9
+    <link href="css/base.css" rel="stylesheet" type="text/css">
10
+    <link href="css/editor.css" rel="stylesheet" type="text/css">
11
+  </head>
12
+  <body class="loading">
13
+    <div class="container">
14
+      <h1>Audiogram</h1>
15
+      <div id="loading">
16
+        <div id="loading-bars">
17
+          <div class="r1"></div><div class="r2"></div><div class="r3"></div><div class="r4"></div><div class="r5"></div>
18
+        </div>
19
+        <div id="loading-message">Loading...</div>
20
+      </div><!-- #loading -->
21
+      <div id="loaded">
22
+        <div id="error"></div>
23
+        <div class="row form-row" id="row-audio">
24
+          <label for="input-audio">Audio</label>
25
+          <input id="input-audio" name="audio" type="file" />
26
+        </div>
27
+        <div class="row form-row" id="row-theme">
28
+          <label for="input-theme">Theme</label>
29
+          <select id="input-theme" name="theme"></select>
30
+        </div>
31
+        <div class="row form-row" id="row-caption">
32
+          <label for="input-caption">
33
+            Caption
34
+            <div class="note">Add two spaces in a row to force a line break.</div>
35
+          </label>
36
+          <input id="input-caption" name="caption" type="text" autocomplete="off" placeholder="Add a caption" />
37
+        </div>
38
+        <div id="preview">
39
+          <div style="background-color: black;">
40
+            <div id="canvas">
41
+              <canvas width="640" height="360"></canvas>
42
+              <div id="preview-label">Preview</div>
43
+            </div>
44
+          </div>
45
+          <div id="minimap" class="hidden">
46
+            <svg width="640" height="80" xmlns="http://www.w3.org/2000/svg">
47
+              <defs>
48
+                <clipPath id="clip">
49
+                  <rect height="80" width="640" x="0" y="0"></rect>
50
+                </clipPath>
51
+              </defs>
52
+              <g class="waveform background">
53
+                <line x1="0" x2="640" y1="40" y2="40"></line>
54
+                <path></path>
55
+              </g>
56
+              <g class="waveform foreground" clip-path="url(#clip)">
57
+                <line x1="0" x2="640" y1="40" y2="40"></line>
58
+                <path></path>
59
+              </g>
60
+              <g class="brush"></g>
61
+              <g class="time">
62
+                <line x1="0" x2="0" y1="0" y2="80"></line>
63
+              </g>
64
+            </svg>
65
+            <div id="controls">
66
+              <span id="tip">Click and drag over the waveform to clip audio<br />Use space bar to play/pause</span>
67
+              <span id="duration"><strong></strong> seconds selected</span>
68
+              <button id="play"><i class="fa fa-play"></i>Play</button>
69
+              <button id="pause" class="hidden"><i class="fa fa-pause"></i>Pause</button>
70
+              <button id="restart"><i class="fa fa-step-backward"></i>Restart</button>
71
+            </div>
72
+          </div>
73
+        </div>
74
+        <div id="audio">
75
+          <audio controls>
76
+            <source />
77
+          </audio>
78
+        </div>
79
+        <div id="video">
80
+          <video width="640" height="360" controls>
81
+            <source type="video/mp4" />
82
+          </video>
83
+        </div>
84
+        <div class="row" id="row-submit">
85
+          <button type="button" id="return" class="left"><i class="fa fa-edit"></i>Return to editor</button>
86
+          <button type="button" id="submit" class="right hidden"><i class="fa fa-cloud-upload"></i>Generate</button>
87
+          <a id="download" class="button right" href="#" target="_blank"><i class="fa fa-download"></i>Download</a>
88
+          <div class="clear"></div>
89
+        </div>
90
+      </div><!-- #loaded -->
91
+    </div><!-- .container -->
92
+    <script src="js/bundle.js"></script>
93
+  </body>
94
+</html>

+ 38 - 0
lib/logger/index.js View File

@@ -0,0 +1,38 @@
1
+var winston = require("winston"),
2
+    morgan = require("morgan");
3
+
4
+winston.setLevels({ error: 0, info: 1, debug: 2, web: 3 });
5
+
6
+winston.level = process.env.DEBUG ? "debug" : "info";
7
+
8
+function log(msg, level) {
9
+
10
+  if (!level) {
11
+    level = "info";
12
+  }
13
+
14
+  // TODO Add timestamp
15
+
16
+  winston.log(level, msg);
17
+
18
+}
19
+
20
+function debug(msg) {
21
+
22
+  log(msg, "debug");
23
+
24
+}
25
+
26
+var stream = {
27
+  write: function(msg) {
28
+    log(msg, "web");
29
+  }
30
+};
31
+
32
+module.exports = {
33
+  log: log,
34
+  debug: debug,
35
+  morgan: function() {
36
+    return morgan("combined", { "stream": stream });
37
+  }
38
+};

+ 10 - 0
lib/register-fonts.js View File

@@ -0,0 +1,10 @@
1
+var fonts = require("../settings/").fonts,
2
+    _ = require("underscore"),
3
+    Canvas = require("canvas");
4
+
5
+// Register custom fonts one time
6
+if (Array.isArray(fonts)) {
7
+  fonts.forEach(function(font){
8
+    Canvas.registerFont(font.file, _.pick(font, "family", "weight", "style"));
9
+  });
10
+}

+ 6 - 0
lib/transports/index.js View File

@@ -0,0 +1,6 @@
1
+var extend = require("underscore").extend,
2
+    serverSettings = require("../../settings/"),
3
+    s3 = require("./s3")(serverSettings),
4
+    redis = require("./redis")(serverSettings);
5
+
6
+module.exports = extend({}, redis, s3);

+ 124 - 0
lib/transports/redis/fake.js View File

@@ -0,0 +1,124 @@
1
+var fs = require("fs"),
2
+    mkdirp = require("mkdirp"),
3
+    path = require("path"),
4
+    filename = path.join(__dirname, "..", "..", "..", ".jobs");
5
+
6
+// Initialize if doesn't exist
7
+try {
8
+  fs.statSync(filename);
9
+} catch(e) {
10
+  fs.writeFileSync(filename, "{}");
11
+}
12
+
13
+function load() {
14
+  return JSON.parse(fs.readFileSync(filename, {encoding: "utf8"}));
15
+}
16
+
17
+function write(obj) {
18
+  fs.writeFileSync(filename, JSON.stringify(obj));
19
+}
20
+
21
+// Read synchronously, make an update, rewrite
22
+function update(cb) {
23
+
24
+  write(cb(load()));
25
+
26
+}
27
+
28
+module.exports = function() {
29
+
30
+  function hset(key, field, value) {
31
+
32
+    update(function(current){
33
+
34
+      current[key] = current[key] || {};
35
+      current[key][field] = value;
36
+
37
+      return current;
38
+
39
+    });
40
+
41
+  }
42
+
43
+  function hgetall(key, cb) {
44
+
45
+    var current = load();
46
+    return cb(null, current[key] === undefined ? null : current[key]);
47
+
48
+  }
49
+
50
+  function hincr(key, field) {
51
+
52
+    update(function(current){
53
+
54
+      current[key] = current[key] || {};
55
+      current[key][field] = current[key][field] || 0;
56
+      current[key][field]++;
57
+
58
+      return current;
59
+
60
+    });
61
+
62
+  }
63
+
64
+  function getJobList(cb) {
65
+    var current = load();
66
+    return cb(null, current.jobs || []);
67
+
68
+  }
69
+
70
+  function addJob(settings) {
71
+
72
+    update(function(current){
73
+
74
+      current.jobs = current.jobs || [];
75
+      current.jobs.push(settings);
76
+
77
+      return current;
78
+
79
+    });
80
+
81
+  }
82
+
83
+  function getJob(cb) {
84
+
85
+    var job = null;
86
+
87
+    update(function(current){
88
+
89
+      if (current.jobs && current.jobs.length) {
90
+        job = current.jobs.shift();
91
+      }
92
+
93
+      return current;
94
+
95
+    });
96
+
97
+    return cb(null, job);
98
+
99
+  }
100
+
101
+  function quit() { }
102
+
103
+  function clean(cb) {
104
+    fs.unlink(filename, cb);
105
+  }
106
+
107
+  // Random delay to minimize collision w/o Redis
108
+  function delay() {
109
+    return Math.random() * 1000;
110
+  }
111
+
112
+  return {
113
+    setField: hset,
114
+    getHash: hgetall,
115
+    incrementField: hincr,
116
+    getJobList: getJobList,
117
+    addJob: addJob,
118
+    getJob: getJob,
119
+    quit: quit,
120
+    cleanJobs: clean,
121
+    workerDelay: delay
122
+  };
123
+
124
+};

+ 9 - 0
lib/transports/redis/index.js View File

@@ -0,0 +1,9 @@
1
+module.exports = function(settings) {
2
+
3
+  if (settings.redisHost) {
4
+    return require("./remote")(settings.redisHost);
5
+  } else {
6
+    return require("./fake")();
7
+  }
8
+
9
+};

+ 75 - 0
lib/transports/redis/remote.js View File

@@ -0,0 +1,75 @@
1
+var redis = require("redis"),
2
+    queue = require("d3").queue;
3
+
4
+module.exports = function(host) {
5
+
6
+  // Prefix all keys to avoid collisions
7
+  var prefix = "audiogram:";
8
+
9
+  var client = redis.createClient({ host: host });
10
+
11
+  client.on("error", function(err) {
12
+    throw err;
13
+  });
14
+
15
+  function hset(key, field, value) {
16
+    client.hset(prefix + key, field, value);
17
+  }
18
+
19
+  function hgetall(key, cb) {
20
+    client.hgetall(prefix + key, cb);
21
+  }
22
+
23
+  function hincr(key, field) {
24
+    client.hincrby(prefix + key, field, 1);
25
+  }
26
+
27
+  function getJobList(cb) {
28
+    client.lrange(prefix + "jobs", 0, -1, function(err, jobs) {
29
+      if (!err && jobs) {
30
+        jobs = jobs.map(function(job){
31
+          return JSON.parse(job);
32
+        });
33
+      }
34
+      cb(err,jobs);
35
+    });
36
+  }
37
+
38
+  function addJob(settings) {
39
+    client.rpush(prefix + "jobs", JSON.stringify(settings));
40
+  }
41
+
42
+  function getJob(cb) {
43
+    client.blpop(prefix + "jobs", 5, function(err, job) {
44
+      cb(err, job ? JSON.parse(job[1]) : null);
45
+    });
46
+  }
47
+
48
+  function quit() {
49
+    client.quit();
50
+  }
51
+
52
+  function clean(cb) {
53
+    client.keys(prefix + "*", function(err, keys){
54
+
55
+      if (err || !keys.length) {
56
+        return cb(err);
57
+      }
58
+
59
+      client.del(keys, cb);
60
+
61
+    });
62
+  }
63
+
64
+  return {
65
+    setField: hset,
66
+    getHash: hgetall,
67
+    incrementField: hincr,
68
+    getJobList: getJobList,
69
+    addJob: addJob,
70
+    getJob: getJob,
71
+    quit: quit,
72
+    cleanJobs: clean
73
+  };
74
+
75
+};

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

@@ -0,0 +1,48 @@
1
+var fs = require("fs"),
2
+    path = require("path"),
3
+    rimraf = require("rimraf"),
4
+    mkdirp = require("mkdirp");
5
+
6
+module.exports = function(storagePath) {
7
+
8
+  storagePath = storagePath || "";
9
+
10
+  if (!path.isAbsolute(storagePath)) {
11
+    storagePath = path.join(__dirname, "..", "..", "..", storagePath);
12
+  }
13
+
14
+  function copy(source, destination, cb) {
15
+
16
+    if (!path.isAbsolute(source)) {
17
+      source = path.join(storagePath, source);
18
+    }
19
+
20
+    if (!path.isAbsolute(destination)) {
21
+      destination = path.join(storagePath, destination);
22
+    }
23
+
24
+    mkdirp.sync(path.dirname(destination));
25
+
26
+    var readable = fs.createReadStream(source).on("error", cb),
27
+        writeable = fs.createWriteStream(destination).on("error", cb).on("close", cb);
28
+
29
+    readable.pipe(writeable);
30
+
31
+  }
32
+
33
+  function clean(cb) {
34
+    rimraf(storagePath, cb);
35
+  }
36
+
37
+  function getURL(id) {
38
+    return "/video/" + id + ".mp4";
39
+  }
40
+
41
+  return {
42
+    upload: copy,
43
+    download: copy,
44
+    getURL: getURL,
45
+    clean: clean
46
+  };
47
+
48
+};

+ 13 - 0
lib/transports/s3/index.js View File

@@ -0,0 +1,13 @@
1
+module.exports = function(settings) {
2
+
3
+  var s3 = settings.s3Bucket ? require("./remote")(settings.s3Bucket, settings.storagePath) : require("./fake")(settings.storagePath);
4
+
5
+  return {
6
+    uploadAudio: s3.upload,
7
+    uploadVideo: s3.upload,
8
+    downloadAudio: s3.download,
9
+    getURL: s3.getURL,
10
+    cleanFiles: s3.clean
11
+  };
12
+
13
+}

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

@@ -0,0 +1,99 @@
1
+var AWS = require("aws-sdk"),
2
+    fs = require("fs");
3
+
4
+module.exports = function(bucket, storagePath) {
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 } });
22
+
23
+  // Test credentials
24
+  s3.headBucket({}, function(err){ if (err) { throw err; } });
25
+
26
+  function upload(source, key, cb) {
27
+
28
+    var params = {
29
+      Key: storagePath + key,
30
+      Body: fs.createReadStream(source),
31
+      ACL: "public-read"
32
+    };
33
+
34
+    // gzipping results in inconsistent file size :(
35
+    s3.upload(params, cb);
36
+
37
+  }
38
+
39
+  function download(key, destination, cb) {
40
+
41
+    var file = fs.createWriteStream(destination)
42
+      .on("error", cb)
43
+      .on("close", cb);
44
+
45
+    s3.getObject({ Key: storagePath + key })
46
+      .createReadStream()
47
+      .pipe(file);
48
+
49
+  }
50
+
51
+  function clean(cb) {
52
+
53
+    s3.listObjects({ Prefix: storagePath }, function(err, data){
54
+
55
+      if (err || !data.Contents || !data.Contents.length) {
56
+        return cb(err);
57
+      }
58
+
59
+      var objects = data.Contents.map(function(obj) {
60
+        return { Key: obj.Key };
61
+      });
62
+
63
+      deleteObjects(objects, !!data.IsTruncated, cb);
64
+
65
+    });
66
+
67
+  }
68
+
69
+  function deleteObjects(objects, truncated, cb) {
70
+
71
+    s3.deleteObjects({ Delete: { Objects: objects } }, function(err, data){
72
+
73
+      if (err) {
74
+        return cb(err);
75
+      }
76
+
77
+      if (truncated) {
78
+        return clean(cb);
79
+      }
80
+
81
+      return cb(null);
82
+
83
+    });
84
+
85
+  }
86
+
87
+  // TODO make this more configurable
88
+  function getURL(id) {
89
+    return "https://s3.amazonaws.com/" + bucket + "/" + storagePath + "video/" + id + ".mp4";
90
+  }
91
+
92
+  return {
93
+    upload: upload,
94
+    download: download,
95
+    getURL: getURL,
96
+    clean: clean
97
+  };
98
+
99
+};

+ 52 - 0
package.json View File

@@ -0,0 +1,52 @@
1
+{
2
+  "name": "audiogram",
3
+  "version": "0.9.0",
4
+  "description": "Turn audio into a shareable video.",
5
+  "main": "index.js",
6
+  "scripts": {
7
+    "start": "bin/server",
8
+    "worker": "bin/worker",
9
+    "postinstall": "mkdir -p editor/js && browserify client/index.js > editor/js/bundle.js",
10
+    "rebuild": "npm run postinstall",
11
+    "watch": "mkdir -p editor/js && watchify client/index.js -o editor/js/bundle.js",
12
+    "debug": "npm run postinstall && DEBUG=1 npm start",
13
+    "test": "tape 'test/**/*-test.js'"
14
+  },
15
+  "repository": {
16
+    "type": "git",
17
+    "url": "https://github.com/nypublicradio/audiogram.git"
18
+  },
19
+  "keywords": [],
20
+  "author": {
21
+    "name": "Noah Veltman",
22
+    "url": "https://twitter.com/veltman"
23
+  },
24
+  "license": "MIT",
25
+  "dependencies": {
26
+    "aws-sdk": "^2.2.39",
27
+    "browserify": "^13.0.0",
28
+    "compression": "^1.6.1",
29
+    "d3": "^4.1.1",
30
+    "dotenv": "^2.0.0",
31
+    "express": "^4.13.3",
32
+    "jquery": "^2.2.1",
33
+    "mkdirp": "^0.5.1",
34
+    "morgan": "^1.7.0",
35
+    "multer": "^1.1.0",
36
+    "node-ffprobe": "^1.2.2",
37
+    "node-uuid": "^1.4.7",
38
+    "redis": "^2.4.2",
39
+    "rimraf": "^2.5.0",
40
+    "smartquotes": "^1.0.0",
41
+    "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",
45
+    "winston": "^2.2.0"
46
+  },
47
+  "devDependencies": {
48
+    "supertest": "^1.2.0",
49
+    "tape": "^4.6.0",
50
+    "watchify": "^3.7.0"
51
+  }
52
+}

+ 55 - 0
renderer/index.js View File

@@ -0,0 +1,55 @@
1
+var d3 = require("d3"),
2
+    patterns = require("./patterns.js"),
3
+    sample = require("./sample-wave.js"),
4
+    textWrapper = require("./text-wrapper.js");
5
+
6
+module.exports = function(context) {
7
+
8
+  context.patternQuality = "best";
9
+
10
+  var renderer = {};
11
+
12
+  renderer.context = context;
13
+
14
+  renderer.update = function(options) {
15
+    options.backgroundColor = options.backgroundColor || "#fff";
16
+    options.waveColor = options.waveColor || options.foregroundColor || "#000";
17
+    options.captionColor = options.captionColor || options.foregroundColor || "#000";
18
+    this.wrapText = textWrapper(context, options);
19
+    this.options = options;
20
+    this.waveform = options.waveform || [sample.slice(0, options.samplesPerFrame)];
21
+    return this;
22
+  };
23
+
24
+  // Get the waveform data for this frame
25
+  renderer.getWaveform = function(frameNumber) {
26
+    return this.waveform[Math.min(this.waveform.length - 1, frameNumber)];
27
+  };
28
+
29
+  // Draw the frame
30
+  renderer.drawFrame = function(frameNumber) {
31
+
32
+    // Draw the background image and/or background color
33
+    context.clearRect(0, 0, this.options.width, this.options.height);
34
+
35
+    context.fillStyle = this.options.backgroundColor;
36
+    context.fillRect(0, 0, this.options.width, this.options.height);
37
+
38
+    if (this.backgroundImage) {
39
+      context.drawImage(this.backgroundImage, 0, 0, this.options.width, this.options.height);
40
+    }
41
+
42
+    patterns[this.options.pattern || "wave"](context, this.getWaveform(frameNumber), this.options);
43
+
44
+    // Write the caption
45
+    if (this.caption) {
46
+      this.wrapText(this.caption);
47
+    }
48
+
49
+    return this;
50
+
51
+  };
52
+
53
+  return renderer;
54
+
55
+}

+ 148 - 0
renderer/patterns.js View File

@@ -0,0 +1,148 @@
1
+var d3 = require("d3");
2
+
3
+module.exports = {
4
+  wave: curve(d3.curveCardinal.tension(0)),
5
+  pixel: curve(d3.curveStep),
6
+  roundBars: bars(true),
7
+  bars: bars(),
8
+  bricks: bricks(),
9
+  equalizer: bricks(true)
10
+};
11
+
12
+function curve(interpolator) {
13
+
14
+  return function drawCurve(context, data, options) {
15
+
16
+    context.fillStyle = options.waveColor;
17
+    context.strokeStyle = options.waveColor;
18
+    context.lineWidth = 3;
19
+
20
+    var line = d3.line()
21
+      .curve(interpolator)
22
+      .context(context);
23
+
24
+    var waveHeight = options.waveBottom - options.waveTop;
25
+
26
+    var baseline = options.waveTop + waveHeight / 2;
27
+
28
+    var x = d3.scalePoint()
29
+      .padding(0.1)
30
+      .domain(d3.range(data.length))
31
+      .rangeRound([0, options.width]);
32
+
33
+    var height = d3.scaleLinear()
34
+      .domain([0, 1])
35
+      .range([0, waveHeight / 2]);
36
+
37
+    var top = data.map(function(d,i){
38
+
39
+      return [x(i), baseline - height(d)];
40
+
41
+    });
42
+
43
+    var bottom = data.map(function(d,i){
44
+
45
+      return [x(i), baseline + height(d)];
46
+
47
+    }).reverse();
48
+
49
+    top.unshift([0, baseline]);
50
+    top.push([options.width, baseline]);
51
+
52
+    // Fill waveform
53
+    context.beginPath();
54
+    line(top.concat(bottom));
55
+    context.fill();
56
+
57
+    // Stroke waveform edges / ensure baseline
58
+    [top, bottom].forEach(function(path){
59
+
60
+      context.beginPath();
61
+      line(path);
62
+      context.stroke();
63
+
64
+    });
65
+  }
66
+
67
+}
68
+
69
+function bars(round) {
70
+
71
+  return function(context, data, options) {
72
+
73
+    context.fillStyle = options.waveColor;
74
+
75
+    var waveHeight = options.waveBottom - options.waveTop;
76
+
77
+    var baseline = options.waveTop + waveHeight / 2;
78
+
79
+    var barX = d3.scaleBand()
80
+      .paddingInner(0.5)
81
+      .paddingOuter(0.01)
82
+      .domain(d3.range(data.length))
83
+      .rangeRound([0, options.width]);
84
+
85
+    var height = d3.scaleLinear()
86
+      .domain([0, 1])
87
+      .range([0, waveHeight / 2]);
88
+
89
+    var barWidth = barX.bandwidth();
90
+
91
+    data.forEach(function(val, i){
92
+
93
+      var h = height(val),
94
+          x = barX(i);
95
+
96
+      context.fillRect(x, baseline - h, barWidth, h * 2);
97
+
98
+      if (round) {
99
+        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);
103
+        context.fill();
104
+      }
105
+
106
+    });
107
+  }
108
+
109
+}
110
+
111
+function bricks(rainbow) {
112
+  return function (context, data, options) {
113
+
114
+    context.fillStyle = options.waveColor;
115
+
116
+    var waveHeight = options.waveBottom - options.waveTop;
117
+
118
+    var barX = d3.scaleBand()
119
+      .paddingInner(0.1)
120
+      .paddingOuter(0.01)
121
+      .domain(d3.range(data.length))
122
+      .rangeRound([0, options.width]);
123
+
124
+    var height = d3.scaleLinear()
125
+      .domain([0, 1])
126
+      .range([0, waveHeight]);
127
+
128
+    var barWidth = barX.bandwidth(),
129
+        brickHeight = 10,
130
+        brickGap = 3,
131
+        maxBricks = Math.max(1, Math.floor(waveHeight / (brickHeight + brickGap)));
132
+
133
+    data.forEach(function(val, i){
134
+
135
+      var bricks = Math.max(1, Math.floor(height(val) / (brickHeight + brickGap))),
136
+          x = barX(i);
137
+
138
+      d3.range(bricks).forEach(function(b){
139
+        if (rainbow) {
140
+          context.fillStyle = d3.interpolateWarm(1 - (b + 1) / maxBricks);
141
+        }
142
+        context.fillRect(x, options.waveBottom - (brickHeight * (b+1)) - brickGap * b, barWidth, brickHeight);
143
+      });
144
+
145
+    });
146
+
147
+  };
148
+}

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


+ 84 - 0
renderer/text-wrapper.js View File

@@ -0,0 +1,84 @@
1
+var smartquotes = require("smartquotes").string;
2
+
3
+module.exports = function(context, options) {
4
+
5
+  context.font = options.captionFont;
6
+  context.textBaseline = "top";
7
+  context.textAlign = options.captionAlign || "center";
8
+
9
+  // 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);
14
+
15
+  if (bottom === null && top === null) {
16
+    top = 0;
17
+  }
18
+
19
+  var captionWidth = right - left;
20
+
21
+  return function(caption) {
22
+
23
+    if (!caption) {
24
+      return;
25
+    }
26
+
27
+    var lines = [[]],
28
+        maxWidth = 0,
29
+        words = smartquotes(caption + "").trim().replace(/\s\s+/g, " \n").split(/ /g);
30
+
31
+    // Check whether each word exceeds the width limit
32
+    // Wrap onto next line as needed
33
+    words.forEach(function(word,i){
34
+
35
+      var width = context.measureText(lines[lines.length - 1].concat([word]).join(" ")).width;
36
+
37
+      if (word[0] === "\n" || (lines[lines.length - 1].length && width > captionWidth)) {
38
+
39
+        word = word.trim();
40
+        lines.push([word]);
41
+        width = context.measureText(word).width;
42
+
43
+      } else {
44
+
45
+        lines[lines.length - 1].push(word);
46
+
47
+      }
48
+
49
+      maxWidth = Math.max(maxWidth,width);
50
+
51
+    });
52
+
53
+    var totalHeight = lines.length * options.captionLineHeight + (lines.length - 1) * options.captionLineSpacing;
54
+
55
+    // horizontal alignment
56
+    var x = options.captionAlign === "left" ? left : options.captionAlign === "right" ? right : (left + right) / 2;
57
+
58
+    // Vertical alignment
59
+    var y;
60
+
61
+    if (top !== null && bottom !== null) {
62
+      // Vertical center
63
+      y = (bottom + top - totalHeight) / 2;
64
+    } else if (bottom !== null) {
65
+      // Vertical align bottom
66
+      y = bottom - totalHeight;
67
+    } else {
68
+      // Vertical align top
69
+      y = top;
70
+    }
71
+
72
+    context.fillStyle = options.captionColor;
73
+    lines.forEach(function(line, i){
74
+      context.fillText(line.join(" "), x, y + i * (options.captionLineHeight + options.captionLineSpacing));
75
+    });
76
+
77
+ };
78
+
79
+
80
+}
81
+
82
+function ifNumeric(val, alt) {
83
+  return (typeof val === "number" && !isNaN(val)) ? val : alt;
84
+}

+ 30 - 0
server/error.js View File

@@ -0,0 +1,30 @@
1
+var serverSettings = require("../settings/");
2
+
3
+module.exports = function(err, req, res, next) {
4
+
5
+  if (!err) {
6
+
7
+    // This should never happen
8
+    return next ? next() : null;
9
+
10
+  }
11
+
12
+  res.status(500);
13
+
14
+  if (err.code === "LIMIT_FILE_SIZE") {
15
+    res.send("Sorry, uploads are limited to " + prettySize(serverSettings.maxUploadSize) + ". Try clipping your file or converting it to an MP3.");
16
+  } else {
17
+    res.send("Unknown error.");
18
+    throw err;
19
+  }
20
+
21
+};
22
+
23
+function prettySize(size) {
24
+
25
+  var mb = size / 1000000,
26
+      rounded = mb >= 1 ? Math.floor(10 * mb) / 10 : Math.floor(100 * mb) / 100;
27
+
28
+  return rounded + " MB";
29
+
30
+}

+ 71 - 0
server/index.js View File

@@ -0,0 +1,71 @@
1
+// Dependencies
2
+var express = require("express"),
3
+    compression = require("compression"),
4
+    path = require("path"),
5
+    multer = require("multer"),
6
+    uuid = require("node-uuid"),
7
+    mkdirp = require("mkdirp");
8
+
9
+// Routes and middleware
10
+var logger = require("../lib/logger/"),
11
+    render = require("./render.js"),
12
+    status = require("./status.js"),
13
+    errorHandlers = require("./error.js");
14
+
15
+// Settings
16
+var serverSettings = require("../settings/");
17
+
18
+var app = express();
19
+
20
+app.use(compression());
21
+app.use(logger.morgan());
22
+
23
+// Options for where to store uploaded audio and max size
24
+var fileOptions = {
25
+  storage: multer.diskStorage({
26
+    destination: function(req, file, cb) {
27
+
28
+      var dir = path.join(serverSettings.workingDirectory, uuid.v1());
29
+
30
+      mkdirp(dir, function(err) {
31
+        return cb(err, dir);
32
+      });
33
+    },
34
+    filename: function(req, file, cb) {
35
+      cb(null, "audio");
36
+    }
37
+  })
38
+};
39
+
40
+if (serverSettings.maxUploadSize) {
41
+  fileOptions.limits = {
42
+    fileSize: +serverSettings.maxUploadSize
43
+  };
44
+}
45
+
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
51
+app.post("/submit/", [multer(fileOptions).single("audio"), render.validate, render.route]);
52
+
53
+// If not using S3, serve videos locally
54
+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")));
60
+}
61
+
62
+// Check the status of a current video
63
+app.get("/status/:id/", status);
64
+
65
+// Serve background images and everything else statically
66
+app.use("/img/", express.static(path.join(__dirname, "..", "settings", "backgrounds")));
67
+app.use(express.static(path.join(__dirname, "..", "editor")));
68
+
69
+app.use(errorHandlers);
70
+
71
+module.exports = app;

+ 76 - 0
server/render.js View File

@@ -0,0 +1,76 @@
1
+var serverSettings = require("../settings/"),
2
+    spawn = require("child_process").spawn,
3
+    path = require("path"),
4
+    _ = require("underscore"),
5
+    Audiogram = require("../audiogram/"),
6
+    logger = require("../lib/logger"),
7
+    transports = require("../lib/transports");
8
+
9
+function validate(req, res, next) {
10
+
11
+  try {
12
+
13
+    req.body.settings = JSON.parse(req.body.settings);
14
+
15
+  } catch(e) {
16
+
17
+    return res.status(500).send("Unknown settings error.");
18
+
19
+  }
20
+
21
+  if (!req.file || !req.file.filename) {
22
+    return res.status(500).send("No valid audio received.");
23
+  }
24
+
25
+  req.body.settings.id = req.file.destination.split(path.sep).pop();
26
+
27
+  // Start at the beginning, or specified time
28
+  if (req.body.settings.start) {
29
+    req.body.settings.start = +req.body.settings.start;
30
+  }
31
+
32
+  if (req.body.settings.end) {
33
+    req.body.settings.end = +req.body.settings.end;
34
+  }
35
+
36
+  return next();
37
+
38
+}
39
+
40
+function route(req, res) {
41
+
42
+  var audiogram = new Audiogram(req.body.settings);
43
+
44
+  transports.uploadAudio(audiogram.audioPath, "audio/" + audiogram.id,function(err) {
45
+
46
+    if (err) {
47
+      throw err;
48
+    }
49
+
50
+    // Queue up the job with a timestamp
51
+    transports.addJob(_.extend({ created: (new Date()).getTime() }, req.body.settings));
52
+
53
+    res.json({ id: req.body.settings.id });
54
+
55
+    // If there's no separate worker, spawn one right away
56
+    if (!serverSettings.worker) {
57
+
58
+      logger.debug("Spawning worker");
59
+
60
+      // Empty args to avoid child_process Linux error
61
+      spawn("bin/worker", [], {
62
+        stdio: "inherit",
63
+        cwd: path.join(__dirname, ".."),
64
+        env: _.extend({}, process.env, { SPAWNED: true })
65
+      });
66
+
67
+    }
68
+
69
+  });
70
+
71
+};
72
+
73
+module.exports = {
74
+  validate: validate,
75
+  route: route
76
+};

+ 43 - 0
server/status.js View File

@@ -0,0 +1,43 @@
1
+var queue = require("d3").queue,
2
+    transports = require("../lib/transports");
3
+
4
+module.exports = function(req, res) {
5
+
6
+  queue(1)
7
+    .defer(transports.getJobList)
8
+    .defer(transports.getHash, req.params.id)
9
+    .await(function(err, jobs, hash) {
10
+
11
+      if (err) {
12
+        res.status(500).json("Unknown error.");
13
+        throw err;
14
+      }
15
+
16
+      var position = -1;
17
+
18
+      jobs.some(function(job, i) {
19
+        if (job.id === req.params.id) {
20
+          position = i;
21
+          return true;
22
+        }
23
+      });
24
+
25
+      if (position >= 0) {
26
+        return res.json({ status: "queued", position: position });
27
+      }
28
+
29
+      if (hash === null) {
30
+        hash = { status: "unknown" };
31
+      }
32
+
33
+      ["duration","numFrames","framesComplete"].forEach(function(key) {
34
+        if (key in hash) {
35
+          hash[key] = +hash[key];
36
+        }
37
+      });
38
+
39
+      res.json(hash);
40
+
41
+    });
42
+
43
+};

BIN
settings/backgrounds/nyc.png View File


BIN
settings/backgrounds/subway.jpg View File


+ 93 - 0
settings/fonts/LICENSE.txt View File

@@ -0,0 +1,93 @@
1
+Copyright 2010, 2012, 2014 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
2
+
3
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+
5
+This license is copied below, and is also available with a FAQ at: 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.

BIN
settings/fonts/Source Sans Pro.ttf View File


BIN
settings/fonts/SourceSansPro-Black.ttf View File


BIN
settings/fonts/SourceSansPro-BlackItalic.ttf View File


BIN
settings/fonts/SourceSansPro-Bold.ttf View File


BIN
settings/fonts/SourceSansPro-BoldItalic.ttf View File


BIN
settings/fonts/SourceSansPro-ExtraLight.ttf View File


BIN
settings/fonts/SourceSansPro-ExtraLightItalic.ttf View File


BIN
settings/fonts/SourceSansPro-Italic.ttf View File


BIN
settings/fonts/SourceSansPro-Light.ttf View File


BIN
settings/fonts/SourceSansPro-LightItalic.ttf View File


BIN
settings/fonts/SourceSansPro-Regular.ttf View File


BIN
settings/fonts/SourceSansPro-Semibold.ttf View File


BIN
settings/fonts/SourceSansPro-SemiboldItalic.ttf View File


+ 32 - 0
settings/index.js View File

@@ -0,0 +1,32 @@
1
+/*
2
+  For details on these options, read SERVER.md
3
+
4
+  Basic options:
5
+
6
+  workingDirectory - where to keep temporary files (required)
7
+  storagePath - where to keep generated audio/videos (required)
8
+  fonts - a list of custom fonts (see THEMES.md for details)
9
+  maxUploadSize - the maximum audio upload size, in bytes
10
+
11
+  Fancy server options:
12
+
13
+  s3Bucket - a bucket to store generated audio/videos.  If storagePath is also set, it will store files at s3://[s3Bucket]/[storagePath]/
14
+  redisHost - a redis host name/address to use for tracking jobs (e.g. "1.2.3.4" or "127.0.0.1")
15
+  worker - if this is truthy, the server will add jobs to a queue. Otherwise, it will render videos on the spot itself
16
+
17
+*/
18
+
19
+var path = require("path");
20
+
21
+module.exports = {
22
+  workingDirectory: path.join(__dirname, "..", "tmp"),
23
+  storagePath: path.join(__dirname, "..", "media"),
24
+  maxUploadSize: 25000000,
25
+  fonts: [
26
+    { family: "Source Sans Pro", file: path.join(__dirname, "fonts", "SourceSansPro-Regular.ttf") },
27
+    { family: "Source Sans Pro", file: path.join(__dirname, "fonts", "SourceSansPro-Light.ttf"), weight: 300 },
28
+    { family: "Source Sans Pro", file: path.join(__dirname, "fonts", "SourceSansPro-Bold.ttf"), weight: "bold" },
29
+    { family: "Source Sans Pro", file: path.join(__dirname, "fonts", "SourceSansPro-Italic.ttf"), style: "italic" },
30
+    { family: "Source Sans Pro", file: path.join(__dirname, "fonts", "SourceSansPro-BoldItalic.ttf"), weight: "bold", style: "italic" }
31
+  ]
32
+};

+ 90 - 0
settings/themes.json View File

@@ -0,0 +1,90 @@
1
+{
2
+  "default": {
3
+    "width": 1280,
4
+    "height": 720,
5
+    "framesPerSecond": 20,
6
+    "maxDuration": 30,
7
+    "samplesPerFrame": 128,
8
+    "pattern": "wave",
9
+    "waveTop": 150,
10
+    "waveBottom": 420,
11
+    "captionTop": 470,
12
+    "captionFont": "300 52px 'Source Sans Pro'",
13
+    "captionLineHeight": 52,
14
+    "captionLineSpacing": 7,
15
+    "captionLeft": 200,
16
+    "captionRight": 1080
17
+  },
18
+  "Basic": {
19
+    "backgroundColor": "#eee",
20
+    "foregroundColor": "#de1e3d"
21
+  },
22
+  "Neon": {
23
+    "backgroundColor": "#556270",
24
+    "foregroundColor": "#c7f464"
25
+  },
26
+  "Three colors": {
27
+    "backgroundColor": "#eee",
28
+    "captionColor": "#515151",
29
+    "waveColor": "#00b4ff"
30
+  },
31
+  "Background image with top right text": {
32
+    "captionAlign": "right",
33
+    "captionTop": 60,
34
+    "captionRight": 1220,
35
+    "captionLeft": 640,
36
+    "backgroundImage": "subway.jpg",
37
+    "foregroundColor": "#fc0",
38
+    "waveBottom": 660,
39
+    "waveTop": 320
40
+  },
41
+  "Bars": {
42
+    "pattern": "bars",
43
+    "foregroundColor": "#d84a4a"
44
+  },
45
+  "Rounded bars with bottom left text": {
46
+    "pattern": "roundBars",
47
+    "foregroundColor": "#d84a4a",
48
+    "captionAlign": "left",
49
+    "captionLeft": 60,
50
+    "captionRight": 640,
51
+    "captionBottom": 660,
52
+    "captionTop": null
53
+  },
54
+  "Bricks": {
55
+    "backgroundColor": "#8771aa",
56
+    "foregroundColor": "#fff",
57
+    "pattern": "bricks",
58
+  },
59
+  "Equalizer on the bottom": {
60
+    "backgroundColor": "#222",
61
+    "foregroundColor": "#fff",
62
+    "pattern": "equalizer",
63
+    "captionTop": 40,
64
+    "captionBottom": 380,
65
+    "waveTop": 420,
66
+    "waveBottom": 720
67
+  },
68
+  "Bold italic orange": {
69
+    "captionAlign": "right",
70
+    "captionTop": null,
71
+    "captionBottom": 660,
72
+    "captionRight": 1220,
73
+    "captionLeft": 640,
74
+    "backgroundColor": "#fd5a1e",
75
+    "waveColor": "#fff",
76
+    "captionFont": "bold italic 52px 'Source Sans Pro'"
77
+  },
78
+  "Square with background image": {
79
+    "width": 640,
80
+    "height": 640,
81
+    "samplesPerFrame": 64,
82
+    "waveTop": 20,
83
+    "waveBottom": 300,
84
+    "captionTop": 340,
85
+    "captionLeft": 20,
86
+    "captionRight": 620,
87
+    "foregroundColor": "#0eb8ba",
88
+    "backgroundImage": "nyc.png"
89
+  }
90
+}

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


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


BIN
test/data/short.mp3 View File


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

@@ -0,0 +1,153 @@
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
+});

+ 161 - 0
test/frame-test.js View File

@@ -0,0 +1,161 @@
1
+var tape = require("tape"),
2
+    Canvas = require("canvas"),
3
+    d3 = require("d3"),
4
+    path = require("path"),
5
+    fs = require("fs"),
6
+    initializeCanvas = require("../audiogram/initialize-canvas.js"),
7
+    drawFrames = require("../audiogram/draw-frames.js");
8
+
9
+require("mkdirp").sync(path.join(__dirname, "tmp", "frames"));
10
+
11
+var frameDir = path.join(__dirname, "tmp", "frames");
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){
26
+
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);
31
+
32
+    drawFrames(renderer, {
33
+      numFrames: 2,
34
+      frameDir: frameDir
35
+    }, function(err){
36
+      test.error(err);
37
+      checkFrame(test, options);
38
+    });
39
+
40
+  });
41
+
42
+});
43
+
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);
66
+      checkFrame(test, options);
67
+    });
68
+
69
+  });
70
+
71
+});
72
+
73
+tape.test("Square frame", function(test){
74
+
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
+  };
84
+
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
+  });
101
+
102
+});
103
+
104
+function checkFrame(test, options) {
105
+
106
+  var testCanvas = new Canvas(options.width, options.height),
107
+      context = testCanvas.getContext("2d");
108
+
109
+  d3.queue()
110
+    .defer(fs.readFile, path.join(frameDir, "000001.png"))
111
+    .defer(fs.readFile, path.join(frameDir, "000002.png"))
112
+    .await(function(e, f1, f2){
113
+
114
+      test.error(e);
115
+
116
+      var img = new Canvas.Image;
117
+      img.src = f1;
118
+
119
+      var bg = getColor(options.backgroundColor || "#fff"),
120
+          fg = getColor(options.waveColor || options.foregroundColor || "#000");
121
+
122
+      context.drawImage(img, 0, 0, options.width, options.height);
123
+      test.deepEqual(getColor(context.getImageData(0, 0, 1, 1)), bg);
124
+      test.deepEqual(getColor(context.getImageData(options.width - 1, options.height - 1, 1, 1)), bg);
125
+      test.deepEqual(getColor(context.getImageData(0, options.height / 2 - 10, 1, 1)), bg);
126
+      test.deepEqual(getColor(context.getImageData(options.width - 1, options.height / 2 + 10, 1, 1)), bg);
127
+      test.deepEqual(getColor(context.getImageData(options.width / 2, options.height / 2, 1, 1)), fg);
128
+      test.deepEqual(getColor(context.getImageData(options.width / 2, options.height / 2 - 10, 1, 1)), fg);
129
+      test.deepEqual(getColor(context.getImageData(options.width / 2, options.height / 2 + 10, 1, 1)), fg);
130
+
131
+      img.src = f2;
132
+
133
+      context.drawImage(img, 10, 0, options.width, options.height);
134
+      test.deepEqual(getColor(context.getImageData(options.width / 2, options.height / 2, 1, 1)), fg);
135
+      test.deepEqual(getColor(context.getImageData(options.width / 2 - 10, options.height / 2 - 10, 1, 1)), bg);
136
+      test.deepEqual(getColor(context.getImageData(options.width / 2 - 10, options.height / 2 + 10, 1, 1)), bg);
137
+
138
+
139
+      test.end();
140
+
141
+    });
142
+
143
+}
144
+
145
+function getColor(c) {
146
+  if (typeof c === "string") {
147
+    c = d3.color(c);
148
+    return [c.r, c.g, c.b];
149
+  } else {
150
+    return Array.prototype.slice.call(c.data, 0, 3);
151
+  }
152
+}
153
+
154
+// Cleanup
155
+tape.onFinish(function(){
156
+  require("rimraf")(path.join(__dirname, "tmp"), function(err){
157
+    if (err) {
158
+      throw err;
159
+    }
160
+  });
161
+});

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

@@ -0,0 +1,16 @@
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
+};

+ 182 - 0
test/server-test.js View File

@@ -0,0 +1,182 @@
1
+var tape = require("tape"),
2
+    path = require("path"),
3
+    fs = require("fs"),
4
+    queue = require("d3").queue,
5
+    request = require("supertest");
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
+});
13
+
14
+var server = require("../server");
15
+
16
+var longSample = path.join(__dirname, "data", "glazed-donut.mp3"),
17
+    shortSample = path.join(__dirname, "data", "short.mp3");
18
+
19
+tape("Server static", function(test) {
20
+
21
+  request(server)
22
+    .get("/")
23
+    .expect(200)
24
+    .expect("Content-Type", /html/)
25
+    .end(function(err, res){
26
+      test.error(err);
27
+      test.assert(res.text.match(/audiogram/i));
28
+      test.end();
29
+    });
30
+
31
+});
32
+
33
+tape("Server static JS", function(test) {
34
+
35
+  request(server)
36
+    .get("/js/bundle.js")
37
+    .expect(200)
38
+    .end(function(err, res){
39
+      test.error(err);
40
+      test.end();
41
+    });
42
+
43
+});
44
+
45
+tape("404 1", function(test) {
46
+
47
+  request(server)
48
+    .get("/settings/index.js")
49
+    .expect(404)
50
+    .end(function(err, res){
51
+      test.error(err);
52
+      test.end();
53
+    });
54
+
55
+});
56
+
57
+tape("404 2", function(test) {
58
+
59
+  request(server)
60
+    .get("/something.html")
61
+    .expect(404)
62
+    .end(function(err, res){
63
+      test.error(err);
64
+      test.end();
65
+    });
66
+
67
+});
68
+
69
+tape("Server static background", function(test) {
70
+
71
+  request(server)
72
+    .get("/img/nyc.png")
73
+    .expect(200)
74
+    .expect("Content-Type", /image/)
75
+    .end(function(err, res){
76
+      test.error(err);
77
+      test.end();
78
+    });
79
+
80
+});
81
+
82
+tape("Max size", function(test) {
83
+
84
+  request(server)
85
+    .post("/submit/")
86
+    .attach("audio", longSample)
87
+    .field("settings", "{}")
88
+    .expect(500)
89
+    .end(function(err, res){
90
+      test.assert(res.text.match(/uploads are limited/i));
91
+      test.end();
92
+    });
93
+
94
+});
95
+
96
+tape("Missing file", function(test) {
97
+
98
+  request(server)
99
+    .post("/submit/")
100
+    .type("json")
101
+    .field("settings", "{}")
102
+    .expect(500)
103
+    .end(function(err, res){
104
+      test.assert(res.text.match(/audio/i));
105
+      test.end();
106
+    });
107
+
108
+});
109
+
110
+tape("Broken settings", function(test) {
111
+
112
+  request(server)
113
+    .post("/submit/")
114
+    .type("multipart/form-data")
115
+    .field("settings", "a")
116
+    .expect(500)
117
+    .end(function(err, res){
118
+      test.assert(res.text.match(/settings/i));
119
+      test.end();
120
+    });
121
+
122
+});
123
+
124
+tape("Successful submission", function(test) {
125
+
126
+  var jobsFile = path.join(__dirname, "..", ".jobs");
127
+
128
+  request(server)
129
+    .post("/submit/")
130
+    .attach("audio", shortSample)
131
+    .field("settings", JSON.stringify({ test: true }))
132
+    .expect(200)
133
+    .end(function(err, res){
134
+
135
+      var body = JSON.parse(res.text);
136
+
137
+      test.assert("id" in body);
138
+
139
+      queue(1)
140
+        .defer(fs.readFile, path.join(serverSettings.workingDirectory, body.id, "audio"))
141
+        .defer(fs.readFile, path.join(serverSettings.storagePath, "audio", body.id))
142
+        .defer(checkStatus, body.id)
143
+        .defer(checkJobsFile, body.id)
144
+        .await(function(err){
145
+          test.error(err);
146
+          test.end();
147
+        });
148
+
149
+    });
150
+
151
+  function checkStatus(id, cb) {
152
+
153
+    request(server)
154
+      .get("/status/" + id + "/")
155
+      .expect(200)
156
+      .end(function(err, res){
157
+        test.equal(JSON.parse(res.text).status, "queued");
158
+        cb(err);
159
+      });
160
+
161
+  }
162
+
163
+  function checkJobsFile(id, cb) {
164
+
165
+    fs.readFile(jobsFile, "utf8", function(err, raw){
166
+      var jobs = JSON.parse(raw);
167
+      test.equal(jobs.jobs.pop().id, id);
168
+      fs.writeFile(jobsFile, JSON.stringify(jobs), cb);
169
+    });
170
+
171
+  }
172
+
173
+});
174
+
175
+// Cleanup
176
+tape.onFinish(function(){
177
+  require("rimraf")(path.join(__dirname, "tmp"), function(err){
178
+    if (err) {
179
+      throw err;
180
+    }
181
+  });
182
+});

+ 68 - 0
test/waveform-test.js View File

@@ -0,0 +1,68 @@
1
+var tape = require("tape"),
2
+    path = require("path");
3
+
4
+var getWaveform = require("../audiogram/waveform.js");
5
+
6
+var sample = path.join(__dirname, "data/glazed-donut.mp3");
7
+
8
+tape("Waveform", function(test) {
9
+
10
+  var options = {
11
+    numFrames: 500,
12
+    samplesPerFrame: 10
13
+  };
14
+
15
+  getWaveform(sample, options, function(err, waveform){
16
+
17
+    test.error(err);
18
+    test.assert(Array.isArray(waveform) && waveform.length === options.numFrames);
19
+
20
+    var firstMax = Math.max.apply(null, waveform[0]);
21
+
22
+    test.assert(firstMax <= 1);
23
+
24
+    test.assert(waveform.every(function(frame){
25
+      return frame.length === options.samplesPerFrame;
26
+    }));
27
+
28
+    test.assert(waveform.every(function(frame){
29
+      return frame.every(function(val){
30
+        return typeof val === "number" && val >= 0 && val <= firstMax;
31
+      });
32
+    }));
33
+
34
+    test.end();
35
+
36
+  });
37
+
38
+});
39
+
40
+tape("Waveform missing numFrames", function(test) {
41
+
42
+  var options = {
43
+    samplesPerFrame: 10
44
+  };
45
+
46
+  getWaveform(sample, options, function(err, waveform){
47
+
48
+    test.ok(err);
49
+    test.end();
50
+
51
+  });
52
+
53
+});
54
+
55
+tape("Waveform missing samplesPerFrame", function(test) {
56
+
57
+  var options = {
58
+    numFrames: 500,
59
+  };
60
+
61
+  getWaveform(sample, options, function(err, waveform){
62
+
63
+    test.ok(err);
64
+    test.end();
65
+
66
+  });
67
+
68
+});