| /* Copyright (c) 2013 The Chromium OS Authors. All rights reserved. |
| * Use of this source code is governed by a BSD-style license that can be |
| * found in the LICENSE file. |
| */ |
| |
| /* This is a program for tuning audio using Web Audio API. The processing |
| * pipeline looks like this: |
| * |
| * INPUT |
| * | |
| * +------------+ |
| * | crossover | |
| * +------------+ |
| * / | \ |
| * (low band) (mid band) (high band) |
| * / | \ |
| * +------+ +------+ +------+ |
| * | DRC | | DRC | | DRC | |
| * +------+ +------+ +------+ |
| * \ | / |
| * \ | / |
| * +-------------+ |
| * | (+) | |
| * +-------------+ |
| * | | |
| * (left) (right) |
| * | | |
| * +----+ +----+ |
| * | EQ | | EQ | |
| * +----+ +----+ |
| * | | |
| * +----+ +----+ |
| * | EQ | | EQ | |
| * +----+ +----+ |
| * . . |
| * . . |
| * +----+ +----+ |
| * | EQ | | EQ | |
| * +----+ +----+ |
| * \ / |
| * \ / |
| * | |
| * / \ |
| * / \ |
| * +-----+ +-----+ |
| * | FFT | | FFT | (for visualization only) |
| * +-----+ +-----+ |
| * \ / |
| * \ / |
| * | |
| * OUTPUT |
| * |
| * The parameters of each DRC and EQ can be adjusted or disabled independently. |
| * |
| * If enable_swap is set to true, the order of the DRC and the EQ stages are |
| * swapped (EQ is applied first, then DRC). |
| */ |
| |
| /* The GLOBAL state has following parameters: |
| * enable_drc - A switch to turn all DRC on/off. |
| * enable_eq - A switch to turn all EQ on/off. |
| * enable_fft - A switch to turn visualization on/off. |
| * enable_swap - A switch to swap the order of EQ and DRC stages. |
| */ |
| |
| /* The DRC has following parameters: |
| * f - The lower frequency of the band, in Hz. |
| * enable - 1 to enable the compressor, 0 to disable it. |
| * threshold - The value above which the compression starts, in dB. |
| * knee - The value above which the knee region starts, in dB. |
| * ratio - The input/output dB ratio after the knee region. |
| * attack - The time to reduce the gain by 10dB, in seconds. |
| * release - The time to increase the gain by 10dB, in seconds. |
| * boost - The static boost value in output, in dB. |
| */ |
| |
| /* The EQ has following parameters: |
| * enable - 1 to enable the eq, 0 to disable it. |
| * type - The type of the eq, the available values are 'lowpass', 'highpass', |
| * 'bandpass', 'lowshelf', 'highshelf', 'peaking', 'notch'. |
| * freq - The frequency of the eq, in Hz. |
| * q, gain - The meaning depends on the type of the filter. See Web Audio API |
| * for details. |
| */ |
| |
| /* The initial values of parameters for GLOBAL, DRC and EQ */ |
| var INIT_GLOBAL_ENABLE_DRC = true; |
| var INIT_GLOBAL_ENABLE_EQ = true; |
| var INIT_GLOBAL_ENABLE_FFT = true; |
| var INIT_GLOBAL_ENABLE_SWAP = false; |
| var INIT_DRC_XO_LOW = 200; |
| var INIT_DRC_XO_HIGH = 2000; |
| var INIT_DRC_ENABLE = true; |
| var INIT_DRC_THRESHOLD = -24; |
| var INIT_DRC_KNEE = 30; |
| var INIT_DRC_RATIO = 12; |
| var INIT_DRC_ATTACK = 0.003; |
| var INIT_DRC_RELEASE = 0.250; |
| var INIT_DRC_BOOST = 0; |
| var INIT_EQ_ENABLE = true; |
| var INIT_EQ_TYPE = 'peaking'; |
| var INIT_EQ_FREQ = 350; |
| var INIT_EQ_Q = 1; |
| var INIT_EQ_GAIN = 0; |
| |
| var NEQ = 8; /* The number of EQs per channel */ |
| var FFT_SIZE = 2048; /* The size of FFT used for visualization */ |
| |
| var audioContext; /* Web Audio context */ |
| var nyquist; /* Nyquist frequency, in Hz */ |
| var sourceNode; |
| var audio_graph; |
| var audio_ui; |
| var analyzer_left; /* The FFT analyzer for left channel */ |
| var analyzer_right; /* The FFT analyzer for right channel */ |
| /* get_emphasis_disabled detects if pre-emphasis in drc is disabled by browser. |
| * The detection result will be stored in this value. When user saves config, |
| * This value is stored in drc.emphasis_disabled in the config. */ |
| var browser_emphasis_disabled_detection_result; |
| /* check_biquad_filter_q detects if the browser implements the lowpass and |
| * highpass biquad filters with the original formula or the new formula from |
| * Audio EQ Cookbook. Chrome changed the filter implementation in R53, see: |
| * https://github.com/GoogleChrome/web-audio-samples/wiki/Detection-of-lowpass-BiquadFilter-implementation |
| * The detection result is saved in this value before the page is initialized. |
| * make_biquad_q() uses this value to compute Q to ensure consistent behavior |
| * on different browser versions. |
| */ |
| var browser_biquad_filter_uses_audio_cookbook_formula; |
| |
| /* Check the lowpass implementation and return a promise. */ |
| function check_biquad_filter_q() { |
| 'use strict'; |
| var context = new OfflineAudioContext(1, 128, 48000); |
| var osc = context.createOscillator(); |
| var filter1 = context.createBiquadFilter(); |
| var filter2 = context.createBiquadFilter(); |
| var inverter = context.createGain(); |
| |
| osc.type = 'sawtooth'; |
| osc.frequency.value = 8 * 440; |
| inverter.gain.value = -1; |
| /* each filter should get a different Q value */ |
| filter1.Q.value = -1; |
| filter2.Q.value = -20; |
| osc.connect(filter1); |
| osc.connect(filter2); |
| filter1.connect(context.destination); |
| filter2.connect(inverter); |
| inverter.connect(context.destination); |
| osc.start(); |
| |
| return context.startRendering().then(function (buffer) { |
| return browser_biquad_filter_uses_audio_cookbook_formula = |
| Math.max(...buffer.getChannelData(0)) !== 0; |
| }); |
| } |
| |
| /* Return the Q value to be used with the lowpass and highpass biquad filters, |
| * given Q in dB for the original filter formula. If the browser uses the new |
| * formula, conversion is made to simulate the original frequency response |
| * with the new formula. |
| */ |
| function make_biquad_q(q_db) { |
| if (!browser_biquad_filter_uses_audio_cookbook_formula) |
| return q_db; |
| |
| var q_lin = dBToLinear(q_db); |
| var q_new = 1 / Math.sqrt((4 - Math.sqrt(16 - 16 / (q_lin * q_lin))) / 2); |
| q_new = linearToDb(q_new); |
| return q_new; |
| } |
| |
| /* The supported audio element names are different on browsers with different |
| * versions.*/ |
| function fix_audio_elements() { |
| try { |
| window.AudioContext = window.AudioContext || window.webkitAudioContext; |
| window.OfflineAudioContext = (window.OfflineAudioContext || |
| window.webkitOfflineAudioContext); |
| } |
| catch(e) { |
| alert('Web Audio API is not supported in this browser'); |
| } |
| } |
| |
| function init_audio() { |
| audioContext = new AudioContext(); |
| nyquist = audioContext.sampleRate / 2; |
| } |
| |
| function build_graph() { |
| if (sourceNode) { |
| audio_graph = new graph(); |
| sourceNode.disconnect(); |
| if (get_global('enable_drc') || get_global('enable_eq') || |
| get_global('enable_fft')) { |
| connect_from_native(pin(sourceNode), audio_graph); |
| connect_to_native(audio_graph, pin(audioContext.destination)); |
| } else { |
| /* no processing needed, directly connect from source to destination. */ |
| sourceNode.connect(audioContext.destination); |
| } |
| } |
| apply_all_configs(); |
| } |
| |
| /* The available configuration variables are: |
| * |
| * global.{enable_drc, enable_eq, enable_fft, enable_swap} |
| * drc.[0-2].{f, enable, threshold, knee, ratio, attack, release, boost} |
| * eq.[01].[0-7].{enable, type, freq, q, gain}. |
| * |
| * Each configuration variable maps a name to a value. For example, |
| * "drc.1.attack" is the attack time for the second drc (the "1" is the index of |
| * the drc instance), and "eq.0.2.freq" is the frequency of the third eq on the |
| * left channel (the "0" means left channel, and the "2" is the index of the |
| * eq). |
| */ |
| var all_configs = {}; /* stores all the configuration variables */ |
| |
| function init_config() { |
| set_config('global', 'enable_drc', INIT_GLOBAL_ENABLE_DRC); |
| set_config('global', 'enable_eq', INIT_GLOBAL_ENABLE_EQ); |
| set_config('global', 'enable_fft', INIT_GLOBAL_ENABLE_FFT); |
| set_config('global', 'enable_swap', INIT_GLOBAL_ENABLE_SWAP); |
| set_config('drc', 0, 'f', 0); |
| set_config('drc', 1, 'f', INIT_DRC_XO_LOW); |
| set_config('drc', 2, 'f', INIT_DRC_XO_HIGH); |
| for (var i = 0; i < 3; i++) { |
| set_config('drc', i, 'enable', INIT_DRC_ENABLE); |
| set_config('drc', i, 'threshold', INIT_DRC_THRESHOLD); |
| set_config('drc', i, 'knee', INIT_DRC_KNEE); |
| set_config('drc', i, 'ratio', INIT_DRC_RATIO); |
| set_config('drc', i, 'attack', INIT_DRC_ATTACK); |
| set_config('drc', i, 'release', INIT_DRC_RELEASE); |
| set_config('drc', i, 'boost', INIT_DRC_BOOST); |
| } |
| for (var i = 0; i <= 1; i++) { |
| for (var j = 0; j < NEQ; j++) { |
| set_config('eq', i, j, 'enable', INIT_EQ_ENABLE); |
| set_config('eq', i, j, 'type', INIT_EQ_TYPE); |
| set_config('eq', i, j, 'freq', INIT_EQ_FREQ); |
| set_config('eq', i, j, 'q', INIT_EQ_Q); |
| set_config('eq', i, j, 'gain', INIT_EQ_GAIN); |
| } |
| } |
| } |
| |
| /* Returns a string from the first n elements of a, joined by '.' */ |
| function make_name(a, n) { |
| var sub = []; |
| for (var i = 0; i < n; i++) { |
| sub.push(a[i].toString()); |
| } |
| return sub.join('.'); |
| } |
| |
| function get_config() { |
| var name = make_name(arguments, arguments.length); |
| return all_configs[name]; |
| } |
| |
| function set_config() { |
| var n = arguments.length; |
| var name = make_name(arguments, n - 1); |
| all_configs[name] = arguments[n - 1]; |
| } |
| |
| /* Convenience function */ |
| function get_global(name) { |
| return get_config('global', name); |
| } |
| |
| /* set_config and apply it to the audio graph and ui. */ |
| function use_config() { |
| var n = arguments.length; |
| var name = make_name(arguments, n - 1); |
| all_configs[name] = arguments[n - 1]; |
| if (audio_graph) { |
| audio_graph.config(name.split('.'), all_configs[name]); |
| } |
| if (audio_ui) { |
| audio_ui.config(name.split('.'), all_configs[name]); |
| } |
| } |
| |
| /* re-apply all the configs to audio graph and ui. */ |
| function apply_all_configs() { |
| for (var name in all_configs) { |
| if (audio_graph) { |
| audio_graph.config(name.split('.'), all_configs[name]); |
| } |
| if (audio_ui) { |
| audio_ui.config(name.split('.'), all_configs[name]); |
| } |
| } |
| } |
| |
| /* Returns a zero-padded two digits number, for time formatting. */ |
| function two(n) { |
| var s = '00' + n; |
| return s.slice(-2); |
| } |
| |
| /* Returns a time string, used for save file name */ |
| function time_str() { |
| var d = new Date(); |
| var date = two(d.getDate()); |
| var month = two(d.getMonth() + 1); |
| var hour = two(d.getHours()); |
| var minutes = two(d.getMinutes()); |
| return month + date + '-' + hour + minutes; |
| } |
| |
| /* Downloads the current config to a file. */ |
| function save_config() { |
| set_config('drc', 'emphasis_disabled', |
| browser_emphasis_disabled_detection_result); |
| var a = document.getElementById('save_config_anchor'); |
| var content = JSON.stringify(all_configs, undefined, 2); |
| var uriContent = 'data:application/octet-stream,' + |
| encodeURIComponent(content); |
| a.href = uriContent; |
| a.download = 'audio-' + time_str() + '.conf'; |
| a.click(); |
| } |
| |
| /* Loads a config file. */ |
| function load_config() { |
| document.getElementById('config_file').click(); |
| } |
| |
| function config_file_changed() { |
| var input = document.getElementById('config_file'); |
| var file = input.files[0]; |
| var reader = new FileReader(); |
| function onloadend() { |
| var configs = JSON.parse(reader.result); |
| init_config(); |
| for (var name in configs) { |
| all_configs[name] = configs[name]; |
| } |
| build_graph(); |
| } |
| reader.onloadend = onloadend; |
| reader.readAsText(file); |
| input.value = ''; |
| } |
| |
| /* ============================ Audio components ============================ */ |
| |
| /* We wrap Web Audio nodes into our own components. Each component has following |
| * methods: |
| * |
| * function input(n) - Returns a list of pins which are the n-th input of the |
| * component. |
| * |
| * function output(n) - Returns a list of pins which are the n-th output of the |
| * component. |
| * |
| * function config(name, value) - Changes the configuration variable for the |
| * component. |
| * |
| * Each "pin" is just one input/output of a Web Audio node. |
| */ |
| |
| /* Returns the top-level audio component */ |
| function graph() { |
| var stages = []; |
| var drcs, eqs, ffts; |
| if (get_global('enable_drc')) { |
| drcs = new drc_3band(); |
| } |
| if (get_global('enable_eq')) { |
| eqs = new eq_2chan(); |
| } |
| if (get_global('enable_swap')) { |
| if (eqs) stages.push(eqs); |
| if (drcs) stages.push(drcs); |
| } else { |
| if (drcs) stages.push(drcs); |
| if (eqs) stages.push(eqs); |
| } |
| if (get_global('enable_fft')) { |
| ffts = new fft_2chan(); |
| stages.push(ffts); |
| } |
| |
| for (var i = 1; i < stages.length; i++) { |
| connect(stages[i - 1], stages[i]); |
| } |
| |
| function input(n) { |
| return stages[0].input(0); |
| } |
| |
| function output(n) { |
| return stages[stages.length - 1].output(0); |
| } |
| |
| function config(name, value) { |
| var p = name[0]; |
| var s = name.slice(1); |
| if (p == 'global') { |
| /* do nothing */ |
| } else if (p == 'drc') { |
| if (drcs) { |
| drcs.config(s, value); |
| } |
| } else if (p == 'eq') { |
| if (eqs) { |
| eqs.config(s, value); |
| } |
| } else { |
| console.log('invalid parameter: name =', name, 'value =', value); |
| } |
| } |
| |
| this.input = input; |
| this.output = output; |
| this.config = config; |
| } |
| |
| /* Returns the fft component for two channels */ |
| function fft_2chan() { |
| var splitter = audioContext.createChannelSplitter(2); |
| var merger = audioContext.createChannelMerger(2); |
| |
| analyzer_left = audioContext.createAnalyser(); |
| analyzer_right = audioContext.createAnalyser(); |
| analyzer_left.fftSize = FFT_SIZE; |
| analyzer_right.fftSize = FFT_SIZE; |
| |
| splitter.connect(analyzer_left, 0, 0); |
| splitter.connect(analyzer_right, 1, 0); |
| analyzer_left.connect(merger, 0, 0); |
| analyzer_right.connect(merger, 0, 1); |
| |
| function input(n) { |
| return [pin(splitter)]; |
| } |
| |
| function output(n) { |
| return [pin(merger)]; |
| } |
| |
| this.input = input; |
| this.output = output; |
| } |
| |
| /* Returns eq for two channels */ |
| function eq_2chan() { |
| var eqcs = [new eq_channel(0), new eq_channel(1)]; |
| var splitter = audioContext.createChannelSplitter(2); |
| var merger = audioContext.createChannelMerger(2); |
| |
| connect_from_native(pin(splitter, 0), eqcs[0]); |
| connect_from_native(pin(splitter, 1), eqcs[1]); |
| connect_to_native(eqcs[0], pin(merger, 0)); |
| connect_to_native(eqcs[1], pin(merger, 1)); |
| |
| function input(n) { |
| return [pin(splitter)]; |
| } |
| |
| function output(n) { |
| return [pin(merger)]; |
| } |
| |
| function config(name, value) { |
| var p = parseInt(name[0]); |
| var s = name.slice(1); |
| eqcs[p].config(s, value); |
| } |
| |
| this.input = input; |
| this.output = output; |
| this.config = config; |
| } |
| |
| /* Returns eq for one channel (left or right). It contains a series of eq |
| * filters. */ |
| function eq_channel(channel) { |
| var eqs = []; |
| var first = new delay(0); |
| var last = first; |
| for (var i = 0; i < NEQ; i++) { |
| eqs.push(new eq()); |
| if (get_config('eq', channel, i, 'enable')) { |
| connect(last, eqs[i]); |
| last = eqs[i]; |
| } |
| } |
| |
| function input(n) { |
| return first.input(0); |
| } |
| |
| function output(n) { |
| return last.output(0); |
| } |
| |
| function config(name, value) { |
| var p = parseInt(name[0]); |
| var s = name.slice(1); |
| eqs[p].config(s, value); |
| } |
| |
| this.input = input; |
| this.output = output; |
| this.config = config; |
| } |
| |
| /* Returns a delay component (output = input with n seconds delay) */ |
| function delay(n) { |
| var delay = audioContext.createDelay(); |
| delay.delayTime.value = n; |
| |
| function input(n) { |
| return [pin(delay)]; |
| } |
| |
| function output(n) { |
| return [pin(delay)]; |
| } |
| |
| function config(name, value) { |
| console.log('invalid parameter: name =', name, 'value =', value); |
| } |
| |
| this.input = input; |
| this.output = output; |
| this.config = config; |
| } |
| |
| /* Returns an eq filter */ |
| function eq() { |
| var filter = audioContext.createBiquadFilter(); |
| filter.type = INIT_EQ_TYPE; |
| filter.frequency.value = INIT_EQ_FREQ; |
| filter.Q.value = INIT_EQ_Q; |
| filter.gain.value = INIT_EQ_GAIN; |
| |
| function input(n) { |
| return [pin(filter)]; |
| } |
| |
| function output(n) { |
| return [pin(filter)]; |
| } |
| |
| function config(name, value) { |
| switch (name[0]) { |
| case 'type': |
| filter.type = value; |
| break; |
| case 'freq': |
| filter.frequency.value = parseFloat(value); |
| break; |
| case 'q': |
| value = parseFloat(value); |
| if (filter.type == 'lowpass' || filter.type == 'highpass') |
| value = make_biquad_q(value); |
| filter.Q.value = value; |
| break; |
| case 'gain': |
| filter.gain.value = parseFloat(value); |
| break; |
| case 'enable': |
| break; |
| default: |
| console.log('invalid parameter: name =', name, 'value =', value); |
| } |
| } |
| |
| this.input = input; |
| this.output = output; |
| this.config = config; |
| } |
| |
| /* Returns DRC for 3 bands */ |
| function drc_3band() { |
| var xo = new xo3(); |
| var drcs = [new drc(), new drc(), new drc()]; |
| |
| var out = []; |
| for (var i = 0; i < 3; i++) { |
| if (get_config('drc', i, 'enable')) { |
| connect(xo, drcs[i], i); |
| out = out.concat(drcs[i].output()); |
| } else { |
| /* The DynamicsCompressorNode in Chrome has 6ms pre-delay buffer. So for |
| * other bands we need to delay for the same amount of time. |
| */ |
| var d = new delay(0.006); |
| connect(xo, d, i); |
| out = out.concat(d.output()); |
| } |
| } |
| |
| function input(n) { |
| return xo.input(0); |
| } |
| |
| function output(n) { |
| return out; |
| } |
| |
| function config(name, value) { |
| if (name[1] == 'f') { |
| xo.config(name, value); |
| } else if (name[0] != 'emphasis_disabled') { |
| var n = parseInt(name[0]); |
| drcs[n].config(name.slice(1), value); |
| } |
| } |
| |
| this.input = input; |
| this.output = output; |
| this.config = config; |
| } |
| |
| |
| /* This snippet came from LayoutTests/webaudio/dynamicscompressor-simple.html in |
| * https://codereview.chromium.org/152333003/. It can determine if |
| * emphasis/deemphasis is disabled in the browser. Then it sets the value to |
| * drc.emphasis_disabled in the config.*/ |
| function get_emphasis_disabled() { |
| var context; |
| var sampleRate = 44100; |
| var lengthInSeconds = 1; |
| var renderedData; |
| // This threshold is experimentally determined. It depends on the the gain |
| // value of the gain node below and the dynamics compressor. When the |
| // DynamicsCompressor had the pre-emphasis filters, the peak value is about |
| // 0.21. Without it, the peak is 0.85. |
| var peakThreshold = 0.85; |
| |
| function checkResult(event) { |
| var renderedBuffer = event.renderedBuffer; |
| renderedData = renderedBuffer.getChannelData(0); |
| // Search for a peak in the last part of the data. |
| var startSample = sampleRate * (lengthInSeconds - .1); |
| var endSample = renderedData.length; |
| var k; |
| var peak = -1; |
| var emphasis_disabled = 0; |
| |
| for (k = startSample; k < endSample; ++k) { |
| var sample = Math.abs(renderedData[k]); |
| if (peak < sample) |
| peak = sample; |
| } |
| |
| if (peak >= peakThreshold) { |
| console.log("Pre-emphasis effect not applied as expected.."); |
| emphasis_disabled = 1; |
| } else { |
| console.log("Pre-emphasis caused output to be decreased to " + peak |
| + " (expected >= " + peakThreshold + ")"); |
| emphasis_disabled = 0; |
| } |
| browser_emphasis_disabled_detection_result = emphasis_disabled; |
| /* save_config button will be disabled until we can decide |
| emphasis_disabled in chrome. */ |
| document.getElementById('save_config').disabled = false; |
| } |
| |
| function runTest() { |
| context = new OfflineAudioContext(1, sampleRate * lengthInSeconds, |
| sampleRate); |
| // Connect an oscillator to a gain node to the compressor. The |
| // oscillator frequency is set to a high value for the (original) |
| // emphasis to kick in. The gain is a little extra boost to get the |
| // compressor enabled. |
| // |
| var osc = context.createOscillator(); |
| osc.frequency.value = 15000; |
| var gain = context.createGain(); |
| gain.gain.value = 1.5; |
| var compressor = context.createDynamicsCompressor(); |
| osc.connect(gain); |
| gain.connect(compressor); |
| compressor.connect(context.destination); |
| osc.start(); |
| context.oncomplete = checkResult; |
| context.startRendering(); |
| } |
| |
| runTest(); |
| |
| } |
| |
| /* Returns one DRC filter */ |
| function drc() { |
| var comp = audioContext.createDynamicsCompressor(); |
| |
| /* The supported method names are different on browsers with different |
| * versions.*/ |
| audioContext.createGainNode = (audioContext.createGainNode || |
| audioContext.createGain); |
| var boost = audioContext.createGainNode(); |
| comp.threshold.value = INIT_DRC_THRESHOLD; |
| comp.knee.value = INIT_DRC_KNEE; |
| comp.ratio.value = INIT_DRC_RATIO; |
| comp.attack.value = INIT_DRC_ATTACK; |
| comp.release.value = INIT_DRC_RELEASE; |
| boost.gain.value = dBToLinear(INIT_DRC_BOOST); |
| |
| comp.connect(boost); |
| |
| function input(n) { |
| return [pin(comp)]; |
| } |
| |
| function output(n) { |
| return [pin(boost)]; |
| } |
| |
| function config(name, value) { |
| var p = name[0]; |
| switch (p) { |
| case 'threshold': |
| case 'knee': |
| case 'ratio': |
| case 'attack': |
| case 'release': |
| comp[p].value = parseFloat(value); |
| break; |
| case 'boost': |
| boost.gain.value = dBToLinear(parseFloat(value)); |
| break; |
| case 'enable': |
| break; |
| default: |
| console.log('invalid parameter: name =', name, 'value =', value); |
| } |
| } |
| |
| this.input = input; |
| this.output = output; |
| this.config = config; |
| } |
| |
| /* Crossover filter |
| * |
| * INPUT --+-- lp1 --+-- lp2a --+-- LOW (0) |
| * | | | |
| * | \-- hp2a --/ |
| * | |
| * \-- hp1 --+-- lp2 ------ MID (1) |
| * | |
| * \-- hp2 ------ HIGH (2) |
| * |
| * [f1] [f2] |
| */ |
| |
| /* Returns a crossover component which splits input into 3 bands */ |
| function xo3() { |
| var f1 = INIT_DRC_XO_LOW; |
| var f2 = INIT_DRC_XO_HIGH; |
| |
| var lp1 = lr4_lowpass(f1); |
| var hp1 = lr4_highpass(f1); |
| var lp2 = lr4_lowpass(f2); |
| var hp2 = lr4_highpass(f2); |
| var lp2a = lr4_lowpass(f2); |
| var hp2a = lr4_highpass(f2); |
| |
| connect(lp1, lp2a); |
| connect(lp1, hp2a); |
| connect(hp1, lp2); |
| connect(hp1, hp2); |
| |
| function input(n) { |
| return lp1.input().concat(hp1.input()); |
| } |
| |
| function output(n) { |
| switch (n) { |
| case 0: |
| return lp2a.output().concat(hp2a.output()); |
| case 1: |
| return lp2.output(); |
| case 2: |
| return hp2.output(); |
| default: |
| console.log('invalid index ' + n); |
| return []; |
| } |
| } |
| |
| function config(name, value) { |
| var p = name[0]; |
| var s = name.slice(1); |
| if (p == '0') { |
| /* Ignore. The lower frequency of the low band is always 0. */ |
| } else if (p == '1') { |
| lp1.config(s, value); |
| hp1.config(s, value); |
| } else if (p == '2') { |
| lp2.config(s, value); |
| hp2.config(s, value); |
| lp2a.config(s, value); |
| hp2a.config(s, value); |
| } else { |
| console.log('invalid parameter: name =', name, 'value =', value); |
| } |
| } |
| |
| this.output = output; |
| this.input = input; |
| this.config = config; |
| } |
| |
| /* Connects two components: the n-th output of c1 and the m-th input of c2. */ |
| function connect(c1, c2, n, m) { |
| n = n || 0; /* default is the first output */ |
| m = m || 0; /* default is the first input */ |
| outs = c1.output(n); |
| ins = c2.input(m); |
| |
| for (var i = 0; i < outs.length; i++) { |
| for (var j = 0; j < ins.length; j++) { |
| var from = outs[i]; |
| var to = ins[j]; |
| from.node.connect(to.node, from.index, to.index); |
| } |
| } |
| } |
| |
| /* Connects from pin "from" to the n-th input of component c2 */ |
| function connect_from_native(from, c2, n) { |
| n = n || 0; /* default is the first input */ |
| ins = c2.input(n); |
| for (var i = 0; i < ins.length; i++) { |
| var to = ins[i]; |
| from.node.connect(to.node, from.index, to.index); |
| } |
| } |
| |
| /* Connects from m-th output of component c1 to pin "to" */ |
| function connect_to_native(c1, to, m) { |
| m = m || 0; /* default is the first output */ |
| outs = c1.output(m); |
| for (var i = 0; i < outs.length; i++) { |
| var from = outs[i]; |
| from.node.connect(to.node, from.index, to.index); |
| } |
| } |
| |
| /* Returns a LR4 lowpass component */ |
| function lr4_lowpass(freq) { |
| return new double(freq, create_lowpass); |
| } |
| |
| /* Returns a LR4 highpass component */ |
| function lr4_highpass(freq) { |
| return new double(freq, create_highpass); |
| } |
| |
| /* Returns a component by apply the same filter twice. */ |
| function double(freq, creator) { |
| var f1 = creator(freq); |
| var f2 = creator(freq); |
| f1.connect(f2); |
| |
| function input(n) { |
| return [pin(f1)]; |
| } |
| |
| function output(n) { |
| return [pin(f2)]; |
| } |
| |
| function config(name, value) { |
| if (name[0] == 'f') { |
| f1.frequency.value = parseFloat(value); |
| f2.frequency.value = parseFloat(value); |
| } else { |
| console.log('invalid parameter: name =', name, 'value =', value); |
| } |
| } |
| |
| this.input = input; |
| this.output = output; |
| this.config = config; |
| } |
| |
| /* Returns a lowpass filter */ |
| function create_lowpass(freq) { |
| var lp = audioContext.createBiquadFilter(); |
| lp.type = 'lowpass'; |
| lp.frequency.value = freq; |
| lp.Q.value = make_biquad_q(0); |
| return lp; |
| } |
| |
| /* Returns a highpass filter */ |
| function create_highpass(freq) { |
| var hp = audioContext.createBiquadFilter(); |
| hp.type = 'highpass'; |
| hp.frequency.value = freq; |
| hp.Q.value = make_biquad_q(0); |
| return hp; |
| } |
| |
| /* A pin specifies one of the input/output of a Web Audio node */ |
| function pin(node, index) { |
| var p = new Pin(); |
| p.node = node; |
| p.index = index || 0; |
| return p; |
| } |
| |
| function Pin(node, index) { |
| } |
| |
| /* ============================ Event Handlers ============================ */ |
| |
| function audio_source_select(select) { |
| var index = select.selectedIndex; |
| var url = document.getElementById('audio_source_url'); |
| url.value = select.options[index].value; |
| url.blur(); |
| audio_source_set(url.value); |
| } |
| |
| /* Loads a local audio file. */ |
| function load_audio() { |
| document.getElementById('audio_file').click(); |
| } |
| |
| function audio_file_changed() { |
| var input = document.getElementById('audio_file'); |
| var file = input.files[0]; |
| var file_url = window.webkitURL.createObjectURL(file); |
| input.value = ''; |
| |
| var url = document.getElementById('audio_source_url'); |
| url.value = file.name; |
| |
| audio_source_set(file_url); |
| } |
| |
| function audio_source_set(url) { |
| var player = document.getElementById('audio_player'); |
| var container = document.getElementById('audio_player_container'); |
| var loading = document.getElementById('audio_loading'); |
| loading.style.visibility = 'visible'; |
| |
| /* Re-create an audio element when the audio source URL is changed. */ |
| player.pause(); |
| container.removeChild(player); |
| player = document.createElement('audio'); |
| player.crossOrigin = 'anonymous'; |
| player.id = 'audio_player'; |
| player.loop = true; |
| player.controls = true; |
| player.addEventListener('canplay', audio_source_canplay); |
| container.appendChild(player); |
| update_source_node(player); |
| |
| player.src = url; |
| player.load(); |
| } |
| |
| function audio_source_canplay() { |
| var player = document.getElementById('audio_player'); |
| var loading = document.getElementById('audio_loading'); |
| loading.style.visibility = 'hidden'; |
| player.play(); |
| } |
| |
| function update_source_node(mediaElement) { |
| sourceNode = audioContext.createMediaElementSource(mediaElement); |
| build_graph(); |
| } |
| |
| function toggle_global_checkbox(name, enable) { |
| use_config('global', name, enable); |
| build_graph(); |
| } |
| |
| function toggle_one_drc(index, enable) { |
| use_config('drc', index, 'enable', enable); |
| build_graph(); |
| } |
| |
| function toggle_one_eq(channel, index, enable) { |
| use_config('eq', channel, index, 'enable', enable); |
| build_graph(); |
| } |
| |
| /* ============================== UI widgets ============================== */ |
| |
| /* Adds a row to the table. The row contains an input box and a slider. */ |
| function slider_input(table, name, initial_value, min_value, max_value, step, |
| suffix, handler) { |
| function id(x) { |
| return x; |
| } |
| |
| return new slider_input_common(table, name, initial_value, min_value, |
| max_value, step, suffix, handler, id, id); |
| } |
| |
| /* This is similar to slider_input, but uses log scale for the slider. */ |
| function slider_input_log(table, name, initial_value, min_value, max_value, |
| suffix, precision, handler, mapping, |
| inverse_mapping) { |
| function mapping(x) { |
| return Math.log(x + 1); |
| } |
| |
| function inv_mapping(x) { |
| return (Math.exp(x) - 1).toFixed(precision); |
| } |
| |
| return new slider_input_common(table, name, initial_value, min_value, |
| max_value, 1e-6, suffix, handler, mapping, |
| inv_mapping); |
| } |
| |
| /* The common implementation of linear and log-scale sliders. Each slider has |
| * the following methods: |
| * |
| * function update(v) - update the slider (and the text box) to the value v. |
| * |
| * function hide(h) - hide/unhide the slider. |
| */ |
| function slider_input_common(table, name, initial_value, min_value, max_value, |
| step, suffix, handler, mapping, inv_mapping) { |
| var row = table.insertRow(-1); |
| var col_name = row.insertCell(-1); |
| var col_box = row.insertCell(-1); |
| var col_slider = row.insertCell(-1); |
| |
| var name_span = document.createElement('span'); |
| name_span.appendChild(document.createTextNode(name)); |
| col_name.appendChild(name_span); |
| |
| var box = document.createElement('input'); |
| box.defaultValue = initial_value; |
| box.type = 'text'; |
| box.size = 5; |
| box.className = 'nbox'; |
| col_box.appendChild(box); |
| var suffix_span = document.createElement('span'); |
| suffix_span.appendChild(document.createTextNode(suffix)); |
| col_box.appendChild(suffix_span); |
| |
| var slider = document.createElement('input'); |
| slider.defaultValue = Math.log(initial_value); |
| slider.type = 'range'; |
| slider.className = 'nslider'; |
| slider.min = mapping(min_value); |
| slider.max = mapping(max_value); |
| slider.step = step; |
| col_slider.appendChild(slider); |
| |
| box.onchange = function() { |
| slider.value = mapping(box.value); |
| handler(parseFloat(box.value)); |
| }; |
| |
| slider.onchange = function() { |
| box.value = inv_mapping(slider.value); |
| handler(parseFloat(box.value)); |
| }; |
| |
| function update(v) { |
| box.value = v; |
| slider.value = mapping(v); |
| } |
| |
| function hide(h) { |
| var v = h ? 'hidden' : 'visible'; |
| name_span.style.visibility = v; |
| box.style.visibility = v; |
| suffix_span.style.visibility = v; |
| slider.style.visibility = v; |
| } |
| |
| this.update = update; |
| this.hide = hide; |
| } |
| |
| /* Adds a enable/disable checkbox to a div. The method "update" can change the |
| * checkbox state. */ |
| function check_button(div, handler) { |
| var check = document.createElement('input'); |
| check.className = 'enable_check'; |
| check.type = 'checkbox'; |
| check.checked = true; |
| check.onchange = function() { |
| handler(check.checked); |
| }; |
| div.appendChild(check); |
| |
| function update(v) { |
| check.checked = v; |
| } |
| |
| this.update = update; |
| } |
| |
| function dummy() { |
| } |
| |
| /* Changes the opacity of a div. */ |
| function toggle_card(div, enable) { |
| div.style.opacity = enable ? 1 : 0.3; |
| } |
| |
| /* Appends a card of DRC controls and graphs to the specified parent. |
| * Args: |
| * parent - The parent element |
| * index - The index of this DRC component (0-2) |
| * lower_freq - The lower frequency of this DRC component |
| * freq_label - The label for the lower frequency input text box |
| */ |
| function drc_card(parent, index, lower_freq, freq_label) { |
| var top = document.createElement('div'); |
| top.className = 'drc_data'; |
| parent.appendChild(top); |
| function toggle_drc_card(enable) { |
| toggle_card(div, enable); |
| toggle_one_drc(index, enable); |
| } |
| var enable_button = new check_button(top, toggle_drc_card); |
| |
| var div = document.createElement('div'); |
| top.appendChild(div); |
| |
| /* Canvas */ |
| var p = document.createElement('p'); |
| div.appendChild(p); |
| |
| var canvas = document.createElement('canvas'); |
| canvas.className = 'drc_curve'; |
| p.appendChild(canvas); |
| |
| canvas.width = 240; |
| canvas.height = 180; |
| var dd = new DrcDrawer(canvas); |
| dd.init(); |
| |
| /* Parameters */ |
| var table = document.createElement('table'); |
| div.appendChild(table); |
| |
| function change_lower_freq(v) { |
| use_config('drc', index, 'f', v); |
| } |
| |
| function change_threshold(v) { |
| dd.update_threshold(v); |
| use_config('drc', index, 'threshold', v); |
| } |
| |
| function change_knee(v) { |
| dd.update_knee(v); |
| use_config('drc', index, 'knee', v); |
| } |
| |
| function change_ratio(v) { |
| dd.update_ratio(v); |
| use_config('drc', index, 'ratio', v); |
| } |
| |
| function change_boost(v) { |
| dd.update_boost(v); |
| use_config('drc', index, 'boost', v); |
| } |
| |
| function change_attack(v) { |
| use_config('drc', index, 'attack', v); |
| } |
| |
| function change_release(v) { |
| use_config('drc', index, 'release', v); |
| } |
| |
| var f_slider; |
| if (lower_freq == 0) { /* Special case for the lowest band */ |
| f_slider = new slider_input_log(table, freq_label, lower_freq, 0, 1, |
| 'Hz', 0, dummy); |
| f_slider.hide(true); |
| } else { |
| f_slider = new slider_input_log(table, freq_label, lower_freq, 1, |
| nyquist, 'Hz', 0, change_lower_freq); |
| } |
| |
| var sliders = { |
| 'f': f_slider, |
| 'threshold': new slider_input(table, 'Threshold', INIT_DRC_THRESHOLD, |
| -100, 0, 1, 'dB', change_threshold), |
| 'knee': new slider_input(table, 'Knee', INIT_DRC_KNEE, 0, 40, 1, 'dB', |
| change_knee), |
| 'ratio': new slider_input(table, 'Ratio', INIT_DRC_RATIO, 1, 20, 0.001, |
| '', change_ratio), |
| 'boost': new slider_input(table, 'Boost', 0, -40, 40, 1, 'dB', |
| change_boost), |
| 'attack': new slider_input(table, 'Attack', INIT_DRC_ATTACK, 0.001, |
| 1, 0.001, 's', change_attack), |
| 'release': new slider_input(table, 'Release', INIT_DRC_RELEASE, |
| 0.001, 1, 0.001, 's', change_release) |
| }; |
| |
| function config(name, value) { |
| var p = name[0]; |
| var fv = parseFloat(value); |
| switch (p) { |
| case 'f': |
| case 'threshold': |
| case 'knee': |
| case 'ratio': |
| case 'boost': |
| case 'attack': |
| case 'release': |
| sliders[p].update(fv); |
| break; |
| case 'enable': |
| toggle_card(div, value); |
| enable_button.update(value); |
| break; |
| default: |
| console.log('invalid parameter: name =', name, 'value =', value); |
| } |
| |
| switch (p) { |
| case 'threshold': |
| dd.update_threshold(fv); |
| break; |
| case 'knee': |
| dd.update_knee(fv); |
| break; |
| case 'ratio': |
| dd.update_ratio(fv); |
| break; |
| case 'boost': |
| dd.update_boost(fv); |
| break; |
| } |
| } |
| |
| this.config = config; |
| } |
| |
| /* Appends a menu of biquad types to the specified table. */ |
| function biquad_type_select(table, handler) { |
| var row = table.insertRow(-1); |
| var col_name = row.insertCell(-1); |
| var col_menu = row.insertCell(-1); |
| |
| col_name.appendChild(document.createTextNode('Type')); |
| |
| var select = document.createElement('select'); |
| select.className = 'biquad_type_select'; |
| var options = [ |
| 'lowpass', |
| 'highpass', |
| 'bandpass', |
| 'lowshelf', |
| 'highshelf', |
| 'peaking', |
| 'notch' |
| /* no need: 'allpass' */ |
| ]; |
| |
| for (var i = 0; i < options.length; i++) { |
| var o = document.createElement('option'); |
| o.appendChild(document.createTextNode(options[i])); |
| select.appendChild(o); |
| } |
| |
| select.value = INIT_EQ_TYPE; |
| col_menu.appendChild(select); |
| |
| function onchange() { |
| handler(select.value); |
| } |
| select.onchange = onchange; |
| |
| function update(v) { |
| select.value = v; |
| } |
| |
| this.update = update; |
| } |
| |
| /* Appends a card of EQ controls to the specified parent. |
| * Args: |
| * parent - The parent element |
| * channel - The index of the channel this EQ component is on (0-1) |
| * index - The index of this EQ on this channel (0-7) |
| * ed - The EQ curve drawer. We will notify the drawer to redraw if the |
| * parameters for this EQ changes. |
| */ |
| function eq_card(parent, channel, index, ed) { |
| var top = document.createElement('div'); |
| top.className = 'eq_data'; |
| parent.appendChild(top); |
| function toggle_eq_card(enable) { |
| toggle_card(table, enable); |
| toggle_one_eq(channel, index, enable); |
| ed.update_enable(index, enable); |
| } |
| var enable_button = new check_button(top, toggle_eq_card); |
| |
| var table = document.createElement('table'); |
| table.className = 'eq_table'; |
| top.appendChild(table); |
| |
| function change_type(v) { |
| ed.update_type(index, v); |
| hide_unused_slider(v); |
| use_config('eq', channel, index, 'type', v); |
| /* Special case: automatically set Q to 0 for lowpass/highpass filters. */ |
| if (v == 'lowpass' || v == 'highpass') { |
| use_config('eq', channel, index, 'q', 0); |
| } |
| } |
| |
| function change_freq(v) |
| { |
| ed.update_freq(index, v); |
| use_config('eq', channel, index, 'freq', v); |
| } |
| |
| function change_q(v) |
| { |
| ed.update_q(index, v); |
| use_config('eq', channel, index, 'q', v); |
| } |
| |
| function change_gain(v) |
| { |
| ed.update_gain(index, v); |
| use_config('eq', channel, index, 'gain', v); |
| } |
| |
| var type_select = new biquad_type_select(table, change_type); |
| |
| var sliders = { |
| 'freq': new slider_input_log(table, 'Frequency', INIT_EQ_FREQ, 1, |
| nyquist, 'Hz', 0, change_freq), |
| 'q': new slider_input_log(table, 'Q', INIT_EQ_Q, 0, 1000, '', 4, |
| change_q), |
| 'gain': new slider_input(table, 'Gain', INIT_EQ_GAIN, -40, 40, 0.1, |
| 'dB', change_gain) |
| }; |
| |
| var unused = { |
| 'lowpass': [0, 0, 1], |
| 'highpass': [0, 0, 1], |
| 'bandpass': [0, 0, 1], |
| 'lowshelf': [0, 1, 0], |
| 'highshelf': [0, 1, 0], |
| 'peaking': [0, 0, 0], |
| 'notch': [0, 0, 1], |
| 'allpass': [0, 0, 1] |
| }; |
| function hide_unused_slider(type) { |
| var u = unused[type]; |
| sliders['freq'].hide(u[0]); |
| sliders['q'].hide(u[1]); |
| sliders['gain'].hide(u[2]); |
| } |
| |
| function config(name, value) { |
| var p = name[0]; |
| var fv = parseFloat(value); |
| switch (p) { |
| case 'type': |
| type_select.update(value); |
| break; |
| case 'freq': |
| case 'q': |
| case 'gain': |
| sliders[p].update(fv); |
| break; |
| case 'enable': |
| toggle_card(table, value); |
| enable_button.update(value); |
| break; |
| default: |
| console.log('invalid parameter: name =', name, 'value =', value); |
| } |
| |
| switch (p) { |
| case 'type': |
| ed.update_type(index, value); |
| hide_unused_slider(value); |
| break; |
| case 'freq': |
| ed.update_freq(index, fv); |
| break; |
| case 'q': |
| ed.update_q(index, fv); |
| break; |
| case 'gain': |
| ed.update_gain(index, fv); |
| break; |
| } |
| } |
| |
| this.config = config; |
| } |
| |
| /* Appends the EQ UI for one channel to the specified parent */ |
| function eq_section(parent, channel) { |
| /* Two canvas, one for eq curve, another for fft. */ |
| var p = document.createElement('p'); |
| p.className = 'eq_curve_parent'; |
| |
| var canvas_eq = document.createElement('canvas'); |
| canvas_eq.className = 'eq_curve'; |
| canvas_eq.width = 960; |
| canvas_eq.height = 270; |
| |
| p.appendChild(canvas_eq); |
| var ed = new EqDrawer(canvas_eq, channel); |
| ed.init(); |
| |
| var canvas_fft = document.createElement('canvas'); |
| canvas_fft.className = 'eq_curve'; |
| canvas_fft.width = 960; |
| canvas_fft.height = 270; |
| |
| p.appendChild(canvas_fft); |
| var fd = new FFTDrawer(canvas_fft, channel); |
| fd.init(); |
| |
| parent.appendChild(p); |
| |
| /* Eq cards */ |
| var eq = {}; |
| for (var i = 0; i < NEQ; i++) { |
| eq[i] = new eq_card(parent, channel, i, ed); |
| } |
| |
| function config(name, value) { |
| var p = parseInt(name[0]); |
| var s = name.slice(1); |
| eq[p].config(s, value); |
| } |
| |
| this.config = config; |
| } |
| |
| function global_section(parent) { |
| var checkbox_data = [ |
| /* config name, text label, checkbox object */ |
| ['enable_drc', 'Enable DRC', null], |
| ['enable_eq', 'Enable EQ', null], |
| ['enable_fft', 'Show FFT', null], |
| ['enable_swap', 'Swap DRC/EQ', null] |
| ]; |
| |
| for (var i = 0; i < checkbox_data.length; i++) { |
| config_name = checkbox_data[i][0]; |
| text_label = checkbox_data[i][1]; |
| |
| var cb = document.createElement('input'); |
| cb.type = 'checkbox'; |
| cb.checked = get_global(config_name); |
| cb.onchange = function(name) { |
| return function() { toggle_global_checkbox(name, this.checked); } |
| }(config_name); |
| checkbox_data[i][2] = cb; |
| parent.appendChild(cb); |
| parent.appendChild(document.createTextNode(text_label)); |
| } |
| |
| function config(name, value) { |
| var i; |
| for (i = 0; i < checkbox_data.length; i++) { |
| if (checkbox_data[i][0] == name[0]) { |
| break; |
| } |
| } |
| if (i < checkbox_data.length) { |
| checkbox_data[i][2].checked = value; |
| } else { |
| console.log('invalid parameter: name =', name, 'value =', value); |
| } |
| } |
| |
| this.config = config; |
| } |
| |
| window.onload = function() { |
| fix_audio_elements(); |
| check_biquad_filter_q().then(function (flag) { |
| console.log('Browser biquad filter uses Audio Cookbook formula:', flag); |
| /* Detects if emphasis is disabled and sets |
| * browser_emphasis_disabled_detection_result. */ |
| get_emphasis_disabled(); |
| init_config(); |
| init_audio(); |
| init_ui(); |
| }).catch(function (reason) { |
| alert('Cannot detect browser biquad filter implementation:', reason); |
| }); |
| }; |
| |
| function init_ui() { |
| audio_ui = new ui(); |
| } |
| |
| /* Top-level UI */ |
| function ui() { |
| var global = new global_section(document.getElementById('global_section')); |
| var drc_div = document.getElementById('drc_section'); |
| var drc_cards = [ |
| new drc_card(drc_div, 0, 0, ''), |
| new drc_card(drc_div, 1, INIT_DRC_XO_LOW, 'Start From'), |
| new drc_card(drc_div, 2, INIT_DRC_XO_HIGH, 'Start From') |
| ]; |
| |
| var left_div = document.getElementById('eq_left_section'); |
| var right_div = document.getElementById('eq_right_section'); |
| var eq_sections = [ |
| new eq_section(left_div, 0), |
| new eq_section(right_div, 1) |
| ]; |
| |
| function config(name, value) { |
| var p = name[0]; |
| var i = parseInt(name[1]); |
| var s = name.slice(2); |
| if (p == 'global') { |
| global.config(name.slice(1), value); |
| } else if (p == 'drc') { |
| if (name[1] == 'emphasis_disabled') { |
| return; |
| } |
| drc_cards[i].config(s, value); |
| } else if (p == 'eq') { |
| eq_sections[i].config(s, value); |
| } else { |
| console.log('invalid parameter: name =', name, 'value =', value); |
| } |
| } |
| |
| this.config = config; |
| } |
| |
| /* Draws the DRC curve on a canvas. The update*() methods should be called when |
| * the parameters change, so the curve can be redrawn. */ |
| function DrcDrawer(canvas) { |
| var canvasContext = canvas.getContext('2d'); |
| |
| var backgroundColor = 'black'; |
| var curveColor = 'rgb(192,192,192)'; |
| var gridColor = 'rgb(200,200,200)'; |
| var textColor = 'rgb(238,221,130)'; |
| var thresholdColor = 'rgb(255,160,122)'; |
| |
| var dbThreshold = INIT_DRC_THRESHOLD; |
| var dbKnee = INIT_DRC_KNEE; |
| var ratio = INIT_DRC_RATIO; |
| var boost = INIT_DRC_BOOST; |
| |
| var curve_slope; |
| var curve_k; |
| var linearThreshold; |
| var kneeThresholdDb; |
| var kneeThreshold; |
| var ykneeThresholdDb; |
| var masterLinearGain; |
| |
| var maxOutputDb = 6; |
| var minOutputDb = -36; |
| |
| function xpixelToDb(x) { |
| /* This is right even though it looks like we should scale by width. We |
| * want the same pixel/dB scale for both. */ |
| var k = x / canvas.height; |
| var db = minOutputDb + k * (maxOutputDb - minOutputDb); |
| return db; |
| } |
| |
| function dBToXPixel(db) { |
| var k = (db - minOutputDb) / (maxOutputDb - minOutputDb); |
| var x = k * canvas.height; |
| return x; |
| } |
| |
| function ypixelToDb(y) { |
| var k = y / canvas.height; |
| var db = maxOutputDb - k * (maxOutputDb - minOutputDb); |
| return db; |
| } |
| |
| function dBToYPixel(db) { |
| var k = (maxOutputDb - db) / (maxOutputDb - minOutputDb); |
| var y = k * canvas.height; |
| return y; |
| } |
| |
| function kneeCurve(x, k) { |
| if (x < linearThreshold) |
| return x; |
| |
| return linearThreshold + |
| (1 - Math.exp(-k * (x - linearThreshold))) / k; |
| } |
| |
| function saturate(x, k) { |
| var y; |
| if (x < kneeThreshold) { |
| y = kneeCurve(x, k); |
| } else { |
| var xDb = linearToDb(x); |
| var yDb = ykneeThresholdDb + curve_slope * (xDb - kneeThresholdDb); |
| y = dBToLinear(yDb); |
| } |
| return y; |
| } |
| |
| function slopeAt(x, k) { |
| if (x < linearThreshold) |
| return 1; |
| var x2 = x * 1.001; |
| var xDb = linearToDb(x); |
| var x2Db = linearToDb(x2); |
| var yDb = linearToDb(kneeCurve(x, k)); |
| var y2Db = linearToDb(kneeCurve(x2, k)); |
| var m = (y2Db - yDb) / (x2Db - xDb); |
| return m; |
| } |
| |
| function kAtSlope(desiredSlope) { |
| var xDb = dbThreshold + dbKnee; |
| var x = dBToLinear(xDb); |
| |
| var minK = 0.1; |
| var maxK = 10000; |
| var k = 5; |
| |
| for (var i = 0; i < 15; i++) { |
| var slope = slopeAt(x, k); |
| if (slope < desiredSlope) { |
| maxK = k; |
| } else { |
| minK = k; |
| } |
| k = Math.sqrt(minK * maxK); |
| } |
| return k; |
| } |
| |
| function drawCurve() { |
| /* Update curve parameters */ |
| linearThreshold = dBToLinear(dbThreshold); |
| curve_slope = 1 / ratio; |
| curve_k = kAtSlope(1 / ratio); |
| kneeThresholdDb = dbThreshold + dbKnee; |
| kneeThreshold = dBToLinear(kneeThresholdDb); |
| ykneeThresholdDb = linearToDb(kneeCurve(kneeThreshold, curve_k)); |
| |
| /* Calculate masterLinearGain */ |
| var fullRangeGain = saturate(1, curve_k); |
| var fullRangeMakeupGain = Math.pow(1 / fullRangeGain, 0.6); |
| masterLinearGain = dBToLinear(boost) * fullRangeMakeupGain; |
| |
| /* Clear canvas */ |
| var width = canvas.width; |
| var height = canvas.height; |
| canvasContext.fillStyle = backgroundColor; |
| canvasContext.fillRect(0, 0, width, height); |
| |
| /* Draw linear response for reference. */ |
| canvasContext.strokeStyle = gridColor; |
| canvasContext.lineWidth = 1; |
| canvasContext.beginPath(); |
| canvasContext.moveTo(dBToXPixel(minOutputDb), dBToYPixel(minOutputDb)); |
| canvasContext.lineTo(dBToXPixel(maxOutputDb), dBToYPixel(maxOutputDb)); |
| canvasContext.stroke(); |
| |
| /* Draw 0dBFS output levels from 0dBFS down to -36dBFS */ |
| for (var dbFS = 0; dbFS >= -36; dbFS -= 6) { |
| canvasContext.beginPath(); |
| |
| var y = dBToYPixel(dbFS); |
| canvasContext.setLineDash([1, 4]); |
| canvasContext.moveTo(0, y); |
| canvasContext.lineTo(width, y); |
| canvasContext.stroke(); |
| canvasContext.setLineDash([]); |
| |
| canvasContext.textAlign = 'center'; |
| canvasContext.strokeStyle = textColor; |
| canvasContext.strokeText(dbFS.toFixed(0) + ' dB', 15, y - 2); |
| canvasContext.strokeStyle = gridColor; |
| } |
| |
| /* Draw 0dBFS input line */ |
| canvasContext.beginPath(); |
| canvasContext.moveTo(dBToXPixel(0), 0); |
| canvasContext.lineTo(dBToXPixel(0), height); |
| canvasContext.stroke(); |
| canvasContext.strokeText('0dB', dBToXPixel(0), height); |
| |
| /* Draw threshold input line */ |
| canvasContext.beginPath(); |
| canvasContext.moveTo(dBToXPixel(dbThreshold), 0); |
| canvasContext.lineTo(dBToXPixel(dbThreshold), height); |
| canvasContext.moveTo(dBToXPixel(kneeThresholdDb), 0); |
| canvasContext.lineTo(dBToXPixel(kneeThresholdDb), height); |
| canvasContext.strokeStyle = thresholdColor; |
| canvasContext.stroke(); |
| |
| /* Draw the compressor curve */ |
| canvasContext.strokeStyle = curveColor; |
| canvasContext.lineWidth = 3; |
| |
| canvasContext.beginPath(); |
| var pixelsPerDb = (0.5 * height) / 40.0; |
| |
| for (var x = 0; x < width; ++x) { |
| var inputDb = xpixelToDb(x); |
| var inputLinear = dBToLinear(inputDb); |
| var outputLinear = saturate(inputLinear, curve_k); |
| outputLinear *= masterLinearGain; |
| var outputDb = linearToDb(outputLinear); |
| var y = dBToYPixel(outputDb); |
| |
| canvasContext.lineTo(x, y); |
| } |
| canvasContext.stroke(); |
| |
| } |
| |
| function init() { |
| drawCurve(); |
| } |
| |
| function update_threshold(v) |
| { |
| dbThreshold = v; |
| drawCurve(); |
| } |
| |
| function update_knee(v) |
| { |
| dbKnee = v; |
| drawCurve(); |
| } |
| |
| function update_ratio(v) |
| { |
| ratio = v; |
| drawCurve(); |
| } |
| |
| function update_boost(v) |
| { |
| boost = v; |
| drawCurve(); |
| } |
| |
| this.init = init; |
| this.update_threshold = update_threshold; |
| this.update_knee = update_knee; |
| this.update_ratio = update_ratio; |
| this.update_boost = update_boost; |
| } |
| |
| /* Draws the EQ curve on a canvas. The update*() methods should be called when |
| * the parameters change, so the curve can be redrawn. */ |
| function EqDrawer(canvas, channel) { |
| var canvasContext = canvas.getContext('2d'); |
| var curveColor = 'rgb(192,192,192)'; |
| var gridColor = 'rgb(200,200,200)'; |
| var textColor = 'rgb(238,221,130)'; |
| var centerFreq = {}; |
| var q = {}; |
| var gain = {}; |
| |
| for (var i = 0; i < NEQ; i++) { |
| centerFreq[i] = INIT_EQ_FREQ; |
| q[i] = INIT_EQ_Q; |
| gain[i] = INIT_EQ_GAIN; |
| } |
| |
| function drawCurve() { |
| /* Create a biquad node to calculate frequency response. */ |
| var filter = audioContext.createBiquadFilter(); |
| var width = canvas.width; |
| var height = canvas.height; |
| var pixelsPerDb = height / 48.0; |
| var noctaves = 10; |
| |
| /* Prepare the frequency array */ |
| var frequencyHz = new Float32Array(width); |
| for (var i = 0; i < width; ++i) { |
| var f = i / width; |
| |
| /* Convert to log frequency scale (octaves). */ |
| f = Math.pow(2.0, noctaves * (f - 1.0)); |
| frequencyHz[i] = f * nyquist; |
| } |
| |
| /* Get the response */ |
| var magResponse = new Float32Array(width); |
| var phaseResponse = new Float32Array(width); |
| var totalMagResponse = new Float32Array(width); |
| |
| for (var i = 0; i < width; i++) { |
| totalMagResponse[i] = 1; |
| } |
| |
| for (var i = 0; i < NEQ; i++) { |
| if (!get_config('eq', channel, i, 'enable')) { |
| continue; |
| } |
| filter.type = get_config('eq', channel, i, 'type'); |
| filter.frequency.value = centerFreq[i]; |
| if (filter.type == 'lowpass' || filter.type == 'highpass') |
| filter.Q.value = make_biquad_q(q[i]); |
| else |
| filter.Q.value = q[i]; |
| filter.gain.value = gain[i]; |
| filter.getFrequencyResponse(frequencyHz, magResponse, |
| phaseResponse); |
| for (var j = 0; j < width; j++) { |
| totalMagResponse[j] *= magResponse[j]; |
| } |
| } |
| |
| /* Draw the response */ |
| canvasContext.fillStyle = 'rgb(0, 0, 0)'; |
| canvasContext.fillRect(0, 0, width, height); |
| canvasContext.strokeStyle = curveColor; |
| canvasContext.lineWidth = 3; |
| canvasContext.beginPath(); |
| |
| for (var i = 0; i < width; ++i) { |
| var response = totalMagResponse[i]; |
| var dbResponse = linearToDb(response); |
| |
| var x = i; |
| var y = height - (dbResponse + 24) * pixelsPerDb; |
| |
| canvasContext.lineTo(x, y); |
| } |
| canvasContext.stroke(); |
| |
| /* Draw frequency scale. */ |
| canvasContext.beginPath(); |
| canvasContext.lineWidth = 1; |
| canvasContext.strokeStyle = gridColor; |
| |
| for (var octave = 0; octave <= noctaves; octave++) { |
| var x = octave * width / noctaves; |
| |
| canvasContext.moveTo(x, 30); |
| canvasContext.lineTo(x, height); |
| canvasContext.stroke(); |
| |
| var f = nyquist * Math.pow(2.0, octave - noctaves); |
| canvasContext.textAlign = 'center'; |
| canvasContext.strokeText(f.toFixed(0) + 'Hz', x, 20); |
| } |
| |
| /* Draw 0dB line. */ |
| canvasContext.beginPath(); |
| canvasContext.moveTo(0, 0.5 * height); |
| canvasContext.lineTo(width, 0.5 * height); |
| canvasContext.stroke(); |
| |
| /* Draw decibel scale. */ |
| for (var db = -24.0; db < 24.0; db += 6) { |
| var y = height - (db + 24) * pixelsPerDb; |
| canvasContext.beginPath(); |
| canvasContext.setLineDash([1, 4]); |
| canvasContext.moveTo(0, y); |
| canvasContext.lineTo(width, y); |
| canvasContext.stroke(); |
| canvasContext.setLineDash([]); |
| canvasContext.strokeStyle = textColor; |
| canvasContext.strokeText(db.toFixed(0) + 'dB', width - 20, y); |
| canvasContext.strokeStyle = gridColor; |
| } |
| } |
| |
| function update_freq(index, v) { |
| centerFreq[index] = v; |
| drawCurve(); |
| } |
| |
| function update_q(index, v) { |
| q[index] = v; |
| drawCurve(); |
| } |
| |
| function update_gain(index, v) { |
| gain[index] = v; |
| drawCurve(); |
| } |
| |
| function update_enable(index, v) { |
| drawCurve(); |
| } |
| |
| function update_type(index, v) { |
| drawCurve(); |
| } |
| |
| function init() { |
| drawCurve(); |
| } |
| |
| this.init = init; |
| this.update_freq = update_freq; |
| this.update_q = update_q; |
| this.update_gain = update_gain; |
| this.update_enable = update_enable; |
| this.update_type = update_type; |
| } |
| |
| /* Draws the FFT curve on a canvas. This will update continuously when the audio |
| * is playing. */ |
| function FFTDrawer(canvas, channel) { |
| var canvasContext = canvas.getContext('2d'); |
| var curveColor = 'rgb(255,160,122)'; |
| var binCount = FFT_SIZE / 2; |
| var data = new Float32Array(binCount); |
| |
| function drawCurve() { |
| var width = canvas.width; |
| var height = canvas.height; |
| var pixelsPerDb = height / 96.0; |
| |
| canvasContext.clearRect(0, 0, width, height); |
| |
| /* Get the proper analyzer from the audio graph */ |
| var analyzer = (channel == 0) ? analyzer_left : analyzer_right; |
| if (!analyzer || !get_global('enable_fft')) { |
| requestAnimationFrame(drawCurve); |
| return; |
| } |
| |
| /* Draw decibel scale. */ |
| for (var db = -96.0; db <= 0; db += 12) { |
| var y = height - (db + 96) * pixelsPerDb; |
| canvasContext.strokeStyle = curveColor; |
| canvasContext.strokeText(db.toFixed(0) + 'dB', 10, y); |
| } |
| |
| /* Draw FFT */ |
| analyzer.getFloatFrequencyData(data); |
| canvasContext.beginPath(); |
| canvasContext.lineWidth = 1; |
| canvasContext.strokeStyle = curveColor; |
| canvasContext.moveTo(0, height); |
| |
| var frequencyHz = new Float32Array(width); |
| for (var i = 0; i < binCount; ++i) { |
| var f = i / binCount; |
| |
| /* Convert to log frequency scale (octaves). */ |
| var noctaves = 10; |
| f = 1 + Math.log(f) / (noctaves * Math.LN2); |
| |
| /* Draw the magnitude */ |
| var x = f * width; |
| var y = height - (data[i] + 96) * pixelsPerDb; |
| |
| canvasContext.lineTo(x, y); |
| } |
| |
| canvasContext.stroke(); |
| requestAnimationFrame(drawCurve); |
| } |
| |
| function init() { |
| requestAnimationFrame(drawCurve); |
| } |
| |
| this.init = init; |
| } |
| |
| function dBToLinear(db) { |
| return Math.pow(10.0, 0.05 * db); |
| } |
| |
| function linearToDb(x) { |
| return 20.0 * Math.log(x) / Math.LN10; |
| } |