JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
initial work
authorJason Woofenden <jason@jasonwoof.com>
Tue, 7 Feb 2017 01:53:44 +0000 (20:53 -0500)
committerJason Woofenden <jason@jasonwoof.com>
Sun, 9 Apr 2017 17:23:10 +0000 (13:23 -0400)
.htaccess [new file with mode: 0644]
index.html [new file with mode: 0644]
main.js [new file with mode: 0644]

diff --git a/.htaccess b/.htaccess
new file mode 100644 (file)
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 (file)
index 0000000..83e615a
--- /dev/null
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+<meta charset="utf-8" />
+<head>
+       <title>Experiments with compter generated music</title>
+       <script src="main.js" async></script>
+</head>
+<body>
+       <h1>Experiments with compter generated music</h1>
+       <p><button>Loading...</button></p>
+       <footer>&copy; 2017 <a href="https://jasonwoof.com/" target="_blank">Jason Woofenden</a>. <a href="https://jasonwoof.com/gitweb/?p=mimpro.git" target="_blank">Source code</a> licensed under <a href="https://gnu.org/licenses/agpl-3.0.html" target="_blank">AGPLv3+</a></footer>
+</body>
+</html>
diff --git a/main.js b/main.js
new file mode 100644 (file)
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()