1995 lines
53 KiB
JavaScript
1995 lines
53 KiB
JavaScript
/* 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 empty() {
|
|
}
|
|
|
|
/* 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, empty);
|
|
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 mainLinearGain;
|
|
|
|
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 mainLinearGain */
|
|
var fullRangeGain = saturate(1, curve_k);
|
|
var fullRangeMakeupGain = Math.pow(1 / fullRangeGain, 0.6);
|
|
mainLinearGain = 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 *= mainLinearGain;
|
|
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;
|
|
}
|