clef 0.1.0 → 1.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 +4 -4
- data/README.md +77 -90
- data/Rakefile +21 -1
- data/exe/clef +21 -0
- data/lib/clef/compiler.rb +107 -4
- data/lib/clef/core/chord.rb +9 -3
- data/lib/clef/core/duration.rb +7 -3
- data/lib/clef/core/key_signature.rb +43 -36
- data/lib/clef/core/measure.rb +14 -10
- data/lib/clef/core/metadata.rb +52 -0
- data/lib/clef/core/note.rb +50 -4
- data/lib/clef/core/pitch.rb +73 -4
- data/lib/clef/core/rest.rb +11 -3
- data/lib/clef/core/score.rb +148 -9
- data/lib/clef/core/staff.rb +13 -3
- data/lib/clef/core/staff_group.rb +8 -2
- data/lib/clef/core/tempo.rb +5 -0
- data/lib/clef/core/tuplet.rb +48 -0
- data/lib/clef/core/validation.rb +39 -0
- data/lib/clef/core/voice.rb +21 -5
- data/lib/clef/engraving/font_manager.rb +1 -1
- data/lib/clef/engraving/glyph_table.rb +18 -3
- data/lib/clef/engraving/style.rb +41 -2
- data/lib/clef/ir/moment.rb +2 -2
- data/lib/clef/ir/music_tree.rb +2 -2
- data/lib/clef/ir/timeline.rb +25 -5
- data/lib/clef/layout/beam_layout.rb +2 -2
- data/lib/clef/layout/item.rb +26 -0
- data/lib/clef/layout/spacing.rb +6 -4
- data/lib/clef/layout/stem.rb +10 -6
- data/lib/clef/layout/system_layout.rb +71 -0
- data/lib/clef/midi/channel_map.rb +5 -2
- data/lib/clef/midi/exporter.rb +316 -38
- data/lib/clef/notation/dynamic.rb +5 -0
- data/lib/clef/notation/lyric.rb +33 -1
- data/lib/clef/parser/dsl.rb +249 -58
- data/lib/clef/parser/lilypond_lexer.rb +43 -3
- data/lib/clef/parser/lilypond_parser.rb +231 -17
- data/lib/clef/plugins/base.rb +24 -4
- data/lib/clef/plugins/registry.rb +80 -10
- data/lib/clef/renderer/base.rb +2 -2
- data/lib/clef/renderer/drawing_context.rb +26 -0
- data/lib/clef/renderer/notation_helpers.rb +92 -1
- data/lib/clef/renderer/pdf_renderer.rb +487 -82
- data/lib/clef/renderer/svg_renderer.rb +510 -97
- data/lib/clef/version.rb +1 -1
- data/lib/clef.rb +60 -7
- data/sig/clef.rbs +292 -0
- metadata +14 -5
data/lib/clef/midi/exporter.rb
CHANGED
|
@@ -8,73 +8,351 @@ module Clef
|
|
|
8
8
|
class Exporter
|
|
9
9
|
include MIDI
|
|
10
10
|
|
|
11
|
+
DEFAULT_VELOCITY = 90
|
|
12
|
+
DEFAULT_PROGRAM = 1
|
|
13
|
+
|
|
14
|
+
attr_reader :score, :channel_map, :ppqn, :instrument_map, :plugins
|
|
15
|
+
|
|
11
16
|
# @param score [Clef::Core::Score]
|
|
12
17
|
# @param channel_map [ChannelMap]
|
|
13
|
-
|
|
18
|
+
# @param ppqn [Integer]
|
|
19
|
+
# @param instrument_map [Hash]
|
|
20
|
+
# @param plugins [Clef::Plugins::Registry]
|
|
21
|
+
def initialize(score, channel_map: ChannelMap.new, ppqn: 480, instrument_map: {}, plugins: score.plugins || Clef.plugins)
|
|
22
|
+
raise ArgumentError, "ppqn must be a positive Integer" unless ppqn.is_a?(Integer) && ppqn.positive?
|
|
23
|
+
|
|
14
24
|
@score = score
|
|
15
25
|
@channel_map = channel_map
|
|
26
|
+
@ppqn = ppqn
|
|
27
|
+
@instrument_map = instrument_map
|
|
28
|
+
@plugins = plugins
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param target [String, #write]
|
|
32
|
+
# @return [String, #write]
|
|
33
|
+
def export(target)
|
|
34
|
+
plugins.run_hook(:on_before_midi, self)
|
|
35
|
+
sequence = build_sequence
|
|
36
|
+
write_sequence(sequence, target)
|
|
37
|
+
target
|
|
16
38
|
end
|
|
17
39
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def build_sequence
|
|
21
43
|
sequence = Sequence.new
|
|
44
|
+
sequence.ppqn = ppqn
|
|
45
|
+
append_tempo_track(sequence)
|
|
46
|
+
append_staff_tracks(sequence)
|
|
47
|
+
sequence
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def append_tempo_track(sequence)
|
|
22
51
|
track = Track.new(sequence)
|
|
23
52
|
sequence.tracks << track
|
|
24
|
-
|
|
53
|
+
last_tick = write_tempo_events(track)
|
|
54
|
+
append_track_end(track, score_length, last_tick)
|
|
55
|
+
end
|
|
25
56
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
57
|
+
def append_staff_tracks(sequence)
|
|
58
|
+
score.staves.each_with_index do |staff, index|
|
|
59
|
+
track = Track.new(sequence)
|
|
60
|
+
sequence.tracks << track
|
|
61
|
+
channel = channel_for(staff, index)
|
|
62
|
+
track.events << ProgramChange.new(channel, instrument_for(staff), 0)
|
|
63
|
+
append_staff(track, staff, channel)
|
|
64
|
+
end
|
|
29
65
|
end
|
|
30
66
|
|
|
31
|
-
|
|
67
|
+
def append_staff(track, staff, channel)
|
|
68
|
+
note_events = collect_staff_note_events(staff, channel)
|
|
69
|
+
last_tick = write_delta_events(track, note_events)
|
|
70
|
+
append_track_end(track, staff_length(staff), last_tick)
|
|
71
|
+
end
|
|
32
72
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
73
|
+
def collect_staff_note_events(staff, channel)
|
|
74
|
+
starts = measure_starts(staff)
|
|
75
|
+
staff.measures.flat_map { |measure| measure.voices.keys }.uniq.flat_map do |voice_id|
|
|
76
|
+
pending_ties = {}
|
|
77
|
+
playback_state = {velocity: DEFAULT_VELOCITY, slur_depth: 0}
|
|
78
|
+
events = starts.flat_map do |measure, measure_start|
|
|
79
|
+
voice = measure.voices[voice_id]
|
|
80
|
+
next [] unless voice
|
|
81
|
+
|
|
82
|
+
collect_voice_events(voice, measure_start, channel,
|
|
83
|
+
pending_ties: pending_ties,
|
|
84
|
+
playback_state: playback_state,
|
|
85
|
+
flush_ties: false)
|
|
86
|
+
end
|
|
87
|
+
events + flush_pending_ties(pending_ties, channel)
|
|
38
88
|
end
|
|
39
89
|
end
|
|
40
90
|
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
91
|
+
def collect_voice_events(voice, start_time, channel, pending_ties: {}, playback_state: {velocity: DEFAULT_VELOCITY}, flush_ties: true)
|
|
92
|
+
events, = collect_elements(voice.elements, start_time, channel, Rational(1, 1), pending_ties, playback_state,
|
|
93
|
+
flush_ties: flush_ties)
|
|
94
|
+
events
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def collect_elements(elements, start_time, channel, ratio, pending_ties, playback_state, flush_ties:)
|
|
98
|
+
cursor = start_time
|
|
99
|
+
events = []
|
|
100
|
+
elements.each do |element|
|
|
101
|
+
case element
|
|
102
|
+
when Clef::Core::Rest
|
|
103
|
+
cursor += element.length * ratio
|
|
104
|
+
when Clef::Core::Note
|
|
105
|
+
events.concat(schedule_note(element, cursor, channel, ratio, pending_ties, playback_state))
|
|
106
|
+
advance_slur_state(element, playback_state)
|
|
107
|
+
cursor += element.length * ratio
|
|
108
|
+
when Clef::Core::Chord
|
|
109
|
+
events.concat(schedule_chord(element, cursor, channel, ratio, playback_state))
|
|
110
|
+
cursor += element.length * ratio
|
|
111
|
+
when Clef::Core::Tuplet
|
|
112
|
+
nested_events, = collect_elements(element.elements, cursor, channel, ratio * element.ratio,
|
|
113
|
+
pending_ties, playback_state, flush_ties: false)
|
|
114
|
+
events.concat(nested_events)
|
|
115
|
+
cursor += element.length * ratio
|
|
116
|
+
when Clef::Notation::Dynamic
|
|
117
|
+
playback_state[:velocity] = velocity_for_dynamic(element)
|
|
118
|
+
when Clef::Core::Tempo
|
|
119
|
+
cursor += element.length
|
|
120
|
+
end
|
|
46
121
|
end
|
|
122
|
+
events.concat(flush_pending_ties(pending_ties, channel)) if flush_ties
|
|
123
|
+
[events, cursor]
|
|
47
124
|
end
|
|
48
125
|
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
126
|
+
def schedule_note(note, start_time, channel, ratio, pending_ties, playback_state)
|
|
127
|
+
duration = note.length * ratio
|
|
128
|
+
midi = note.pitch.to_midi
|
|
129
|
+
if note.tie_state == :start || note.tie_state == :continue
|
|
130
|
+
pending = pending_ties[midi] ||= {
|
|
131
|
+
start_time: start_time,
|
|
132
|
+
duration: Rational(0, 1),
|
|
133
|
+
note: note,
|
|
134
|
+
velocity: playback_state[:velocity]
|
|
135
|
+
}
|
|
136
|
+
pending[:duration] += duration
|
|
137
|
+
return []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if note.tie_state == :stop && pending_ties.key?(midi)
|
|
141
|
+
pending = pending_ties.delete(midi)
|
|
142
|
+
pending[:duration] += duration
|
|
143
|
+
return [note_event(pending[:note], pending[:start_time], pending[:duration], channel, pending[:velocity])]
|
|
52
144
|
end
|
|
53
|
-
|
|
145
|
+
|
|
146
|
+
[note_event(note, start_time, effective_note_length(note, duration,
|
|
147
|
+
slurred: legato_slur_note?(note, playback_state)), channel, playback_state[:velocity])]
|
|
54
148
|
end
|
|
55
149
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
150
|
+
def schedule_chord(chord, start_time, channel, ratio, playback_state)
|
|
151
|
+
duration = chord.length * ratio
|
|
152
|
+
chord.pitches.map do |pitch|
|
|
153
|
+
{start_time: start_time, duration: duration, pitch: pitch.to_midi,
|
|
154
|
+
velocity: playback_state[:velocity], channel: channel}
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def flush_pending_ties(pending_ties, channel)
|
|
159
|
+
pending_ties.values.map do |pending|
|
|
160
|
+
note_event(pending[:note], pending[:start_time], pending[:duration], channel, pending[:velocity])
|
|
161
|
+
end.tap { pending_ties.clear }
|
|
162
|
+
end
|
|
59
163
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
164
|
+
def note_event(note, start_time, duration, channel, base_velocity)
|
|
165
|
+
{
|
|
166
|
+
start_time: start_time,
|
|
167
|
+
duration: duration,
|
|
168
|
+
pitch: note.pitch.to_midi,
|
|
169
|
+
velocity: velocity_for(note, base_velocity),
|
|
170
|
+
channel: channel
|
|
171
|
+
}
|
|
63
172
|
end
|
|
64
173
|
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
174
|
+
def write_delta_events(track, note_events)
|
|
175
|
+
absolute_events = note_events.flat_map do |event|
|
|
176
|
+
start_tick = ticks_for(event[:start_time])
|
|
177
|
+
end_tick = ticks_for(event[:start_time] + event[:duration])
|
|
178
|
+
[
|
|
179
|
+
[start_tick, 1, NoteOn.new(event[:channel], event[:pitch], event[:velocity], 0)],
|
|
180
|
+
[end_tick, 0, NoteOff.new(event[:channel], event[:pitch], event[:velocity], 0)]
|
|
181
|
+
]
|
|
182
|
+
end.sort_by { |tick, priority, _event| [tick, priority] }
|
|
183
|
+
|
|
184
|
+
previous_tick = 0
|
|
185
|
+
absolute_events.each do |tick, _priority, event|
|
|
186
|
+
event.delta_time = tick - previous_tick
|
|
187
|
+
track.events << event
|
|
188
|
+
previous_tick = tick
|
|
69
189
|
end
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
190
|
+
previous_tick
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def effective_note_length(note, duration, slurred: false)
|
|
194
|
+
return duration * Rational(1, 2) if note.articulations.include?(:staccato)
|
|
195
|
+
return duration * Rational(19, 20) if note.articulations.include?(:tenuto)
|
|
196
|
+
return duration * Rational(101, 100) if slurred
|
|
197
|
+
|
|
198
|
+
duration
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def legato_slur_note?(note, playback_state)
|
|
202
|
+
(playback_state.fetch(:slur_depth, 0).positive? || note.slur_start) && !note.slur_end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def advance_slur_state(note, playback_state)
|
|
206
|
+
depth = playback_state.fetch(:slur_depth, 0)
|
|
207
|
+
depth += 1 if note.slur_start
|
|
208
|
+
depth -= 1 if note.slur_end && depth.positive?
|
|
209
|
+
playback_state[:slur_depth] = depth
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def velocity_for(note, base_velocity)
|
|
213
|
+
return 112 if note.articulations.include?(:accent) || note.articulations.include?(:marcato)
|
|
214
|
+
|
|
215
|
+
base_velocity
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def velocity_for_dynamic(dynamic)
|
|
219
|
+
{
|
|
220
|
+
pp: 36,
|
|
221
|
+
p: 48,
|
|
222
|
+
mp: 64,
|
|
223
|
+
mf: 80,
|
|
224
|
+
f: 96,
|
|
225
|
+
ff: 112,
|
|
226
|
+
fff: 120,
|
|
227
|
+
sfz: 120,
|
|
228
|
+
fp: 96,
|
|
229
|
+
cresc: 88,
|
|
230
|
+
dim: 64
|
|
231
|
+
}.fetch(dynamic.type)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def write_tempo_events(track)
|
|
235
|
+
events = ([[Rational(0, 1), quarter_note_bpm]] + collect_score_tempo_events)
|
|
236
|
+
.uniq { |time, _bpm| time }
|
|
237
|
+
.sort_by(&:first)
|
|
238
|
+
previous_tick = 0
|
|
239
|
+
events.each do |time, bpm|
|
|
240
|
+
tick = ticks_for(time)
|
|
241
|
+
event = Tempo.new(Tempo.bpm_to_mpq(bpm))
|
|
242
|
+
event.delta_time = tick - previous_tick
|
|
243
|
+
track.events << event
|
|
244
|
+
previous_tick = tick
|
|
73
245
|
end
|
|
246
|
+
previous_tick
|
|
74
247
|
end
|
|
75
248
|
|
|
76
|
-
def
|
|
77
|
-
|
|
249
|
+
def append_track_end(track, length, previous_tick)
|
|
250
|
+
tick = ticks_for(length)
|
|
251
|
+
track.events << MetaEvent.new(META_TRACK_END, nil, tick - previous_tick)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def collect_score_tempo_events
|
|
255
|
+
score.staves.flat_map do |staff|
|
|
256
|
+
measure_starts(staff).flat_map do |measure, measure_start|
|
|
257
|
+
events = measure.voices.values.flat_map { |voice| collect_tempo_events(voice.elements, measure_start, Rational(1, 1)) }
|
|
258
|
+
events
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def measure_starts(staff)
|
|
264
|
+
measure_start = Rational(0, 1)
|
|
265
|
+
staff.measures.map do |measure|
|
|
266
|
+
[measure, measure_start].tap do
|
|
267
|
+
measure_start += measure_length_for(measure)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def collect_tempo_events(elements, start_time, ratio)
|
|
273
|
+
cursor = start_time
|
|
274
|
+
elements.flat_map do |element|
|
|
275
|
+
case element
|
|
276
|
+
when Clef::Core::Rest, Clef::Core::Note, Clef::Core::Chord
|
|
277
|
+
cursor += element.length * ratio
|
|
278
|
+
[]
|
|
279
|
+
when Clef::Core::Tuplet
|
|
280
|
+
nested = collect_tempo_events(element.elements, cursor, ratio * element.ratio)
|
|
281
|
+
cursor += element.length * ratio
|
|
282
|
+
nested
|
|
283
|
+
when Clef::Core::Tempo
|
|
284
|
+
[[cursor, tempo_to_quarter_bpm(element)]]
|
|
285
|
+
else
|
|
286
|
+
[]
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def ticks_for(time)
|
|
292
|
+
(time * ppqn * 4).round
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def measure_length_for(measure)
|
|
296
|
+
return measure.time_signature.measure_length if measure.time_signature
|
|
297
|
+
|
|
298
|
+
measure.voices.values.map(&:total_length).max || Rational(0, 1)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def staff_length(staff)
|
|
302
|
+
staff.measures.sum { |measure| measure_length_for(measure) }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def score_length
|
|
306
|
+
score.staves.map { |staff| staff_length(staff) }.max || Rational(0, 1)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def quarter_note_bpm
|
|
310
|
+
tempo = score.tempo
|
|
311
|
+
return 120 unless tempo
|
|
312
|
+
|
|
313
|
+
tempo_to_quarter_bpm(tempo)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def tempo_to_quarter_bpm(tempo)
|
|
317
|
+
(tempo.bpm * (tempo.beat_unit.length / Clef::Core::Duration.quarter.length)).round
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def channel_for(staff, index)
|
|
321
|
+
channel_map.channel_for(index, percussion: staff.clef.type == :percussion)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def instrument_for(staff)
|
|
325
|
+
program = instrument_map.fetch(staff.id) do
|
|
326
|
+
instrument_map.fetch(index_key(staff)) do
|
|
327
|
+
staff.metadata.fetch(:midi_program) { staff.metadata.fetch(:program, DEFAULT_PROGRAM) }
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
validate_program!(program)
|
|
331
|
+
program
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def index_key(staff)
|
|
335
|
+
score.staves.index(staff)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def validate_program!(program)
|
|
339
|
+
return if program.is_a?(Integer) && (0..127).cover?(program)
|
|
340
|
+
|
|
341
|
+
raise ArgumentError, "MIDI program must be an Integer between 0 and 127"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def write_sequence(sequence, target)
|
|
345
|
+
return sequence.write(target) if target.respond_to?(:write)
|
|
346
|
+
|
|
347
|
+
ensure_parent_directory!(target)
|
|
348
|
+
File.open(target, "wb") { |file| sequence.write(file) }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def ensure_parent_directory!(path)
|
|
352
|
+
parent = File.dirname(path.to_s)
|
|
353
|
+
return if parent.nil? || parent == "." || Dir.exist?(parent)
|
|
354
|
+
|
|
355
|
+
raise ArgumentError, "output directory does not exist: #{parent}"
|
|
78
356
|
end
|
|
79
357
|
end
|
|
80
358
|
end
|
data/lib/clef/notation/lyric.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module Clef
|
|
4
4
|
module Notation
|
|
5
5
|
class Lyric
|
|
6
|
+
HYPHEN = "--"
|
|
7
|
+
EXTENDER = "_"
|
|
8
|
+
|
|
6
9
|
attr_reader :voice_id, :text, :syllables
|
|
7
10
|
|
|
8
11
|
# @param voice_id [Symbol]
|
|
@@ -15,14 +18,43 @@ module Clef
|
|
|
15
18
|
@syllables = parse_syllables(text)
|
|
16
19
|
end
|
|
17
20
|
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
def note_slot_count
|
|
23
|
+
syllables.count { |syllable| self.class.note_syllable?(syllable) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param syllable [String]
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
def self.note_syllable?(syllable)
|
|
29
|
+
syllable != HYPHEN
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param syllable [String]
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def self.hyphen?(syllable)
|
|
35
|
+
syllable == HYPHEN
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param syllable [String]
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def self.extender?(syllable)
|
|
41
|
+
syllable == EXTENDER
|
|
42
|
+
end
|
|
43
|
+
|
|
18
44
|
private
|
|
19
45
|
|
|
20
46
|
def parse_syllables(input)
|
|
21
47
|
input
|
|
22
48
|
.split(/\s+/)
|
|
23
|
-
.flat_map { |token| token
|
|
49
|
+
.flat_map { |token| lyric_token_parts(token) }
|
|
24
50
|
.reject(&:empty?)
|
|
25
51
|
end
|
|
52
|
+
|
|
53
|
+
def lyric_token_parts(token)
|
|
54
|
+
return [token] if [EXTENDER, HYPHEN].include?(token)
|
|
55
|
+
|
|
56
|
+
token.split("-")
|
|
57
|
+
end
|
|
26
58
|
end
|
|
27
59
|
end
|
|
28
60
|
end
|