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.
@@ -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