Class: HeadMusic::Content::Composition::HashDeserializer

Inherits:
Object
  • Object
show all
Defined in:
lib/head_music/content/composition.rb

Overview

Rebuilds a composition from a schema v3 hash by replaying the public builder API in dependency order: meter and key changes first (position strings roll counts and ticks over via the meter map), then placements, then repeat flags (a pickup-bar flag needs its bar allocated), then comments. Validates values at the boundary so corrupted input raises ArgumentError with path context instead of silently deserializing wrong.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(hash) ⇒ HashDeserializer

Returns a new instance of HashDeserializer.

Raises:

  • (ArgumentError)


153
154
155
156
157
158
# File 'lib/head_music/content/composition.rb', line 153

def initialize(hash)
  raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)

  @hash = hash.deep_transform_keys(&:to_s)
  validate_schema_version
end

Instance Attribute Details

#hashObject (readonly, private)

Returns the value of attribute hash.



171
172
173
# File 'lib/head_music/content/composition.rb', line 171

def hash
  @hash
end

Instance Method Details

#add_comments(composition) ⇒ Object (private)



234
235
236
237
238
239
# File 'lib/head_music/content/composition.rb', line 234

def add_comments(composition)
  Array(hash["comments"]).each_with_index do |comment_hash, index|
    position = parsed_position(comment_hash["position"], "comments[#{index}]") if comment_hash["position"]
    composition.add_comment(comment_hash["text"], position)
  end
end

#apply_bar_changes(composition) ⇒ Object (private)



199
200
201
202
203
204
205
206
207
208
# File 'lib/head_music/content/composition.rb', line 199

def apply_bar_changes(composition)
  bar_hashes.each_with_index do |bar_hash, index|
    number = validated_bar_number(bar_hash, index)
    path = "bars[#{index}]"
    key_signature = parsed_key_signature(bar_hash["key_signature"], path)
    meter = parsed_meter(bar_hash["meter"], path)
    composition.change_key_signature(number, key_signature) if key_signature
    composition.change_meter(number, meter) if meter
  end
end

#apply_repeat_flags(composition) ⇒ Object (private)



223
224
225
226
227
228
229
230
231
232
# File 'lib/head_music/content/composition.rb', line 223

def apply_repeat_flags(composition)
  bar_hashes.each_with_index do |bar_hash, index|
    next unless repeat_state?(bar_hash)

    bar = composition.bars(validated_bar_number(bar_hash, index)).last
    bar.starts_repeat = true if bar_hash["starts_repeat"]
    bar.ends_repeat_after_num_plays = bar_hash["ends_repeat_after_num_plays"] if bar_hash["ends_repeat_after_num_plays"]
    bar.plays_on_passes = bar_hash["plays_on_passes"] if bar_hash["plays_on_passes"]
  end
end

#bar_hashesObject (private)



195
196
197
# File 'lib/head_music/content/composition.rb', line 195

def bar_hashes
  @bar_hashes ||= Array(hash["bars"])
end

#build_base_compositionObject (private)



185
186
187
188
189
190
191
192
193
# File 'lib/head_music/content/composition.rb', line 185

def build_base_composition
  HeadMusic::Content::Composition.new(
    name: hash["name"],
    key_signature: parsed_key_signature(hash["key_signature"], "key_signature"),
    meter: parsed_meter(hash["meter"], "meter"),
    composer: hash["composer"],
    origin: hash["origin"]
  )
end

#build_voices(composition) ⇒ Object (private)



210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/head_music/content/composition.rb', line 210

def build_voices(composition)
  Array(hash["voices"]).each_with_index do |voice_hash, voice_index|
    voice = composition.add_voice(role: voice_hash["role"])
    Array(voice_hash["placements"]).each_with_index do |placement_hash, placement_index|
      path = "voices[#{voice_index}].placements[#{placement_index}]"
      position = parsed_position(placement_hash["position"], path)
      rhythmic_value = parsed_rhythmic_value(placement_hash["rhythmic_value"], path)
      sounds = parsed_placement_sounds(placement_hash, path)
      voice.place(position, rhythmic_value, sounds)
    end
  end
end

#compositionObject



160
161
162
163
164
165
166
167
# File 'lib/head_music/content/composition.rb', line 160

def composition
  @composition ||= build_base_composition.tap do |composition|
    apply_bar_changes(composition)
    build_voices(composition)
    apply_repeat_flags(composition)
    add_comments(composition)
  end
end

#parsed_key_signature(value, path) ⇒ Object (private)

KeySignature.get returns a hollow object (nil tonic_spelling) for garbage rather than nil, so presence of the tonic is the real check.



267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/head_music/content/composition.rb', line 267

def parsed_key_signature(value, path)
  return nil if value.nil?

  key_signature = begin
    HeadMusic::Rudiment::KeySignature.get(value)
  rescue
    nil
  end
  unless key_signature&.tonic_spelling
    raise ArgumentError, "#{path}: unknown key signature #{value.inspect}"
  end
  key_signature
end

#parsed_meter(value, path) ⇒ Object (private)



281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/head_music/content/composition.rb', line 281

def parsed_meter(value, path)
  return nil if value.nil?

  meter = begin
    HeadMusic::Rudiment::Meter.get(value)
  rescue
    nil
  end
  unless meter&.top_number&.positive? && meter.bottom_number.positive?
    raise ArgumentError, "#{path}: unknown meter #{value.inspect}"
  end
  meter
end

#parsed_pitch(value, path) ⇒ Object (private)

