inevitable_cacophony 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/bin/inevitable_cacophony +175 -0
- data/lib/inevitable_cacophony.rb +2 -0
- data/lib/inevitable_cacophony/midi_generator.rb +118 -0
- data/lib/inevitable_cacophony/midi_generator/frequency_table.rb +126 -0
- data/lib/inevitable_cacophony/note.rb +39 -0
- data/lib/inevitable_cacophony/octave_structure.rb +233 -0
- data/lib/inevitable_cacophony/parser/rhythm_line.rb +79 -0
- data/lib/inevitable_cacophony/parser/rhythms.rb +138 -0
- data/lib/inevitable_cacophony/parser/sectioned_text.rb +60 -0
- data/lib/inevitable_cacophony/phrase.rb +16 -0
- data/lib/inevitable_cacophony/polyrhythm.rb +169 -0
- data/lib/inevitable_cacophony/rhythm.rb +127 -0
- data/lib/inevitable_cacophony/tone_generator.rb +68 -0
- data/lib/inevitable_cacophony/version.rb +3 -0
- metadata +119 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 23972ffb2207ab73ee426746749fbef9f92b0a4481deef7201de95b3b8522a56
|
4
|
+
data.tar.gz: c8735fccc00b5815308a13b95c6be437ae2fbc0782df3beeaf7a05874b2b9c88
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9905f4be065544add64a03acd8b5cb4777204fc087736a8945fd21822a77cb6e48c200084e46e5aa15001e9d1f82eb617de779581eca1087f8b7b0f331edef8d
|
7
|
+
data.tar.gz: 922a493d81a05e53a66ae086a2ad32dfa8fcd73376aeec6b703f679006f72fb64e0b7433076ced6d3bf7405a2c494f8c38bac3e233927db8a437cdbfb0428661
|
@@ -0,0 +1,175 @@
|
|
1
|
+
#!/usr/bin/env/ruby
|
2
|
+
|
3
|
+
require 'optparse'
|
4
|
+
require 'stringio'
|
5
|
+
|
6
|
+
require 'inevitable_cacophony/parser/rhythms'
|
7
|
+
require 'inevitable_cacophony/octave_structure'
|
8
|
+
require 'inevitable_cacophony/midi_generator'
|
9
|
+
require 'inevitable_cacophony/tone_generator'
|
10
|
+
require 'inevitable_cacophony/phrase'
|
11
|
+
require 'inevitable_cacophony/version'
|
12
|
+
|
13
|
+
command = -> {
|
14
|
+
octave = InevitableCacophony::OctaveStructure.new(input)
|
15
|
+
|
16
|
+
3.times.map do
|
17
|
+
chord_notes = []
|
18
|
+
rhythm.each_beat.map do |beat|
|
19
|
+
chords = octave.chords.values
|
20
|
+
if chord_notes.empty?
|
21
|
+
chord_notes = chords.sample(random: rng).note_scalings.dup
|
22
|
+
end
|
23
|
+
|
24
|
+
note = InevitableCacophony::Note.new(chord_notes.shift, beat)
|
25
|
+
InevitableCacophony::Phrase.new(note, tempo: options[:tempo])
|
26
|
+
end
|
27
|
+
end.flatten
|
28
|
+
}
|
29
|
+
|
30
|
+
render = -> (phrases) {
|
31
|
+
tone = InevitableCacophony::ToneGenerator.new(options[:tonic])
|
32
|
+
phrases.each { |phrase| tone.add_phrase(phrase) }
|
33
|
+
|
34
|
+
# Have to buffer output so wavefile can seek back to the beginning to write format info
|
35
|
+
output_buffer = StringIO.new
|
36
|
+
tone.write(output_buffer)
|
37
|
+
$stdout.write(output_buffer.string)
|
38
|
+
}
|
39
|
+
|
40
|
+
def input
|
41
|
+
@input ||= $stdin.read
|
42
|
+
end
|
43
|
+
|
44
|
+
def options
|
45
|
+
@options ||= {
|
46
|
+
tempo: 120, # beats per minute
|
47
|
+
tonic: 440 # Hz; middle A
|
48
|
+
}
|
49
|
+
end
|
50
|
+
|
51
|
+
def rng
|
52
|
+
@rng ||= Random.new(options[:seed] || Random.new_seed)
|
53
|
+
end
|
54
|
+
|
55
|
+
def midi_generator
|
56
|
+
@midi_generator ||= begin
|
57
|
+
octave_structure = InevitableCacophony::OctaveStructure.new(input)
|
58
|
+
InevitableCacophony::MidiGenerator.new(octave_structure, options[:tonic])
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def rhythm
|
63
|
+
@rhythm ||= begin
|
64
|
+
all_rhythms = InevitableCacophony::Parser::Rhythms.new.parse(input)
|
65
|
+
|
66
|
+
# InevitableCacophony::Pick the first rhythm mentioned in the file, which should be the one
|
67
|
+
# used by the first section of the piece.
|
68
|
+
rhythm_name = all_rhythms.keys.sort_by { |name| input.index(name.to_s) }.first
|
69
|
+
|
70
|
+
if all_rhythms[rhythm_name]
|
71
|
+
all_rhythms[rhythm_name]
|
72
|
+
else
|
73
|
+
|
74
|
+
# InevitableCacophony::If no rhythms are mentioned, parse any rhythm string we can find in the input.
|
75
|
+
rhythm_score = input.match(/(\|( |\`)((-|x|X|!)( |\`|\'))+)+\|/).to_s
|
76
|
+
InevitableCacophony::Parser::RhythmLine.new.parse(rhythm_score)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
OptionParser.new do |opts|
|
82
|
+
|
83
|
+
opts.banner = 'Usage: ruby -Ilib cacophony.rb [options]'
|
84
|
+
|
85
|
+
opts.on('-b', '--beat', 'Play a beat in the given rhythm') do
|
86
|
+
command = -> {
|
87
|
+
notes = 3.times.map do
|
88
|
+
rhythm.each_beat.map do |beat|
|
89
|
+
InevitableCacophony::Note.new(1, beat)
|
90
|
+
end
|
91
|
+
end.flatten
|
92
|
+
|
93
|
+
[InevitableCacophony::Phrase.new(*notes, tempo: options[:tempo])]
|
94
|
+
}
|
95
|
+
end
|
96
|
+
|
97
|
+
opts.on('-s', '--scale', 'Play a scale in the given style') do
|
98
|
+
command = -> {
|
99
|
+
octave = InevitableCacophony::OctaveStructure.new(input)
|
100
|
+
|
101
|
+
scale = if options[:chromatic]
|
102
|
+
octave.chromatic_scale
|
103
|
+
else
|
104
|
+
octave.scales.values.first
|
105
|
+
end
|
106
|
+
|
107
|
+
rising_and_falling = scale.open.note_scalings + scale.note_scalings.reverse
|
108
|
+
notes = rising_and_falling.map do |factor|
|
109
|
+
InevitableCacophony::Note.new(factor, InevitableCacophony::Rhythm::Beat.new(1, 1, 0))
|
110
|
+
end
|
111
|
+
|
112
|
+
[InevitableCacophony::Phrase.new(*notes, tempo: options[:tempo])]
|
113
|
+
}
|
114
|
+
end
|
115
|
+
|
116
|
+
opts.on('-p', '--polyrhythm RATIO', "Rather than loading rhythm normally, use a polyrhythm in the given ratio (e.g 7:11, 2:3:4).") do |ratio|
|
117
|
+
components = ratio.split(':').map do |length|
|
118
|
+
InevitableCacophony::Rhythm.new([InevitableCacophony::Rhythm::Beat.new(1, 1, 0)] * length.to_i)
|
119
|
+
end
|
120
|
+
|
121
|
+
primary, *secondaries = components
|
122
|
+
@rhythm = InevitableCacophony::Polyrhythm.new(primary, secondaries)
|
123
|
+
end
|
124
|
+
|
125
|
+
opts.on('-e', '--eval FORM', 'Parse FORM rather than reading a form description from stdin') do |form|
|
126
|
+
@input = form
|
127
|
+
end
|
128
|
+
|
129
|
+
opts.on('--chromatic', 'Use "chromatic" scales (all notes in the form) rather than the named scales typical of the form') do
|
130
|
+
options[:chromatic] = true
|
131
|
+
end
|
132
|
+
|
133
|
+
opts.on('-h', '--help', 'Prints this help') do
|
134
|
+
puts opts
|
135
|
+
exit
|
136
|
+
end
|
137
|
+
|
138
|
+
opts.on('-v', '--version', 'Show version and exit') do
|
139
|
+
puts "Inevitable Cacophony version #{InevitableCacophony::VERSION}"
|
140
|
+
exit
|
141
|
+
end
|
142
|
+
|
143
|
+
opts.on('-t', '--tempo TEMPO', "Play at the given tempo in beats per minute (default #{options[:tempo]}). For polyrhythms, this applies to whatever number is given first: at the same tempo, a 2:3 rhythm will play faster than a 3:2.") do |tempo|
|
144
|
+
options[:tempo] = tempo.to_i
|
145
|
+
end
|
146
|
+
|
147
|
+
opts.on('-S', '--seed SEED', 'Generate random melodies with the given seed, for repeatable results.') do |seed|
|
148
|
+
int_seed = seed.to_i
|
149
|
+
raise "Expected seed to be a number" unless seed == int_seed.to_s
|
150
|
+
|
151
|
+
options[:seed] = int_seed
|
152
|
+
end
|
153
|
+
|
154
|
+
opts.on('-m', '--midi', 'Generate output in MIDI rather than WAV format (needs file from -M to play in tune)') do
|
155
|
+
render = -> (phrases) {
|
156
|
+
phrases.each do |phrase|
|
157
|
+
midi_generator.add_phrase(phrase)
|
158
|
+
end
|
159
|
+
midi_generator.write($stdout)
|
160
|
+
}
|
161
|
+
end
|
162
|
+
|
163
|
+
opts.on('-M', '--midi-tuning', 'Instead of music, generate a Scala (Timidity-compatible) tuning file for use with MIDI output from --midi') do
|
164
|
+
command = -> {
|
165
|
+
midi_generator.frequency_table
|
166
|
+
}
|
167
|
+
render = -> (frequencies) {
|
168
|
+
frequencies.table.each do |frequency|
|
169
|
+
$stdout.puts (frequency * 1000).round
|
170
|
+
end
|
171
|
+
}
|
172
|
+
end
|
173
|
+
end.parse!
|
174
|
+
|
175
|
+
render.call(command.call)
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# Converts Inevitable Cacophony internal note representation
|
2
|
+
# into MIDI messages usable by an external synthesizer.
|
3
|
+
# Based on examples in the `midilib` gem.
|
4
|
+
|
5
|
+
require 'midilib/sequence'
|
6
|
+
require 'midilib/consts'
|
7
|
+
|
8
|
+
require 'inevitable_cacophony/midi_generator/frequency_table'
|
9
|
+
|
10
|
+
module InevitableCacophony
|
11
|
+
class MidiGenerator
|
12
|
+
|
13
|
+
# Set up a MIDI generator for a specific octave structure and tonic
|
14
|
+
# We need to know the octave structure because it determines
|
15
|
+
# how we allocate MIDI note indices to frequencies.
|
16
|
+
def initialize(octave_structure, tonic)
|
17
|
+
@frequency_table = FrequencyTable.new(octave_structure, tonic)
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_reader :frequency_table
|
21
|
+
|
22
|
+
# Add a phrase to the MIDI output we will generate.
|
23
|
+
def add_phrase(phrase)
|
24
|
+
@phrases ||= []
|
25
|
+
@phrases << phrase
|
26
|
+
end
|
27
|
+
|
28
|
+
# @return [Midi::Track] Notes to be output to MIDI; mainly for testing.
|
29
|
+
def notes_track(sequence=build_sequence)
|
30
|
+
build_notes_track(sequence, @phrases)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Write MIDI output to the given stream.
|
34
|
+
def write(io)
|
35
|
+
sequence = build_sequence
|
36
|
+
sequence.tracks << notes_track(sequence)
|
37
|
+
|
38
|
+
# Buffer output so this method can be called on stdout.
|
39
|
+
buffer = StringIO.new
|
40
|
+
sequence.write(buffer)
|
41
|
+
|
42
|
+
io.write(buffer.string)
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def build_sequence
|
48
|
+
seq = MIDI::Sequence.new
|
49
|
+
seq.tracks << meta_track(seq)
|
50
|
+
seq
|
51
|
+
end
|
52
|
+
|
53
|
+
# TODO: why do I have to pass `seq` in,
|
54
|
+
# when I'm then later adding the track back to seq.tracks?
|
55
|
+
def meta_track(seq)
|
56
|
+
track = MIDI::Track.new(seq)
|
57
|
+
|
58
|
+
# TODO: handle tempo changes (how?)
|
59
|
+
track.events << MIDI::Tempo.new(
|
60
|
+
MIDI::Tempo.bpm_to_mpq(@phrases.first.tempo)
|
61
|
+
)
|
62
|
+
track.events << MIDI::MetaEvent.new(
|
63
|
+
MIDI::META_SEQ_NAME,
|
64
|
+
'TODO: name sequence'
|
65
|
+
)
|
66
|
+
|
67
|
+
track
|
68
|
+
end
|
69
|
+
|
70
|
+
# TODO: multiple instruments?
|
71
|
+
def build_notes_track(seq, phrases)
|
72
|
+
track = MIDI::Track.new(seq)
|
73
|
+
track.name = 'Cacophony'
|
74
|
+
|
75
|
+
# TODO: why this particular instrument.
|
76
|
+
track.instrument = MIDI::GM_PATCH_NAMES[0]
|
77
|
+
|
78
|
+
# TODO: what's this for?
|
79
|
+
track.events << MIDI::ProgramChange.new(0, 1, 0)
|
80
|
+
|
81
|
+
# Inter-note delay from the end of the previous beat.
|
82
|
+
leftover_delay = 0
|
83
|
+
|
84
|
+
phrases.each do |phrase|
|
85
|
+
phrase.notes.each do |note|
|
86
|
+
track.events += midi_events_for_note(leftover_delay, note, seq)
|
87
|
+
leftover_delay = seq.length_to_delta(note.beat.after_delay)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
track
|
92
|
+
end
|
93
|
+
|
94
|
+
# TODO: code smell to pass in seq
|
95
|
+
def midi_events_for_note(delay_before, note, seq)
|
96
|
+
midi_note = @frequency_table.index_for_ratio(note.ratio)
|
97
|
+
beat = note.beat
|
98
|
+
|
99
|
+
[
|
100
|
+
MIDI::NoteOn.new(
|
101
|
+
0,
|
102
|
+
midi_note,
|
103
|
+
(beat.amplitude * 127).ceil,
|
104
|
+
# TODO: can notes be out of order?
|
105
|
+
# Beat duration 1 conveniently matches
|
106
|
+
# midilib's quarter-note = 1.
|
107
|
+
seq.length_to_delta(beat.start_delay) + delay_before
|
108
|
+
),
|
109
|
+
MIDI::NoteOff.new(
|
110
|
+
0,
|
111
|
+
midi_note,
|
112
|
+
127,
|
113
|
+
seq.length_to_delta(beat.sounding_time)
|
114
|
+
)
|
115
|
+
]
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# A frequency table maps Dwarf fortress notes (specific frequencies) to
|
2
|
+
# MIDI indices for use in MIDI indexes.
|
3
|
+
#
|
4
|
+
# Where possible we use the standard MIDI values for DF notes; where that
|
5
|
+
# won't work, we try to keep as close to the MIDI structure as the DF scale
|
6
|
+
# system will allow.
|
7
|
+
|
8
|
+
# Using for OctaveStructure::OCTAVE_RATIO; may be better to just use +2+.
|
9
|
+
require 'inevitable_cacophony/octave_structure'
|
10
|
+
|
11
|
+
module InevitableCacophony
|
12
|
+
class MidiGenerator
|
13
|
+
class FrequencyTable
|
14
|
+
|
15
|
+
# Raised when there is no MIDI index available for
|
16
|
+
# a note we're trying to output
|
17
|
+
class OutOfRange < StandardError
|
18
|
+
def initialize(frequency, table)
|
19
|
+
super("Not enough MIDI indices to represent #{frequency} Hz. "\
|
20
|
+
"Available range is #{table.inspect}")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Range of allowed MIDI 1 indices.
|
25
|
+
MIDI_RANGE = 0..127
|
26
|
+
|
27
|
+
# Middle A in MIDI
|
28
|
+
MIDI_TONIC = 69
|
29
|
+
|
30
|
+
# Standard western notes per octave assumed by MIDI
|
31
|
+
MIDI_OCTAVE_NOTES = 12
|
32
|
+
|
33
|
+
# 12TET values of those notes.
|
34
|
+
STANDARD_MIDI_FREQUENCIES = MIDI_OCTAVE_NOTES.times.map do |index|
|
35
|
+
OctaveStructure::OCTAVE_RATIO ** (index / MIDI_OCTAVE_NOTES.to_f)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Maximum increase/decrease between two frequencies we still treat as
|
39
|
+
# "equal". Approximately 1/30th of human Just Noticeable Difference
|
40
|
+
# for pitch.
|
41
|
+
FREQUENCY_FUDGE_FACTOR = (1.0/10_000)
|
42
|
+
|
43
|
+
# Create a frequency table with a given structure and tonic.
|
44
|
+
#
|
45
|
+
# @param octave_structure [OctaveStructure]
|
46
|
+
# @param tonic [Integer] The tonic frequency in Hertz.
|
47
|
+
# This will correspond to Cacophony frequency 1,
|
48
|
+
# and MIDI pitch 69
|
49
|
+
def initialize(octave_structure, tonic)
|
50
|
+
@tonic = tonic
|
51
|
+
@table = build_table(octave_structure, tonic)
|
52
|
+
end
|
53
|
+
|
54
|
+
attr_reader :table
|
55
|
+
|
56
|
+
# @param ratio [Float] The given note as a ratio to the tonic
|
57
|
+
# (e.g. A above middle A = 2.0)
|
58
|
+
def index_for_ratio(ratio)
|
59
|
+
# TODO: not reliable for approximate matching
|
60
|
+
frequency = @tonic * ratio
|
61
|
+
|
62
|
+
if (match = table.index(frequency))
|
63
|
+
match
|
64
|
+
else
|
65
|
+
raise OutOfRange.new(frequency, table)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def build_table(octave_structure, tonic)
|
72
|
+
chromatic = octave_structure.chromatic_scale.open.note_scalings
|
73
|
+
octave_breakdown = best_match_ratios(chromatic)
|
74
|
+
|
75
|
+
MIDI_RANGE.map do |index|
|
76
|
+
tonic_offset = index - MIDI_TONIC
|
77
|
+
octave_offset, note = tonic_offset.divmod(octave_breakdown.length)
|
78
|
+
|
79
|
+
bottom_of_octave = tonic * OctaveStructure::OCTAVE_RATIO**octave_offset
|
80
|
+
bottom_of_octave * octave_breakdown[note]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Pick a MIDI index within the octave for each given frequency.
|
85
|
+
#
|
86
|
+
# If there are few enough (<12) frequencies in the generated scale,
|
87
|
+
# we try to keep as much of the normal MIDI tuning as possible, and
|
88
|
+
# only re-tune what we need. If the DF scale is a subset of 12TET,
|
89
|
+
# this should return the standard MIDI tuning.
|
90
|
+
#
|
91
|
+
# Other than that it isn't guaranteed to be optimal; currently it's
|
92
|
+
# a fairly naieve greedy algorithm.
|
93
|
+
#
|
94
|
+
# @return [Array] Re-tuned ratios for each position in the MIDI octave.
|
95
|
+
def best_match_ratios(frequencies_to_cover)
|
96
|
+
standard_octave = STANDARD_MIDI_FREQUENCIES.dup
|
97
|
+
ratios = []
|
98
|
+
|
99
|
+
while (next_frequency = frequencies_to_cover.shift)
|
100
|
+
|
101
|
+
# Skip ahead (padding slots with 12TET frequencies from low to high) until:
|
102
|
+
#
|
103
|
+
# * the next 12TET frequency would be sharper, or
|
104
|
+
# * any more padding will leave us without enough space.
|
105
|
+
while (standard = standard_octave.shift) &&
|
106
|
+
sounds_flatter?(standard, next_frequency) &&
|
107
|
+
standard_octave.length > frequencies_to_cover.length
|
108
|
+
|
109
|
+
ratios << standard
|
110
|
+
end
|
111
|
+
|
112
|
+
# Use this frequency in this slot.
|
113
|
+
ratios << next_frequency
|
114
|
+
end
|
115
|
+
|
116
|
+
ratios
|
117
|
+
end
|
118
|
+
|
119
|
+
# Like < but considers values within FREQUENCY_FUDGE_FACTOR equal
|
120
|
+
def sounds_flatter?(a, b)
|
121
|
+
threshold = b * (1 - FREQUENCY_FUDGE_FACTOR)
|
122
|
+
a < threshold
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# Represents a single note of a tune
|
2
|
+
|
3
|
+
# TODO: only for Beat class
|
4
|
+
require 'inevitable_cacophony/rhythm'
|
5
|
+
|
6
|
+
module InevitableCacophony
|
7
|
+
class Note < Struct.new(:ratio, :beat)
|
8
|
+
|
9
|
+
# @param ratio [Numeric] Note frequency, as a multiple of the tonic.
|
10
|
+
# @param amplitude [Rhythm::Beat] A Beat object defining amplitude and timing
|
11
|
+
def initialize(ratio, beat)
|
12
|
+
super(ratio, beat)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Create a rest for the duration of the given beat.
|
16
|
+
# @param beat [Beat]
|
17
|
+
def self.rest(beat)
|
18
|
+
|
19
|
+
# Can't set ratio to 0 as it causes divide-by-zero errors
|
20
|
+
new(1, Rhythm::Beat.new(0, beat.duration, beat.timing))
|
21
|
+
end
|
22
|
+
|
23
|
+
def start_delay
|
24
|
+
beat.start_delay
|
25
|
+
end
|
26
|
+
|
27
|
+
def after_delay
|
28
|
+
beat.after_delay
|
29
|
+
end
|
30
|
+
|
31
|
+
def duration
|
32
|
+
beat.duration
|
33
|
+
end
|
34
|
+
|
35
|
+
def frequency
|
36
|
+
ratio
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|