├── server.js ├── demo └── 868276.ogg ├── .gitignore ├── downloads └── .gitignore ├── .gitattributes ├── .github └── FUNDING.yml ├── .editorconfig ├── package.json ├── LICENSE ├── server ├── library │ ├── fileManager.js │ ├── directoryManager.js │ ├── waveformData.js │ ├── fileDownloader.js │ └── audioDataAnalyzer.js └── bootstrap.js ├── cli.js └── README.md /server.js: -------------------------------------------------------------------------------- 1 | export * from './server/bootstrap.js' -------------------------------------------------------------------------------- /demo/868276.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisweb/waveform-data-generator/HEAD/demo/868276.ogg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | node_modules 3 | 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # rollup-plugin-typescript2 9 | .rpt2_cache -------------------------------------------------------------------------------- /downloads/.gitignore: -------------------------------------------------------------------------------- 1 | *.mp3 2 | *.ogg 3 | *.wav 4 | *.aiff 5 | *.wma 6 | *.webm 7 | *.oga 8 | *.opus 9 | *.m4a 10 | *.aac 11 | *.flac -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # JS files must always use LF for tools to work 5 | *.js eol=lf 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: chrisweb 2 | buy_me_a_coffee: chriswwweb 3 | 4 | # Hello World! 🚀 Thank you, for buying me a coffee, it will allow me to continue writing and coding tutorials on chris.lu and GitHub -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | max_line_length = 80 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | max_line_length = 0 16 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "waveform-data-generator", 3 | "version": "1.0.1", 4 | "description": "nodejs (ffmpeg) waveform data (peaks) generator", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+ssh://git@github.com/chrisweb/waveform-data-generator.git" 12 | }, 13 | "keywords": [ 14 | "waveform", 15 | "nodejs", 16 | "javascript", 17 | "data", 18 | "generator", 19 | "peaks", 20 | "ffmpeg" 21 | ], 22 | "author": "Chris Weber (chrisweb)", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/chrisweb/waveform-data-generator/issues" 26 | }, 27 | "homepage": "https://github.com/chrisweb/waveform-data-generator", 28 | "dependencies": { 29 | "marked": "14.1.1" 30 | }, 31 | "type": "module" 32 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 chris weber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/library/fileManager.js: -------------------------------------------------------------------------------- 1 | import { exists as _exists, existsSync } from 'fs' 2 | 3 | /** 4 | * 5 | * file manager 6 | * 7 | * @returns {fileManagerConstructor} 8 | */ 9 | class FileManager { 10 | 11 | constructor() { 12 | } 13 | 14 | /** 15 | * 16 | * check if the file exists 17 | * 18 | * @param {type} file 19 | * @param {type} callback 20 | * @returns {undefined} 21 | */ 22 | exists(file, callback) { 23 | 24 | //console.log('file exists? file: ' + file) 25 | 26 | if (callback !== undefined) { 27 | 28 | if (file !== undefined) { 29 | 30 | // async exists 31 | _exists(file, function (exists) { 32 | callback(null, exists) 33 | }); 34 | 35 | } else { 36 | 37 | callback('file is undefined') 38 | 39 | } 40 | 41 | } else { 42 | 43 | if (file !== undefined) { 44 | return existsSync(file) 45 | } else { 46 | throw 'file is undefined' 47 | } 48 | 49 | } 50 | 51 | } 52 | } 53 | 54 | export default FileManager -------------------------------------------------------------------------------- /server/library/directoryManager.js: -------------------------------------------------------------------------------- 1 | import { exists as _exists, existsSync, mkdir, mkdirSync } from 'fs' 2 | 3 | class DirectoryManager { 4 | 5 | constructor() { 6 | } 7 | 8 | /** 9 | * 10 | * check if the directory exists 11 | * 12 | * @param {type} directory 13 | * @param {type} callback 14 | * @returns {undefined} 15 | */ 16 | exists(directory, callback) { 17 | 18 | //console.log('directory exists? directory: ' + directory) 19 | 20 | if (callback !== undefined) { 21 | 22 | if (directory !== undefined) { 23 | 24 | // async exists 25 | _exists(directory, function (exists) { 26 | callback(null, exists) 27 | }) 28 | 29 | } else { 30 | callback('directory is undefined') 31 | } 32 | 33 | } else { 34 | 35 | if (directory !== undefined) { 36 | existsSync(directory) 37 | } else { 38 | throw 'directory is undefined' 39 | } 40 | 41 | } 42 | 43 | } 44 | /** 45 | * 46 | * create a new directory 47 | * 48 | * @param {type} directory 49 | * @param {type} callback 50 | * @returns {undefined} 51 | */ 52 | create(directory, callback) { 53 | 54 | //console.log('create directory: ' + directory) 55 | 56 | if (callback !== undefined) { 57 | 58 | mkdir(directory, 666, function (error) { 59 | 60 | if (!error) { 61 | callback(null) 62 | } else { 63 | callback(error) 64 | } 65 | 66 | }) 67 | 68 | } else { 69 | 70 | return mkdirSync(directory, 666) 71 | 72 | } 73 | 74 | } 75 | } 76 | 77 | export default DirectoryManager -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | import { getLocalWaveData, getRemoteWaveData } from './server/library/waveformData.js' 2 | 3 | const queryObject = {} 4 | 5 | process.argv.forEach(function (value, index, array) { 6 | 7 | const commandParametersLength = array.length - 2 8 | 9 | if (commandParametersLength !== 7) { 10 | 11 | const error = 'invalid parameters length, please call the cli with the following parameters: node cli SERVER_PATH TRACK_NAME TRACK_EXTENSION AMOUT_OF_PEAKS LOCAL_OR_REMOTE_SERVICENAME PEAKSLIST_OUTPUT_FORMAT TRACK_FORMAT_DETECTION\n' 12 | 13 | process.stderr.write(error + "\n") 14 | 15 | process.exit(1) 16 | 17 | } 18 | 19 | if (index === 2) { 20 | queryObject.serverDirectory = value 21 | } 22 | 23 | if (index === 3) { 24 | queryObject.trackId = value 25 | } 26 | 27 | if (index === 4) { 28 | queryObject.trackFormat = value.toLowerCase() 29 | } 30 | 31 | if (index === 5) { 32 | queryObject.peaksAmount = parseInt(value) 33 | } 34 | 35 | if (index === 6) { 36 | queryObject.service = value.toLowerCase() 37 | } 38 | 39 | if (index === 7) { 40 | queryObject.outputFormat = value.toLowerCase() 41 | } 42 | 43 | if (index === 8) { 44 | 45 | if (typeof value === 'boolean') { 46 | queryObject.detectFormat = value 47 | } else { 48 | 49 | const detectFormat = value.toLowerCase() 50 | 51 | if (detectFormat === 'true') { 52 | queryObject.detectFormat = true 53 | } else if (detectFormat.substr(0, 2) === 'sr') { 54 | const splittedDetectFormat = detectFormat.split('=') 55 | queryObject.detectFormat = parseInt(splittedDetectFormat[1]) 56 | } else { 57 | queryObject.detectFormat = false 58 | } 59 | 60 | } 61 | } 62 | }) 63 | 64 | const outputResponse = function outputResponseFunction(error, peaks) { 65 | 66 | if (error) { 67 | process.stderr.write(error + "\n") 68 | process.exit(1) 69 | } else { 70 | 71 | let output = '' 72 | 73 | // outputFormat can be json or text 74 | if (queryObject.outputFormat === 'json') { 75 | 76 | const outputData = { 77 | "peaks": peaks 78 | } 79 | 80 | output = JSON.stringify(outputData) 81 | 82 | } else { 83 | 84 | const peaksString = '' 85 | let i 86 | const peaksLength = peaks.length 87 | 88 | for (i = 0; i < peaksLength; i++) { 89 | peaksString += peaks[i] + ',' 90 | } 91 | 92 | output = peaksString.substring(0, peaksString.length - 1) 93 | 94 | } 95 | 96 | process.stdout.write(output + "\n") 97 | 98 | process.exit(0) 99 | 100 | } 101 | } 102 | 103 | if (queryObject.service === 'local') { 104 | getLocalWaveData(queryObject, outputResponse) 105 | } else { 106 | getRemoteWaveData(queryObject, outputResponse) 107 | } -------------------------------------------------------------------------------- /server/library/waveformData.js: -------------------------------------------------------------------------------- 1 | import AudioDataAnalyzer from './audioDataAnalyzer.js' 2 | import FileDownloader from './fileDownloader.js' 3 | import DirectoryManager from './directoryManager.js' 4 | import FileManager from './fileManager.js' 5 | 6 | const queryObjectToOptions = function queryObjectToOptionsFunction(queryObject) { 7 | 8 | const options = { 9 | trackId: queryObject.trackId, 10 | trackFormat: queryObject.trackFormat || 'ogg', 11 | peaksAmount: queryObject.peaksAmount || 200, 12 | method: 'GET', 13 | serverDirectory: queryObject.serverDirectory || './downloads', 14 | service: queryObject.service || 'jamendo', 15 | detectFormat: queryObject.detectFormat || false 16 | } 17 | 18 | options.fileName = options.trackId + '.' + options.trackFormat 19 | 20 | return options 21 | 22 | } 23 | 24 | const analyzeAudio = function analyzeAudioFunction(filePath, options, callback) { 25 | 26 | // initialize the audioAnalyzer 27 | const audioDataAnalyzer = new AudioDataAnalyzer() 28 | 29 | audioDataAnalyzer.setDetectFormat(options.detectFormat) 30 | 31 | // analyze the track using ffmpeg 32 | audioDataAnalyzer.getPeaks(filePath, options.peaksAmount, function getValuesCallback(error, peaks) { 33 | 34 | // if there was no error analyzing the track 35 | if (!error) { 36 | callback(null, peaks) 37 | } else { 38 | callback(error) 39 | } 40 | 41 | }) 42 | 43 | } 44 | 45 | /** 46 | * 47 | * get the wave data for a given trackId from a file on a remote server 48 | * 49 | * @param {type} queryObject 50 | * @param {type} callback 51 | * @returns {undefined} 52 | */ 53 | const getRemoteWaveData = function getRemoteWaveDataFunction(queryObject, callback) { 54 | 55 | // track options 56 | const options = queryObjectToOptions(queryObject) 57 | 58 | if (typeof options.trackId !== 'undefined') { 59 | 60 | // service options 61 | switch (queryObject.service) { 62 | 63 | case 'jamendo': 64 | default: 65 | 66 | // track format 67 | switch (queryObject.trackFormat) { 68 | case 'ogg': 69 | // format code seems to have changed (as of 31.12.2022) 70 | //options.formatCode = 'ogg1' 71 | options.formatCode = 'ogg' 72 | break 73 | default: 74 | options.formatCode = 'mp31' 75 | // there also seems to be another format called mp32 but files seem to be identical 76 | // example: https://prod-1.storage.jamendo.com/download/track/1886257/mp32/ 77 | //options.formatCode = 'mp32' 78 | } 79 | 80 | // old server seems to be gone (as of 31.12.2022) 81 | //options.remoteHost = 'storage-new.newjamendo.com' 82 | // new download server: 83 | options.remoteHost = 'prod-1.storage.jamendo.com' 84 | options.remotePath = '/download/track/' + options.trackId + '/' + options.formatCode 85 | 86 | } 87 | 88 | // initialize the track downloader 89 | const fileDownloader = new FileDownloader() 90 | 91 | // download the track and write it on the disc of it does not already exist 92 | fileDownloader.writeToDisc(options, function writeFileCallback(error, filePath) { 93 | 94 | // if there was no error downloading and writing the track 95 | if (!error) { 96 | analyzeAudio(filePath, options, callback) 97 | } else { 98 | callback(error) 99 | } 100 | 101 | }) 102 | 103 | } else { 104 | callback('please specify at least a trackId') 105 | } 106 | 107 | } 108 | 109 | /** 110 | * 111 | * get the wave data for a given trackId from a local file 112 | * 113 | * @param {type} queryObject 114 | * @param {type} callback 115 | * @returns {undefined} 116 | */ 117 | const getLocalWaveData = function getLocalWaveDataFunction(queryObject, callback) { 118 | 119 | // track options 120 | const options = queryObjectToOptions(queryObject) 121 | 122 | const directoryManager = new DirectoryManager() 123 | 124 | directoryManager.exists(options.serverDirectory, function directoryExistsCallback(error, exists) { 125 | 126 | // if there was no error checking if the directory exists 127 | if (!error) { 128 | 129 | // if the directory does not exist 130 | if (!exists) { 131 | 132 | callback('the server directory does not exist') 133 | 134 | } else { 135 | 136 | const fileManager = new FileManager() 137 | 138 | const filePath = options.serverDirectory + '/' + options.fileName 139 | 140 | // check if the file exists 141 | fileManager.exists(filePath, function fileExistsCallback(error, exists) { 142 | 143 | // if there was no error checking if the file exists 144 | if (!error) { 145 | 146 | if (!exists) { 147 | callback('the file does not exist') 148 | } else { 149 | analyzeAudio(filePath, options, callback) 150 | } 151 | 152 | } else { 153 | callback(error) 154 | } 155 | 156 | }) 157 | 158 | } 159 | 160 | } else { 161 | callback(error) 162 | } 163 | 164 | }) 165 | 166 | } 167 | 168 | export { getRemoteWaveData, getLocalWaveData } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![waveform-data-generator version](https://img.shields.io/github/package-json/v/chrisweb/waveform-data-generator)](https://github.com/chrisweb/waveform-data-generator) 2 | [![waveform-data-generator license](https://img.shields.io/github/license/chrisweb/waveform-data-generator)](https://github.com/chrisweb/waveform-data-generator) 3 | 4 | # waveform-data-generator 5 | 6 | Generates waveform data (peaks) that can then get visualized using for example my other project the javascript client [waveform-visualizer](https://github.com/chrisweb/waveform-visualizer) that uses animated html5 canvas or you can of course also create / use your own visualizer 7 | 8 | ![waveform example image](https://raw.githubusercontent.com/chrisweb/waveform-visualizer/master/docs/images/waveform_example.png) 9 | 10 | Waveform created using the visualizer with data from waveform data generator 11 | 12 | ## Getting started 13 | 14 | * if you haven't already, install git and nodejs ( (which includes npm and will be used to run the server or use the cli tool) 15 | * update npm to ensure you have the latest version installed 16 | `npm i -g npm@latest` 17 | * clone this project locally 18 | * now install ffmeg package based on your operating system () (and if you develop in windows add it to your path: ) 19 | * use your command line tool and go to the root of this project 20 | `cd /ROOT/waveform-data-generator` 21 | * install the required dependencies (node_modules) 22 | `npm i` 23 | 24 | ## Launch the server 25 | 26 | * Use your command line tool and go to the root of this project: 27 | `cd /ROOT/waveform-data-generator` 28 | * To lauch the server use the following command: 29 | `npm run start` 30 | * Open your browser and visit the following URL 31 | `127.0.0.1:35000` 32 | * if you see the readme of this project as homepage, it means the server is running 33 | 34 | ## Using the command line tool 35 | 36 | * use your command line tool and go to the root of this project: 37 | `cd /ROOT/waveform-data-generator` 38 | * copy the song `868276.ogg` from the `/demo` folder into the `/downloads` folder or use any song of your own 39 | * to use the cli you need to pass parameters (see the following chapter "[Cli Options (Parameters)](#cli-options-parameters)" for an explanation) 40 | * for example use this command to create the peaks using the demo song: 41 | `node cli ./downloads 868276 ogg 200 local json false` 42 | 43 | note: the demo file 868276.ogg that can be found in the `/demo` folder, is a song called "[Hasta Abajo](https://www.jamendo.com/track/868276/hasta-abajo)" by artist [Kellee Maize](https://www.jamendo.com/artist/357359/kellee-maize) and released under [CC BY-SA](https://creativecommons.org/licenses/by-sa/2.0/) 44 | 45 | ### Cli Options (Parameters) 46 | 47 | * The first parameter "./downloads" is the repository where you want the audio files to get stored 48 | * The second parameter "868276" is the filename of the track (in my example the filename is the track ID) 49 | * The third parameter "ogg" is the audio file format (also file exntension) of the track (ogg / mp3) 50 | * The fourth parameter "200" is the amount of peaks you want to get 51 | * The fifth parameter "local" tells the script if the file is already on your local machine, you can use "jamendo.com" to download music files from their library and store them locally in your /downloads directory 52 | * The sixth parameter "json" is the type of output you want, the peaks can get outputted in either json or as a string 53 | * The seventh parameter tells the script if it should use ffprobe to detect the track format (number of channels, the sampling frequency, ...) or use default values 54 | 55 | ## Using the web interface 56 | 57 | * if you haven't already, start the server, using the command: 58 | `npm run start` 59 | * then put the following URL into your browsers address bar: 60 | `http://127.0.0.1:35000/getwavedata?service=jamendo&trackId=868276` 61 | * this will download the song into the `/downloads` folder and then print a json representation of the peak values on screen 62 | * Note: currently there only two options, the first one is to use local files and the second one will download songs from [jamendo.com](https://www.jamendo.com/start), you are welcome to fork this project if you want to add another serive (source for songs) and then create a PR on github so that it can get reviewed and hopefully included into this project 63 | 64 | ### web interface Options (Parameters) 65 | 66 | * trackId: the ID of a track [required / numeric track ID] 67 | * trackFormat: the audio file format (file exntension) of the track (ogg or mp3) [default ogg] 68 | * peaksAmount: the amount of peaks you want to get [default 200] 69 | * method: the http request method to get the remote file [default GET] 70 | * serverDirectory: the repository where you want the audio files to get stored on the server [default ./downloads] 71 | * service: the audio file provider you want to get the track from (you can use 'local' if the file is already in your server directory) [default jamendo] 72 | * detectFormat: tells the script if it should use ffprobe to detect the track format (number of channels, the sampling frequency, ...) or use default values (true or false) [default false] 73 | 74 | ## 🚨 Out of memory 75 | 76 | Error "FATAL ERROR: JS Allocation failed - process out of memory" 77 | 78 | If the file you want to parse is too big, try increasing the memory limit of your node process, like this: 79 | 80 | node --max-old-space-size=1900 cli ./downloads 868276 ogg 200 local json false 81 | 82 | If you still run out of memory try to reduce the sample rate by passing custom values as seventh parameter, for example if the song sample rate is 44100 but runs out of memory, then try again with 22050, like this: 83 | 84 | node --max-old-space-size=1900 cli ./downloads 868276 ogg 200 local json sr=22050 85 | 86 | ## TODOs 87 | 88 | * fix memory problems for big audio files 89 | * Create a client side waveform data generator using the web audio API () 90 | -------------------------------------------------------------------------------- /server/bootstrap.js: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http' 2 | import { readFile, statSync, createReadStream } from 'fs' 3 | import { parse } from 'url' 4 | import { parse as _parse } from 'querystring' 5 | import { getRemoteWaveData } from './library/waveformData.js' 6 | import { marked } from 'marked' 7 | 8 | const serverPort = process.env.PORT || 35000 9 | const serverIp = process.env.HOSTNAME || '127.0.0.1' 10 | 11 | /** 12 | * 13 | * create a new nodejs server handle incoming requests 14 | * 15 | * @param {type} request 16 | * @param {type} response 17 | */ 18 | const server = createServer(function (request, response) { 19 | 20 | // parse the url 21 | const urlParts = parse(request.url) 22 | 23 | // check if its is the url of a javascript file 24 | if (urlParts.pathname.split('.').pop() === 'js') { 25 | 26 | // if the file exists send it to the client 27 | // not really secure but this is a prototype 28 | // TODO: filter the file request 29 | readFile('client' + urlParts.pathname, function (error, fileContent) { 30 | 31 | if (!error) { 32 | // send the static file to the client 33 | response.writeHead(200, { 'Content-Type': 'application/javascript' }) 34 | response.write(fileContent) 35 | response.end() 36 | } else { 37 | // the file was not on the server send a 404 page to the client 38 | response.writeHead(404, { 'Content-Type': 'text/html' }) 39 | response.write('page not found') 40 | response.end() 41 | } 42 | 43 | }) 44 | 45 | } else { 46 | 47 | let queryObject 48 | 49 | // handle the "routes" 50 | switch (urlParts.pathname) { 51 | case '/': 52 | /*fs.readFile('client/index.html', function (error, html) { 53 | 54 | if (!error) { 55 | // send the main html page to the client 56 | response.writeHead(200, { 'Content-Type': 'text/html' }) 57 | response.write(html) 58 | response.end() 59 | } else { 60 | // the main page could not be found return a page not 61 | // found message 62 | response.writeHead(404, { 'Content-Type': 'text/html' }) 63 | response.write('page not found') 64 | response.end() 65 | } 66 | 67 | })*/ 68 | 69 | readFile('README.md', 'utf-8', function (error, document) { 70 | 71 | if (!error) { 72 | // send the main html page to the client 73 | response.writeHead(200, { 'Content-Type': 'text/html' }) 74 | response.write(marked(document)) 75 | response.end() 76 | } else { 77 | // the main page could not be found return a page not 78 | // found message 79 | response.writeHead(404, { 'Content-Type': 'text/html' }) 80 | response.write('page not found') 81 | response.end() 82 | } 83 | 84 | }) 85 | 86 | break 87 | case '/getwavedata': 88 | 89 | queryObject = _parse(urlParts.query) 90 | 91 | if (typeof queryObject !== 'undefined') { 92 | 93 | getRemoteWaveData(queryObject, function (error, peaks) { 94 | 95 | if (!error) { 96 | // success, send the track peaks to the client 97 | response.writeHead(200, { 'Content-Type': 'application/json' }) 98 | response.write('{ "peaks": ' + JSON.stringify(peaks) + ' }') 99 | response.end() 100 | } else { 101 | // fail, send the error to the client 102 | response.writeHead(500, { 'Content-Type': 'application/json' }) 103 | response.write('{ error: ' + error + ' }') 104 | response.end() 105 | } 106 | 107 | }) 108 | 109 | } 110 | 111 | break 112 | case '/getTrack': 113 | 114 | queryObject = _parse(urlParts.query) 115 | 116 | //console.log(queryObject) 117 | 118 | if (typeof queryObject !== 'undefined' && queryObject.trackId !== 'undefined' && queryObject.trackFormat !== 'undefined') { 119 | 120 | const trackName = queryObject.trackId + '.' + queryObject.trackFormat 121 | 122 | if (queryObject.serverDirectory === undefined) { 123 | queryObject.serverDirectory = './downloads' 124 | } 125 | 126 | const trackPath = queryObject.serverDirectory + '/' + trackName 127 | const fileStat = statSync(trackPath) 128 | let mimeType 129 | 130 | switch (queryObject.trackFormat) { 131 | case 'ogg': 132 | mimeType = 'audio/ogg' 133 | break 134 | case 'mp3': 135 | mimeType = 'audio/mpeg' 136 | break 137 | } 138 | 139 | response.writeHead(200, { 'Content-Type': mimeType, 'Content-Length': fileStat.size }) 140 | 141 | const readStream = createReadStream(trackPath) 142 | 143 | readStream.pipe(response) 144 | 145 | } 146 | 147 | break 148 | default: 149 | response.writeHead(404, { 'Content-Type': 'text/html' }) 150 | response.write('page not found') 151 | response.end() 152 | 153 | } 154 | } 155 | 156 | }) 157 | 158 | server.listen(serverPort, serverIp, function () { 159 | console.log('server is listening, ip: ' + serverIp + ', port: ' + serverPort) 160 | }) -------------------------------------------------------------------------------- /server/library/fileDownloader.js: -------------------------------------------------------------------------------- 1 | import { createWriteStream } from 'fs' 2 | import { request } from 'https' 3 | import DirectoryManager from './directoryManager.js' 4 | import FileManager from './fileManager.js' 5 | 6 | class FileDownloader { 7 | 8 | constructor() { 9 | } 10 | 11 | /** 12 | * 13 | * fetches a file from a remote server and writes it into a folder on disc 14 | * 15 | * @param {type} options 16 | * @param {type} callback 17 | * @returns {undefined} 18 | */ 19 | writeToDisc(options, callback) { 20 | 21 | //console.log(options) 22 | 23 | if (options === undefined) { 24 | options = {} 25 | } 26 | 27 | const directoryManager = new DirectoryManager() 28 | 29 | const that = this 30 | 31 | // check if the temporary tracks directory already exists 32 | directoryManager.exists(options.serverDirectory, function directoryExistsCallback(error, exists) { 33 | 34 | // if there was no error checking if the directory exists 35 | if (!error) { 36 | 37 | // if the directory does not exist 38 | if (!exists) { 39 | 40 | // create a new directory 41 | directoryManager.create(options.serverDirectory, createDirectoryCallback = function (error) { 42 | 43 | // if there was no error creating the new directory 44 | if (!error) { 45 | // download the the track and store it on disc 46 | that.downloadIfNotExists(options, callback) 47 | } else { 48 | callback(error) 49 | } 50 | 51 | }) 52 | 53 | } else { 54 | // download the the track and store it on disc 55 | that.downloadIfNotExists(options, callback) 56 | } 57 | 58 | } else { 59 | callback(error) 60 | } 61 | 62 | }) 63 | 64 | } 65 | /** 66 | * 67 | * donwloads a track if does not already exist on disc 68 | * 69 | * @param {type} options 70 | * @param {type} callback 71 | * @returns {undefined} 72 | */ 73 | downloadIfNotExists(options, callback) { 74 | 75 | const fileManager = new FileManager() 76 | const filePath = options.serverDirectory + '/' + options.fileName 77 | const that = this 78 | 79 | // check if the file already exists 80 | fileManager.exists(filePath, function fileExistsCallback(error, exists) { 81 | 82 | // if there was no error checking if the file exists 83 | if (!error) { 84 | 85 | if (!exists) { 86 | // download the file and store it in the temporary directory 87 | that.downloadFile(options, callback) 88 | } else { 89 | callback(null, filePath) 90 | } 91 | 92 | } else { 93 | callback(error) 94 | } 95 | 96 | }) 97 | 98 | } 99 | /** 100 | * 101 | * download a file 102 | * 103 | * @param {type} downloadOptions 104 | * @param {type} callback 105 | * @returns {undefined} 106 | */ 107 | downloadFile(downloadOptions, callback) { 108 | 109 | //console.log('downloadFile: ' + downloadOptions.fileName) 110 | 111 | if (downloadOptions === undefined) { 112 | callback('downloadOptions is undefined') 113 | } 114 | 115 | if (downloadOptions.method === undefined) { 116 | downloadOptions.method = 'GET' 117 | } 118 | 119 | if (downloadOptions.remotePort === undefined) { 120 | downloadOptions.remotePort = 443 121 | } 122 | 123 | if (downloadOptions.remoteHost === undefined) { 124 | callback('download host is undefined') 125 | } 126 | 127 | if (downloadOptions.remotePath === undefined) { 128 | callback('download path is undefined') 129 | } 130 | 131 | // the file path on the server 132 | const serverFilePath = downloadOptions.serverDirectory + '/' + downloadOptions.fileName 133 | 134 | // create a write stream 135 | const writeStream = createWriteStream(serverFilePath) 136 | 137 | // open a new write stream 138 | writeStream.on('open', function () { 139 | 140 | const requestOptions = { 141 | hostname: downloadOptions.remoteHost, 142 | port: downloadOptions.remotePort, 143 | path: downloadOptions.remotePath, 144 | method: downloadOptions.method 145 | } 146 | 147 | //console.log(requestOptions) 148 | 149 | // request the file from remote server 150 | const httpRequest = request(requestOptions, function (httpResponse) { 151 | 152 | //console.log('writeTrackToDisc httpRequest STATUS: ' + httpResponse.statusCode) 153 | //console.log('writeTrackToDisc httpRequest HEADERS: ' + JSON.stringify(httpResponse.headers)) 154 | 155 | // on successful request 156 | httpResponse.on('data', function (chunk) { 157 | // write the file 158 | writeStream.write(chunk) 159 | }) 160 | 161 | // the connection got closed 162 | httpResponse.on('end', function () { 163 | 164 | //console.log('remote file: ' + downloadOptions.fileName + ', got downloaded into: ' + downloadOptions.serverDirectory) 165 | 166 | // close the write stream 167 | writeStream.end() 168 | 169 | callback(null, serverFilePath) 170 | }) 171 | 172 | }) 173 | 174 | // the request to the remote server failed 175 | httpRequest.on('error', function (error) { 176 | 177 | //console.log('writeToDisc, http request error: ' + error.message) 178 | 179 | writeStream.end() 180 | 181 | callback(error) 182 | }) 183 | 184 | httpRequest.end() 185 | }) 186 | 187 | // writing the file failed 188 | writeStream.on('error', function (error) { 189 | 190 | //console.log('writeToDisc writeStream, error: ' + error) 191 | 192 | // close the stream 193 | writeStream.end() 194 | 195 | callback(error) 196 | }) 197 | 198 | } 199 | } 200 | 201 | export default FileDownloader -------------------------------------------------------------------------------- /server/library/audioDataAnalyzer.js: -------------------------------------------------------------------------------- 1 | import { spawn as childProcessSpawn } from 'child_process' 2 | //import util from 'util' 3 | 4 | /** 5 | * 6 | * audio data analyzer 7 | * 8 | * credits: inspired by https://github.com/jhurliman/node-pcm 9 | * 10 | */ 11 | 12 | class AudioDataAnalyzer { 13 | 14 | constructor() { 15 | 16 | this.stdoutFfprobeOuputString = '' 17 | this.stderrFfprobeOuputString = '' 18 | 19 | this.stderrFfmpgegOuputString = '' 20 | 21 | this.samples = [] 22 | this.peaksInPercent = [] 23 | 24 | this.trackData = {} 25 | 26 | this.detectFormat = false 27 | 28 | this.logMemory = false 29 | 30 | } 31 | 32 | /** 33 | * 34 | * printMemory 35 | * 36 | * @param {type} options 37 | * 38 | * @returns {undefined} 39 | */ 40 | printMemory(options) { 41 | 42 | if (this.logMemory) { 43 | const memoryData = process.memoryUsage() 44 | console.log('rss: ' + memoryData.rss / 1000000 + ', heapTotal: ' + memoryData.heapTotal / 1000000 + ', heapUsed: ' + memoryData.heapUsed / 1000000 + ', options: ' + JSON.stringify(options)) 45 | } 46 | 47 | } 48 | 49 | /** 50 | * 51 | * set detect format option 52 | * 53 | * @param {type} detectFormat 54 | * @returns {undefined} 55 | */ 56 | setDetectFormat(detectFormat) { 57 | this.detectFormat = detectFormat 58 | } 59 | 60 | /** 61 | * 62 | * get detect format option 63 | * 64 | * @returns {undefined} 65 | */ 66 | getDetectFormat() { 67 | return this.detectFormat 68 | } 69 | 70 | /** 71 | * 72 | * get track format using ffprobe (channels, samplerate, ...) 73 | * 74 | * @param {type} trackPath 75 | * @param {type} callback 76 | * @returns {undefined} 77 | */ 78 | getFormat(trackPath, callback) { 79 | 80 | if (this.detectFormat === true) { 81 | 82 | const that = this 83 | 84 | // ffprobe file data 85 | const ffprobeSpawn = childProcessSpawn( 86 | 'ffprobe', 87 | [ 88 | trackPath, 89 | '-v', 90 | 'quiet', 91 | '-show_streams', 92 | '-show_format', 93 | '-print_format', 94 | 'json' 95 | ] 96 | ) 97 | 98 | ffprobeSpawn.stdout.setEncoding('utf8') 99 | ffprobeSpawn.stderr.setEncoding('utf8') 100 | 101 | // ffprobe recieves data on stdout 102 | ffprobeSpawn.stdout.on('data', function (data) { 103 | 104 | that.stdoutFfprobeOuputString += data 105 | 106 | }) 107 | 108 | ffprobeSpawn.stdout.on('end', function (data) { 109 | 110 | if (that.stdoutFfprobeOuputString !== '') { 111 | 112 | // parse the ffprobe json string response 113 | const stdoutOuput = JSON.parse(that.stdoutFfprobeOuputString) 114 | 115 | if (Object.keys(stdoutOuput).length > 0) { 116 | 117 | // create a trackdata object with the informations we need 118 | that.trackData.duration = stdoutOuput['format']['duration'] 119 | that.trackData.size = stdoutOuput['format']['size'] 120 | that.trackData.bitRate = stdoutOuput['format']['bit_rate'] 121 | that.trackData.sampleRate = stdoutOuput['streams'][0]['sample_rate'] 122 | that.trackData.channels = stdoutOuput['streams'][0]['channels'] 123 | 124 | } 125 | 126 | } 127 | 128 | }) 129 | 130 | ffprobeSpawn.stderr.on('data', function (data) { 131 | that.stderrFfprobeOuputString += data 132 | }) 133 | 134 | ffprobeSpawn.stderr.on('end', function () { 135 | //console.log('ffprobeSpawn stderr end') 136 | }) 137 | 138 | ffprobeSpawn.on('exit', function (code) { 139 | 140 | // if the code is an error code 141 | if (code > 0) { 142 | 143 | if (that.stderrFfprobeOuputString === '') { 144 | that.stderrFfprobeOuputString = 'unknown ffprobe error' 145 | } 146 | 147 | callback(that.stderrFfprobeOuputString) 148 | 149 | } else { 150 | 151 | // if the trackdata object isnt empty 152 | if (Object.keys(that.trackData).length > 0) { 153 | callback(null, that.trackData) 154 | } else { 155 | callback('ffprobe did not output any data') 156 | } 157 | 158 | } 159 | }) 160 | 161 | ffprobeSpawn.on('close', function () { 162 | //console.log('ffprobeSpawn close') 163 | }) 164 | 165 | ffprobeSpawn.on('error', function (error) { 166 | 167 | if (error.code === 'ENOENT') { 168 | callback('Unable to locate ffprobe, check it is installed and in the path') 169 | } else { 170 | callback(error.syscall + ' ' + error.errno) 171 | } 172 | 173 | }) 174 | 175 | } else { 176 | callback(null, this.trackData) 177 | } 178 | 179 | } 180 | 181 | /** 182 | * 183 | * get pcm data of a track 184 | * 185 | * @param {type} trackPath 186 | * @param {type} peaksAmountRaw 187 | * @param {type} callback 188 | * @returns {undefined} 189 | */ 190 | getPeaks(trackPath, peaksAmountRaw, callback) { 191 | 192 | const that = this 193 | 194 | this.printMemory({ 'file': 'audioDataAnalyzer', 'line': '242' }) 195 | 196 | this.getFormat(trackPath, function (error, trackData) { 197 | 198 | let peaksAmount 199 | 200 | if (!error) { 201 | 202 | if (peaksAmountRaw !== undefined) { 203 | peaksAmount = parseInt(peaksAmountRaw) 204 | } else { 205 | callback('peaksAmount is undefined') 206 | } 207 | 208 | that.printMemory({ 'file': 'audioDataAnalyzer', 'line': '258' }) 209 | 210 | if (Object.keys(trackData).length === 0 && trackData.constructor === Object) { 211 | 212 | if (typeof that.detectFormat === 'number') { 213 | trackData.sampleRate = that.detectFormat 214 | } else { 215 | trackData.sampleRate = 44100 216 | } 217 | 218 | trackData.channels = 1 219 | 220 | } 221 | 222 | // get audio pcm as 16bit little endians 223 | const ffmpegSpawn = childProcessSpawn( 224 | 'ffmpeg', 225 | [ 226 | '-i', 227 | trackPath, 228 | '-f', 229 | 's16le', 230 | '-ac', 231 | trackData.channels, 232 | '-acodec', 233 | 'pcm_s16le', 234 | '-ar', 235 | trackData.sampleRate, 236 | '-y', 237 | 'pipe:1' // pipe to stdout 238 | ] 239 | ) 240 | 241 | //ffmpegSpawn.stdout.setEncoding('utf8') 242 | 243 | ffmpegSpawn.stderr.setEncoding('utf8') 244 | 245 | ffmpegSpawn.stdout.on('data', function (buffer) { 246 | // each buffer contains a certain amount of bytes (8bit) 247 | // https://trac.ffmpeg.org/wiki/audio%20types 248 | 249 | // and we convert them to signed 16-bit little endians 250 | // http://nodejs.org/api/buffer.html#buffer_buf_readint16le_offset_noassert 251 | that.printMemory({ 'file': 'audioDataAnalyzer', 'line': '299' }) 252 | 253 | let i 254 | const dataLen = buffer.length 255 | 256 | // each buffer data contains 8bit but we read 16bit so i += 2 257 | for (i = 0; i < dataLen; i += 2) { 258 | 259 | const positiveSample = Math.abs(buffer.readInt16LE(i, false)) 260 | 261 | that.samples.push(positiveSample) 262 | 263 | } 264 | 265 | that.printMemory({ 'file': 'audioDataAnalyzer', 'line': '313' }) 266 | 267 | }) 268 | 269 | ffmpegSpawn.stdout.on('end', function (data) { 270 | 271 | const samplesLength = that.samples.length 272 | 273 | // check if we got enough samples to put at least one sample 274 | // into each peak 275 | if (samplesLength > peaksAmount) { 276 | 277 | // calculate how much samples we have to put into one peak 278 | const samplesCountPerPeak = Math.floor(samplesLength / peaksAmount) 279 | const peaks = [] 280 | 281 | let i 282 | let start = 0 283 | let end = start + samplesCountPerPeak 284 | let highestPeak = 0 285 | 286 | that.printMemory({ 'file': 'audioDataAnalyzer', 'line': '336' }) 287 | 288 | // build as much peaks as got requested 289 | for (i = 0; i < peaksAmount; i++) { 290 | 291 | // get a series of samples collection 292 | const peaksGroup = that.samples.slice(start, end) 293 | let x 294 | let samplesSum = 0 295 | const peaksGroupLength = peaksGroup.length 296 | 297 | // merge the samples into a single peak 298 | for (x = 0; x < peaksGroupLength; x++) { 299 | 300 | samplesSum += peaksGroup[x] 301 | 302 | } 303 | 304 | peaks.push(samplesSum) 305 | 306 | // find the highest peak 307 | if (samplesSum > highestPeak) { 308 | highestPeak = samplesSum 309 | } 310 | 311 | start += samplesCountPerPeak 312 | end += samplesCountPerPeak 313 | 314 | } 315 | 316 | that.printMemory({ 'file': 'audioDataAnalyzer', 'line': '368' }) 317 | 318 | let y 319 | const peaksLength = peaks.length 320 | 321 | // convert the peaks into percantage values 322 | for (y = 0; y < peaksLength; y++) { 323 | const peakInPercent = Math.round((peaks[y] / highestPeak) * 100) 324 | that.peaksInPercent.push(peakInPercent) 325 | } 326 | 327 | that.printMemory({ 'file': 'audioDataAnalyzer', 'line': '382' }) 328 | 329 | } 330 | 331 | }) 332 | 333 | ffmpegSpawn.stderr.on('data', function (data) { 334 | that.stderrFfprobeOuputString += data 335 | }) 336 | 337 | ffmpegSpawn.stderr.on('end', function () { 338 | //console.log('ffmpegSpawn stderr end') 339 | }) 340 | 341 | ffmpegSpawn.on('exit', function (code) { 342 | 343 | // under heavy load it seems that sometimes ffmpegSpawn.on('exit') gets called 344 | // before ffmpegSpawn.stdout.on('end') got called 345 | // so we now wait 200ms before trying to read the peaks 346 | setTimeout(function () { 347 | 348 | if (code > 0) { 349 | 350 | if (that.stderrFfmpegOuputString === '') { 351 | that.stderrFfmpegOuputString = 'unknown ffmpeg error' 352 | } 353 | 354 | callback(that.stderrFfmpegOuputString) 355 | 356 | } else { 357 | 358 | const peaksInPercentLength = that.peaksInPercent.length 359 | 360 | if (peaksInPercentLength > 0) { 361 | callback(null, that.peaksInPercent) 362 | } else { 363 | 364 | const samplesLength = that.samples.length 365 | 366 | if (samplesLength === 0) { 367 | callback('no output recieved') 368 | } else if (samplesLength < peaksAmount) { 369 | callback('not enough peaks in this song for a full wave') 370 | } 371 | 372 | } 373 | 374 | } 375 | 376 | }, 200) 377 | 378 | }) 379 | 380 | ffmpegSpawn.on('close', function () { 381 | //console.log('ffmpegSpawn close') 382 | }) 383 | 384 | ffmpegSpawn.on('error', function (error) { 385 | 386 | if (error.code === 'ENOENT') { 387 | callback('Unable to locate ffmpeg, check it is installed and in the path') 388 | } else { 389 | callback(error.syscall + ' ' + error.errno) 390 | } 391 | 392 | }) 393 | 394 | } else { 395 | 396 | callback(error) 397 | 398 | } 399 | 400 | }) 401 | 402 | } 403 | } 404 | 405 | export default AudioDataAnalyzer --------------------------------------------------------------------------------