A value that fails to parse would otherwise silently deserialize as a rest.

Raises:

  • (ArgumentError)


315
316
317
318
319
320
# File 'lib/head_music/content/composition.rb', line 315

def parsed_pitch(value, path)
  pitch = HeadMusic::Rudiment::Pitch.get(value)
  raise ArgumentError, "#{path}: unknown pitch #{value.inspect}" unless pitch

  pitch
end

#parsed_placement_sounds(placement_hash, path) ⇒ Object (private)

“sounds” is an array of sound data, empty for a rest. A pitched sound is a pitch string; an unpitched sound is a one-key => name_key hash. A nil element is never a rest, so it fails like any other unknown sound.



326
327
328
329
330
331
332
333
334
335
# File 'lib/head_music/content/composition.rb', line 326

def parsed_placement_sounds(placement_hash, path)
  values = placement_hash["sounds"]
  unless values.is_a?(Array)
    raise ArgumentError, "#{path}: sounds must be an Array, got #{values.inspect}"
  end

  values.each_with_index.map do |value, index|
    parsed_sound(value, "#{path}.sounds[#{index}]")
  end
end

#parsed_position(value, path) ⇒ Object (private)

Position silently coerces garbage strings to “0:1:000”, which would mislocate content with no error, so the shape is validated up front. Accepts “bar”, “bar:count”, or “bar:count:tick” with non-negative parts.



256
257
258
259
260
261
262
263
# File 'lib/head_music/content/composition.rb', line 256

def parsed_position(value, path)
  return nil if value.nil?

  unless value.is_a?(String) && value.match?(/\A\d+(:\d+){0,2}\z/)
    raise ArgumentError, "#{path}: unknown position #{value.inspect}"
  end
  value
end

#parsed_rhythmic_value(value, path) ⇒ Object (private)



295
296
297
298
299
300
301
# File 'lib/head_music/content/composition.rb', line 295

def parsed_rhythmic_value(value, path)
  rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(value)
  unless valid_rhythmic_value?(rhythmic_value)
    raise ArgumentError, "#{path}: unknown rhythmic value #{value.inspect}"
  end
  rhythmic_value
end

#parsed_sound(value, path) ⇒ Object (private)

Raises:

  • (ArgumentError)


337
338
339
340
341
342
# File 'lib/head_music/content/composition.rb', line 337

def parsed_sound(value, path)
  return parsed_pitch(value, path) if value.is_a?(String)
  return parsed_unpitched_sound(value, path) if value.is_a?(Hash)

  raise ArgumentError, "#{path}: unknown sound #{value.inspect}"
end

#parsed_unpitched_sound(value, path) ⇒ Object (private)

A nil name_key is the generic unpitched sound. A pitched instrument is a valid hit surface (a knock on a violin body is unpitched), so any catalog name or alias resolves.

Raises:

  • (ArgumentError)


347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/head_music/content/composition.rb', line 347

def parsed_unpitched_sound(value, path)
  unless value.keys == ["unpitched"]
    raise ArgumentError, "#{path}: unknown sound #{value.inspect}"
  end

  name = value["unpitched"]
  valid_name = name.nil? || (name.is_a?(String) && !name.empty?)
  sound = HeadMusic::Rudiment::UnpitchedSound.get(name) if valid_name
  raise ArgumentError, "#{path}: unknown instrument #{name.inspect}" unless sound

  sound
end

#repeat_state?(bar_hash) ⇒ Boolean (private)

Returns:

  • (Boolean)


241
242
243
# File 'lib/head_music/content/composition.rb', line 241

def repeat_state?(bar_hash)
  bar_hash["starts_repeat"] || bar_hash["ends_repeat_after_num_plays"] || bar_hash["plays_on_passes"]
end

#valid_rhythmic_value?(rhythmic_value) ⇒ Boolean (private)

RhythmicValue.get returns a hollow object (nil unit) for garbage rather than nil, and a tied tail can be hollow while the head parses, so the whole tie chain is checked.

Returns:

  • (Boolean)


306
307
308
309
310
311
# File 'lib/head_music/content/composition.rb', line 306

def valid_rhythmic_value?(rhythmic_value)
  return false unless rhythmic_value.is_a?(HeadMusic::Rudiment::RhythmicValue)
  return false unless rhythmic_value.unit

  rhythmic_value.tied_value.nil? || valid_rhythmic_value?(rhythmic_value.tied_value)
end

#validate_schema_versionObject (private)

Raises:

  • (ArgumentError)


173
174
175
176
177
178
179
180
181
182
183
# File 'lib/head_music/content/composition.rb', line 173

def validate_schema_version
  version = hash["schema_version"]
  return if version.is_a?(Integer) && version == SCHEMA_VERSION

  message = "unsupported schema_version: #{version.inspect} (supported: #{SCHEMA_VERSION})"
  if version == 2
    message += "; migrate v2 \"pitches\" arrays to v3 \"sounds\" arrays " \
      "(pitch strings unchanged; unpitched sounds are {\"unpitched\" => name_key} objects)"
  end
  raise ArgumentError, message
end

#validated_bar_number(bar_hash, index) ⇒ Object (private)



245
246
247
248
249
250
251
# File 'lib/head_music/content/composition.rb', line 245

def validated_bar_number(bar_hash, index)
  number = bar_hash["number"]
  unless number.is_a?(Integer) && number >= 0
    raise ArgumentError, "bars[#{index}]: bar number must be an Integer of at least 0, got #{number.inspect}"
  end
  number
end