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