inevitable_cacophony 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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