From: Jason Woofenden Date: Tue, 7 Feb 2017 01:53:44 +0000 (-0500) Subject: initial work X-Git-Url: https://jasonwoof.com/gitweb/?p=mimpro.git;a=commitdiff_plain;h=eae73a6864979517847a4f563b19796c18a06ccf initial work --- diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..717ec6f --- /dev/null +++ b/.htaccess @@ -0,0 +1 @@ +DirectoryIndex index.html diff --git a/index.html b/index.html new file mode 100644 index 0000000..83e615a --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + Experiments with compter generated music + + + +

Experiments with compter generated music

+

+ + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..bb244b0 --- /dev/null +++ b/main.js @@ -0,0 +1,707 @@ +// SETTINGS +const INITIAL_LEAD_TIME = 0.05 // seconds delay at start of program for processing +const LEAD_TIME = 6.0 // seconds ahead of time to queue up notes +const BEAT_LENGTH = 0.5 // seconds per beat +const INTERVAL_GOODNESS = [ // when played simultaniously + 2, // octave + 0.0, // 2nd/7th + 1.4, // 3rd/6th + 1.8, // 4th/5th + 1.8, // 4th/5th + 1.6, // 3rd/6th + 0.0, // 2nd/7th +] +const BEAT_LOUDNESS = [ + 0.10, + 0.07, + 0.09, + 0.05, +] + +function timeout (s, cb) { + return setTimeout(cb, s * 1000) +} + +// This can make one musical note at a time +class NoteChannel { + constructor (ctx) { + this.ctx = ctx + this.done_at = 0 + this.gain = ctx.createGain() + this.gain.gain.setValueAtTime(0.00001, ctx.currentTime) + this.oscillator = ctx.createOscillator() + } + start () { + this.oscillator.connect(this.gain) + this.oscillator.start() + } + play (args) { + let duration = args.duration || 1 + let amplitude = args.amplitude || 0.3 + let grow, decay, end + if (args.grow != null) { + grow = args.grow + } else { + if (duration >= 0.1) { + grow = 0.05 + } else { + grow = duration / 2 + } + grow *= 0.7 + 440 / args.frequency * 0.6 + } + if (args.decay != null) { + decay = args.decay + } else { + if (duration >= 1) { + decay = 0.5 + } else { + decay = duration / 2 + } + decay *= 0.7 + args.frequency / 440 * 0.6 + } + if (grow + decay / 2 >= duration) { + end = args.time + grow + decay + } else { + end = args.time + duration + decay / 2 + } + this.done_at = end + this.oscillator.frequency.setValueAtTime(args.frequency, args.time) + this.gain.gain.setValueAtTime(0.00001, args.time) + this.gain.gain.exponentialRampToValueAtTime(amplitude, args.time + grow) + this.gain.gain.setValueAtTime(amplitude, end - decay) + this.gain.gain.exponentialRampToValueAtTime(0.00001, end) + } +} + +// This can play as many notes as you like at once +class NotePlayer { + constructor (args) { + if (args == null) args = {} + if (args.context != null) { + this.ctx = args.context + } else if (window.AudioContext) { + this.ctx = new AudioContext() + } else if (window.webkitAudioContext) { + this.ctx = new webkitAudioContext() + } else { + console.log("Your browser doesn't support Web Audio") + throw("heck"); + } + if (args.epoc) { + this.epoc = args.epoc + } else { + this.epoc = this.ctx.currentTime + } + this.channels = [] + } + // return a channel that can be used starting at time + get_channel (time) { + let i + for (i in this.channels) { + if (time >= this.channels[i].done_at) { + return this.channels[i] + } + } + i = this.channels.length + this.channels[i] = new NoteChannel(this.ctx) + this.channels[i].gain.connect(this.ctx.destination) + this.channels[i].start() + // TODO use AudioContext.createDynamicsCompressor() to avoid clipping + return this.channels[i] + } + // required args: frequency, time + play (args) { + let channel = this.get_channel(args.time) + channel.play(args) + } + stop () { + while (this.channels.length) { + this.channels[0].gain.disconnect() + this.channels.shift() + } + } +} + +// equal temperment: multiply by this to go up a half step +const HALF_STEP = Math.pow(2, 1/12) +const LOW_D = 110 * Math.pow(2, 5/12) +let half_steps = [] +for (let f = LOW_D, i = 0; i < 37; f *= HALF_STEP, ++i) { + half_steps.push(f) +} +let scale = [ + half_steps[0], + half_steps[2], + half_steps[4], + half_steps[5], + half_steps[7], + half_steps[9], + half_steps[11], + half_steps[12], + half_steps[14], + half_steps[16], + half_steps[17], + half_steps[19], + half_steps[21], + half_steps[23], + half_steps[24], + half_steps[26], + half_steps[28], + half_steps[29], + half_steps[31], + half_steps[33], + half_steps[35], + half_steps[36], +] + +let CHR_a = 'a'.charCodeAt(0) + +class Choice { + constructor(value, priority) { + this.value = value + this.priority = priority + } +} + +class NoteInScaleChoice extends Choice { + constructor(value, priority) { + super(value, priority) + this.freq = scale[value] + } +} + + +class setting { + constructor (...args) { + this.setting = new Map() + this.locked = false + this.chosen = null + this.chosen_key = '' + this.total_priority = 0 + this.relationships = new Set() + this.init(...args) + } + set_to (key) { + if (this.chosen_key === key) { + return + } + this.chosen_key = key + this.chosen = this.setting.get(key).choice + } + choose () { + if (this.locked) { + return this.chosen_key + } + if (this.setting.length === 1) { + this.chosen = this.setting.get('a').value + return this.chosen_key = 'a' + } + let win = Math.random() * this.total_priority - 0.000001 + let total = 0 + for (let [key, c] of this.setting) { + total += c.priority + if (total > win) { + this.chosen = c.choice + return this.chosen_key = c.key + } + } + return 'WTF' + } + add (choice, priority) { + let key = String.fromCharCode(CHR_a + this.setting.size) + this.setting.set(key, { + key: key, + choice: choice, + priority: priority, + }) + this.total_priority += priority + } + follow (relationship) { + this.relationships.add(relationship) + } + unfollow (relationship) { + this.relationships.delete(relationship) + } + lock () { + this.locked = true + this.relationships.clear() + } + destroy () { + this.relationships.clear() + } +} + +class BassNoteSetting extends setting { + init () { + this.add(4, 1) + this.add(5, 0.4) + this.add(6, 5) + this.add(7, 2) + this.add(8, 0.7) + this.add(9, 1) + this.add(10, 0.5) + this.add(11, 1) + } +} + +class HarmonyNoteSetting extends setting { + init () { + this.add(9, 1) + this.add(10, 0.5) + this.add(11, 1) + this.add(12, 0.4) + this.add(13, 5) + this.add(14, 2) + this.add(15, 0.7) + } +} + +class MelodyNoteSetting extends setting { + init () { + this.add(11, 1) + this.add(12, 0.4) + this.add(13, 5) + this.add(14, 2) + this.add(15, 0.7) + this.add(16, 1) + this.add(17, 0.5) + this.add(18, 1) + } +} + +class Relationship { + constructor (...settings) { + this.settings = settings + for (let setting of settings) { + setting.follow(this) + } + } + destroy () { + for (let setting of this.settings) { + setting.follow(this) + } + } +} + +class NoopRel extends Relationship { + constructor (...settings) { + super() // don't pass args + } + score () { + return 0 + } +} + +// create a subclass of relationship that initializes with the last X items of +// constructor arg +const LastXMixin = (x, base_class) => class extends base_class { + constructor (notes) { + if (notes.length < x) { + return new NoopRel() + } else { + let settings = [] + for (let i = 0; i < x; ++i) { + settings.unshift(notes[i].note) + } + super(...settings) + } + } +} + +// pass two note numbers +// rates how good they sound at the same time +class Chord2Rel extends Relationship { + score () { + let a = this.settings[0].chosen + let b = this.settings[1].chosen + let diff = Math.abs((a % 7) - (b % 7)) + return INTERVAL_GOODNESS[diff] + } +} + +class HigherGoodRel extends Relationship { + score () { + if (this.settings[0].chosen > this.settings[1].chosen) { + return 1 + } + return 0.5 + } +} + +class RangeGood8Rel extends Relationship { + score () { + let lowest, highest + lowest = highest = this.settings[0].chosen + for (let i = 1; i < this.settings.length; ++i) { + let chosen = this.settings[i].chosen + if (chosen < lowest) { + lowest = chosen + } else if (chosen > highest) { + highest = chosen + } + } + return (highest - lowest) / 9 + } +} +const L8RangeGood8Rel = LastXMixin(8, RangeGood8Rel) + +class WalkRel extends Relationship { + score () { + let a = this.settings[0].chosen + let b = this.settings[1].chosen + if (a === b) { + return 0.2 + } + if (Math.abs(a - b) === 1) { + return 1 + } + let fives = [4, 7, 11] + // 1 <--> 5 is cool + if (a in fives && b in fives) { + return 0.8 + } + // jumping a third to the third is pretty cool + if ((a === 7 || a === 11) && b === 9) { + return 0.8 + } + // other thirds + if (Math.abs(a - b) === 2) { + if (a in fives || b in fives) { + return 0.4 + } + return 0.3 + } + return 0 + } +} +const L2WalkRel = LastXMixin(2, WalkRel) + +class SameBad2Rel extends Relationship { + score () { + let ick = 0 + for (let i = 0; i < 2; ++i) { + if (this.settings[i].chosen == this.settings[i+2].chosen) { + ick += 1 + } + } + return 1 - ick + } +} +const L4SameBad2Rel = LastXMixin(4, SameBad2Rel) + +class SameBad4Rel extends Relationship { + score () { + let ick = 0 + let prev_same = false + for (let i = 0; i < 4; ++i) { + if (this.settings[i].chosen == this.settings[i+4].chosen) { + ick += 1 + if (prev_same) { + ick += 1 + } + prev_same = true + } else { + prev_same = false + } + } + if (ick < 2) { + return 1 + } + return Math.pow(0.9, ick) + } +} +const L8SameBad4Rel = LastXMixin(8, SameBad4Rel) + +class RunGood3Rel extends Relationship { + score () { + let a = this.settings[0].chosen + let b = this.settings[1].chosen + let c = this.settings[2].chosen + if (a !== b && Math.abs(a - b) == Math.abs(b - c)) { + return 1 + } else { + return 0.7 + } + } +} +const L3RunGood3Rel = LastXMixin(3, RunGood3Rel) + +class Beat { + constructor (song) { + this.settings = new Set() + this.actors = new Set() + this.song = song + // snapshot some song values + this.time = song.next_beat_at + this.number = song.beat_number + this.measure = song.measure_number + } + on_play (cb) { + this.actors.add(cb) + } + add_setting (key, setting) { + if (key != null) { + this[key] = setting + } + this.settings.add(setting) + } + resolve_and_play () { + let already_keyss = new Set() + let best_keys = '' + let best_score = 0 + let relationships = new Set() + let settings = new Set() + // initialize + for (let setting of this.settings) { + if (setting.relationships.size === 0) { + setting.choose() + } else { + for (let r of setting.relationships) { + relationships.add(r) + // track setting on the other end + for (let setting of r.settings) { + if (!settings.has(setting)) { + // also choose and track + if (!setting.locked) { + best_keys += setting.choose() + settings.add(setting) + } + // TODO add relationships that relate this back to settings + } + } + } + } + } + for (let r of relationships) { + best_score += r.score() + } + already_keyss.add(best_keys) + // try some combinations (brute force, random everything each time) + // TODO be more efficient about which setting are changed + for (let i = 0; i < 50; ++i) { + let keys = '' + let score = 0 + for (let setting of settings) { + keys += setting.choose() + } + if (already_keyss.has(keys)) { + continue + } + for (let r of relationships) { + score += r.score() + } + already_keyss.add(keys) + if (score > best_score) { + best_keys = keys + best_score = score + } + } + // set all setting to our favorite + let i = 0 + for (let setting of settings) { + setting.set_to(best_keys.charAt(i++)) + } + // lock only the ones in this beat + for (let setting of this.settings) { + setting.lock() + } + // play + for (let cb of this.actors) { + cb(this) + } + } +} + +class Note { + constructor (song, ...args) { + this.song = song + this.beat = song.beat + this.init(...args) + this.beat.on_play(this.play.bind(this)) + } +} +class BassNote extends Note { + init () { + if (this.beat.measure_number % 4 === 2) { + this.note = this.song.bass[7].note + } else { + this.note = new BassNoteSetting() + } + this.song.bass.unshift(this) + this.beat.add_setting('bass_note', this.note) + new L2WalkRel(this.song.bass) + new L3RunGood3Rel(this.song.bass) + new L4SameBad2Rel(this.song.bass) + new L4SameBad2Rel(this.song.bass) + new L8SameBad4Rel(this.song.bass) + } + play () { + let amp = BEAT_LOUDNESS[this.beat.number] + this.song.np.play({ + frequency: scale[this.note.chosen], + time: this.beat.time, + duration: BEAT_LENGTH, + amplitude: amp + }) + } +} +class QuickNote extends Note { + constructor (song, lag, ...args) { + super(song, lag, ...args) + this.time = this.beat.time + if (lag) { + this.time += BEAT_LENGTH / 2 + } + } + play () { + let amp = this.amp * BEAT_LOUDNESS[this.beat.number] + this.song.np.play({ + frequency: scale[this.note.chosen], + time: this.time, + duration: BEAT_LENGTH / 2, + amplitude: amp + }) + } +} + +class MelodyNote extends QuickNote { + init () { + if (this.beat.measure_number % 4 === 1 && this.beat.number < 3) { + this.note = this.song.melody[7].note + } else if (this.beat.measure_number % 4 === 2) { + this.note = this.song.melody[15].note + } else { + this.note = new MelodyNoteSetting() + } + this.song.melody.unshift(this) + this.beat.add_setting('melody_note', this.note) + new Chord2Rel(this.song.bass[0].note, this.note) + //new L2WalkRel(this.song.melody) + if (this.song.melody.length > 1) { + new WalkRel(this.song.melody[1].note, this.note) + } + new L3RunGood3Rel(this.song.melody) + new L4SameBad2Rel(this.song.melody) + new L8SameBad4Rel(this.song.melody) + new L8RangeGood8Rel(this.song.melody) + this.amp = 0.8 + } +} +class HarmonyNote extends QuickNote { + init () { + if (this.beat.measure_number % 4 === 3) { + this.note = this.song.harmony[7].note + } else { + this.note = new HarmonyNoteSetting() + } + this.song.harmony.unshift(this) + this.beat.add_setting('harmony_note', this.note) + new Chord2Rel(this.song.bass[0].note, this.note) + new Chord2Rel(this.song.melody[0].note, this.note) + //new L2WalkRel(this.song.harmony) + //new L3RunGood3Rel(this.song.harmony) + new L4SameBad2Rel(this.song.harmony) + new L8SameBad4Rel(this.song.harmony) + new L8RangeGood8Rel(this.song.harmony) + new HigherGoodRel(this.note, this.song.bass[0].note) + new HigherGoodRel(this.song.melody[0].note, this.note) + this.amp = 0.5 + } +} + +class Song { + constructor () { + this.np = null + this.beat = null + this.next_beat_timeout = null + this.next_beat_at = 0 + this.beats = [] + this.bass = [] + this.melody = [] + this.harmony = [] + this.beat_number = 0 + this.measure_number = 0 + } + play () { + this.np = new NotePlayer() + this.next_beat_at = this.np.ctx.currentTime + INITIAL_LEAD_TIME + + this.scheduler() + } + scheduler () { + this.next_beat_timeout = null + this.next_beat() + let next_output_at = this.next_beat_at - (4 * BEAT_LENGTH) + let until_next = (next_output_at - LEAD_TIME) - this.np.ctx.currentTime + if (until_next < 0.001) { + this.scheduler() + } else { + this.next_beat_timeout = timeout(until_next, this.scheduler.bind(this)) + } + } + + stop () { + if (this.next_beat_timeout != null) { + clearTimeout(this.next_beat_timeout) + this.next_beat_timeout = null + } + this.np.stop() + } + next_beat () { + this.beat = new Beat(this) + this.beats.unshift(this.beat) + new BassNote(this) + new MelodyNote(this) + new HarmonyNote(this) + new MelodyNote(this, true) + new HarmonyNote(this, true) + + if (this.beats.length >= 5) { + this.beats[4].resolve_and_play() + } + + if (this.beats.length > 15) { + // FIXME + this.beats.pop() + this.bass.pop() + this.melody.pop() + this.melody.pop() + this.harmony.pop() + this.harmony.pop() + } + + this.beat_number = (this.beat_number + 1) % 4 + if (this.beat_number === 0) { + this.measure_number += 1 + } + this.next_beat_at += BEAT_LENGTH + } +} + +function main() { + let button = document.querySelector('button') + if (button == null) { + document.addEventListener('DOMContentLoaded', main) + return + } + let song = null + let start = () => { + window.song = song = new Song() + song.play() + button.childNodes[0].textContent = 'Stop' + button.onclick = stop + } + let stop = () => { + if (song != null) { + song.stop() + } + button.childNodes[0].textContent = 'Play' + button.onclick = start + } + if (document.location.hash == '#autoplay') { + start() + } else { + stop() + } +} +main()