├── LICENSE ├── README.md ├── apigen.neon ├── composer.json ├── demo ├── js │ └── waveform.js ├── player.html ├── track.php ├── tracks │ ├── demo.wav │ └── song.mp3 ├── waveform.php └── waveformjs.html └── src └── Jasny └── Audio ├── Track.php └── Waveform.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Arnold Daniels 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Jasny Audio 2 | =========== 3 | 4 | Process audio files using [SoX](http://sox.sourceforge.net/) 5 | 6 | ## Waveform 7 | 8 | With the Jasny\Audio\Waveform class you can create a waveform as PNG like: 9 | 10 | ![waveform](https://f.cloud.github.com/assets/100821/1049488/8c209342-10a6-11e3-9149-cc56e1fcfcea.png) 11 | 12 | ```php 13 | $waveform = new Waveform($filename, $options); 14 | $waveform->output(); 15 | ``` 16 | 17 | Alternatively you can request a set of samples. This can be used to set draw a waveform in JavaScript (see waveform.js). 18 | 19 | ### Options 20 | 21 | option | default | unit | description 22 | ---------|---------|-------------|----------------------------------------- 23 | width | 1800 | pixels | Image width 24 | height | 280 | pixels | Image height 25 | color | 000000 | hex or rgba | Color of the graph 26 | axis | null | hex or rgba | Color of the x axis 27 | level | null | | The max amplitute (y axis) 28 | offset | null | seconds | Starting point. Negative counts from end 29 | duration | null | seconds | Duration of the track of chart 30 | 31 | 32 | ## Track statistics 33 | ```php 34 | $track = new Track($filename); 35 | $track->getStats(); 36 | ``` 37 | 38 | ```js 39 | { 40 | channels: "1", 41 | dc_offset: "0.000016", 42 | min_level: "-0.162134", 43 | max_level: "0.153157", 44 | pk_lev: "-15.80", 45 | rms_lev: "-33.56", 46 | rms_pk: "-24.31", 47 | rms_tr: "-55.44", 48 | crest_factor: "7.72", 49 | flat_factor: "0.00", 50 | pk_count: "2", 51 | bit_depth: "30/32", 52 | length: "1.935601", 53 | scale_max: "1.000000", 54 | window: "0.050", 55 | samples: "42680", 56 | scaled_by: "2147483647.0", 57 | maximum_amplitude: "0.153157", 58 | minimum_amplitude: "-0.162134", 59 | midline_amplitude: "-0.004489", 60 | mean_norm: "0.010709", 61 | mean_amplitude: "0.000016", 62 | rms_amplitude: "0.020990", 63 | maximum_delta: "0.115579", 64 | minimum_delta: "0.000000", 65 | mean_delta: "0.003656", 66 | rms_delta: "0.008325", 67 | rough_frequency: "1391", 68 | volume_adjustment: "6.168", 69 | sample_rate: "22050" 70 | } 71 | ``` 72 | 73 | ## Convert track 74 | 75 | Convert a track to a different format. Uses avconv (or ffmpeg). 76 | 77 | ```php 78 | $track = new Track("sometrack.wav"); 79 | $track->convert("sometrack.mp3"); 80 | ``` 81 | 82 | 83 | ## Combine tracks 84 | 85 | Combine two tracks. Uses [`sox --combine`](http://sox.sourceforge.net/sox.html#OPTIONS). 86 | 87 | Available methods 88 | * concatenate 89 | * merge 90 | * mix 91 | * mix-power 92 | * multiply 93 | * sequence 94 | 95 | ```php 96 | $track = new Track($track1); 97 | $track->combine($method, $track2, $outputFilename); 98 | ``` 99 | -------------------------------------------------------------------------------- /apigen.neon: -------------------------------------------------------------------------------- 1 | # Source file or directory to parse 2 | source: src 3 | # Directory where to save the generated documentation 4 | destination: docs 5 | # List of allowed file extensions 6 | extensions: [php] 7 | # Mask to exclude file or directory from processing 8 | exclude: 9 | # Don't generate documentation for classes from file or directory with this mask 10 | skipDocPath: 11 | # Don't generate documentation for classes with this name prefix 12 | skipDocPrefix: 13 | # Character set of source files 14 | charset: auto 15 | # Main project name prefix 16 | main: Jasny\ 17 | 18 | # Title of generated documentation 19 | title: 'Jasny Audio · API documentation' 20 | # Documentation base URL 21 | baseUrl: 22 | # Google Custom Search ID 23 | googleCseId: 24 | # Google Custom Search label 25 | googleCseLabel: 26 | # Google Analytics tracking code 27 | googleAnalytics: 28 | # Grouping of classes 29 | groups: auto 30 | # List of allowed HTML tags in documentation 31 | allowedHtml: [b, i, a, ul, ol, li, p, br, var, samp, kbd, tt] 32 | # Element types for search input autocomplete 33 | autocomplete: [classes, constants, functions] 34 | 35 | # Generate documentation for methods and properties with given access level 36 | accessLevels: [public] 37 | # Generate documentation for elements marked as internal and display internal documentation parts 38 | internal: No 39 | # Generate documentation for PHP internal classes 40 | php: No 41 | # Generate tree view of classes, interfaces and exceptions 42 | tree: Yes 43 | # Generate documentation for deprecated classes, methods, properties and constants 44 | deprecated: No 45 | # Generate documentation of tasks 46 | todo: No 47 | # Generate highlighted source code files 48 | sourceCode: Yes 49 | # Add a link to download documentation as a ZIP archive 50 | download: No 51 | # Wipe out the destination directory first 52 | wipeout: Yes 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jasny/audio", 3 | "description": "Process audio files using SoX", 4 | "keywords": ["audio"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Arnold Daniels", 9 | "email": "arnold@jasny.net", 10 | "homepage": "http://www.jasny.net" 11 | } 12 | ], 13 | "support": { 14 | "issues": "https://github.com/jasny/audio/issues", 15 | "source": "https://github.com/jasny/audio" 16 | }, 17 | "require": { 18 | "php": ">=5.6.0" 19 | }, 20 | "autoload": { 21 | "psr-0": { 22 | "Jasny\\Audio": "src/" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /demo/js/waveform.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var JSONP, Waveform, 3 | __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; 4 | 5 | window.Waveform = Waveform = (function() { 6 | 7 | Waveform.name = 'Waveform'; 8 | 9 | function Waveform(options) { 10 | this.redraw = __bind(this.redraw, this); 11 | this.container = options.container; 12 | this.canvas = options.canvas; 13 | this.data = options.data || []; 14 | this.outerColor = options.outerColor || "transparent"; 15 | this.innerColor = options.innerColor || "#000000"; 16 | this.interpolate = true; 17 | if (options.interpolate === false) { 18 | this.interpolate = false; 19 | } 20 | if (this.canvas == null) { 21 | if (this.container) { 22 | this.canvas = this.createCanvas(this.container, options.width || this.container.clientWidth, options.height || this.container.clientHeight); 23 | } else { 24 | throw "Either canvas or container option must be passed"; 25 | } 26 | } 27 | this.patchCanvasForIE(this.canvas); 28 | this.context = this.canvas.getContext("2d"); 29 | this.width = parseInt(this.context.canvas.width, 10); 30 | this.height = parseInt(this.context.canvas.height, 10); 31 | if (options.data) { 32 | this.update(options); 33 | } 34 | } 35 | 36 | Waveform.prototype.setData = function(data) { 37 | return this.data = data; 38 | }; 39 | 40 | Waveform.prototype.setDataInterpolated = function(data) { 41 | return this.setData(this.interpolateArray(data, this.width)); 42 | }; 43 | 44 | Waveform.prototype.setDataCropped = function(data) { 45 | return this.setData(this.expandArray(data, this.width)); 46 | }; 47 | 48 | Waveform.prototype.update = function(options) { 49 | if (options.interpolate != null) { 50 | this.interpolate = options.interpolate; 51 | } 52 | if (this.interpolate === false) { 53 | this.setDataCropped(options.data); 54 | } else { 55 | this.setDataInterpolated(options.data); 56 | } 57 | return this.redraw(); 58 | }; 59 | 60 | Waveform.prototype.redraw = function() { 61 | var d, i, middle, t, _i, _len, _ref, _results; 62 | this.clear(); 63 | this.context.fillStyle = this.innerColor; 64 | middle = this.height / 2; 65 | i = 0; 66 | _ref = this.data; 67 | _results = []; 68 | for (_i = 0, _len = _ref.length; _i < _len; _i++) { 69 | d = _ref[_i]; 70 | t = this.width / this.data.length; 71 | if (typeof this.innerColor === "function") { 72 | this.context.fillStyle = this.innerColor(i / this.width, d); 73 | } 74 | this.context.clearRect(t * i, middle - middle * d, t, middle * d * 2); 75 | this.context.fillRect(t * i, middle - middle * d, t, middle * d * 2); 76 | _results.push(i++); 77 | } 78 | return _results; 79 | }; 80 | 81 | Waveform.prototype.clear = function() { 82 | this.context.fillStyle = this.outerColor; 83 | this.context.clearRect(0, 0, this.width, this.height); 84 | return this.context.fillRect(0, 0, this.width, this.height); 85 | }; 86 | 87 | Waveform.prototype.patchCanvasForIE = function(canvas) { 88 | var oldGetContext; 89 | if (typeof window.G_vmlCanvasManager !== "undefined") { 90 | canvas = window.G_vmlCanvasManager.initElement(canvas); 91 | oldGetContext = canvas.getContext; 92 | return canvas.getContext = function(a) { 93 | var ctx; 94 | ctx = oldGetContext.apply(canvas, arguments); 95 | canvas.getContext = oldGetContext; 96 | return ctx; 97 | }; 98 | } 99 | }; 100 | 101 | Waveform.prototype.createCanvas = function(container, width, height) { 102 | var canvas; 103 | canvas = document.createElement("canvas"); 104 | container.appendChild(canvas); 105 | canvas.width = width; 106 | canvas.height = height; 107 | return canvas; 108 | }; 109 | 110 | Waveform.prototype.expandArray = function(data, limit, defaultValue) { 111 | var i, newData, _i, _ref; 112 | if (defaultValue == null) { 113 | defaultValue = 0.0; 114 | } 115 | newData = []; 116 | if (data.length > limit) { 117 | newData = data.slice(data.length - limit, data.length); 118 | } else { 119 | for (i = _i = 0, _ref = limit - 1; 0 <= _ref ? _i <= _ref : _i >= _ref; i = 0 <= _ref ? ++_i : --_i) { 120 | newData[i] = data[i] || defaultValue; 121 | } 122 | } 123 | return newData; 124 | }; 125 | 126 | Waveform.prototype.linearInterpolate = function(before, after, atPoint) { 127 | return before + (after - before) * atPoint; 128 | }; 129 | 130 | Waveform.prototype.interpolateArray = function(data, fitCount) { 131 | var after, atPoint, before, i, newData, springFactor, tmp; 132 | newData = new Array(); 133 | springFactor = new Number((data.length - 1) / (fitCount - 1)); 134 | newData[0] = data[0]; 135 | i = 1; 136 | while (i < fitCount - 1) { 137 | tmp = i * springFactor; 138 | before = new Number(Math.floor(tmp)).toFixed(); 139 | after = new Number(Math.ceil(tmp)).toFixed(); 140 | atPoint = tmp - before; 141 | newData[i] = this.linearInterpolate(data[before], data[after], atPoint); 142 | i++; 143 | } 144 | newData[fitCount - 1] = data[data.length - 1]; 145 | return newData; 146 | }; 147 | 148 | Waveform.prototype.optionsForSyncedStream = function(options) { 149 | var innerColorWasSet, that; 150 | if (options == null) { 151 | options = {}; 152 | } 153 | innerColorWasSet = false; 154 | that = this; 155 | return { 156 | whileplaying: this.redraw, 157 | whileloading: function() { 158 | var stream; 159 | if (!innerColorWasSet) { 160 | stream = this; 161 | that.innerColor = function(x, y) { 162 | if (x < stream.position / stream.durationEstimate) { 163 | return options.playedColor || "rgba(255, 102, 0, 0.8)"; 164 | } else if (x < stream.bytesLoaded / stream.bytesTotal) { 165 | return options.loadedColor || "rgba(0, 0, 0, 0.8)"; 166 | } else { 167 | return options.defaultColor || "rgba(0, 0, 0, 0.4)"; 168 | } 169 | }; 170 | innerColorWasSet = true; 171 | } 172 | return this.redraw; 173 | } 174 | }; 175 | }; 176 | 177 | Waveform.prototype.dataFromSoundCloudTrack = function(track) { 178 | var _this = this; 179 | return JSONP.get("http://www.waveformjs.org/w", { 180 | url: track.waveform_url 181 | }, function(data) { 182 | return _this.update({ 183 | data: data 184 | }); 185 | }); 186 | }; 187 | 188 | return Waveform; 189 | 190 | })(); 191 | 192 | JSONP = (function() { 193 | var config, counter, encode, head, jsonp, key, load, query, setDefaults, window; 194 | load = function(url) { 195 | var done, head, script; 196 | script = document.createElement("script"); 197 | done = false; 198 | script.src = url; 199 | script.async = true; 200 | script.onload = script.onreadystatechange = function() { 201 | if (!done && (!this.readyState || this.readyState === "loaded" || this.readyState === "complete")) { 202 | done = true; 203 | script.onload = script.onreadystatechange = null; 204 | if (script && script.parentNode) { 205 | return script.parentNode.removeChild(script); 206 | } 207 | } 208 | }; 209 | if (!head) { 210 | head = document.getElementsByTagName("head")[0]; 211 | } 212 | return head.appendChild(script); 213 | }; 214 | encode = function(str) { 215 | return encodeURIComponent(str); 216 | }; 217 | jsonp = function(url, params, callback, callbackName) { 218 | var key, query; 219 | query = ((url || "").indexOf("?") === -1 ? "?" : "&"); 220 | params = params || {}; 221 | for (key in params) { 222 | if (params.hasOwnProperty(key)) { 223 | query += encode(key) + "=" + encode(params[key]) + "&"; 224 | } 225 | } 226 | jsonp = "json" + (++counter); 227 | window[jsonp] = function(data) { 228 | callback(data); 229 | try { 230 | delete window[jsonp]; 231 | } catch (_error) {} 232 | return window[jsonp] = null; 233 | }; 234 | load(url + query + (callbackName || config["callbackName"] || "callback") + "=" + jsonp); 235 | return jsonp; 236 | }; 237 | setDefaults = function(obj) { 238 | var config; 239 | return config = obj; 240 | }; 241 | counter = 0; 242 | head = void 0; 243 | query = void 0; 244 | key = void 0; 245 | window = this; 246 | config = {}; 247 | return { 248 | get: jsonp, 249 | init: setDefaults 250 | }; 251 | })(); 252 | 253 | }).call(this); -------------------------------------------------------------------------------- /demo/player.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Jasny Audio jPlayer demo 5 | 6 | 7 | 8 | 9 | 10 | 57 | 58 | 59 |
60 |
61 | 62 | 105 | 106 |
107 | Note: Seeking in jPlayer is not always accurate for compressed formats like mp3. Consider converting the track to a .wav file. 108 |
109 |
110 | 111 | 112 | 113 | 114 | 134 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /demo/track.php: -------------------------------------------------------------------------------- 1 | getAnnotations(true) + (array)$track->getStats(); 11 | 12 | header('Content-Type: application/json'); 13 | echo json_encode($result); 14 | -------------------------------------------------------------------------------- /demo/tracks/demo.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasny/audio/fe9f984407a0462deb8f4fa1304d058b02bd87e0/demo/tracks/demo.wav -------------------------------------------------------------------------------- /demo/tracks/song.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasny/audio/fe9f984407a0462deb8f4fa1304d058b02bd87e0/demo/tracks/song.mp3 -------------------------------------------------------------------------------- /demo/waveform.php: -------------------------------------------------------------------------------- 1 | output($format); 13 | -------------------------------------------------------------------------------- /demo/waveformjs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Demo using waveform.js 5 | 6 | 7 |

Demo using waveform.js

8 |

9 | Waveform.js makes drawing SoundCloud waveforms simple and lets you style 10 | and color them the way you want it. 11 |

12 | 13 |
14 | 15 | 16 | 17 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Jasny/Audio/Track.php: -------------------------------------------------------------------------------- 1 | filename = $filename; 66 | } 67 | 68 | /** 69 | * Cast the track to a string 70 | * 71 | * @return type 72 | */ 73 | public function __toString() 74 | { 75 | return $this->filename; 76 | } 77 | 78 | 79 | /** 80 | * Get statistics of the audio file 81 | * 82 | * @return array 83 | */ 84 | public function getStats() 85 | { 86 | if (isset($this->stats)) return $this->stats; 87 | 88 | $stats = array(); 89 | $stats['channels'] = '1'; 90 | 91 | foreach (explode("\n", $this->sox('-n', 'stats')) as $line) { 92 | if (empty($line) || preg_match('/^\S+ WARN/', $line)) continue; 93 | 94 | if ($line[0] == ' ') { 95 | $stats['channels'] = (string)count(preg_split('/\s+/', trim($line))) - 1; 96 | continue; 97 | } 98 | 99 | list($key, $value) = preg_split('/\s{2,}/', $line) + array(1=>null); 100 | if (!isset($value)) continue; 101 | 102 | $key = strtolower(preg_replace(array('/\s(s|dB)$/', '/\W+/'), array('', '_'), $key)); 103 | $value = preg_replace('/\s.*$/', '', $value); 104 | $stats[$key] = $value; 105 | } 106 | 107 | foreach (explode("\n", $this->sox('-n', 'stat')) as $line) { 108 | if (preg_match('/^\S+ WARN/', $line)) continue; 109 | 110 | list($key, $value) = explode(':', $line) + array(1=>null); 111 | if (!isset($value)) continue; 112 | 113 | if ($key == 'Samples read') $key = 'samples'; 114 | elseif ($key == 'Length (seconds)') $key = 'length'; 115 | else $key = strtolower(preg_replace('/\s+/', '_', $key)); 116 | 117 | $stats[$key] = trim($value); 118 | } 119 | 120 | unset($stats['num_samples']); 121 | $stats['sample_rate'] = (string)round(($stats['samples'] / $stats['channels']) / $stats['length']); 122 | 123 | $this->stats = (object)$stats; 124 | return $this->stats; 125 | } 126 | 127 | /** 128 | * Get the a stat of the track 129 | * 130 | * @param string $stat 131 | * @param string $soxi_arg 132 | * @param string $cast 133 | * @return mixed 134 | */ 135 | private function getStat($stat, $soxi_arg, $cast) 136 | { 137 | if (!isset($this->$stat)) { 138 | $this->$stat = isset($this->stats) ? 139 | (float)$this->stats->$stat : 140 | (float)$this->soxi($soxi_arg); 141 | 142 | settype($this->$stat, $cast); 143 | } 144 | 145 | return $this->$stat; 146 | } 147 | 148 | /** 149 | * Get the sample rate of the track 150 | * 151 | * @return int 152 | */ 153 | public function getSampleRate() 154 | { 155 | return $this->getStat('sample_rate', '-r', 'int'); 156 | } 157 | 158 | /** 159 | * Get the number of channels 160 | * 161 | * @return int 162 | */ 163 | public function getChannelCount() 164 | { 165 | return $this->getStat('channels', '-c', 'int'); 166 | } 167 | 168 | /** 169 | * Get the number of samples 170 | * 171 | * @return int 172 | */ 173 | public function getSampleCount() 174 | { 175 | return $this->getStat('samples', '-s', 'int'); 176 | } 177 | 178 | /** 179 | * Get the duration of the track in seconds 180 | * 181 | * @return float 182 | */ 183 | public function getLength() 184 | { 185 | return $this->getStat('length', '-D', 'float'); 186 | } 187 | 188 | /** 189 | * Get the file comments (annotations) 190 | * 191 | * @param boolean $parse Parse and return a value object 192 | * @return string|object 193 | */ 194 | public function getAnnotations($parse=false) 195 | { 196 | if (!isset($this->annotations)) { 197 | $this->annotations = trim($this->soxi('-a')); 198 | } 199 | 200 | if (!$parse) return $this->annotations; 201 | 202 | if (empty($this->annotations)) return (object)array(); 203 | 204 | $result = array(); 205 | foreach (explode("\n", $this->annotations) as $line) { 206 | if (empty($line)) continue; 207 | list($key, $value) = explode("=", $line, 2); 208 | $result[strtolower($key)] = $value; 209 | } 210 | 211 | return (object)$result; 212 | } 213 | 214 | 215 | /** 216 | * Plot a waveform for this audio track 217 | * 218 | * @param array $settings 219 | * @return Waveform 220 | */ 221 | public function getWaveform(array $settings=array()) 222 | { 223 | return new Waveform($this, $settings); 224 | } 225 | 226 | 227 | /** 228 | * Convert the audio file (using avconv) 229 | * 230 | * @param $filename New filename 231 | * @return Track 232 | */ 233 | public function convert($filename) 234 | { 235 | $this->avconv($filename); 236 | return new static($filename); 237 | } 238 | 239 | /** 240 | * Combine two audio files 241 | * 242 | * @param string $method 'concatenate', 'merge', 'mix', 'mix-power', 'multiply', 'sequence' 243 | * @param string|Track $in File to mix with 244 | * @param string $out New filename 245 | * @return Track 246 | */ 247 | public function combine($method, $in, $out) 248 | { 249 | if ($in instanceof self) $in = $in->filename; 250 | 251 | $this->sox('--combine', $method, $in, $out); 252 | return new static($out); 253 | } 254 | 255 | 256 | /** 257 | * Execute sox. 258 | * Each argument will be used in the command. 259 | * 260 | * @return string 261 | */ 262 | public function sox() 263 | { 264 | $args = func_get_args(); 265 | array_unshift($args, $this->filename); 266 | 267 | return self::exec('sox', $args); 268 | } 269 | 270 | /** 271 | * Execute soxi. 272 | * Each argument will be used in the command. 273 | * 274 | * @return string 275 | */ 276 | public function soxi() 277 | { 278 | $args = func_get_args(); 279 | $args[] = $this->filename; 280 | 281 | return self::exec('soxi', $args); 282 | } 283 | 284 | /** 285 | * Execute avconv. 286 | * Each argument will be used in the command. 287 | * 288 | * @return string 289 | */ 290 | public function avconv() 291 | { 292 | $args = array_merge(array('-i', $this->filename), func_get_args()); 293 | 294 | return self::exec('avconv', $args); 295 | } 296 | 297 | /** 298 | * Get executable 299 | * 300 | * @return string 301 | */ 302 | public static function which($cmd) 303 | { 304 | return escapeshellcmd(self::${$cmd}); 305 | } 306 | 307 | /** 308 | * Execute a command 309 | * 310 | * @param string $cmd 311 | * @param string $args 312 | * @return string 313 | */ 314 | protected static function exec($cmd, $args) 315 | { 316 | $command = self::which($cmd) . ' ' . join(' ', array_map('escapeshellarg', $args)); 317 | 318 | $descriptorspec = array( 319 | 1 => array("pipe", "w"), // stdout 320 | 2 => array("pipe", "w") // stderr 321 | ); 322 | 323 | $handle = proc_open($command, $descriptorspec, $pipes); 324 | if (!$handle) throw new \Exception("Failed to run sox command"); 325 | 326 | $out = stream_get_contents($pipes[1]); 327 | $err = stream_get_contents($pipes[2]); 328 | 329 | $ret = proc_close($handle); 330 | if ($ret != 0) throw new \Exception("$cmd command failed. " . trim($err)); 331 | 332 | return $out ?: $err; 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/Jasny/Audio/Waveform.php: -------------------------------------------------------------------------------- 1 | $value) { 83 | if (!property_exists($this, $key)) continue; 84 | $this->$key = $value; 85 | } 86 | 87 | $this->track = $track instanceof Track ? $track : new Track($track); 88 | } 89 | 90 | /** 91 | * Get the input audio track 92 | * 93 | * @return Track 94 | */ 95 | public function getTrack() 96 | { 97 | return $this->track; 98 | } 99 | 100 | 101 | /** 102 | * Calculate the samples. 103 | * 104 | * @return array 105 | */ 106 | protected function calc() 107 | { 108 | if (!file_exists($this->track)) throw new \Exception("File '{$this->track}' doesn't exist"); 109 | 110 | $length = $this->track->getLength(); 111 | $rate = null; 112 | $sample_count = $this->track->getSampleCount(); 113 | 114 | $trim = null; 115 | if ($this->offset || $this->duration) { 116 | $offset = $this->offset >= 0 ? $this->offset : $length + $this->offset; 117 | $trim = $offset . ($this->duration ? " " . (float)$this->duration : ''); 118 | 119 | $newlength = $this->duration ?: $length - $offset; 120 | $sample_count = floor(($newlength / $length) * $sample_count); 121 | $length = $newlength; 122 | } 123 | 124 | // Downsample to max 500 samples per pixel with a minimum sample rate of 4k/s 125 | if ($sample_count / $this->width > 500) { 126 | $rate = max(($this->width / $length) * 500, 4000); 127 | $sample_count = $rate * $length; 128 | } 129 | 130 | $this->length = $length; 131 | 132 | $this->samples = $this->calcExecute($sample_count, $trim, $rate); 133 | 134 | if (!isset($this->level)) { 135 | $this->level = max(-1 * min($this->samples), max($this->samples)); 136 | } 137 | } 138 | 139 | /** 140 | * Calculate the samples 141 | * 142 | * @param int $sample_count 143 | * @param string $trim 144 | * @param float $rate 145 | * @return array 146 | */ 147 | protected function calcExecute($sample_count, $trim, $rate) 148 | { 149 | $track = escapeshellarg($this->track); 150 | if ($trim) $trim = "trim $trim"; 151 | $resample = $rate ? "-r $rate" : ''; 152 | $chunk_size = floor($sample_count / $this->width); 153 | 154 | $descriptorspec = array( 155 | 1 => array("pipe", "w"), // stdout 156 | 2 => array("pipe", "w") // stderr 157 | ); 158 | 159 | $sox = escapeshellcmd(Track::which('sox')); 160 | 161 | $handle = proc_open("$sox $track -t raw $resample -c 1 -e floating-point -L - $trim", $descriptorspec, $pipes); 162 | if (!$handle) throw new \Exception("Failed to get the samples using sox"); 163 | 164 | $chunk = array(); 165 | $samples = array(); 166 | 167 | while ($data = fread($pipes[1], 4 * $chunk_size)) { 168 | $chunk = unpack('f*', $data); 169 | $chunk[] = 0; 170 | $samples[] = min($chunk); 171 | $samples[] = max($chunk); 172 | }; 173 | 174 | $err = stream_get_contents($pipes[2]); 175 | 176 | $ret = proc_close($handle); 177 | if ($ret != 0) throw new \Exception("Sox command failed. " . trim($err)); 178 | 179 | return $samples; 180 | } 181 | 182 | /** 183 | * Get the samples. 184 | * 185 | * @return array 186 | */ 187 | public function getSamples() 188 | { 189 | if (!isset($this->samples)) $this->calc(); 190 | return $this->samples; 191 | } 192 | 193 | /** 194 | * Get the length of the track. 195 | * 196 | * @return array 197 | */ 198 | public function getLength() 199 | { 200 | if (!isset($this->length)) $this->calc(); 201 | return $this->length; 202 | } 203 | 204 | /** 205 | * Get the level (max amplitude). 206 | * 207 | * @return array 208 | */ 209 | public function getLevel() 210 | { 211 | if (!isset($this->level)) $this->calc(); 212 | return $this->level; 213 | } 214 | 215 | 216 | /** 217 | * Plot the waveform 218 | * 219 | * @return resource 220 | */ 221 | public function plot() 222 | { 223 | $this->getSamples(); 224 | 225 | $im = imagecreatetruecolor($this->width, $this->height); 226 | imagesavealpha($im, true); 227 | imagefill($im, 0, 0, imagecolorallocatealpha($im, 0, 0, 0, 127)); 228 | 229 | $center = ($this->height / 2); 230 | $scale = ($center / $this->level); 231 | $color = self::strToColor($im, $this->color); 232 | 233 | for ($i = 0, $n = count($this->samples); $i < $n-1; $i += 2) { 234 | $max = $center + (-1 * $this->samples[$i] * $scale); 235 | $min = $center + (-1 * $this->samples[$i+1] * $scale); 236 | 237 | imageline($im, $i / 2, $min, $i / 2, $max, $color); 238 | } 239 | 240 | if (!empty($this->axis)) { 241 | imageline($im, 0, $this->height / 2, $this->width, $this->height / 2, self::strToColor($im, $this->axis)); 242 | } 243 | 244 | return $im; 245 | } 246 | 247 | /** 248 | * Create a gd color using a hexidecimal color notation 249 | * 250 | * @param resource $im 251 | * @param string $color 252 | * @return int 253 | */ 254 | protected static function strToColor($im, $color) 255 | { 256 | $color = ltrim($color, '#'); 257 | 258 | if (strpos($color, ',') !== false) { 259 | list($red, $green, $blue, $opacity) = explode(',', $color) + [3 => null]; 260 | } else { 261 | $red = hexdec(substr($color, 0, 2)); 262 | $green = hexdec(substr($color, 2, 2)); 263 | $blue = hexdec(substr($color, 4, 2)); 264 | $opacity = 1; 265 | } 266 | 267 | $alpha = round((1 - $opacity) * 127); 268 | return imagecolorallocatealpha($im, $red, $green, $blue, $alpha); 269 | } 270 | 271 | 272 | /** 273 | * Output the generated waveform 274 | * 275 | * @param string $format Options: png or json 276 | */ 277 | public function output($format='png') 278 | { 279 | $fn = "output$format"; 280 | if (!method_exists($this, $fn)) throw new \Exception("Unknown format '$format'"); 281 | 282 | $this->$fn(); 283 | } 284 | 285 | /** 286 | * Output the generated waveform as PNG 287 | */ 288 | protected function outputPng() 289 | { 290 | $im = $this->plot(); 291 | 292 | header("X-Waveform-Length: {$this->length}"); 293 | header("X-Waveform-Level: {$this->level}"); 294 | header('Content-Type: image/png'); 295 | imagepng($im); 296 | } 297 | 298 | /** 299 | * Output the generated waveform as JSON 300 | */ 301 | protected function outputJson() 302 | { 303 | header('Content-Type: application/json'); 304 | echo json_encode(array( 305 | 'length'=>$this->getLength(), 306 | 'level'=>$this->getLevel(), 307 | 'samples'=>$this->getSamples() 308 | )); 309 | } 310 | } 311 | --------------------------------------------------------------------------------