inevitable_cacophony 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
@@ -0,0 +1,233 @@
|
|
1
|
+
# Represents and parses Dwarf Fortress scale descriptions
|
2
|
+
|
3
|
+
require 'inevitable_cacophony/parser/sectioned_text'
|
4
|
+
|
5
|
+
module InevitableCacophony
|
6
|
+
class OctaveStructure
|
7
|
+
|
8
|
+
# Frequency scaling for a difference of one whole octave
|
9
|
+
OCTAVE_RATIO = 2
|
10
|
+
|
11
|
+
# Represent a sequence of notes from an octave -- either a chord,
|
12
|
+
# or the notes of a scale.
|
13
|
+
# TODO: call this something more useful
|
14
|
+
class NoteSequence
|
15
|
+
|
16
|
+
# @param note_scalings [Array<Float>] The frequencies of each note in the scale,
|
17
|
+
# as multiples of the tonic.
|
18
|
+
def initialize(note_scalings)
|
19
|
+
@note_scalings = note_scalings
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_accessor :note_scalings
|
23
|
+
|
24
|
+
def length
|
25
|
+
note_scalings.length
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class Chord < NoteSequence
|
30
|
+
end
|
31
|
+
|
32
|
+
# As above, but also tracks the chords that make up the scale.
|
33
|
+
class Scale < NoteSequence
|
34
|
+
|
35
|
+
# @param chords [Array<Chord>] The chords that make up the scale, in order.
|
36
|
+
# @param note_scalings [Array<Fixnum>] Specific note scalings to use; for internal use.
|
37
|
+
def initialize(chords, note_scalings=nil)
|
38
|
+
@chords = chords
|
39
|
+
super(note_scalings || chords.map(&:note_scalings).flatten)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Convert this scale to an "open" one -- i.e. one not including
|
43
|
+
# the last note of the octave.
|
44
|
+
#
|
45
|
+
# This form is more convenient when concatenating scales together.
|
46
|
+
#
|
47
|
+
# @return [Scale]
|
48
|
+
def open
|
49
|
+
if note_scalings.last == OCTAVE_RATIO
|
50
|
+
|
51
|
+
# -1 is the last note; we want to end on the one before that, so -2
|
52
|
+
Scale.new(@chords, note_scalings[0..-2])
|
53
|
+
else
|
54
|
+
# This scale is already open.
|
55
|
+
self
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Regular expressions used in parsing
|
61
|
+
OCTAVE_STRUCTURE_SENTENCE = /Scales are constructed/
|
62
|
+
|
63
|
+
# @param scale_text [String] Dwarf Fortress musical form description including scale information.
|
64
|
+
# TODO: Allow contructing these without parsing text
|
65
|
+
def initialize(scale_text)
|
66
|
+
description = Parser::SectionedText.new(scale_text)
|
67
|
+
octave_description = description.find_paragraph(OCTAVE_STRUCTURE_SENTENCE)
|
68
|
+
@octave_divisions = parse_octave_structure(octave_description)
|
69
|
+
|
70
|
+
@chords = parse_chords(description)
|
71
|
+
@scales = parse_scales(description, chords)
|
72
|
+
end
|
73
|
+
|
74
|
+
attr_reader :chords, :scales
|
75
|
+
|
76
|
+
# @return [Scale] A scale including all available notes in the octave.
|
77
|
+
# (As the chromatic scale does for well-tempered Western instruments)
|
78
|
+
def chromatic_scale
|
79
|
+
Scale.new([], @octave_divisions + [2])
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def parse_octave_structure(octave_paragraph)
|
85
|
+
octave_sentence = octave_paragraph.find(OCTAVE_STRUCTURE_SENTENCE)
|
86
|
+
note_count_match = octave_sentence.match(/Scales are constructed from ([-a-z ]+) notes spaced evenly throughout the octave/)
|
87
|
+
|
88
|
+
if note_count_match
|
89
|
+
note_count_word = note_count_match.captures.first
|
90
|
+
divisions = parse_number_word(note_count_word)
|
91
|
+
numerator = divisions.to_f
|
92
|
+
|
93
|
+
(0...divisions).map { |index| 2 ** (index/numerator) }
|
94
|
+
else
|
95
|
+
parse_exact_notes(octave_paragraph)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def parse_exact_notes(octave_paragraph)
|
100
|
+
exact_spacing_sentence = octave_paragraph.find(/their spacing is roughly/)
|
101
|
+
spacing_match = exact_spacing_sentence.match(/In quartertones, their spacing is roughly 1((-|x){23})0/)
|
102
|
+
|
103
|
+
if spacing_match
|
104
|
+
# Always include the tonic
|
105
|
+
note_scalings = [1]
|
106
|
+
|
107
|
+
note_positions = spacing_match.captures.first
|
108
|
+
step_size = 2**(1.0 / note_positions.length.succ)
|
109
|
+
ratio = 1
|
110
|
+
note_positions.each_char do |pos|
|
111
|
+
ratio *= step_size
|
112
|
+
|
113
|
+
case pos
|
114
|
+
when 'x'
|
115
|
+
note_scalings << ratio
|
116
|
+
when '-'
|
117
|
+
# Do nothing; no note here
|
118
|
+
else
|
119
|
+
raise "Unexpected note position symbol #{pos.inspect}"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
note_scalings
|
124
|
+
else
|
125
|
+
raise "Cannot parse octave text"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# @param description [Parser::SectionedText] The description text from which to extract chord data.
|
130
|
+
def parse_chords(description)
|
131
|
+
|
132
|
+
# TODO: extract to constant
|
133
|
+
chord_paragraph_regex = /The ([^ ]+) [a-z]*chord is/
|
134
|
+
|
135
|
+
{}.tap do |chords|
|
136
|
+
chord_paragraphs = description.find_all_paragraphs(chord_paragraph_regex)
|
137
|
+
|
138
|
+
chord_paragraphs.each do |paragraph|
|
139
|
+
degrees_sentence = paragraph.find(chord_paragraph_regex)
|
140
|
+
|
141
|
+
name, degrees = degrees_sentence.match(/The ([^ ]+) [a-z]*chord is the (.*) degrees of the .* scale/).captures
|
142
|
+
chords[name.to_sym] = parse_chord(degrees)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# @param degrees[String] The list of degrees used by this particular scale
|
148
|
+
def parse_chord(degrees)
|
149
|
+
ordinals = degrees.split(/(?:,| and) the/)
|
150
|
+
|
151
|
+
chord_notes = ordinals.map do |degree_ordinal|
|
152
|
+
# degree_ordinal is like "4th",
|
153
|
+
# or may be like "13th (completing the octave)"
|
154
|
+
# in which case it's not in our list of notes, but always has a factor of 2
|
155
|
+
# (the tonic, an octave higher)
|
156
|
+
|
157
|
+
if degree_ordinal.include?('(completing the octave)')
|
158
|
+
2
|
159
|
+
else
|
160
|
+
index = degree_ordinal.strip.to_i
|
161
|
+
@octave_divisions[index - 1]
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
Chord.new(chord_notes)
|
166
|
+
end
|
167
|
+
|
168
|
+
# @param description [Parser::SectionedText]
|
169
|
+
# @param chords [Hash{Symbol,Chord}]
|
170
|
+
def parse_scales(description, chords)
|
171
|
+
scale_topic_regex = /The [^ ]+ [^ ]+ scale is/
|
172
|
+
|
173
|
+
{}.tap do |scales|
|
174
|
+
description.find_all_paragraphs(scale_topic_regex).each do |scale_paragraph|
|
175
|
+
scale_sentence = scale_paragraph.find(scale_topic_regex)
|
176
|
+
name, scale_type = scale_sentence.match(/The ([^ ]+) [a-z]+tonic scale is (thought of as .*|constructed by)/).captures
|
177
|
+
|
178
|
+
case scale_type
|
179
|
+
when /thought of as ([a-z]+ )?(disjoint|joined) chords/
|
180
|
+
scales[name.to_sym] = parse_disjoint_chords_scale(scale_paragraph, chords)
|
181
|
+
else
|
182
|
+
raise "Unknown scale type #{scale_type} in #{scale_sentence}"
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
def parse_disjoint_chords_scale(scale_paragraph, chords)
|
189
|
+
chords_sentence = scale_paragraph.find(/These chords are/)
|
190
|
+
chord_list = chords_sentence.match(/These chords are named ([^.]+)\.?/).captures.first
|
191
|
+
chord_names = chord_list.split(/,|and/).map(&:strip).map(&:to_sym)
|
192
|
+
|
193
|
+
Scale.new(chords.values_at(*chord_names))
|
194
|
+
end
|
195
|
+
|
196
|
+
# Convert a number word to text -- rough approximation for now.
|
197
|
+
# TODO: Rails or something may do this.
|
198
|
+
#
|
199
|
+
# @param word [String]
|
200
|
+
# @return [Fixnum]
|
201
|
+
def parse_number_word(word)
|
202
|
+
words_to_numbers = {
|
203
|
+
'one' => 1,
|
204
|
+
'two' => 2,
|
205
|
+
'three' => 3,
|
206
|
+
'four' => 4,
|
207
|
+
'five' => 5,
|
208
|
+
'six' => 6,
|
209
|
+
'seven' => 7,
|
210
|
+
'eight' => 8,
|
211
|
+
'nine' => 9,
|
212
|
+
'ten' => 10,
|
213
|
+
'eleven' => 11,
|
214
|
+
'twelve' => 12,
|
215
|
+
'thirteen' => 13,
|
216
|
+
'fourteen' => 14,
|
217
|
+
'fifteen' => 15,
|
218
|
+
'sixteen' => 16,
|
219
|
+
'seventeen' => 17,
|
220
|
+
'eighteen' => 18,
|
221
|
+
'nineteen' => 19,
|
222
|
+
}
|
223
|
+
|
224
|
+
if words_to_numbers[word]
|
225
|
+
words_to_numbers[word]
|
226
|
+
elsif word.start_with?('twenty-')
|
227
|
+
words_to_numbers[word.delete_prefix('twenty-')] + 20
|
228
|
+
else
|
229
|
+
"Unsupported number name #{word}"
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# Parses Dwarf Fortress rhythm lines, like | x x'X - |,
|
2
|
+
# into Inevitable Cacophony's own internal rhythm representation.
|
3
|
+
|
4
|
+
require 'inevitable_cacophony/rhythm'
|
5
|
+
|
6
|
+
module InevitableCacophony
|
7
|
+
module Parser
|
8
|
+
class RhythmLine
|
9
|
+
|
10
|
+
# Amplitude symbols used by Dwarf Fortress
|
11
|
+
# These are in no particular scale; the maximum volume will be whatever's loudest in any particular string.
|
12
|
+
BEAT_VALUES = {
|
13
|
+
|
14
|
+
# Silence
|
15
|
+
'-' => 0,
|
16
|
+
|
17
|
+
# Regular beat
|
18
|
+
'x' => 4,
|
19
|
+
|
20
|
+
# Accented beat
|
21
|
+
'X' => 6,
|
22
|
+
|
23
|
+
# Primary accent
|
24
|
+
'!' => 9
|
25
|
+
}
|
26
|
+
|
27
|
+
# Values for each kind of timing symbol.
|
28
|
+
# By default a beat is in the middle of its time-slice (0.0);
|
29
|
+
# a value of 1.0 means to play it as late as possible,
|
30
|
+
# and -1.0 means play as early as possible.
|
31
|
+
#
|
32
|
+
# Technically position of these matters, but we handle that in the parser regexp.
|
33
|
+
TIMING_VALUES = {
|
34
|
+
|
35
|
+
# Normal beat (no special timing)
|
36
|
+
'' => 0.0,
|
37
|
+
|
38
|
+
# Early beat
|
39
|
+
'`' => -1.0,
|
40
|
+
|
41
|
+
# Late beat
|
42
|
+
'\'' => 1.0
|
43
|
+
}
|
44
|
+
|
45
|
+
BAR_LINE = '|'
|
46
|
+
|
47
|
+
# @param rhythm_string [String] In the notation Dwarf Fortress produces, like | X x ! x |
|
48
|
+
# @return [Rhythm]
|
49
|
+
def parse(rhythm_string)
|
50
|
+
|
51
|
+
# TODO: should I be ignoring bar lines? Is there anything I can do with them?
|
52
|
+
raw_beats = rhythm_string.split(/ |(?=`)|(?<=')/).reject { |beat| beat == BAR_LINE }.map do |beat|
|
53
|
+
timing_symbol = beat.chars.reject { |char| BEAT_VALUES.keys.include?(char) }.join
|
54
|
+
timing = TIMING_VALUES[timing_symbol] || raise("Unknown timing symbol #{timing_symbol}")
|
55
|
+
|
56
|
+
accent_symbol = beat.delete(timing_symbol)
|
57
|
+
amplitude = BEAT_VALUES[accent_symbol] || raise("Unknown beat symbol #{accent_symbol}")
|
58
|
+
|
59
|
+
Rhythm::Beat.new(amplitude, 1, timing)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Ensure all our amplitudes are between 0.0 and 1.0
|
63
|
+
# TODO: find a way to do this without creating twice as many beats as we need.
|
64
|
+
highest_volume = raw_beats.map(&:amplitude).max
|
65
|
+
scaled_beats = raw_beats.map do |beat|
|
66
|
+
scaled = beat.amplitude.to_f / highest_volume
|
67
|
+
|
68
|
+
Rhythm::Beat.new(scaled, 1, beat.timing)
|
69
|
+
end
|
70
|
+
|
71
|
+
Rhythm.new(scaled_beats)
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.parse(rhythm_string)
|
75
|
+
new.parse(rhythm_string)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
# Knows how to parse the rhythm-description paragraphs
|
2
|
+
# of a Dwarf Fortress musical form
|
3
|
+
|
4
|
+
require 'inevitable_cacophony/parser/sectioned_text'
|
5
|
+
require 'inevitable_cacophony/parser/rhythm_line'
|
6
|
+
require 'inevitable_cacophony/polyrhythm'
|
7
|
+
|
8
|
+
module InevitableCacophony
|
9
|
+
module Parser
|
10
|
+
|
11
|
+
# TODO: maybe move errors elsewhere
|
12
|
+
class Error < StandardError
|
13
|
+
end
|
14
|
+
|
15
|
+
class UnknownBaseRhythm < Error
|
16
|
+
def initialize(base)
|
17
|
+
@base = base
|
18
|
+
|
19
|
+
super("Could not find base rhythm #{base} for polyrhythm")
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_accessor :base
|
23
|
+
end
|
24
|
+
|
25
|
+
class UnrecognisedFormSyntax < Error
|
26
|
+
end
|
27
|
+
|
28
|
+
# For all the Americans out there, following Webster rather than Johnson :-)
|
29
|
+
UnrecognizedFormSyntax = UnrecognisedFormSyntax
|
30
|
+
|
31
|
+
class Rhythms
|
32
|
+
|
33
|
+
# Regular expressions used in parsing
|
34
|
+
SIMPLE_RHYTHM_SENTENCE = /The (?<name>[[:alpha:]]+) rhythm is a single line with [-a-z ]+ beats?( divided into [-a-z ]+ bars in a [-0-9]+ pattern)?(\.|$)/
|
35
|
+
COMPOSITE_RHYTHM_SENTENCE = /The (?<name>[[:alpha:]]+) rhythm is made from [-a-z ]+ patterns: (?<patterns>[^.]+)(\.|$)/
|
36
|
+
|
37
|
+
# "the <rhythm>". Used to match individual components in COMPOSITE_RHYTHM_SENTENCE
|
38
|
+
THE_RHYTHM = /the (?<rhythm_name>[[:alpha:]]+)( \((?<reference_comment>[^)]+)\))?/
|
39
|
+
IS_PRIMARY_COMMENT = 'considered the primary'
|
40
|
+
|
41
|
+
# Used to recognise how multiple rhythms are to be combined
|
42
|
+
COMBINATION_TYPE_SENTENCE = /The patterns are to be played (?<type_summary>[^.,]+), [^.]+\./
|
43
|
+
POLYRHYTHM_TYPE_SUMMARY = 'over the same period of time'
|
44
|
+
|
45
|
+
|
46
|
+
# Parses the rhythms from the given form text.
|
47
|
+
#
|
48
|
+
# @param form_text [String]
|
49
|
+
# @return [Hash{Symbol,Rhythm}]
|
50
|
+
def parse(form_text)
|
51
|
+
parser = Parser::SectionedText.new(form_text)
|
52
|
+
|
53
|
+
simple_rhythms = parse_simple_rhythms(parser)
|
54
|
+
composite_rhythms = parse_composite_rhythms(parser, simple_rhythms)
|
55
|
+
|
56
|
+
simple_rhythms.merge(composite_rhythms)
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
# @param parser [Parser::SectionedText]
|
62
|
+
# @return [Hash{Symbol,Rhythm}]
|
63
|
+
def parse_simple_rhythms(parser)
|
64
|
+
|
65
|
+
rhythms = {}
|
66
|
+
|
67
|
+
# Find the rhythm description and the following paragraph with the score.
|
68
|
+
parser.sections.each_cons(2) do |rhythm, score|
|
69
|
+
match = SIMPLE_RHYTHM_SENTENCE.match(rhythm)
|
70
|
+
|
71
|
+
# Make sure we're actually dealing with a rhythm, not some other form element.
|
72
|
+
next unless match
|
73
|
+
|
74
|
+
rhythms[match[:name].to_sym] = RhythmLine.parse(score)
|
75
|
+
end
|
76
|
+
|
77
|
+
rhythms
|
78
|
+
end
|
79
|
+
|
80
|
+
# @param parser [Parser::SectionedText]
|
81
|
+
# @param base_rhythms [Hash{Symbol,Rhythm}] Simpler rhythms that can be used by the composite forms we're parsing.
|
82
|
+
# @return [Hash{Symbol,Rhythm}]
|
83
|
+
def parse_composite_rhythms(parser, base_rhythms)
|
84
|
+
|
85
|
+
composite_rhythms = {}
|
86
|
+
parser.find_all_paragraphs(COMPOSITE_RHYTHM_SENTENCE).each do |paragraph|
|
87
|
+
|
88
|
+
# TODO: write something that handles named matches a bit better
|
89
|
+
intro_sentence = paragraph.find(COMPOSITE_RHYTHM_SENTENCE).match(COMPOSITE_RHYTHM_SENTENCE)
|
90
|
+
polyrhythm_name = intro_sentence[:name].to_sym
|
91
|
+
primary, *secondaries = parse_polyrhythm_components(intro_sentence[:patterns], base_rhythms)
|
92
|
+
|
93
|
+
combination_type = paragraph.find(COMBINATION_TYPE_SENTENCE).match(COMBINATION_TYPE_SENTENCE)[:type_summary]
|
94
|
+
|
95
|
+
unless combination_type == POLYRHYTHM_TYPE_SUMMARY
|
96
|
+
raise UnrecognisedFormSyntax.new("Unrecognised polyrhythm type #{combination_type}")
|
97
|
+
end
|
98
|
+
|
99
|
+
composite_rhythms[polyrhythm_name] = Polyrhythm.new(primary, secondaries)
|
100
|
+
end
|
101
|
+
|
102
|
+
composite_rhythms
|
103
|
+
end
|
104
|
+
|
105
|
+
# @param reference_string [String] The list of rhythms used, like "the anto (considered the primary), the tak, ..."
|
106
|
+
# @param base_rhythms [Hash{Symbol, Rhythm}]
|
107
|
+
# @return [Array<Rhythm>] The matched rhythms, with the primary one as first element/.
|
108
|
+
def parse_polyrhythm_components(reference_string, base_rhythms)
|
109
|
+
primary = nil
|
110
|
+
secondaries = []
|
111
|
+
|
112
|
+
rhythms_with_comments = reference_string.scan(THE_RHYTHM).map do |rhythm_name, comment|
|
113
|
+
component = base_rhythms[rhythm_name.to_sym]
|
114
|
+
raise(UnknownBaseRhythm.new(rhythm_name)) unless component
|
115
|
+
|
116
|
+
[component, comment]
|
117
|
+
end
|
118
|
+
|
119
|
+
primary_rhythms = rhythms_with_comments.select { |_, comment| comment == IS_PRIMARY_COMMENT }
|
120
|
+
|
121
|
+
if primary_rhythms.length != 1
|
122
|
+
raise "Unexpected number of primary rhythms in #{primary_rhythms.inspect}; expected exactly 1"
|
123
|
+
end
|
124
|
+
primary = primary_rhythms.first.first
|
125
|
+
|
126
|
+
remaining_rhythms = rhythms_with_comments - primary_rhythms
|
127
|
+
remaining_comments = remaining_rhythms.map(&:last).compact.uniq
|
128
|
+
|
129
|
+
if remaining_comments.any?
|
130
|
+
raise "Unrecognised rhythm comment(s) #{remaining_comments.inspect}"
|
131
|
+
end
|
132
|
+
|
133
|
+
secondaries = remaining_rhythms.select {|_, comment| comment.nil? }.map(&:first)
|
134
|
+
[primary, *secondaries]
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|