User:Xenwolf/common.js: Difference between revisions
Jump to navigation
Jump to search
test in-browser sound generation |
copy from User:Plumtree/common.js |
||
| Line 67: | Line 67: | ||
node.setPeriodicWave(wave); | node.setPeriodicWave(wave); | ||
node.start(); | node.start(); | ||
node = limit(node, duration); | if (shift >= 0){ | ||
node.stop(audio.currentTime + duration / 1000 + 0.1); | |||
node = limit(node, duration); | |||
node = delay(node, shift); | |||
} else { | |||
node.stop(audio.currentTime + (shift + duration) / 1000 + 0.1); | |||
node = limit(node, shift + duration); | |||
} | |||
node = set_gain(node, gain); | node = set_gain(node, gain); | ||
return node; | return node; | ||
| Line 103: | Line 110: | ||
// parse and play a sequence | // parse and play a sequence | ||
function demonstrate_sequence(s, custom_spectra){ | function demonstrate_sequence(s, custom_spectra, start_from){ | ||
var seq = parse_sequence(s); | var seq = parse_sequence(s); | ||
| Line 117: | Line 124: | ||
var notes = []; | var notes = []; | ||
var total_duration = 0; | var total_duration = 0; | ||
var total_shift = 0; | var total_shift = -(start_from || 0); | ||
for (var i = 0; i < seq.length; i++){ | for (var i = 0; i < seq.length; i++){ | ||
if (seq[i].wait){ | if (seq[i].wait){ | ||
total_shift += seq[i].dur; | total_shift += seq[i].dur; | ||
} else { | } else if (total_shift + seq[i].shift + seq[i].dur > 0) { | ||
notes.push(play_frequency( | notes.push(play_frequency( | ||
seq[i].freq, | seq[i].freq, | ||
| Line 137: | Line 144: | ||
connect_all(notes, total_duration); | connect_all(notes, total_duration); | ||
return total_duration; | var stop = function(){ | ||
for (var i = 0; i < notes.length; i++) | |||
notes[i].disconnect(); | |||
}; | |||
return [total_duration, stop]; | |||
} | } | ||
| Line 144: | Line 156: | ||
if (!button.hasAttribute('data-sequence')){ | if (!button.hasAttribute('data-sequence')){ | ||
console.error('Audio element requires "data-sequence" attribute to be set!'); | console.error('Audio element requires "data-sequence" attribute to be set!'); | ||
console.log(button); | console.log(button); | ||
button.classList.add('sequence-audio-invalid'); | button.classList.add('sequence-audio-invalid'); | ||
| Line 167: | Line 173: | ||
// indicate that audio JS has been loaded | // indicate that audio JS has been loaded | ||
button.classList.add('sequence-audio-valid') | button.classList.add('sequence-audio-valid') | ||
var start_time = null; | |||
var paused_at = null; | |||
var stop_function = null; | |||
button.addEventListener('click', function(){ | button.addEventListener('click', function(){ | ||
// should the button lock while the sequence is playing? | // should the button lock while the sequence is playing? | ||
var lock = button.hasAttribute('data-lock'); | var lock = button.hasAttribute('data-lock'); | ||
if (lock && button. | // should the sequence be paused instead of being stopped on interruptions? | ||
// | var pause = button.hasAttribute('data-pause'); | ||
if (lock && button.classList.contains('sequence-audio-playing')){ | |||
if (pause){ | |||
// try pausing the sequence | |||
if (start_time !== null){ | |||
const end_time = new Date(); | |||
if (paused_at !== null){ | |||
paused_at += end_time.getTime() - start_time.getTime(); | |||
} else { | |||
paused_at = end_time.getTime() - start_time.getTime(); | |||
} | |||
start_time = null; | |||
console.log('Pausing at ' + paused_at); | |||
} | |||
if (typeof stop_function === 'function'){ | |||
stop_function(); | |||
stop_function = null; | |||
button.classList.add('sequence-audio-paused'); | |||
} else { | |||
console.error('Could not pause the sequence.'); | |||
} | |||
} else { | |||
// try stopping the sequence | |||
if (typeof stop_function === 'function'){ | |||
stop_function(); | |||
stop_function = null; | |||
} else { | |||
console.error('Could not stop the sequence.'); | |||
} | |||
} | |||
} else { | } else { | ||
if (lock){ | if (lock){ | ||
button.classList.add('sequence-audio-playing'); | button.classList.add('sequence-audio-playing'); | ||
button.classList.remove('sequence-audio-paused'); | |||
} | } | ||
var s = button.getAttribute('data-sequence'); | var s = button.getAttribute('data-sequence'); | ||
| Line 192: | Line 231: | ||
console.log('Custom spectra:', custom_spectra); | console.log('Custom spectra:', custom_spectra); | ||
console.log('Playing sequence: ' + s); | console.log('Playing sequence: ' + s); | ||
var | if (paused_at !== null){ | ||
console.log('Starting from:' + paused_at) | |||
} | |||
var result = demonstrate_sequence(s, custom_spectra, paused_at); | |||
var duration = result[0]; | |||
var stop = result[1]; | |||
start_time = new Date(); | |||
if (lock){ | if (lock){ | ||
setTimeout(function(){ | const cleanup = setTimeout(function(){ | ||
button.classList.remove('sequence-audio-playing'); | button.classList.remove('sequence-audio-playing'); | ||
paused_at = null; | |||
}, duration); | }, duration); | ||
stop_function = function(){ | |||
stop(); | |||
clearTimeout(cleanup); | |||
button.classList.remove('sequence-audio-playing'); | |||
} | |||
} | } | ||
} | } | ||
}); | }); | ||
}); | }); | ||
const timbre_selectors = document.querySelectorAll('.sequence-audio-timbre-selector'); | |||
if (timbre_selectors.length > 0){ | |||
// loading OOUI | |||
mw.loader.using(['oojs-ui-core', 'oojs-ui-widgets']).done(function(){ | |||
timbre_selectors.forEach(function(element){ | |||
var targets = []; | |||
for (var i = 0; i < element.attributes.length; i++){ | |||
if (element.attributes[i].name.startsWith('data-target')){ | |||
const target_id = element.attributes[i].value; | |||
const target = document.getElementById(target_id); | |||
if (target === null){ | |||
console.error('Timbre selector requires each "data-target" to point to a valid HTML element!'); | |||
console.log(element); | |||
continue; | |||
} | |||
if (!target.classList.contains('sequence-audio')){ | |||
console.error('Timbre selector requires each "data-target" to point to a sequence-audio element!') | |||
console.log(element); | |||
continue; | |||
} | |||
if (!target.classList.contains('sequence-audio-valid')){ | |||
console.error('Timbre selector requires each "data-target" to point to a valid sequence-audio element!') | |||
console.log(element); | |||
continue; | |||
} | |||
targets.push(target); | |||
} | |||
} | |||
var instruments = Object.keys(builtin_spectra); | |||
for (var i = 0; i < targets.length; i++){ | |||
for (var j = 0; j < targets[i].attributes.length; j++){ | |||
const name = targets[i].attributes[j].name; | |||
if (name.startsWith('data-timbre-')){ | |||
const instrument = name.substring(12); | |||
if (!instruments.includes(instrument)) | |||
instruments.push(instrument); | |||
} | |||
} | |||
} | |||
var default_instrument = null; | |||
var storage_key = null; | |||
if (element.hasAttribute('data-key')){ | |||
storage_key = element.getAttribute('data-key'); | |||
const storage_value = window.localStorage.getItem('timbre-' + storage_key); | |||
if (storage_value && instruments.includes(storage_value)){ | |||
default_instrument = storage_value; | |||
} | |||
} | |||
if (!default_instrument && element.hasAttribute('data-default')){ | |||
const attribute_value = element.getAttribute('data-default'); | |||
if (instruments.includes(attribute_value)){ | |||
default_instrument = attribute_value; | |||
} | |||
} | |||
if (!default_instrument){ | |||
default_instrument = 'sine'; | |||
} | |||
var options = []; | |||
for (var i = 0; i < instruments.length; i++){ | |||
options.push(new OO.ui.ButtonOptionWidget({ | |||
data: i, | |||
label: instruments[i] | |||
})); | |||
} | |||
var selector = new OO.ui.ButtonSelectWidget({ | |||
items: options | |||
}); | |||
selector.on('select', function(item){ | |||
const instrument = item.label; | |||
console.log('Switching to ' + instrument); | |||
if (storage_key){ | |||
window.localStorage.setItem('timbre-' + storage_key, instrument); | |||
} | |||
for (var i = 0; i < targets.length; i++){ | |||
var seq = parse_sequence(targets[i].getAttribute('data-sequence')); | |||
var s = ''; | |||
for (var j = 0; j < seq.length; j++){ | |||
if (j > 0){ s += ' '; } | |||
if (seq[j].wait){ | |||
s += 'wait:' + seq[j].dur; | |||
} else { | |||
s += seq[j].freq + ':' + seq[j].dur + ':' + seq[j].gain + ':' + seq[j].shift + ':' + instrument | |||
} | |||
} | |||
console.log(s); | |||
targets[i].setAttribute('data-sequence', s); | |||
} | |||
}); | |||
selector.selectItemByLabel(default_instrument); | |||
element.replaceWith(selector.$element[0]); | |||
}); | |||
}); | |||
} | |||
Latest revision as of 08:20, 7 November 2022
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audio = new AudioContext();
// the first element should be 0
const builtin_spectra = {
sine: [0, 1],
semisine: [0],
flute: [0, 1, 0.55, 0.2, 0.2, 0.05],
violin: [0, 1, 0.5, 0.45, 0.5, 0.7, 0.6, 0.75, 0.6, 0.45, 0.35, 0.25, 0.05, 0.2, 0.15],
};
for (var i = 1; i <= 100; i++)
builtin_spectra.semisine.push(1 / (i * i));
function construct_wave(harmonic_spectrum){
var zeros = new Array(harmonic_spectrum.length).fill(0);
return audio.createPeriodicWave(harmonic_spectrum, zeros);
}
// delay `node` by `duration` ms
function delay(node, duration){
var delay = audio.createDelay(120);
delay.delayTime.value = duration / 1000;
node.connect(delay);
return delay;
}
// connect `nodes` to audio destination and schedule a disconnect after `duration` ms
function connect_all(nodes, duration){
for (var i = 0; i < nodes.length; i++)
nodes[i].connect(audio.destination);
setTimeout(function(){
for (var i = 0; i < nodes.length; i++)
nodes[i].disconnect();
}, duration);
}
// limit `node` to ~`duration` ms, also filtering out frequencies >= 20000Hz
function limit(node, duration){
var gain = audio.createGain();
gain.gain.value = 0;
gain.gain.linearRampToValueAtTime(1, audio.currentTime + 0.05);
gain.gain.linearRampToValueAtTime(1, audio.currentTime + duration / 1000);
gain.gain.exponentialRampToValueAtTime(0.00001, audio.currentTime + duration / 1000 + 0.05);
node.connect(gain);
node = gain;
var filter = audio.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 20000;
node.connect(filter);
return filter;
}
// set gain of the `node` to `value`
function set_gain(node, value){
var gain = audio.createGain();
gain.gain.value = value;
node.connect(gain);
return gain;
}
// construct an oscillator sound active for `duration` ms after `shift` ms
function play_frequency(frequency, duration, shift, gain, wave){
var node = audio.createOscillator();
node.frequency.value = frequency;
node.setPeriodicWave(wave);
node.start();
if (shift >= 0){
node.stop(audio.currentTime + duration / 1000 + 0.1);
node = limit(node, duration);
node = delay(node, shift);
} else {
node.stop(audio.currentTime + (shift + duration) / 1000 + 0.1);
node = limit(node, shift + duration);
}
node = set_gain(node, gain);
return node;
}
// whitespace-separated tokens which are either:
// * wait:DURATION - increase global time shift by DURATION ms.
// * FREQUENCY:DURATION:GAIN[:SHIFT[:INSTRUMENT]] - see play_frequency().
// SHIFT here is local time shift, global time shift will remain intact. Default: 0.
// INSTRUMENT is the name of the corresponding instrument. Default: sine.
function parse_sequence(s){
var output = [];
var notes = s.split(/\s+/);
for (var i = 0; i < notes.length; i++){
var values = notes[i].split(':');
var note_data = {};
if (values[0] == 'wait'){
note_data.wait = true;
note_data.dur = parseInt(values[1]); // duration in ms
} else {
note_data.freq = parseFloat(values[0]); // frequency
note_data.dur = parseInt(values[1]); // duration in ms
note_data.gain = parseFloat(values[2]); // gain
note_data.shift = parseInt(values[3] || '0'); // shift in ms
note_data.instrument = values[4] || 'sine';
}
output.push(note_data);
}
return output;
}
// parse and play a sequence
function demonstrate_sequence(s, custom_spectra, start_from){
var seq = parse_sequence(s);
var waves = {};
for (var i = 0; i < seq.length; i++){
if (seq[i].instrument && waves[seq[i].instrument] === undefined){
waves[seq[i].instrument] = construct_wave(
builtin_spectra[seq[i].instrument] || custom_spectra[seq[i].instrument]
);
}
}
var notes = [];
var total_duration = 0;
var total_shift = -(start_from || 0);
for (var i = 0; i < seq.length; i++){
if (seq[i].wait){
total_shift += seq[i].dur;
} else if (total_shift + seq[i].shift + seq[i].dur > 0) {
notes.push(play_frequency(
seq[i].freq,
seq[i].dur,
total_shift + seq[i].shift,
seq[i].gain,
waves[seq[i].instrument]
));
if (total_shift + seq[i].shift + seq[i].dur > total_duration)
total_duration = total_shift + seq[i].shift + seq[i].dur;
}
}
total_duration += 100;
connect_all(notes, total_duration);
var stop = function(){
for (var i = 0; i < notes.length; i++)
notes[i].disconnect();
};
return [total_duration, stop];
}
document.querySelectorAll('.sequence-audio').forEach(function(button){
// error handling
if (!button.hasAttribute('data-sequence')){
console.error('Audio element requires "data-sequence" attribute to be set!');
console.log(button);
button.classList.add('sequence-audio-invalid');
return;
}
var seq = parse_sequence(button.getAttribute('data-sequence'));
for (var i = 0; i < seq.length; i++){
if (seq[i].instrument && builtin_spectra[seq[i].instrument] === undefined){
if (!button.hasAttribute('data-timbre-' + seq[i].instrument)){
console.error('Audio element requires "data-timbre-' + seq[i].instrument + '" attribute to be set!');
console.log(button);
button.classList.add('sequence-audio-invalid');
return;
}
}
}
// indicate that audio JS has been loaded
button.classList.add('sequence-audio-valid')
var start_time = null;
var paused_at = null;
var stop_function = null;
button.addEventListener('click', function(){
// should the button lock while the sequence is playing?
var lock = button.hasAttribute('data-lock');
// should the sequence be paused instead of being stopped on interruptions?
var pause = button.hasAttribute('data-pause');
if (lock && button.classList.contains('sequence-audio-playing')){
if (pause){
// try pausing the sequence
if (start_time !== null){
const end_time = new Date();
if (paused_at !== null){
paused_at += end_time.getTime() - start_time.getTime();
} else {
paused_at = end_time.getTime() - start_time.getTime();
}
start_time = null;
console.log('Pausing at ' + paused_at);
}
if (typeof stop_function === 'function'){
stop_function();
stop_function = null;
button.classList.add('sequence-audio-paused');
} else {
console.error('Could not pause the sequence.');
}
} else {
// try stopping the sequence
if (typeof stop_function === 'function'){
stop_function();
stop_function = null;
} else {
console.error('Could not stop the sequence.');
}
}
} else {
if (lock){
button.classList.add('sequence-audio-playing');
button.classList.remove('sequence-audio-paused');
}
var s = button.getAttribute('data-sequence');
var custom_spectra = {};
for (var i = 0; i < button.attributes.length; i++){
if (button.attributes[i].name.startsWith('data-timbre-')){
var key = button.attributes[i].name.substring(12);
var val = button.attributes[i].value.split(/\s+/);
for (var j = 0; j < val.length; j++)
val[j] = parseFloat(val[j]);
custom_spectra[key] = val;
}
}
console.log('Custom spectra:', custom_spectra);
console.log('Playing sequence: ' + s);
if (paused_at !== null){
console.log('Starting from:' + paused_at)
}
var result = demonstrate_sequence(s, custom_spectra, paused_at);
var duration = result[0];
var stop = result[1];
start_time = new Date();
if (lock){
const cleanup = setTimeout(function(){
button.classList.remove('sequence-audio-playing');
paused_at = null;
}, duration);
stop_function = function(){
stop();
clearTimeout(cleanup);
button.classList.remove('sequence-audio-playing');
}
}
}
});
});
const timbre_selectors = document.querySelectorAll('.sequence-audio-timbre-selector');
if (timbre_selectors.length > 0){
// loading OOUI
mw.loader.using(['oojs-ui-core', 'oojs-ui-widgets']).done(function(){
timbre_selectors.forEach(function(element){
var targets = [];
for (var i = 0; i < element.attributes.length; i++){
if (element.attributes[i].name.startsWith('data-target')){
const target_id = element.attributes[i].value;
const target = document.getElementById(target_id);
if (target === null){
console.error('Timbre selector requires each "data-target" to point to a valid HTML element!');
console.log(element);
continue;
}
if (!target.classList.contains('sequence-audio')){
console.error('Timbre selector requires each "data-target" to point to a sequence-audio element!')
console.log(element);
continue;
}
if (!target.classList.contains('sequence-audio-valid')){
console.error('Timbre selector requires each "data-target" to point to a valid sequence-audio element!')
console.log(element);
continue;
}
targets.push(target);
}
}
var instruments = Object.keys(builtin_spectra);
for (var i = 0; i < targets.length; i++){
for (var j = 0; j < targets[i].attributes.length; j++){
const name = targets[i].attributes[j].name;
if (name.startsWith('data-timbre-')){
const instrument = name.substring(12);
if (!instruments.includes(instrument))
instruments.push(instrument);
}
}
}
var default_instrument = null;
var storage_key = null;
if (element.hasAttribute('data-key')){
storage_key = element.getAttribute('data-key');
const storage_value = window.localStorage.getItem('timbre-' + storage_key);
if (storage_value && instruments.includes(storage_value)){
default_instrument = storage_value;
}
}
if (!default_instrument && element.hasAttribute('data-default')){
const attribute_value = element.getAttribute('data-default');
if (instruments.includes(attribute_value)){
default_instrument = attribute_value;
}
}
if (!default_instrument){
default_instrument = 'sine';
}
var options = [];
for (var i = 0; i < instruments.length; i++){
options.push(new OO.ui.ButtonOptionWidget({
data: i,
label: instruments[i]
}));
}
var selector = new OO.ui.ButtonSelectWidget({
items: options
});
selector.on('select', function(item){
const instrument = item.label;
console.log('Switching to ' + instrument);
if (storage_key){
window.localStorage.setItem('timbre-' + storage_key, instrument);
}
for (var i = 0; i < targets.length; i++){
var seq = parse_sequence(targets[i].getAttribute('data-sequence'));
var s = '';
for (var j = 0; j < seq.length; j++){
if (j > 0){ s += ' '; }
if (seq[j].wait){
s += 'wait:' + seq[j].dur;
} else {
s += seq[j].freq + ':' + seq[j].dur + ':' + seq[j].gain + ':' + seq[j].shift + ':' + instrument
}
}
console.log(s);
targets[i].setAttribute('data-sequence', s);
}
});
selector.selectItemByLabel(default_instrument);
element.replaceWith(selector.$element[0]);
});
});
}