Fix Playability
ci / checks (push) Failing after 23m26s

This commit is contained in:
khodowany
2026-10-08 19:04:06 -05:00
parent bc7baea7c4
commit 3f63b255ac
23 changed files with 633 additions and 374 deletions
+15 -6
View File
@@ -8,6 +8,7 @@ import typing
import generator.acoustic
import generator.fretboard
import generator.harmony
import generator.hand_position
class Voicing(typing.NamedTuple):
bass: generator.fretboard.TabPosition
@@ -35,12 +36,14 @@ def candidates(requirements: generator.fretboard.ChordRequirements, complete_tri
return tuple(sorted(result))
@functools.lru_cache(maxsize=16384)
def movement(previous: Voicing, current: Voicing) -> int:
return sum(abs(a.fret - b.fret) for a, b in zip(previous, current)) + abs(previous.bass.string_number - current.bass.string_number)
def movement(previous: Voicing, current: Voicing, old_hand: int, hand: int) -> int:
return generator.hand_position.motion(previous, current, old_hand, hand, True).shift
class Cost(typing.NamedTuple):
extended_grips: int = 0
travel: int = 0
peak_position: int = 0
effort: int = 0
missing_tones: int = 0
@dataclasses.dataclass
@@ -48,14 +51,20 @@ class Placement:
cost: Cost
voicing: Voicing | None = None
previous: Placement | None = None
hand: int = 0
def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.ChordRequirements, states: list[Placement]) -> list[Placement]:
result = []
result = {}
for voicing in options:
missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing})
cost, index = min((Cost(p.cost.extended_grips + generator.fretboard.span_penalty(voicing), p.cost.travel + (movement(p.voicing, voicing) if p.voicing else 0), p.cost.missing_tones + missing), i) for i, p in enumerate(states))
result.append(Placement(cost, voicing, states[index]))
return result
for hand in generator.hand_position.regions(voicing):
for previous in states:
motion = generator.hand_position.motion(previous.voicing, voicing, previous.hand, hand, True)
cost = Cost(previous.cost.extended_grips + generator.fretboard.span_penalty(voicing), previous.cost.travel + motion.shift, max(previous.cost.peak_position, hand), previous.cost.effort + motion.ergonomics, previous.cost.missing_tones + missing)
key = (voicing, hand, cost.peak_position)
if key not in result or cost < result[key].cost:
result[key] = Placement(cost, voicing, previous, hand)
return list(result.values())
def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
states = [Placement(Cost())]
+8 -2
View File
@@ -17,7 +17,7 @@ def chord(source):
prior = generator.accompaniment.carried_chords(effective)[-1]
return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior)
def append(source, output, part, *, sounding=False, center=55, composition=None):
def append(source, output, part, *, sounding=False, center=55, composition=None, incoming=None):
composition = composition or generator.vocabulary.current()
if not source or not output:
return
@@ -41,7 +41,13 @@ def append(source, output, part, *, sounding=False, center=55, composition=None)
if part == 'acoustic':
bass = next((recent[s] for s in (5, 6) if s in recent), None)
previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else ()
grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center, composition=composition).moved(generator.comping.movement(part, previous, v)), v))
if incoming is not None:
previous, hand = incoming
else:
hand = None
costs = {v: generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center, composition=composition) for v in options}
path, _, _ = generator.comping.select_grips((options,), (costs,), part, 'ending-position', left=previous, left_hand=hand)
grip = path[0]
length = generator.model.fraction(model['duration'])
previous = None
for index in range(generator.arrange.ending_plan.BARS):
+6 -4
View File
@@ -39,7 +39,7 @@ def section_source(source: list[dict], section, next_section=None) -> list[dict]
generator.arrange.pickup.harmonies(destination, result[0])
return result
def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None, accompaniment=None, composition=None) -> dict:
def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None, accompaniment=None, composition=None, placement_report=None) -> dict:
composition = composition or generator.vocabulary.current()
if part_type not in generator.parts.PART_TYPES:
raise ValueError(f'unknown arrangement part: {part_type}')
@@ -100,7 +100,7 @@ def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None,
if sections and sections[-1].section_index == FINAL_SECTION_INDEX:
generator.arrange.ending.append(source, output, part_type,
sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned),
center=composition.role(part_type + '_accompaniment').get('center', 55), composition=composition)
center=composition.role(part_type + '_accompaniment').get('center', 55), composition=composition, incoming=accompaniment.last_position if accompaniment is not None else None)
tuplets = generator.arrange.pickup.retain_links(output, tuplets)
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo', 124)
@@ -112,9 +112,11 @@ def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None,
generator.acoustic.combine(result)
elif part_type == 'electric' and (owned is None or 'melody' in owned):
generator.melody.harmonize(result)
generator.guitar.place_guitar(result, composition=composition)
report = generator.guitar.place_guitar(result, composition=composition)
if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result, composition=composition)
report = generator.guitar.place_guitar(result, composition=composition)
if placement_report is not None:
placement_report.update(report)
# Source labels identify the head while arranging; published labels identify
# the complete performance, including its lead-in and repeated choruses.
for number, measure in enumerate(output, 1):
+108 -70
View File
@@ -16,6 +16,7 @@ import generator.comping_rhythm
import generator.fretboard
import generator.guitar
import generator.harmony
import generator.hand_position
import generator.model
import generator.parts
import generator.score
@@ -61,78 +62,88 @@ def _voicings(key, part, composition):
raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and at most {generator.fretboard.MAX_FRET_SPAN} consecutive frets remain binding')
return tuple(sorted(options))
def movement(part, previous, current):
if not previous or not current:
return 0
if part == 'acoustic':
return generator.acoustic_voicing.movement(previous, current)
return generator.guitar.movement(previous, current)
def movement(part, previous, current, old_hand, hand):
return generator.hand_position.motion(previous, current, old_hand, hand, part == 'acoustic').shift
class VoicingCost(typing.NamedTuple):
extended_grips: int = 0
family: int = 0
travel: int = 0
peak_position: int = 0
effort: fractions.Fraction = fractions.Fraction()
missing_tones: int = 0
register: fractions.Fraction = fractions.Fraction()
def plus(self, other):
return VoicingCost(*(a + b for a, b in zip(self, other)))
def moved(self, distance):
return self._replace(effort=self.effort + distance)
def voicing_preferences(part, grip, center, *, composition=None):
composition = composition or generator.vocabulary.current()
return generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center, composition=composition))
return VoicingCost(*(max(a, b) if field == 'peak_position' else a + b for field, a, b in zip(self._fields, self, other)))
def moved(self, distance, ergonomics=0):
return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics)
def local_cost(part, key, grip, center, *, composition=None):
composition = composition or generator.vocabulary.current()
required = requirements(json.loads(key), part)
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
preference = sum(voicing_preferences(part, grip, center, composition=composition))
register = generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center, composition=composition))
if part == 'electric':
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed, composition=composition)
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort + preference, value.missing_tones, register)
# Familiar degree orders on either bass string, then balanced whole-form
# movement, sounding register and hand position. Neither is an absolute ban.
return VoicingCost(extended_grips=value.extended_grips, family=3 * value.string_shortfall + value.family_penalty, effort=value.effort, missing_tones=value.missing_tones, register=register)
# Keep the familiar families, then compare physical effort before position.
# Sounding register only breaks otherwise equal placement decisions.
family = generator.chord_families.acoustic(json.loads(key), grip, composition=composition)
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), preference, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
return VoicingCost(extended_grips=generator.fretboard.span_penalty(grip), family=int(not family['preferred']), missing_tones=len(set(required.allowed) - {p.midi % 12 for p in grip}), register=register)
def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
"""Exact small-state dynamic programming; never prune by hand center.
def select_grips(layers, costs, part, seed, *, left=(), right=(), left_hand=None, right_hand=None, close=False, fixed_hands=None):
"""Return complete grips, cost and hand windows, including repeat travel.
Both roles balance register/position with travel. Electric includes its
base grip effort; acoustic follows all four voices and bass-string changes.
Compact span and family remain higher tiers.
Fixing the first grip permits an exact repeat-join cost without a beam.
A concrete grip may occupy several windows. Preserve distinct earlier peaks
at a common grip/hand, and fix both starting values for a cyclic path.
"""
if not layers:
return (), VoicingCost()
# Every accompaniment transition is feasible (travel is a cost). A wider
# option cannot beat a primary grip at the same span, regardless of travel.
# This exact dominance reduction avoids growing the cubic repeat search.
return (), VoicingCost(), ()
def hands(grip, fixed=None):
options = generator.hand_position.regions(grip)
if fixed is not None:
return tuple(h for h in options if h == fixed)
return options
retained = []
for i, layer in enumerate(layers):
tier = min(costs[i][v].extended_grips for v in layer)
retained.append(tuple(v for v in layer if costs[i][v].extended_grips == tier))
layers = tuple(retained)
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v in layer} for i, layer in enumerate(layers)]
fixed = fixed_hands[i] if fixed_hands is not None else None
options = tuple((v, h) for v in layer if costs[i][v].extended_grips == tier for h in hands(v, fixed))
if not options:
raise ValueError(f'accompaniment span {i}: no grip fits the committed hand position')
retained.append(options)
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v, _ in layer} for i, layer in enumerate(retained)]
winners = []
for first in layers[0] if close else [None]:
states = {left: (VoicingCost(), (), ())}
for i, layer in enumerate(layers):
for first in retained[0] if close else [None]:
states = {(left, h, h): (VoicingCost(peak_position=h), (), (), ()) for h in hands(left, left_hand)}
for i, layer in enumerate(retained):
following = {}
for grip in [first] if i == 0 and first is not None else layer:
local = costs[i][grip]
choices = []
for previous, (cost, ties, path) in states.items():
total = cost.plus(local).moved(movement(part, previous, grip))
choices.append((total, (*ties, ranks[i][grip]), (*path, grip)))
following[grip] = min(choices)
states = following
for grip, (cost, ties, path) in states.items():
travel = movement(part, grip, first if close else right)
winners.append((cost.moved(travel), ties, path))
cost, _, path = min(winners)
return path, cost
for grip, hand in [first] if i == 0 and first is not None else layer:
local = costs[i][grip]._replace(peak_position=hand)
for (previous, old_hand, _), (cost, ties, path, positions) in states.items():
motion = generator.hand_position.motion(previous, grip, old_hand, hand, part == 'acoustic')
total = cost.plus(local).moved(motion.shift, motion.ergonomics)
candidate = (total, (*ties, (ranks[i][grip], hand)), (*path, grip), (*positions, hand))
key = (grip, hand, total.peak_position)
if key not in following or candidate < following[key]:
following[key] = candidate
# Future travel is identical at a common concrete grip and hand.
best = {}
for (grip, hand, _), value in following.items():
primary = value[0][:3]
best[grip, hand] = min(best.get((grip, hand), primary), primary)
states = {key: value for key, value in following.items() if value[0][:3] == best[key[:2]]}
endpoints = [first] if close else [(right, h) for h in hands(right, right_hand)]
for (grip, hand, _), (cost, ties, path, positions) in states.items():
for endpoint, end_hand in endpoints:
motion = generator.hand_position.motion(grip, endpoint, hand, end_hand, part == 'acoustic')
total = cost.plus(VoicingCost(peak_position=end_hand)).moved(motion.shift, motion.ergonomics)
winners.append((total, ties, path, positions))
if not winners:
raise ValueError('accompaniment boundary grip does not fit the committed hand position')
cost, _, path, positions = min(winners)
return path, cost, positions
@dataclasses.dataclass(frozen=True)
class HarmonyChoice:
@@ -143,6 +154,7 @@ class HarmonyChoice:
events: tuple
figure: dict
options: tuple
hand: int | None = None
@property
def key(self):
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
@@ -193,6 +205,8 @@ class Canonical:
self.part, self.seed = part, seed
self.policy = self.composition.role(part + '_accompaniment')
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
self.cache_positions = {}
self.last_position = ((), 0)
self.measures = generator.score.source_staff(document)['measures']
# A held accompaniment chord may continue into the bar containing the
# source melody pickup. Keep its voice identity through that splice.
@@ -230,15 +244,16 @@ class Canonical:
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
active = tuple(span for spans in prepared for span in spans if span.events)
self.canonical_spans = {group[0][2]: spans for group, spans in zip(self.groups, prepared)}
path, self.path_cost = self.select(active, close=self.closed)
path, self.path_cost, self.hand_order = self.select(active, close=self.closed)
self.base = dict(zip((s.key for s in active), path))
self.hands = dict(zip((s.key for s in active), self.hand_order))
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
self.rhythms = {}
previous = path[-1] if self.closed and path else ()
previous = (path[-1], self.hand_order[-1]) if self.closed and path else ((), 0)
for group, rhythm, spans in zip(self.groups, selected, prepared):
grips = tuple(self.base[s.key] for s in spans if s.events)
grips = tuple((self.base[s.key], self.hands[s.key]) for s in spans if s.events)
key = (self.key(group), rhythm.signature) if part == 'acoustic' else self.key(group)
self.cache[key] = self.render(group, rhythm, spans, grips, previous, False)
self.cache[key], self.cache_positions[key] = self.render(group, rhythm, spans, grips, previous, False)
cursor = fractions.Fraction()
for m, _, i in group:
length = generator.model.fraction(m['duration'])
@@ -250,6 +265,17 @@ class Canonical:
def report(self):
return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
@property
def placement_report(self):
previous = self.hand_order[-1] if self.closed and self.hand_order else None
shifts = []
for hand in self.hand_order:
shifts.append(abs(hand - previous) if previous is not None else 0)
previous = hand
return {'version': generator.hand_position.VERSION, 'priority': 'hand travel, maximum hand position, ergonomics, coverage/register, stable tie',
**generator.hand_position.summary(shifts), 'peak_position': self.path_cost.peak_position,
'cost': {k: str(v) for k, v in self.path_cost._asdict().items()},
'positions': [{'source_index': i, 'onset': str(t), 'hand_position': h} for (i, t, _), h in zip(self.order, self.hand_order)]}
@property
def rhythm_report(self):
return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
@staticmethod
@@ -314,10 +340,10 @@ class Canonical:
raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
return tuple(spans)
def select(self, spans, *, left=(), right=(), close=False):
def select(self, spans, *, left=(), right=(), left_hand=None, right_hand=None, close=False):
layers = tuple(s.options for s in spans)
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55), composition=self.composition) for v in layer} for s, layer in zip(spans, layers))
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, left_hand=left_hand, right_hand=right_hand, close=close, fixed_hands=tuple(s.hand for s in spans))
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
original_group = group
@@ -371,23 +397,27 @@ class Canonical:
for span in spans:
original = next((s for s in self.canonical_spans.get(original_group[0][2], ()) if s.key in self.base
and s.start <= span.start + offset < s.end and semantic_key(s.chord) == semantic_key(span.chord)), None)
fixed.append(dataclasses.replace(span, options=(self.base[original.key],)) if original else span)
fixed.append(dataclasses.replace(span, options=(self.base[original.key],), hand=self.hands[original.key]) if original else span)
spans = tuple(fixed)
before = [v for i, _, v in self.order if i < start]
after = [v for i, _, v in self.order if i > end]
left = before[-1] if before else self.order[-1][2] if self.closed and self.order else ()
right = after[0] if after else self.order[0][2] if self.closed and self.order else ()
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right)
self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit)
before = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i < start]
after = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i > end]
left = before[-1] if before else (self.order[-1][2], self.hand_order[-1]) if self.closed and self.order else ((), 0)
right = after[0] if after else (self.order[0][2], self.hand_order[0]) if self.closed and self.order else ((), 0)
selected, _, hands = self.select(tuple(s for s in spans if s.events), left=left[0], right=right[0], left_hand=left[1], right_hand=right[1])
self.cache[key], self.cache_positions[key] = self.render(group, rhythm, spans, tuple(zip(selected, hands)), left, True, limit)
placement = self.cache_positions[key][index - group[0][2]]
if placement is not None:
self.last_position = placement
return copy.deepcopy(self.cache[key][index - group[0][2]])
def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
flat = generator.comping_rhythm.flatten(inputs)
length = generator.model.fraction(flat['duration'])
index = inputs[0][2]
attacks, rows = [], []
attacks, rows, placed_events = [], [], []
choices = iter(selected)
for span in spans:
grip = next(choices) if span.events else ()
grip, hand = next(choices) if span.events else ((), 0)
motion = generator.hand_position.motion(previous[0], grip, previous[1], hand, self.part == 'acoustic')
bass = grip[0] if grip and self.part == 'acoustic' else None
degrees = generator.harmony.degrees(span.chord)
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
@@ -401,22 +431,30 @@ class Canonical:
'positions': [{'string': p.string_number, 'fret': p.fret, 'sounding_midi': p.midi} for p in grip],
'bass_degree': next((d for d, a in degrees.items() if bass and (root + generator.harmony.degree_semitones(d, a)) % 12 == bass.midi % 12), None),
'bass_string': bass.string_number if bass else None,
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
'movement': motion.shift, 'voicing_candidates': len(span.options)})
if grip:
register, position = voicing_preferences(self.part, grip, self.policy.get('center', 55), composition=self.composition)
travel = movement(self.part, previous, grip)
effort = generator.guitar.voicing_cost(grip, 'electric', composition=self.composition).effort if self.part == 'electric' else 0
rows[-1]['voicing_cost'] = {'movement': travel, 'grip_effort': str(effort), 'register': str(register), 'position': str(position), 'combined': str(travel + effort + register + position)}
local = local_cost(self.part, semantic_key(span.chord), grip, self.policy.get('center', 55), composition=self.composition)
rows[-1]['hand_position'] = hand
rows[-1]['voicing_cost'] = {'movement': motion.shift, 'hand_shift': motion.shift, 'finger_adjustment': motion.fingers,
'string_changes': motion.strings, 'grip_effort': str(local.effort), 'register_tiebreak': str(local.register),
'secondary_effort': str(motion.ergonomics + local.effort)}
for event in span.events:
placed_events.append((event.start, event.end, (grip, hand)))
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
for lane in lanes:
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
attacks.append((lane, event.start, event.end, positions, span.chord))
if grip:
previous = grip
previous = (grip, hand)
key = self.key(inputs, limit)
if self.part == 'acoustic':
key = (key, rhythm.signature)
measure_hands, cursor = [], fractions.Fraction()
for m, _, _ in inputs:
end = cursor + generator.model.fraction(m['duration'])
played = sorted((a, b, p) for a, b, p in placed_events if a < end and b > cursor)
measure_hands.append(played[-1][2] if played else None)
cursor = end
self._reports[key] = rows
voices = []
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
@@ -456,4 +494,4 @@ class Canonical:
'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]})
cursor += duration
self._rhythm_reports[key] = reports
return result
return result, tuple(measure_hands)
+5 -2
View File
@@ -134,9 +134,12 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
elif role.endswith('_accompaniment'):
canonical = generator.comping.Canonical(document, part, seed, composition=composition, plan=plan)
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'accompaniment'}, accompaniment=canonical, composition=composition)
details = {'canonical_comping': canonical.report, 'accompaniment_rhythm': canonical.rhythm_report, 'groove_cycles': canonical.groove_report, 'rhythm_source': choice_source}
details = {'canonical_comping': canonical.report, 'guitar_placement': canonical.placement_report, 'accompaniment_rhythm': canonical.rhythm_report, 'groove_cycles': canonical.groove_report, 'rhythm_source': choice_source}
else:
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'vocal' if part == 'vocal' else 'melody'}, composition=composition)
placement_report = {}
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'vocal' if part == 'vocal' else 'melody'}, composition=composition, placement_report=placement_report)
if placement_report:
details['guitar_placement'] = placement_report
allowed = tuple((a, b) for a, b, r in plan.windows if r == role)
generator.arrange.assembly.mask(result, allowed)
if role == "acoustic_accompaniment":
+65 -87
View File
@@ -10,10 +10,10 @@ import typing
import generator.chord_families
import generator.fretboard
import generator.model
import generator.hand_position
import generator.pitch
import generator.vocabulary
PREFERRED_MAX_FRET = 12
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
class VoicingCost(typing.NamedTuple):
@@ -23,13 +23,16 @@ class VoicingCost(typing.NamedTuple):
covering_tones: int = 0
string_shortfall: int = 0
family_penalty: int = 0
travel: int = 0
peak_position: int = 0
effort: fractions.Fraction = fractions.Fraction()
missing_tones: int = 0
register: fractions.Fraction = fractions.Fraction()
def plus(self, other: VoicingCost) -> VoicingCost:
return VoicingCost(*(a + b for a, b in zip(self, other)))
return VoicingCost(*(max(a, b) if field == "peak_position" else a + b for field, a, b in zip(self._fields, self, other)))
@functools.lru_cache(maxsize=16384)
def moved(self, distance: int) -> VoicingCost:
return self._replace(effort=self.effort + distance)
def moved(self, distance: int, ergonomics: int = 0) -> VoicingCost:
return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics)
def voicing_preference(role, center=None, *, composition=None):
"""Immutable role settings for both selection and scored-retrieval caches."""
@@ -37,19 +40,20 @@ def voicing_preference(role, center=None, *, composition=None):
settings = composition.role(role)
policy = settings.get('voicing', {})
center = settings.get('center', 53 if role.startswith('acoustic') else 60) if center is None else center
result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1), policy.get('high_position_weight', 1), policy.get('comfortable_max_fret', 9)))
if result[1] < 0 or result[2] < 0 or result[3] < 1:
raise ValueError('voicing preference requires nonnegative weights and a positive fret threshold')
if set(policy) - {'register_weight'}:
raise ValueError('unknown voicing preference; hand position is ranked across the passage')
result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1)))
if result[1] < 0:
raise ValueError('voicing preference requires a nonnegative register tie weight')
return result
@functools.lru_cache(maxsize=8192)
def voicing_preferences(positions, preference):
if not positions or not preference:
return fractions.Fraction(), fractions.Fraction()
center, register_weight, position_weight, threshold = preference
return fractions.Fraction()
center, register_weight = preference
register = abs(fractions.Fraction(sum(p.midi for p in positions), len(positions)) - center)
position = max(fractions.Fraction(), fractions.Fraction(sum(p.fret for p in positions), len(positions)) - threshold)
return register_weight * register, position_weight * position
return register_weight * register
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = (), *, composition=None) -> VoicingCost:
composition = composition or generator.vocabulary.current()
@@ -64,12 +68,12 @@ def _voicing_cost(positions, profile, allowed, melody, families, preference):
above = sum(p.midi > max(melody) for p in positions) if melody else 0
span = compactness(positions)
if profile == 'electric':
effort = 2 * max(0, len(frets) - 1) + 3 * span + sum(voicing_preferences(positions, preference))
effort = 2 * max(0, len(frets) - 1) + 3 * span
else:
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
effort = span
family_penalty = dict(families).get(generator.chord_families.electric(positions), 0) if positions and families else 0
melody_string = min((p.string_number - 1 for p in positions if melody and p.midi == max(melody)), default=0)
return VoicingCost(extended_grips=generator.fretboard.span_penalty(positions), melody_string=melody_string, string_shortfall=max(0, 4 - len(positions)) if profile == 'electric' else 0, covered_melody=int(bool(above)), covering_tones=above, family_penalty=family_penalty, effort=effort, missing_tones=len(set(allowed) - classes))
return VoicingCost(extended_grips=generator.fretboard.span_penalty(positions), melody_string=melody_string, string_shortfall=max(0, 4 - len(positions)) if profile == 'electric' else 0, covered_melody=int(bool(above)), covering_tones=above, family_penalty=family_penalty, effort=effort, missing_tones=len(set(allowed) - classes), register=voicing_preferences(positions, preference))
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), *, composition=None) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
composition = composition or generator.vocabulary.current()
@@ -96,9 +100,8 @@ def _chord_positions(requirements, prune, profile, melody, families, preference)
if profile:
by_string = {string: options[i] for i, (string, _) in enumerate(generator.fretboard.STANDARD_TUNING)}
# A lower source melody can be the top of a three-note adaptation on
# strings 2-4. It need not be buried beneath an added first-string tone.
for strings in (*ELECTRIC_STRINGS, (4, 3, 2)) if melody else ELECTRIC_STRINGS:
# Every electric chord includes string 1, even with a lower melody inside.
for strings in ELECTRIC_STRINGS:
for selected in itertools.product(*(by_string[s] for s in strings)):
if not generator.fretboard.within_fret_span(selected) or not generator.fretboard.has_fret_anchor(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
continue
@@ -107,29 +110,13 @@ def _chord_positions(requirements, prune, profile, melody, families, preference)
result.append(selected)
else:
visit(0, (), set())
if not prune:
return tuple(sorted(result))
# Independent attacks with equal centers have identical movement costs.
# Held strings require the full candidate set instead of this reduction.
best = {}
for positions in result:
center = position_center(positions)
rank = (_voicing_cost(positions, profile, requirements.allowed, melody, families, preference), positions)
if center not in best or rank < best[center][0]:
best[center] = (rank, positions)
return tuple(sorted(item[1] for item in best.values()))
# Equal centers no longer imply equal future movement. Keep concrete grips;
# only identical positions can be merged before contextual placement.
return tuple(sorted(set(result)))
@functools.lru_cache(maxsize=16384)
def movement(previous: tuple[generator.fretboard.TabPosition, ...], current: tuple[generator.fretboard.TabPosition, ...]) -> int:
if not previous or not current:
return 0
return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current)))
@functools.lru_cache(maxsize=4096)
def position_center(positions: tuple[generator.fretboard.TabPosition, ...]) -> tuple[int, int]:
frets = [p.fret for p in positions] or [0]
strings = [p.string_number for p in positions]
return min(frets) + max(frets), min(strings) + max(strings) - 2
def movement(previous, current, old_hand, hand) -> int:
return generator.hand_position.motion(previous, current, old_hand, hand).shift
def compactness(positions) -> int:
frets = [p.fret for p in positions]
@@ -151,38 +138,7 @@ class Placement:
active: tuple
previous: Placement | None
choices: tuple
def nearest_positions(states: dict) -> list[list[tuple | None]]:
"""Exact Manhattan distance transform on the small fret/string position grid."""
initial = min(((p.cost, i, p) for i, p in enumerate(states.values()) if not p.active), key=lambda item: item[:2], default=None)
grid = [[initial for _ in range(11)] for _ in range(2 * generator.fretboard.MAX_FRET + 1)]
for index, previous in enumerate(states.values()):
if not previous.active:
continue
x, y = position_center(tuple(p for _, p in previous.active))
candidate = (previous.cost, index, previous)
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
grid[x][y] = candidate
def relax(x, y, a, b):
source = grid[a][b]
if source is not None:
cost, index, previous = source
candidate = (cost.moved(1), index, previous)
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
grid[x][y] = candidate
for y in range(11):
for x in range(1, len(grid)):
relax(x, y, x - 1, y)
for x in range(len(grid) - 2, -1, -1):
relax(x, y, x + 1, y)
for x in range(len(grid)):
for y in range(1, 11):
relax(x, y, x, y - 1)
for y in range(9, -1, -1):
relax(x, y, x, y + 1)
return grid
hand: int = 0
@dataclasses.dataclass(frozen=True)
class GuitarTone:
@@ -318,16 +274,37 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
def keep_position(result, candidate, previous, fresh=True):
old_shape = tuple(p for _, p in previous.active)
hands = [previous.hand]
if fresh:
hands = generator.hand_position.regions(candidate.shape)
for hand in hands:
if candidate.shape and not all(hand <= p.fret <= hand + 3 for p in candidate.shape):
continue
motion = generator.hand_position.motion(old_shape, candidate.shape, previous.hand, hand) if fresh else generator.hand_position.Motion()
local = candidate.cost._replace(peak_position=hand)
cost = previous.cost.plus(local).moved(motion.shift, motion.ergonomics)
key = (candidate.active, hand, cost.peak_position)
value = Placement(cost, candidate.active, previous, candidate.choices, hand)
if key not in result or cost < result[key].cost:
result[key] = value
def reduce_positions(result):
# Future effort depends on the concrete held strings and hand, not on the
# earlier route. A strictly worse primary cost is dominated even if lower.
best = {}
for (active, hand, _), state in result.items():
key = (active, hand)
best[key] = min(best.get(key, state.cost[:7]), state.cost[:7])
return {key: state for key, state in result.items() if state.cost[:7] == best[key[:2]]}
def advance_attacks(candidates, states: dict) -> dict:
nearest = nearest_positions(states)
result = {}
for candidate in candidates:
x, y = position_center(candidate.shape)
cost, _, previous = nearest[x][y]
cost = cost.plus(candidate.cost)
if candidate.active not in result or cost < result[candidate.active].cost:
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
return result
for previous in states.values():
keep_position(result, candidate, previous)
return reduce_positions(result)
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False, *, composition=None) -> dict:
composition = composition or generator.vocabulary.current()
@@ -335,12 +312,6 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
fresh = has_attack(notes)
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
result = {}
def keep(candidate, previous, cost):
cost = cost.plus(candidate.cost)
if candidate.active not in result or cost < result[candidate.active].cost:
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
if not held_ids and not continued:
result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback, composition=composition), states)
else:
@@ -348,15 +319,14 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
active = dict(previous.active)
held = {i: p for i, p in previous.active if i not in continued and i in held_ids}
for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback, composition=composition):
distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0
keep(candidate, previous, previous.cost.moved(distance))
keep_position(result, candidate, previous, fresh)
if not result:
if not fallback and any(n.get('_harmonize') for n in notes):
return advance_positions(notes, onset, tones, states, preserve_strings, True, composition=composition)
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement; single notes need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_SINGLE_FRET}; chords need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, an anchor <= {generator.fretboard.MAX_ANCHOR_FRET}, and at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; held/tied strings must remain fixed')
return result
return reduce_positions(result)
def place_guitar(document: dict, *, composition=None) -> None:
def place_guitar(document: dict, *, composition=None) -> dict:
"""Choose electric voicings or exact/held melody placements."""
composition = composition or generator.vocabulary.current()
events, tones = guitar_events(document)
@@ -367,8 +337,15 @@ def place_guitar(document: dict, *, composition=None) -> None:
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
states = advance_positions(notes, onset, tones, states, preserve_strings, composition=composition)
selected = min(states.values(), key=lambda s: (s.cost, s.active))
trace = []
final_cost = selected.cost
skipped = []
while selected.previous is not None:
fresh = has_attack([c.note for c in selected.choices])
motion = generator.hand_position.motion(tuple(p for _, p in selected.previous.active), tuple(p for _, p in selected.active), selected.previous.hand, selected.hand) if fresh else generator.hand_position.Motion()
trace.append({'hand_position': selected.hand, 'events': [c.note['id'] for c in selected.choices],
'hand_shift': motion.shift, 'finger_adjustment': motion.fingers, 'string_changes': motion.strings,
'effort': str(selected.cost.effort - selected.previous.cost.effort)})
for choice in selected.choices:
note = choice.note
for tone, position in choice.exact:
@@ -388,3 +365,4 @@ def place_guitar(document: dict, *, composition=None) -> None:
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1])
document['staves'][0]['tablature'] = True
generator.fretboard.validate_document(document)
return {'version': generator.hand_position.VERSION, **generator.hand_position.summary(r['hand_shift'] for r in trace), 'peak_position': final_cost.peak_position, 'cost': {k: str(v) for k, v in final_cost._asdict().items()}, 'positions': list(reversed(trace))}
+52
View File
@@ -0,0 +1,52 @@
"""Four-fret hand windows shared by chord and single-note placement.
This describes playable regions, not an anatomical fingering solver. Chord
and single tones may occupy any frets within the window. Only shifting the
window is movement. Actual sounded-note limits remain in fretboard.py.
"""
import functools
import typing
import generator.fretboard
VERSION = 'hand-window-2'
class Motion(typing.NamedTuple):
shift: int = 0
fingers: int = 0
strings: int = 0
@property
def ergonomics(self):
return self.fingers + self.strings
def base(shape):
return min((p.fret for p in shape), default=0)
@functools.lru_cache(maxsize=4096)
def regions(shape):
if not shape:
return tuple([0])
return tuple(range(max(1, max(p.fret for p in shape) - 3), min(base(shape), generator.fretboard.MAX_ANCHOR_FRET) + 1))
@functools.lru_cache(maxsize=32768)
def motion(previous, current, old_hand, hand, acoustic=False):
if not previous or not current:
return Motion()
if len(previous) == len(current) == 1:
pairs = ((previous[0], current[0]),)
unmatched = 0
else:
def voices(shape):
return {('bass' if acoustic and p.string_number in (5, 6) else p.string_number): p for p in shape}
a, b = voices(previous), voices(current)
pairs = tuple((a[s], b[s]) for s in a.keys() & b.keys())
unmatched = len(a.keys() ^ b.keys())
return Motion(abs(hand - old_hand),
unmatched + sum(abs((a.fret - old_hand) - (b.fret - hand)) for a, b in pairs),
sum(abs(a.string_number - b.string_number) for a, b in pairs))
def summary(shifts):
shifts = tuple(shifts)
return {'total_travel': sum(shifts), 'shift_count': sum(s > 0 for s in shifts), 'largest_shift': max(shifts, default=0)}
+6 -4
View File
@@ -41,15 +41,17 @@ def layout_part(document, fonts):
header_style = dataclasses.replace(style, width=style.width // 2, margin_x=style.margin_x / 2, margin_y=style.margin_y / 2)
header = tuple(p.scaled(2) for p in generator.engraving.layout.part_header(document, fonts, header_style))
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
bottom = style.height - style.margin_y - 20 - style.page_clearance
# Reserve the measured outline for every possible page number, just as
# screen pagination does. A nominal font size is not its actual ascent.
footers = tuple(fonts.text(str(number), style.width / 2, style.height - style.margin_y - 8, 20, 'page_number', (), True)
for number in range(1, len(systems) + 1))
bottom = min(p.box.top for p in footers) - style.page_clearance
pages, placed, cursor = [], [], top
def finish():
number = len(pages) + 1
# Include clearance for numeric descenders and JPEG block-edge ringing.
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y - 8, 20, 'page_number', (), True)
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed),
(*header, *(p for system in placed for p in system.primitives), footer)))
(*header, *(p for system in placed for p in system.primitives), footers[number - 1])))
for system in systems:
system = generator.engraving.layout.make_system(index, system.start, system.end, fonts, style, justify=system.end < index.end, staff_width=style.width - 2 * style.margin_x - 2)
+69 -40
View File
@@ -5,77 +5,106 @@ import fractions
import functools
import generator.fretboard
import generator.hand_position
import generator.solo.types
import generator.solo.profiles
import generator.solo.policy
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
if previous is None:
return 0
return transition(previous.position, current.position, current.start - previous.start, tempo)
@functools.lru_cache(maxsize=16384)
def transition(previous, current, duration, tempo):
distance = abs(previous.fret - current.fret) + abs(previous.string_number - current.string_number)
seconds = float(duration * 60 / tempo)
# Movement is a preference, not an unsupported claim about a player's limits.
return round(100 * distance / max(seconds, 0.05))
def hand_positions(position):
return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3)
return generator.hand_position.regions((position,))
@functools.lru_cache(maxsize=512)
def position_rank(position):
return generator.solo.policy.stable_rank('guitar-position', position)
def stable_path(path):
return tuple(position_rank(n.position) for n in path)
def seconds(duration, tempo):
return max(fractions.Fraction(1, 20), duration * 60 / tempo)
@functools.lru_cache(maxsize=4096)
def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
"""Phrase paths with a four-fret hand region and distinct endpoint choices."""
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
@dataclasses.dataclass(frozen=True)
class Path:
travel: int = 0
peak: int = 0
effort: fractions.Fraction = fractions.Fraction()
notes: tuple = ()
hands: tuple = ()
ties: tuple = ()
@property
def rank(self):
return self.travel, self.peak, self.effort, self.ties
@functools.lru_cache(maxsize=512)
def paths(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED, fixed=False):
"""Complete TAB paths: hand travel first, then maximum hand position.
Preserve different peaks at a common endpoint. No barline or tied fragment
resets the hand or shortens the elapsed time from the previous attack.
"""
states = [(Path(peak=h), h) for h in hand_positions(previous.position)] if previous and previous.position else [(Path(), 0)]
last_attack = previous.start if previous else None
for note in notes:
following = {}
for position in positions(note.pitch.number(), profile):
options = (note.position,) if fixed and note.position else positions(note.pitch.number(), profile)
for position in options:
current = dataclasses.replace(note, position=position)
for hand in hand_positions(position):
best = None
for cost, path, old_hand in states:
prior = path[-1] if path else previous
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
for path, old_hand in states:
prior = path.notes[-1] if path.notes else previous
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start or hand != old_hand):
continue
movement = 0
motion = generator.hand_position.Motion()
effort = fractions.Fraction()
if prior and not note.continuation:
seconds = max(.05, float((note.start - prior.start) * 60 / tempo))
distance = abs(hand - old_hand) if old_hand is not None else 0
distance += .25 * abs(position.fret - prior.position.fret) + .5 * abs(position.string_number - prior.position.string_number)
movement = round(100 * distance / seconds)
candidate = (cost + movement, (*path, current), hand)
if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]):
best = candidate
if best is not None:
following[position, hand] = best
motion = generator.hand_position.motion((prior.position,), (position,), old_hand, hand)
effort = 100 * (motion.shift + motion.ergonomics) / seconds(note.start - last_attack, tempo)
candidate = Path(path.travel + motion.shift, max(path.peak, hand), path.effort + effort, (*path.notes, current), (*path.hands, hand), (*path.ties, (position_rank(position), hand)))
key = (position, hand, candidate.peak)
if key not in following or candidate.rank < following[key].rank:
following[key] = candidate
if not following:
return ()
states = list(following.values())
# Endpoints survive so the following phrase can choose a better transition.
# A strictly worse hand travel at the identical endpoint/hand cannot recover.
best = {}
for (position, hand, _), path in following.items():
best[position, hand] = min(best.get((position, hand), path.travel), path.travel)
states = [(path, hand) for (position, hand, _), path in following.items() if path.travel == best[position, hand]]
if not note.continuation:
last_attack = note.start
return tuple(sorted((path for path, _ in states), key=lambda path: path.rank))
@functools.lru_cache(maxsize=512)
def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
selected, seen = [], set()
for cost, path, hand in sorted(states, key=lambda v: (v[0], stable_path(v[1]))):
endpoint = path[-1].position if path else None
for path in paths(notes, tempo, previous, profile):
endpoint = (path.notes[-1].position, path.hands[-1]) if path.notes else None
if endpoint in seen:
continue
seen.add(endpoint)
selected.append((cost, path))
selected.append((path.travel, path.notes))
if len(selected) == limit:
break
return tuple(selected)
@functools.lru_cache(maxsize=4096)
@functools.lru_cache(maxsize=512)
def place(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED):
result = alternatives(notes, tempo, previous, 1, profile)
return result[0] if result else None
def describe(notes, tempo):
candidates = paths(notes, tempo, fixed=True)
if not candidates:
raise ValueError('cannot describe an invalid guitar path')
path = candidates[0]
result, prior, old_hand, last_attack = [], None, 0, None
for note, hand in zip(path.notes, path.hands):
motion = generator.hand_position.motion((prior.position,), (note.position,), old_hand, hand) if prior and not note.continuation else generator.hand_position.Motion()
effort = 100 * (motion.shift + motion.ergonomics) / seconds(note.start - last_attack, tempo) if prior and not note.continuation else 0
result.append({'start': str(note.start), 'end': str(note.end), 'hand_position': hand, 'string': note.position.string_number,
'fret': note.position.fret, 'hand_shift': motion.shift, 'finger_adjustment': motion.fingers, 'string_changes': motion.strings, 'secondary_effort': str(effort)})
if not note.continuation:
last_attack = note.start
prior, old_hand = note, hand
return {'version': generator.hand_position.VERSION, **generator.hand_position.summary(r['hand_shift'] for r in result), 'secondary_effort': str(path.effort), 'peak_position': path.peak, 'positions': result}
@functools.lru_cache(maxsize=2048)
def positions(number: int, profile=generator.solo.profiles.UNRESTRICTED):
profile = profile or generator.solo.profiles.UNRESTRICTED
+1 -1
View File
@@ -32,7 +32,7 @@ def structure(solo, *, composition=None):
ns = solo.notes()
duration = solo.end - solo.start
return {'schema': 'composer-report-3', 'selection_trace': plain({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}),
'instrument_profile': plain(solo.profile), 'phrases': phrases, 'expressions': plain(contracts), 'fretboard': fretboard_report(solo),
'instrument_profile': plain(solo.profile), 'phrases': phrases, 'expressions': plain(contracts), 'fretboard': fretboard_report(solo), 'guitar_placement': generator.solo.placement.describe(solo.notes(), solo.tempo),
'phrasing': {'attacks': len(ns), 'allocation_beats': str(duration),
'attacks_per_four_beats': 4 * len(ns) / float(duration) if duration else 0,
'occupancy': sum(float(n.end - n.start) for n in ns) / float(duration) if duration else 0},