User:Xenwolf/common.js

Revision as of 18:28, 26 October 2022 by Xenwolf (talk | contribs) (test in-browser sound generation)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Note: After publishing, 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)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
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();
	node.stop(audio.currentTime + duration / 1000 + 0.1);
	
	node = limit(node, duration);
	node = set_gain(node, gain);
	node = delay(node, shift);
	
	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){
	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 = 0;
	for (var i = 0; i < seq.length; i++){
		if (seq[i].wait){
			total_shift += seq[i].dur;
		} else {
			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);
	
	return total_duration;
}

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;
	}
	if (button.hasAttribute('data-playing')){
		console.error('Audio element should not have "data-playing" attribute 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')
	
	button.addEventListener('click', function(){
		// should the button lock while the sequence is playing?
		var lock = button.hasAttribute('data-lock');
		if (lock && button.hasAttribute('data-playing')){
			// do nothing
		} else {
			if (lock){
				button.setAttribute('data-playing', 'true');
				button.classList.add('sequence-audio-playing');
			}
			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);
			var duration = demonstrate_sequence(s, custom_spectra);
			
			if (lock){
				setTimeout(function(){
					button.classList.remove('sequence-audio-playing');
					button.removeAttribute('data-playing');
				}, duration);
			}
		}
	});
});