User:Plumtree/common.js

From Xenharmonic Wiki
Jump to navigation Jump to search

Note: After saving, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Go to Menu → Settings (Opera → Preferences on a Mac) and then to Privacy & security → Clear browsing data → Cached images and files.
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]);
		});
	});
}