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 v1 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)



229
230
231
232
233
234
# File 'lib/head_music/content/composition.rb', line 229

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)



194
195
196
197
198
199
200
201
202
203
# File 'lib/head_music/content/composition.rb', line 194

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)



218
219
220
221
222
223
224
225
226
227
# File 'lib/head_music/content/composition.rb', line 218

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)



190
191
192
# File 'lib/head_music/content/composition.rb', line 190

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

#build_base_compositionObject (private)



180
181
182
183
184
185
186
187
188
# File 'lib/head_music/content/composition.rb', line 180

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)



205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/head_music/content/composition.rb', line 205

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)
      pitch = parsed_pitch(placement_hash["pitch"], path)
      voice.place(position, rhythmic_value, pitch)
    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.



262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/head_music/content/composition.rb', line 262

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)



276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/head_music/content/composition.rb', line 276

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 nil pitch is a rest; a non-nil string that fails to parse would otherwise silently deserialize as a rest.

Raises:

  • (ArgumentError)


310
311
312
313
314
315
316
317
# File 'lib/head_music/content/composition.rb', line 310

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

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

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



251
252
253
254
255
256
257
258
# File 'lib/head_music/content/composition.rb', line 251

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)



290
291
292
293
294
295
296
# File 'lib/head_music/content/composition.rb', line 290

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

#repeat_state?(bar_hash) ⇒ Boolean (private)

Returns:

  • (Boolean)


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

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)


301
302
303
304
305
306
# File 'lib/head_music/content/composition.rb', line 301

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

  raise ArgumentError, "unsupported schema_version: #{version.inspect} (supported: #{SCHEMA_VERSION})"
end

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



240
241
242
243
244
245
246
# File 'lib/head_music/content/composition.rb', line 240

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