├── .gitignore ├── Makefile ├── README.md ├── communication.js ├── example.wav ├── main.html ├── packages-osx.txt ├── packages.txt ├── requirements.txt ├── screenshot.png ├── server.py ├── specsize.js └── spectrogram.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean run installdeps lint 2 | 3 | OS := $(shell uname) 4 | 5 | ifeq ($(RPM),1) 6 | PKG_INSTALLER = yum 7 | else 8 | PKG_INSTALLER = apt-get 9 | endif 10 | 11 | run: 12 | python server.py 13 | 14 | clean: 15 | find . -type f -name '*.py[cod]' -delete 16 | find . -type f -name '*.*~' -delete 17 | 18 | installdeps: 19 | ifeq ('$(OS)','Darwin') 20 | # Run MacOS commands 21 | cat packages-osx.txt | xargs brew install 22 | export PKG_CONFIG_PATH=/usr/local/Cellar/libffi/3.0.13/lib/pkgconfig/ 23 | else 24 | # Run Linux commands 25 | cat packages.txt | xargs sudo $(PKG_INSTALLER) -y install 26 | endif 27 | pip install -r requirements.txt 28 | 29 | lint: clean 30 | flake8 . 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | WebGL-Spectrogram 2 | ================= 3 | 4 | ## Get running in 5 minutes 5 | 6 | First, check out WebGL-Spectrogram and launch it locally: 7 | 8 | ```bash 9 | git clone https://github.com/bastibe/WebGL-Spectrogram.git 10 | cd WebGL-Spectrogram 11 | ``` 12 | 13 | We have some installation commands that utilize `brew`, `yum`, and `apt-get` 14 | based on your system and package manager preference. 15 | 16 | All Python packages are install using `pip` from the `requirements.txt` file. 17 | If you wish to install via another method i.e. `conda`, all the packages and 18 | versions can be found in `requirements.txt`. 19 | 20 | The commands below install common packages for OSX or Linux and run the server. 21 | 22 | Note: You need to use `sudo` if you are not working in a 23 | [virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/). 24 | 25 | If you are not using OSX, the `apt-get` manger is used by default. 26 | 27 | To use `yum` to install packages instead, run `make installdeps RPM=1`. 28 | 29 | ```bash 30 | make installdeps 31 | make run 32 | ``` 33 | 34 | A webpage should now open and you can select the file `example.wav` to generate 35 | a sample spectrogram. 36 | 37 | ## About 38 | 39 | This is a small local web app that displays a spectrogram of an audio signal in 40 | the browser using WebGL. It is known to work with Firefox and Chrome, though 41 | performance is best in Firefox. 42 | 43 | The spectrogram display can be zoomed and panned smoothly and has a 44 | configurable FFT length. The amplitude range can be adjusted on the fly as 45 | well. It can open any local `.wav` or `.flac` file. 46 | 47 | ![screenshot](https://raw.githubusercontent.com/bastibe/WebGL-Spectrogram/master/screenshot.png) 48 | 49 | `server.py` contains a small web server written in Python and Tornado that 50 | responds to messages on `ws://localhost:XXXX/spectrogram`, where `XXXX` is a 51 | random port in the local range. Currently, it supports two kinds of messages: 52 | One that requests a spectrogram from a file name, and another that requests a 53 | spectrogram from a file content attached to the message. It responds to these 54 | messages with a message containing a full spectrogram for the given audio file. 55 | 56 | `communications.js` contains the Javascript implementation of the messaging 57 | protocol. 58 | 59 | `specsize.js` contains a helper class for storing the extent of a 60 | spectro-temporal display. 61 | 62 | `spectrogram.js` contains Javascript code that can load audio files and request 63 | spectrograms from the server, and draw those spectrograms using WebGL. It also 64 | contains a time and frequency scale and a small indicator that shows the cursor 65 | position in time/frequency coordinates. 66 | 67 | `main.html` contains the website used to display the spectrogram. 68 | 69 | The messaging protocol is JSON-based, easily extensible and supports 70 | transmission of textual or binary data. 71 | -------------------------------------------------------------------------------- /communication.js: -------------------------------------------------------------------------------- 1 | var ws = new WebSocket("ws://localhost:" + getURLParameters()['port'] + "/spectrogram"); 2 | ws.binaryType = 'arraybuffer'; 3 | 4 | /* return an object containing the URL parameters */ 5 | function getURLParameters() { 6 | var params = {}; 7 | if (location.search) { 8 | var parts = location.search.substring(1).split('&'); 9 | for (var i = 0; i < parts.length; i++) { 10 | var pair = parts[i].split('='); 11 | if (!pair[0]) continue; 12 | params[pair[0]] = pair[1] || ""; 13 | } 14 | } 15 | return params 16 | } 17 | 18 | /* Send a message. 19 | 20 | Arguments: 21 | type the message type as string. 22 | content the message content as json-serializable data. 23 | payload binary data as ArrayBuffer. 24 | */ 25 | function sendMessage(type, content, payload) { 26 | if (payload === undefined) { 27 | ws.send(JSON.stringify({ 28 | type: type, 29 | content: content 30 | })); 31 | } else { 32 | var headerString = JSON.stringify({ 33 | type: type, 34 | content: content 35 | }); 36 | // append enough spaces so that the payload starts at an 8-byte 37 | // aligned position. The first four bytes will be the length of 38 | // the header, encoded as a 32 bit signed integer: 39 | var alignmentBytes = 8 - ((headerString.length + 4) % 8); 40 | for (var i = 0; i < alignmentBytes; i++) { 41 | headerString += " "; 42 | } 43 | 44 | var message = new ArrayBuffer(4 + headerString.length + payload.byteLength); 45 | 46 | // write the length of the header as a binary 32 bit signed integer: 47 | var prefixData = new Int32Array(message, 0, 4); 48 | prefixData[0] = headerString.length; 49 | 50 | // write the header data 51 | var headerData = new Uint8Array(message, 4, headerString.length) 52 | for (var i = 0; i < headerString.length; i++) { 53 | headerData[i] = headerString.charCodeAt(i); 54 | } 55 | 56 | // write the payload data 57 | payloadData = new Uint8Array(message, 4 + headerString.length, payload.byteLength); 58 | payloadData.set(new Uint8Array(payload)); 59 | ws.send(message); 60 | } 61 | } 62 | 63 | /* Request the spectrogram for a file. 64 | 65 | Arguments: 66 | filename the file name from which to load audio data. 67 | nfft the FFT length used for calculating the spectrogram. 68 | overlap the amount of overlap between consecutive spectra. 69 | */ 70 | function requestFileSpectrogram(filename, nfft, overlap) { 71 | sendMessage("request_file_spectrogram", { 72 | filename: filename, 73 | nfft: nfft, 74 | overlap: overlap 75 | }); 76 | } 77 | 78 | /* Request the spectrogram for a file. 79 | 80 | Arguments: 81 | data the content of a file from which to load audio data. 82 | nfft the FFT length used for calculating the spectrogram. 83 | overlap the amount of overlap between consecutive spectra. 84 | */ 85 | function requestDataSpectrogram(data, nfft, overlap) { 86 | sendMessage("request_data_spectrogram", { 87 | nfft: nfft, 88 | overlap: overlap 89 | }, data); 90 | } 91 | 92 | /* Parses a message 93 | 94 | Each message must contain the message type, the message content, 95 | and an optional binary payload. The decoded message will be 96 | forwarded to different functions based on the message type. 97 | 98 | Arguments: 99 | event the message, either as string or ArrayBuffer. 100 | */ 101 | ws.onmessage = function(event) { 102 | if (event.data.constructor.name === "ArrayBuffer") { 103 | var headerLen = new Int32Array(event.data, 0, 1)[0]; 104 | var header = String.fromCharCode.apply(null, new Uint8Array(event.data, 4, headerLen)); 105 | } else { 106 | var header = event.data; 107 | } 108 | 109 | try { 110 | msg = JSON.parse(header); 111 | } catch (e) { 112 | console.error("Message", e.message, "is not a valid JSON object"); 113 | return 114 | } 115 | 116 | var type = msg.type 117 | var content = msg.content 118 | 119 | if (type === "spectrogram") { 120 | loadSpectrogram(new Float32Array(event.data, headerLen + 4), 121 | content.extent[0], content.extent[1], 122 | content.fs, content.length); 123 | } else if (type === "loading_progress") { 124 | updateProgressBar(content.progress); 125 | } else { 126 | console.log(type, content); 127 | } 128 | } 129 | 130 | /* Sets the progress bar 131 | 132 | If progress is 0 or 1, the progress bar will be turned invisible. 133 | */ 134 | function updateProgressBar(progress) { 135 | var progressBar = document.getElementById('progressBar'); 136 | if (progress === 0 || progress === 1) { 137 | progressBar.hidden = true; 138 | } else { 139 | progressBar.hidden = false; 140 | progressBar.value = progress; 141 | } 142 | } 143 | 144 | /* log some info about the GL, then display spectrogram */ 145 | ws.onopen = function() { 146 | logGLInfo(); 147 | reloadSpectrogram(); 148 | } 149 | 150 | /* Test the keyup event for a submission and then reload the spectrogram. 151 | */ 152 | function submitSpectrogram(e) { 153 | e.which = e.which || e.keyCode; 154 | if (e.which == 13) { 155 | reloadSpectrogram(); 156 | } 157 | } 158 | 159 | /* Loads the spectrogram for the currently seleced file/FFT-length. 160 | 161 | Reads the audioFile input field to get the current file and the 162 | select field to get the current FFT length. 163 | 164 | This only sends the request for a spectrogram. Delivering the 165 | spectrogram is up to the server. 166 | */ 167 | function reloadSpectrogram() { 168 | var audioFile = document.getElementById('audioFileByData').files[0]; 169 | var fftLen = parseFloat(document.getElementById('fftLen').value); 170 | // first we try to load a file 171 | if (audioFile) { 172 | var reader = new FileReader(); 173 | reader.readAsArrayBuffer(audioFile); 174 | reader.onloadend = function() { 175 | requestDataSpectrogram(reader.result, fftLen); 176 | } 177 | } else { // otherwise see if there is a filename 178 | audioFile = document.getElementById('audioFileByName').value; 179 | if (audioFile) { 180 | console.log("Requesting spectrogram for: " + audioFile); 181 | requestFileSpectrogram(audioFile, fftLen); 182 | } 183 | } 184 | if (!audioFile) { 185 | console.log("Could not load spectrogram: No file selected"); 186 | return; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /example.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bastibe/WebGL-Spectrogram/135b43ab20f07a01cc45e25540ca1cc997d493dd/example.wav -------------------------------------------------------------------------------- /main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Spectrogram 5 | 54 | 55 | 56 |
57 |
58 | 59 | 60 | 61 | 62 | 63 | 71 | 72 | 78 | 79 | 80 | 81 | 82 |
83 |
84 |
85 | 86 |
NaN
87 | 88 | 89 | 90 |
91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /packages-osx.txt: -------------------------------------------------------------------------------- 1 | pkg-config 2 | libffi 3 | libsndfile 4 | -------------------------------------------------------------------------------- /packages.txt: -------------------------------------------------------------------------------- 1 | pkg-config 2 | libffi-dev 3 | libsndfile-dev 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PySoundFile==0.5.0 2 | backports.ssl-match-hostname==3.4.0.2 3 | certifi==14.05.14 4 | cffi==0.8.6 5 | flake8==2.2.5 6 | numpy==1.9.1 7 | pycparser==2.10 8 | scipy==0.14.0 9 | tornado==4.0.2 10 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bastibe/WebGL-Spectrogram/135b43ab20f07a01cc45e25540ca1cc997d493dd/screenshot.png -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | import io 4 | import json 5 | import struct 6 | import numpy as np 7 | 8 | from tornado.websocket import WebSocketHandler 9 | from pysoundfile import SoundFile 10 | 11 | 12 | def hann(n): 13 | return 0.5 - 0.5 * np.cos(2.0 * np.pi * np.arange(n) / (n - 1)) 14 | 15 | 16 | def from_bytes(b): 17 | return struct.unpack("@i", b)[0] 18 | 19 | 20 | def to_bytes(n): 21 | return struct.pack("@i", n) 22 | 23 | 24 | class JSONWebSocket(WebSocketHandler): 25 | 26 | """A websocket that sends/receives JSON messages. 27 | 28 | Each message has a type, a content and optional binary data. 29 | Message type and message content are stored as a JSON object. Type 30 | must be a string and content must be JSON-serializable. 31 | 32 | If binary data is present, the message will be sent in binary, 33 | with the first four bytes storing a signed integer containing the 34 | length of the JSON data, then the JSON data, then the binary data. 35 | The binary data will be stored 8-byte aligned. 36 | 37 | """ 38 | 39 | def check_origin(self, origin): 40 | return True 41 | 42 | def open(self): 43 | print("WebSocket opened") 44 | 45 | def send_message(self, msg_type, content, data=None): 46 | """Send a message. 47 | 48 | Arguments: 49 | msg_type the message type as string. 50 | content the message content as json-serializable data. 51 | data raw bytes that are appended to the message. 52 | 53 | """ 54 | 55 | if data is None: 56 | self.write_message(json.dumps({"type": msg_type, 57 | "content": content}).encode()) 58 | else: 59 | header = json.dumps({'type': msg_type, 60 | 'content': content}).encode() 61 | # append enough spaces so that the payload starts at an 8-byte 62 | # aligned position. The first four bytes will be the length of 63 | # the header, encoded as a 32 bit signed integer: 64 | header += b' ' * (8 - ((len(header) + 4) % 8)) 65 | # the length of the header as a binary 32 bit signed integer: 66 | prefix = to_bytes(len(header)) 67 | self.write_message(prefix + header + data, binary=True) 68 | 69 | def on_message(self, msg): 70 | """Parses a message 71 | 72 | Each message must contain the message type, the message 73 | content, and an optional binary payload. The decoded message 74 | will be forwarded to receive_message(). 75 | 76 | Arguments: 77 | msg the message, either as str or bytes. 78 | 79 | """ 80 | 81 | if isinstance(msg, bytes): 82 | header_len = from_bytes(msg[:4]) 83 | header = msg[4:header_len + 4].decode() 84 | data = msg[4 + header_len:] 85 | else: 86 | header = msg 87 | data = None 88 | 89 | try: 90 | header = json.loads(header) 91 | except ValueError: 92 | print('message {} is not a valid JSON object'.format(msg)) 93 | return 94 | 95 | if 'type' not in header: 96 | print('message {} does not have a "type" field'.format(header)) 97 | elif 'content' not in header: 98 | print('message {} does not have a "content" field'.format(header)) 99 | else: 100 | self.receive_message(header['type'], header['content'], data) 101 | 102 | def receive_message(self, msg_type, content, data=None): 103 | """Message dispatcher. 104 | 105 | This is meant to be overwritten by subclasses. By itself, it 106 | does nothing but complain. 107 | 108 | """ 109 | 110 | if msg_type == "information": 111 | print(content) 112 | else: 113 | print(("Don't know what to do with message of type {}" + 114 | "and content {}").format(msg_type, content)) 115 | 116 | def on_close(self): 117 | print("WebSocket closed") 118 | 119 | 120 | class SpectrogramWebSocket(JSONWebSocket): 121 | 122 | """A websocket that sends spectrogram data. 123 | 124 | It calculates a spectrogram with a given FFT length and overlap 125 | for a requested file. The file can either be supplied as a binary 126 | data blob, or as a file name. 127 | 128 | This implements two message types: 129 | - request_file_spectrogram, which needs a filename, and optionally 130 | `nfft` and `overlap`. 131 | - request_data_spectrogram, which needs the file as a binary data 132 | blob, and optionally `nfft` and `overlap`. 133 | 134 | """ 135 | 136 | def receive_message(self, msg_type, content, data=None): 137 | """Message dispatcher. 138 | 139 | Dispatches 140 | - `request_file_spectrogram` to self.on_file_spectrogram 141 | - `request_data_spectrogram` to self.on_data_spectrogram 142 | 143 | Arguments: 144 | msg_type the message type as string. 145 | content the message content as dictionary. 146 | data raw bytes. 147 | 148 | """ 149 | 150 | if msg_type == 'request_file_spectrogram': 151 | self.on_file_spectrogram(**content) 152 | elif msg_type == 'request_data_spectrogram': 153 | self.on_data_spectrogram(data, **content) 154 | else: 155 | super(self.__class__, self).receive_message( 156 | msg_type, content, data) 157 | 158 | def on_file_spectrogram(self, filename, nfft=1024, overlap=0.5): 159 | """Loads an audio file and calculates a spectrogram. 160 | 161 | Arguments: 162 | filename the file name from which to load the audio data. 163 | nfft the FFT length used for calculating the spectrogram. 164 | overlap the amount of overlap between consecutive spectra. 165 | 166 | """ 167 | 168 | try: 169 | file = SoundFile(filename) 170 | sound = file[:].sum(axis=1) 171 | spec = self.spectrogram(sound, nfft, overlap) 172 | 173 | self.send_message('spectrogram', 174 | {'extent': spec.shape, 175 | 'fs': file.samplerate, 176 | 'length': len(file) / file.samplerate}, 177 | spec.tostring()) 178 | except RuntimeError as e: 179 | error_msg = 'Filename: {} could not be loaded.\n{}'.format(filename, e) 180 | self.send_message('error', { 181 | 'error_msg': error_msg 182 | }) 183 | print(error_msg) 184 | 185 | def on_data_spectrogram(self, data, nfft=1024, overlap=0.5): 186 | """Loads an audio file and calculates a spectrogram. 187 | 188 | Arguments: 189 | data the content of a file from which to load audio data. 190 | nfft the FFT length used for calculating the spectrogram. 191 | overlap the amount of overlap between consecutive spectra. 192 | 193 | """ 194 | 195 | file = SoundFile(io.BytesIO(data)) 196 | sound = file[:].sum(axis=1) 197 | spec = self.spectrogram(sound, nfft, overlap) 198 | 199 | self.send_message('spectrogram', 200 | {'extent': spec.shape, 201 | 'fs': file.samplerate, 202 | 'length': len(file) / file.samplerate}, 203 | spec.tostring()) 204 | 205 | def spectrogram(self, data, nfft, overlap): 206 | """Calculate a real spectrogram from audio data 207 | 208 | An audio data will be cut up into overlapping blocks of length 209 | `nfft`. The amount of overlap will be `overlap*nfft`. Then, 210 | calculate a real fourier transform of length `nfft` of every 211 | block and save the absolute spectrum. 212 | 213 | Arguments: 214 | data audio data as a numpy array. 215 | nfft the FFT length used for calculating the spectrogram. 216 | overlap the amount of overlap between consecutive spectra. 217 | 218 | """ 219 | 220 | shift = round(nfft * overlap) 221 | num_blocks = int((len(data) - nfft) / shift + 1) 222 | specs = np.zeros((nfft / 2 + 1, num_blocks), dtype=np.float32) 223 | window = hann(nfft) 224 | for idx in range(num_blocks): 225 | specs[:, idx] = np.abs( 226 | np.fft.rfft( 227 | data[idx * shift:idx * shift + nfft] * window, 228 | n=nfft)) / nfft 229 | if idx % 10 == 0: 230 | self.send_message( 231 | "loading_progress", {"progress": idx / num_blocks}) 232 | specs[:, -1] = np.abs( 233 | np.fft.rfft(data[num_blocks * shift:], n=nfft)) / nfft 234 | self.send_message("loading_progress", {"progress": 1}) 235 | return specs.T 236 | 237 | 238 | if __name__ == "__main__": 239 | import os 240 | import webbrowser 241 | from tornado.web import Application 242 | from tornado.ioloop import IOLoop 243 | import random 244 | 245 | app = Application([("/spectrogram", SpectrogramWebSocket)]) 246 | 247 | random.seed() 248 | port = random.randrange(49152, 65535) 249 | app.listen(port) 250 | webbrowser.open('file://{}/main.html?port={}'.format(os.getcwd(), port)) 251 | IOLoop.instance().start() 252 | -------------------------------------------------------------------------------- /specsize.js: -------------------------------------------------------------------------------- 1 | /* the size of a spectrogram in time, frequency and amplitude */ 2 | function SpecSize(minT, maxT, minF, maxF, minA, maxA) { 3 | this.minT = minT; 4 | this.maxT = maxT; 5 | this.minF = minF; 6 | this.maxF = maxF; 7 | this.minA = minA; 8 | this.maxA = maxA; 9 | } 10 | 11 | /* the size in time */ 12 | SpecSize.prototype.widthT = function() { 13 | return this.maxT - this.minT; 14 | } 15 | 16 | /* the size in frequency */ 17 | SpecSize.prototype.widthF = function() { 18 | return this.maxF - this.minF; 19 | } 20 | 21 | /* the size in amplitude */ 22 | SpecSize.prototype.widthA = function() { 23 | return this.maxA - this.minA; 24 | } 25 | 26 | /* the center in time */ 27 | SpecSize.prototype.centerT = function() { 28 | return (this.minT + this.maxT) / 2; 29 | } 30 | 31 | /* the center in frequency */ 32 | SpecSize.prototype.centerF = function() { 33 | return (this.minF + this.maxF) / 2; 34 | } 35 | 36 | /* the center in amplitude */ 37 | SpecSize.prototype.centerA = function() { 38 | return (this.minA + this.maxA) / 2; 39 | } 40 | 41 | /* the relative position of a time */ 42 | SpecSize.prototype.scaleT = function(t) { 43 | return t * this.widthT() + this.minT; 44 | } 45 | 46 | /* the relative position of a frequency */ 47 | SpecSize.prototype.scaleF = function(f) { 48 | return f * this.widthF() + this.minF; 49 | } 50 | 51 | /* the relative position of a amplitude */ 52 | SpecSize.prototype.scaleA = function(a) { 53 | return a * this.widthA() + this.minA; 54 | } 55 | -------------------------------------------------------------------------------- /spectrogram.js: -------------------------------------------------------------------------------- 1 | var specView; 2 | var specTimeScale; 3 | var specFrequencyScale; 4 | var specDataView; 5 | 6 | var gl; // the WebGL instance 7 | 8 | // shader attributes: 9 | var vertexPositionAttribute; 10 | var textureCoordAttribute; 11 | 12 | // shader uniforms: 13 | var samplerUniform; 14 | var ampRangeUniform; 15 | var zoomUniform; 16 | var specSizeUniform; 17 | var specDataSizeUniform; 18 | var specModeUniform; 19 | var specLogarithmicUniform; 20 | 21 | // vertex buffer objects 22 | var vertexPositionBuffers; 23 | var textureCoordBuffer; 24 | 25 | // textures objects 26 | var spectrogramTextures; 27 | 28 | var specSize; // total size of the spectrogram 29 | var specViewSize; // visible size of the spectrogram 30 | 31 | /* initialize all canvases */ 32 | function start() { 33 | specView = document.getElementById('spectrogram'); 34 | specTimeScale = document.getElementById('specTimeScale'); 35 | specFrequencyScale = document.getElementById('specFreqScale'); 36 | specDataView = document.getElementById('specDataView'); 37 | initSpectrogram(); 38 | window.addEventListener("resize", updateCanvasResolutions, false); 39 | updateCanvasResolutions(); 40 | eventsInit() 41 | } 42 | 43 | /* set resolution of all canvases to native resolution */ 44 | function updateCanvasResolutions() { 45 | specView.width = specView.clientWidth; 46 | specView.height = specView.clientHeight; 47 | gl.viewport(0, 0, specView.width, specView.height); 48 | specTimeScale.width = specTimeScale.clientWidth; 49 | specTimeScale.height = specTimeScale.clientHeight; 50 | specFrequencyScale.width = specFrequencyScale.clientWidth; 51 | specFrequencyScale.height = specFrequencyScale.clientHeight; 52 | window.requestAnimationFrame(drawScene); 53 | } 54 | 55 | /* log version and memory information about WebGL */ 56 | function logGLInfo() { 57 | sendMessage('information', 58 | "version: " + gl.getParameter(gl.VERSION) + "\n" + 59 | "shading language version: " + gl.getParameter(gl.SHADING_LANGUAGE_VERSION) + "\n" + 60 | "vendor: " + gl.getParameter(gl.VENDOR) + "\n" + 61 | "renderer: " + gl.getParameter(gl.RENDERER) + "\n" + 62 | "max texture size: " + gl.getParameter(gl.MAX_TEXTURE_SIZE) + "\n" + 63 | "max combined texture image units: " + gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS)); 64 | } 65 | 66 | /* get WebGL context and load required extensions */ 67 | function initSpectrogram() { 68 | try { 69 | gl = specView.getContext('webgl'); 70 | } catch (e) { 71 | alert('Could not initialize WebGL'); 72 | gl = null; 73 | } 74 | // needed for floating point textures 75 | gl.getExtension("OES_texture_float"); 76 | var error = gl.getError(); 77 | if (error != gl.NO_ERROR) { 78 | alert("Could not enable float texture extension"); 79 | } 80 | // needed for linear filtering of floating point textures 81 | gl.getExtension("OES_texture_float_linear"); 82 | if (error != gl.NO_ERROR) { 83 | alert("Could not enable float texture linear extension"); 84 | } 85 | // 2D-drawing only 86 | gl.disable(gl.DEPTH_TEST); 87 | 88 | // get shaders ready 89 | loadSpectrogramShaders(); 90 | 91 | // load dummy data 92 | loadSpectrogram(new Float32Array(1), 1, 1, 44100, 1); 93 | } 94 | 95 | /* link shaders and save uniforms and attributes 96 | 97 | saves the following attributes to global scope: 98 | - vertexPositionAttribute: aVertexPosition 99 | - textureCoordAttribute: aTextureCoord 100 | 101 | saves the following uniforms to global scope: 102 | - samplerUniform: uSampler 103 | - zoomUniform: mZoom 104 | - ampRangeUniform: vAmpRange 105 | - specSizeUniform: vSpecSize 106 | - specModeUniform: uSpecMode 107 | */ 108 | function loadSpectrogramShaders() { 109 | // 110 | // creates a shader of the given type, uploads the source and 111 | // compiles it. 112 | // 113 | function loadShader(gl, type, source) { 114 | const shader = gl.createShader(type); 115 | 116 | // Send the source to the shader object 117 | 118 | gl.shaderSource(shader, source); 119 | 120 | // Compile the shader program 121 | 122 | gl.compileShader(shader); 123 | 124 | // See if it compiled successfully 125 | 126 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { 127 | alert('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader)); 128 | gl.deleteShader(shader); 129 | return null; 130 | } 131 | 132 | return shader; 133 | } 134 | 135 | // Vertex shader program 136 | var vsSource = ` 137 | attribute vec2 aVertexPosition; 138 | attribute vec2 aTextureCoord; 139 | uniform mat3 mZoom; 140 | varying highp vec2 vTextureCoord; 141 | void main() { 142 | vec3 zoomedVertexPosition = vec3(aVertexPosition, 1.0) * mZoom; 143 | gl_Position = vec4(zoomedVertexPosition, 1.0); 144 | vTextureCoord = aTextureCoord; 145 | } 146 | `; 147 | 148 | // Fragment shader program 149 | var fsSource = ` 150 | varying highp vec2 vTextureCoord; 151 | uniform sampler2D uSampler; 152 | uniform highp vec2 vAmpRange; 153 | uniform highp vec2 vSpecSize; 154 | uniform highp vec2 vDataSize; 155 | uniform int uSpecMode; 156 | uniform bool bSpecLogarithmic; 157 | highp vec3 physicalColor(highp float amplitude) { 158 | /* 159 | The physical color scaling is partitioning the full [0 ... 1] range into 160 | seven evenly spaced sub-ranges. That's what the modulo and floor are 161 | doing. Then, each range modifies only one color channel while keeping 162 | the other two constant. This is done in such a way that no two 163 | different values map to the same color. 164 | The ranges are: 165 | 1. black (0,0,0) to red (1,0,0) 166 | 2. red (1,0,0) to yellow (1,1,0) 167 | 3. yellow (1,1,0) to green (0,1,0) 168 | 4. green (0,1,0) to cyan (0,1,1) 169 | 5. cyan (0,1,1) to blue (0,0,1) 170 | 6. blue (0,0,1) to magenta (1,0,1) 171 | 7. magenta (1,0,1) to white (1,1,1) 172 | */ 173 | highp float fractional = mod(amplitude*8.0, 1.0); 174 | highp float integer = floor(amplitude*8.0); 175 | if (integer == 0.0) { 176 | return vec3(fractional, 0.0, 0.0); 177 | } else if (integer == 1.0) { 178 | return vec3(1.0, fractional, 0.0); 179 | } else if (integer == 2.0) { 180 | return vec3(1.0-fractional, 1.0, 0.0); 181 | } else if (integer == 3.0) { 182 | return vec3(0.0, 1.0, fractional); 183 | } else if (integer == 4.0) { 184 | return vec3(0.0, 1.0-fractional, 1.0); 185 | } else if (integer == 5.0) { 186 | return vec3(fractional, 0.0, 1.0); 187 | } else if (integer == 6.0) { 188 | return vec3(1.0, 1.0-fractional, 1.0); 189 | } else { 190 | return vec3(1.0, 1.0, 1.0); 191 | } 192 | } 193 | // from http://lolengine.net/blog/2013/07/27/rgb-to-hsv-in-glsl 194 | highp vec3 rgb2hsv(highp vec3 c) 195 | { 196 | highp vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); 197 | highp vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); 198 | highp vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); 199 | highp float d = q.x - min(q.w, q.y); 200 | highp float e = 1.0e-10; 201 | return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); 202 | } 203 | // from http://lolengine.net/blog/2013/07/27/rgb-to-hsv-in-glsl 204 | highp vec3 hsv2rgb(highp vec3 c) 205 | { 206 | highp vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); 207 | highp vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); 208 | return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); 209 | } 210 | highp float logScale(highp float value) { 211 | value = 20.0*log(value)/log(10.0); 212 | //value = max(value, vAmpRange[0]); 213 | //value = min(value, vAmpRange[1]); 214 | value = (value - vAmpRange[0]) / (vAmpRange[1] - vAmpRange[0]); 215 | return value; 216 | } 217 | highp float getAmplitude(highp vec2 coord) { 218 | if (bSpecLogarithmic) { 219 | coord.y = pow(vSpecSize.y, coord.y)/vSpecSize.y; 220 | coord.y += 1.0/512.0; 221 | } 222 | return texture2D(uSampler, coord).r; 223 | } 224 | highp vec3 physicalMode() { 225 | highp float amplitude = getAmplitude(vTextureCoord); 226 | return physicalColor(logScale(amplitude)); 227 | } 228 | highp vec3 normalMode() { 229 | highp float amplitude = getAmplitude(vTextureCoord); 230 | amplitude = logScale(amplitude); 231 | return vec3(amplitude, amplitude, amplitude); 232 | } 233 | highp vec3 direction() { 234 | highp vec2 d = vec2(0.0, 0.0); 235 | for (int x=0; x<=5; x++) { 236 | for (int y=-5; y<=5; y+=1) { 237 | highp vec2 vIter=vec2(x, y); 238 | highp float amplitude = getAmplitude(vTextureCoord+vIter/vDataSize) * 239 | getAmplitude(vTextureCoord-vIter/vDataSize); 240 | d += vIter * amplitude / ((x==0&&y==0) ? 1.0 : sqrt(float(x*x + y*y))); 241 | } 242 | } 243 | highp float direction = atan(d.x, d.y)/3.14; 244 | highp float strength = sqrt(d.x*d.x + d.y*d.y); 245 | strength = logScale(strength*1000.0); 246 | return hsv2rgb(vec3((direction-0.5), abs((direction-0.5))*2.0, strength)); 247 | } 248 | highp float amplitudeAt(highp float angle, highp float distance) { 249 | highp vec2 coord = vec2(cos(angle)*distance, 250 | sin(angle)*distance); 251 | highp float amplitude = getAmplitude(vTextureCoord+coord/vDataSize) * 252 | getAmplitude(vTextureCoord-coord/vDataSize); 253 | return amplitude; 254 | } 255 | highp float amplitudesAt(highp float angle) { 256 | highp float amplitude = 0.0; 257 | for (int i=1; i<=5; i++) { 258 | amplitude += amplitudeAt(angle, float(i))/float(i*i); 259 | } 260 | return amplitude; 261 | } 262 | highp vec3 direction2() { 263 | highp vec2 histogram[16]; 264 | histogram[0] = vec2(amplitudesAt(-3.14), -3.14); 265 | histogram[1] = vec2(amplitudesAt(-2.36), -2.36); 266 | histogram[2] = vec2(amplitudesAt(-1.57), -1.57); 267 | histogram[3] = vec2(amplitudesAt(-0.78), -0.78); 268 | histogram[4] = vec2(amplitudesAt( 0.0 ), 0.0 ); 269 | histogram[5] = vec2(amplitudesAt( 0.78), 0.78); 270 | histogram[6] = vec2(amplitudesAt( 1.57), 1.57); 271 | histogram[7] = vec2(amplitudesAt( 2.36), 2.36); 272 | histogram[9] = vec2(amplitudesAt( 3.14), 3.14); 273 | highp vec2 max_value = vec2(0.0, 0.0); 274 | max_value = histogram[0].x > max_value.x ? histogram[0] : max_value; 275 | max_value = histogram[1].x > max_value.x ? histogram[1] : max_value; 276 | max_value = histogram[2].x > max_value.x ? histogram[2] : max_value; 277 | max_value = histogram[3].x > max_value.x ? histogram[3] : max_value; 278 | max_value = histogram[4].x > max_value.x ? histogram[4] : max_value; 279 | max_value = histogram[5].x > max_value.x ? histogram[5] : max_value; 280 | max_value = histogram[6].x > max_value.x ? histogram[6] : max_value; 281 | max_value = histogram[7].x > max_value.x ? histogram[7] : max_value; 282 | max_value = histogram[8].x > max_value.x ? histogram[8] : max_value; 283 | max_value = histogram[9].x > max_value.x ? histogram[9] : max_value; 284 | // scale to a third color rotation from green to red. 285 | highp float direction = max_value.y/3.14/3.0+0.333; 286 | highp float value = logScale(max_value.x); 287 | return hsv2rgb(vec3(direction, 1.0, value)); 288 | } 289 | highp vec3 multiples() { 290 | highp float amplitude = getAmplitude(vTextureCoord); 291 | int iterations = 0; 292 | for (int m=2; m<=5; m++) { 293 | highp float y = vTextureCoord.y*float(m); 294 | if (y<1.0) { 295 | amplitude *= getAmplitude(vec2(vTextureCoord.x, y)); 296 | iterations++; 297 | } 298 | } 299 | amplitude = pow(amplitude, 1.0/float(iterations)); 300 | amplitude = logScale(amplitude); 301 | return physicalColor(amplitude); 302 | } 303 | void main() { 304 | if (uSpecMode == 0) { 305 | gl_FragColor = vec4(physicalMode(), 1.0); 306 | } else if (uSpecMode == 1) { 307 | gl_FragColor = vec4(normalMode(), 1.0); 308 | } else if (uSpecMode == 2) { 309 | gl_FragColor = vec4(direction2(), 1.0); 310 | } else if (uSpecMode == 3) { 311 | gl_FragColor = vec4(multiples(), 1.0); 312 | } 313 | } 314 | `; 315 | 316 | var vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); 317 | var fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); 318 | 319 | var shaderProgram = gl.createProgram(); 320 | gl.attachShader(shaderProgram, vertexShader); 321 | gl.attachShader(shaderProgram, fragmentShader); 322 | gl.linkProgram(shaderProgram); 323 | 324 | if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { 325 | alert("unable to link shader program."); 326 | } 327 | 328 | gl.useProgram(shaderProgram); 329 | 330 | vertexPositionAttribute = gl.getAttribLocation(shaderProgram, 'aVertexPosition'); 331 | gl.enableVertexAttribArray(vertexPositionAttribute); 332 | 333 | textureCoordAttribute = gl.getAttribLocation(shaderProgram, 'aTextureCoord'); 334 | gl.enableVertexAttribArray(textureCoordAttribute); 335 | 336 | samplerUniform = gl.getUniformLocation(shaderProgram, 'uSampler'); 337 | zoomUniform = gl.getUniformLocation(shaderProgram, 'mZoom'); 338 | ampRangeUniform = gl.getUniformLocation(shaderProgram, 'vAmpRange'); 339 | specSizeUniform = gl.getUniformLocation(shaderProgram, 'vSpecSize'); 340 | specDataSizeUniform = gl.getUniformLocation(shaderProgram, 'vDataSize'); 341 | specModeUniform = gl.getUniformLocation(shaderProgram, 'uSpecMode'); 342 | specLogarithmicUniform = gl.getUniformLocation(shaderProgram, 'bSpecLogarithmic'); 343 | } 344 | 345 | /* load and compile a shader 346 | 347 | Attributes: 348 | id the id of a script element that contains the shader source. 349 | 350 | Returns the compiled shader. 351 | */ 352 | function getShader(id) { 353 | var script = document.getElementById(id); 354 | 355 | if (script.type == "x-shader/x-fragment") { 356 | var shader = gl.createShader(gl.FRAGMENT_SHADER); 357 | } else if (script.type == "x-shader/x-vertex") { 358 | var shader = gl.createShader(gl.VERTEX_SHADER); 359 | } else { 360 | return null; 361 | } 362 | 363 | gl.shaderSource(shader, script.innerHTML); 364 | gl.compileShader(shader); 365 | 366 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { 367 | alert("An error occurred compiling the shaders: " + gl.getShaderInfoLog(shader)); 368 | return null; 369 | } 370 | 371 | return shader; 372 | } 373 | 374 | /* loads a spectrogram into video memory and fills VBOs 375 | 376 | If there is more data than fits into a single texture, several 377 | textures are allocated and the data is written into consecutive 378 | textures. According vertex positions are saved into an equal number 379 | of VBOs. 380 | 381 | - saves textures into a global array `spectrogramTextures`. 382 | - saves vertexes into a global array `vertexPositionBuffers`. 383 | - saves texture coordinates into global `textureCoordBuffer`. 384 | 385 | Attributes: 386 | data a Float32Array containing nblocks x nfreqs values. 387 | nblocks the width of the data, the number of blocks. 388 | nfreqs the height of the data, the number of frequency bins. 389 | fs the sample rate of the audio data. 390 | length the length of the audio data in seconds. 391 | dB an amplitude - {min,max} 392 | */ 393 | function loadSpectrogram(data, nblocks, nfreqs, fs, length, db = undefined) { 394 | // calculate the number of textures needed 395 | var maxTexSize = gl.getParameter(gl.MAX_TEXTURE_SIZE); 396 | var numTextures = nblocks / maxTexSize; 397 | var currentDB; 398 | if (db) 399 | { 400 | currentDB = db; 401 | } 402 | else 403 | { 404 | currentDB = {'min':-120,'max':0} 405 | } 406 | 407 | // bail if too big for video memory 408 | if (Math.ceil(numTextures) > gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS)) { 409 | alert("Not enough texture units to display spectrogram"); 410 | return; 411 | } 412 | 413 | // delete previously allocated textures and VBOs 414 | for (var i in spectrogramTextures) { 415 | gl.deleteBuffer(vertexPositionBuffers[i]); 416 | gl.deleteTexture(spectrogramTextures[i]); 417 | } 418 | gl.deleteBuffer(textureCoordBuffer); 419 | 420 | 421 | vertexPositionBuffers = new Array(Math.ceil(numTextures)); 422 | spectrogramTextures = new Array(Math.ceil(numTextures)); 423 | 424 | // texture coordinates for all textures are identical 425 | textureCoordBuffer = gl.createBuffer(); 426 | gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer); 427 | var textureCoordinates = new Float32Array([ 428 | 1.0, 1.0, 429 | 1.0, 0.0, 430 | 0.0, 1.0, 431 | 0.0, 0.0 432 | ]); 433 | gl.bufferData(gl.ARRAY_BUFFER, textureCoordinates, gl.STATIC_DRAW); 434 | 435 | // for every texture, calculate vertex indices and texture content 436 | for (var i = 0; i < numTextures; i++) { 437 | // texture position in 0..1: 438 | var minX = i / numTextures; 439 | var maxX = ((i + 1) < numTextures) ? (i + 1) / numTextures : 1; 440 | 441 | // calculate vertex positions, scaled to -1..1 442 | vertexPositionBuffers[i] = gl.createBuffer(); 443 | gl.bindBuffer(gl.ARRAY_BUFFER, vertexPositionBuffers[i]); 444 | var vertices = new Float32Array([ 445 | maxX * 2 - 1, 1.0, 446 | maxX * 2 - 1, -1.0, 447 | minX * 2 - 1, 1.0, 448 | minX * 2 - 1, -1.0 449 | ]); 450 | gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); 451 | 452 | // fill textures with spectrogram data 453 | var blocks = ((i + 1) < numTextures) ? maxTexSize : (numTextures % 1) * maxTexSize; 454 | var chunk = data.subarray(i * maxTexSize * nfreqs, (i * maxTexSize + blocks) * nfreqs); 455 | var tmp = new Float32Array(chunk.length); 456 | for (var x = 0; x < blocks; x++) { 457 | for (var y = 0; y < nfreqs; y++) { 458 | tmp[x + blocks * y] = chunk[y + nfreqs * x]; 459 | } 460 | } 461 | spectrogramTextures[i] = gl.createTexture(); 462 | gl.activeTexture(gl.TEXTURE0 + i); 463 | gl.bindTexture(gl.TEXTURE_2D, spectrogramTextures[i]); 464 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 465 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 466 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, blocks, nfreqs, 0, gl.LUMINANCE, gl.FLOAT, tmp); 467 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); 468 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); 469 | } 470 | 471 | // save spectrogram sizes 472 | specSize = new SpecSize(0, length, 0, fs / 2); 473 | specSize.numT = nblocks; 474 | specSize.numF = nfreqs; 475 | specViewSize = new SpecSize(0, length, 0, fs / 2, currentDB['min'], currentDB['max']); 476 | 477 | window.requestAnimationFrame(drawScene); 478 | } 479 | 480 | /* updates the spectrogram and the scales */ 481 | function drawScene() { 482 | drawSpectrogram(); 483 | drawSpecTimeScale(); 484 | drawSpecFrequencyScale(); 485 | } 486 | 487 | /* draw the zoomed spectrogram, one texture at a time */ 488 | function drawSpectrogram() { 489 | // load the texture coordinates VBO 490 | gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer); 491 | gl.vertexAttribPointer(textureCoordAttribute, 2, gl.FLOAT, false, 0, 0); 492 | 493 | // set the current model view matrix 494 | var panX = (specViewSize.centerT() - specSize.centerT()) / specSize.widthT(); 495 | var panY = (specViewSize.centerF() - specSize.centerF()) / specSize.widthF(); 496 | var zoomX = specSize.widthT() / specViewSize.widthT(); 497 | var zoomY = specSize.widthF() / specViewSize.widthF(); 498 | var zoomMatrix = [ 499 | zoomX, 0.0, -2 * panX * zoomX, 500 | 0.0, zoomY, -2 * panY * zoomY, 501 | 0.0, 0.0, 1.0 502 | ]; 503 | gl.uniformMatrix3fv(zoomUniform, gl.FALSE, zoomMatrix); 504 | 505 | // set the current amplitude range to display 506 | gl.uniform2f(ampRangeUniform, specViewSize.minA, specViewSize.maxA); 507 | // set the size of the spectrogram 508 | gl.uniform2f(specSizeUniform, specSize.widthT(), specSize.widthF()); 509 | gl.uniform2f(specDataSizeUniform, specSize.numT, specSize.numF); 510 | // set the spectrogram display mode 511 | var specMode = document.getElementById('specMode').value; 512 | gl.uniform1i(specModeUniform, specMode); 513 | var specLogarithmic = document.getElementById('specLogarithmic').checked; 514 | gl.uniform1i(specLogarithmicUniform, specLogarithmic); 515 | 516 | // switch interpolation on or off 517 | var interpolate = document.getElementById('specInterpolation').checked; 518 | 519 | // draw the spectrogram textures 520 | for (var i = 0; i < spectrogramTextures.length; i++) { 521 | gl.activeTexture(gl.TEXTURE0 + i); 522 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, interpolate ? gl.LINEAR : gl.NEAREST); 523 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, interpolate ? gl.LINEAR : gl.NEAREST); 524 | gl.bindTexture(gl.TEXTURE_2D, spectrogramTextures[i]); 525 | gl.uniform1i(samplerUniform, i); 526 | 527 | gl.bindBuffer(gl.ARRAY_BUFFER, vertexPositionBuffers[i]); 528 | gl.vertexAttribPointer(vertexPositionAttribute, 2, gl.FLOAT, false, 0, 0); 529 | 530 | gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); 531 | } 532 | } 533 | 534 | /* format a time in mm:ss.ss 535 | 536 | Attributes: 537 | seconds a time in seconds. 538 | 539 | returns a formatted string containing minutes, seconds, and 540 | hundredths. 541 | */ 542 | function formatTime(seconds) { 543 | var minutes = Math.floor(seconds / 60); 544 | var seconds = seconds % 60; 545 | minutes = minutes.toString(); 546 | if (minutes.length === 1) { 547 | minutes = "0" + minutes; 548 | } 549 | seconds = seconds.toFixed(2); 550 | if (seconds.length === 4) { 551 | seconds = "0" + seconds; 552 | } 553 | return minutes + ":" + seconds; 554 | } 555 | 556 | /* draw the time scale canvas 557 | 558 | The time scale prints the minimum and maximum currently visible 559 | time and an axis with two ticks. Minimum and maximum time are taken 560 | from specViewSize.(min|max)T. 561 | */ 562 | function drawSpecTimeScale() { 563 | var ctx = specTimeScale.getContext('2d'); 564 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 565 | // draw axis line and two ticks 566 | ctx.fillStyle = "black"; 567 | ctx.fillRect(10, 2, ctx.canvas.width - 20, 1); 568 | ctx.fillRect(10, 2, 1, 5); 569 | ctx.fillRect(ctx.canvas.width - 10, 3, 1, 5); 570 | // draw lower time bound 571 | ctx.font = "8px sans-serif"; 572 | var text = formatTime(specViewSize.minT); 573 | var textWidth = ctx.measureText(text).width; 574 | ctx.fillText(text, 0, ctx.canvas.height - 2); 575 | // draw upper time bound 576 | var text = formatTime(specViewSize.maxT); 577 | var textWidth = ctx.measureText(text).width; 578 | ctx.fillText(text, ctx.canvas.width - textWidth, ctx.canvas.height - 2); 579 | } 580 | 581 | /* convert a linear frequency coordinate to logarithmic frequency */ 582 | function freqLin2Log(f) { 583 | return Math.pow(specSize.widthF(), f / specSize.widthF()); 584 | } 585 | 586 | /* format a frequency 587 | 588 | Attributes: 589 | frequency a frequency in Hz. 590 | 591 | returns a formatted string with the frequency in Hz or kHz, 592 | with an appropriate number of decimals. If logarithmic 593 | frequency is enabled, the returned frequency will be 594 | appropriately distorted. 595 | */ 596 | function formatFrequency(frequency) { 597 | frequency = document.getElementById('specLogarithmic').checked ? freqLin2Log(frequency) : frequency; 598 | 599 | if (frequency < 10) { 600 | return frequency.toFixed(2) + " Hz"; 601 | } else if (frequency < 100) { 602 | return frequency.toFixed(1) + " Hz"; 603 | } else if (frequency < 1000) { 604 | return Math.round(frequency).toString() + " Hz"; 605 | } else if (frequency < 10000) { 606 | return (frequency / 1000).toFixed(2) + " KHz"; 607 | } else if (frequency < 100000) { 608 | return (frequency / 1000).toFixed(1) + " KHz"; 609 | } else if (frequency < 1000000) { 610 | return Math.round(frequency / 1000) + " KHz"; 611 | } else if (frequency < 10000000) { 612 | return (frequency / 1000000).toFixed(2) + " MHz"; 613 | } else if (frequency < 100000000) { 614 | return (frequency / 1000000).toFixed(1) + " MHz"; 615 | } else { 616 | return Math.round(frequency / 1000000) + " MHz"; 617 | } 618 | } 619 | 620 | /* draw the frequency scale canvas 621 | 622 | The frequency scale prints the minimum and maximum currently 623 | visible frequency and an axis with two ticks. Minimum and maximum 624 | frequency are taken from specViewSize.(min|max)F. 625 | */ 626 | function drawSpecFrequencyScale() { 627 | var ctx = specFrequencyScale.getContext('2d'); 628 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 629 | // draw axis line and two ticks 630 | ctx.fillStyle = "black"; 631 | ctx.fillRect(2, 10, 1, ctx.canvas.height - 20); 632 | ctx.fillRect(2, 10, 5, 1); 633 | ctx.fillRect(2, ctx.canvas.height - 10, 5, 1); 634 | // draw upper frequency bound 635 | ctx.font = "8px sans-serif"; 636 | var text = formatFrequency(specViewSize.maxF); 637 | var textWidth = ctx.measureText(text).width; 638 | ctx.fillText(text, 8, 14); 639 | // draw lower frequency bound 640 | var text = formatFrequency(specViewSize.minF); 641 | var textWidth = ctx.measureText(text).width; 642 | ctx.fillText(text, 8, ctx.canvas.height - 8); 643 | } 644 | 645 | function eventsInit() 646 | { 647 | /* zoom or pan when on scrolling 648 | 649 | If no modifier is pressed, scrolling scrolls the spectrogram. 650 | 651 | If alt is pressed, scrolling changes the displayed amplitude range. 652 | Pressing shift as well switches X/Y scrolling. 653 | 654 | If ctrl is pressed, scrolling zooms in or out. If ctrl and shift is 655 | pressed, scrolling only zooms the time axis. 656 | 657 | At no time will any of this zoom or pan outside of the spectrogram 658 | view area. 659 | */ 660 | specView.onwheel = function(wheel) { 661 | var stepF = specViewSize.widthF() / 100; 662 | var stepT = specViewSize.widthT() / 100; 663 | if (wheel.altKey) { 664 | var center = specViewSize.centerA(); 665 | var range = specViewSize.widthA(); 666 | range += wheel.shiftKey ? wheel.deltaY / 10 : wheel.deltaX / 10; 667 | range = Math.max(range, 1); 668 | center += wheel.shiftKey ? wheel.deltaX / 10 : wheel.deltaY / 10; 669 | specViewSize.minA = center - range / 2; 670 | specViewSize.maxA = center + range / 2; 671 | } else if (wheel.ctrlKey) { 672 | var deltaT = wheel.deltaY * stepT; 673 | if (specViewSize.widthT() + 2 * deltaT > specSize.widthT()) { 674 | deltaT = (specSize.widthT() - specViewSize.widthT()) / 2; 675 | } 676 | var deltaF = wheel.shiftKey ? 0 : wheel.deltaY * stepF; 677 | if (specViewSize.widthF() + 2 * deltaF > specSize.widthF()) { 678 | deltaF = (specSize.widthF() - specViewSize.widthF()) / 2; 679 | } 680 | specViewSize.minF -= deltaF; 681 | specViewSize.maxF += deltaF; 682 | specViewSize.minT -= deltaT; 683 | specViewSize.maxT += deltaT; 684 | if (specViewSize.minT < specSize.minT) { 685 | specViewSize.maxT += specSize.minT - specViewSize.minT; 686 | specViewSize.minT += specSize.minT - specViewSize.minT; 687 | } 688 | if (specViewSize.maxT > specSize.maxT) { 689 | specViewSize.minT += specSize.maxT - specViewSize.maxT; 690 | specViewSize.maxT += specSize.maxT - specViewSize.maxT; 691 | } 692 | if (specViewSize.minF < specSize.minF) { 693 | specViewSize.maxF += specSize.minF - specViewSize.minF; 694 | specViewSize.minF += specSize.minF - specViewSize.minF; 695 | } 696 | if (specViewSize.maxF > specSize.maxF) { 697 | specViewSize.minF += specSize.maxF - specViewSize.maxF; 698 | specViewSize.maxF += specSize.maxF - specViewSize.maxF; 699 | } 700 | } else { 701 | var deltaT = (wheel.shiftKey ? -wheel.deltaY : wheel.deltaX) * stepT / 10; 702 | if (specViewSize.maxT + deltaT > specSize.maxT) { 703 | deltaT = specSize.maxT - specViewSize.maxT; 704 | } 705 | if (specViewSize.minT + deltaT < specSize.minT) { 706 | deltaT = specSize.minT - specViewSize.minT; 707 | } 708 | var deltaF = (wheel.shiftKey ? wheel.deltaX : -wheel.deltaY) * stepF / 10; 709 | if (specViewSize.maxF + deltaF > specSize.maxF) { 710 | deltaF = specSize.maxF - specViewSize.maxF; 711 | } 712 | if (specViewSize.minF + deltaF < specSize.minF) { 713 | deltaF = specSize.minF - specViewSize.minF; 714 | } 715 | specViewSize.minF += deltaF; 716 | specViewSize.maxF += deltaF; 717 | specViewSize.minT += deltaT; 718 | specViewSize.maxT += deltaT; 719 | } 720 | wheel.preventDefault(); 721 | specView.onmousemove(wheel); 722 | window.requestAnimationFrame(drawScene); 723 | } 724 | 725 | /* update the specDataView with cursor position. 726 | 727 | The specDataView should contain the current cursor position in 728 | frequency/time coordinates. It is updated every time the mouse is 729 | moved on the spectrogram. 730 | */ 731 | specView.onmousemove = function(mouse) { 732 | var t = specViewSize.scaleT(mouse.layerX / specView.clientWidth); 733 | var f = specViewSize.scaleF(1 - mouse.layerY / specView.clientHeight); 734 | specDataView.innerHTML = formatTime(t) + ", " + formatFrequency(f) + "
" + 735 | specViewSize.centerA().toFixed(2) + " dB " + 736 | "± " + (specViewSize.widthA() / 2).toFixed(2) + " dB"; 737 | } 738 | 739 | /* update spectrogram display mode on keypress */ 740 | window.onkeypress = function(e) { 741 | var specMode = -1; 742 | if (e.key === 'p') { 743 | specMode = 0; 744 | } else if (e.key === 'n') { 745 | specMode = 1; 746 | } else if (e.key === 'd') { 747 | specMode = 2; 748 | } else if (e.key === 'm') { 749 | specMode = 3; 750 | } 751 | // prevent the default action of submitting the GET parameters. 752 | e.which = e.which || e.keyCode; 753 | if (e.which === 13) { 754 | e.preventDefault(); 755 | } 756 | 757 | document.getElementById('specMode').value = specMode; 758 | window.requestAnimationFrame(drawScene); 759 | } 760 | } 761 | --------------------------------------------------------------------------------