Initial public khodo snapshot
ci / checks (push) Successful in 38m58s

This commit is contained in:
khodowany
2026-09-04 19:30:11 -05:00
commit 4f9a269f85
99 changed files with 59585 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Performance arrangement generation."""
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from generator.score import body_start_measure_index
SectionRole = Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"]
@dataclass(frozen=True)
class ArrangementSection:
label: str
source_measure_index: int
measure_count: int
section_index: int
role: SectionRole
def phrase_aware_edge_measure_count(body_measure_count: int) -> int:
if body_measure_count >= 8:
return 8
if body_measure_count >= 4:
return 4
return body_measure_count
def section_role(part_type: str, section_index: int) -> SectionRole:
if part_type == "acoustic":
return "slash" if section_index == 3 else "rhythm"
if part_type == "electric":
if section_index == 4:
return "slash"
if section_index == 2:
return "melody"
if section_index in {1, 5, 6}:
return "accompaniment"
return "rest"
return "vocal" if section_index in {1, 5, 6} else "rest"
def full_arrangement_sections(measures: list[dict], part_type: str) -> list[ArrangementSection]:
if not measures:
return []
body_start = body_start_measure_index(measures)
if body_start >= len(measures):
return []
body_measure_count = len(measures) - body_start
edge_measure_count = phrase_aware_edge_measure_count(body_measure_count)
edge_source_measure = body_start + body_measure_count - edge_measure_count
specs = (
("Rhythm Lead-in", edge_source_measure, edge_measure_count, 0),
("Vocal Melody", body_start, body_measure_count, 1),
("Guitar Melody", body_start, body_measure_count, 2),
("Guitar Solo", body_start, body_measure_count, 3),
("Guitar Solo", body_start, body_measure_count, 4),
("Vocal Melody", body_start, body_measure_count, 5),
("Ending", edge_source_measure, edge_measure_count, 6),
)
return [
ArrangementSection(label=label, source_measure_index=start, measure_count=count, section_index=index, role=section_role(part_type, index))
for label, start, count, index in specs
if count > 0
]
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from itertools import product
from typing import NamedTuple
from generator.transposition import mod12
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
class TabPosition(NamedTuple):
string_number: int
fret: int
midi: int
ROOTS = {
"C": 0,
"C#": 1,
"Db": 1,
"D": 2,
"D#": 3,
"Eb": 3,
"E": 4,
"F": 5,
"F#": 6,
"Gb": 6,
"G": 7,
"G#": 8,
"Ab": 8,
"A": 9,
"A#": 10,
"Bb": 10,
"B": 11,
}
def parse_root(symbol: str) -> tuple[str, str] | None:
if not symbol or symbol[0].upper() not in "ABCDEFG":
return None
length = 1
if len(symbol) > 1 and symbol[1] in {"#", "b"}:
length = 2
return symbol[:length], symbol[length:]
def chord_intervals(quality: str) -> tuple[int, ...]:
value = quality.lower()
if "7b5" in value or "ø" in value or "half" in value:
return (0, 3, 6, 10)
if "dim" in value or "o" in value:
return (0, 3, 6, 9)
if "aug" in value or "+" in value:
return (0, 4, 8, 10)
if "sus" in value:
return (0, 5, 7, 10)
if value.startswith("-") or (value.startswith("m") and not value.startswith("maj")):
return (0, 3, 7, 10 if "7" in value else 10)
if "maj7" in value or "ma7" in value or "Δ" in value:
return (0, 4, 7, 11)
if "6" in value and "7" not in value:
return (0, 4, 7, 9)
if "7" in value:
return (0, 4, 7, 10)
return (0, 4, 7)
def chord_pitch_classes(symbol: str) -> tuple[int, ...]:
parsed = parse_root(symbol)
if parsed is None:
return ()
root, quality = parsed
root_pc = ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper()))
if root_pc is None:
return ()
return tuple(mod12(root_pc + interval) for interval in chord_intervals(quality))
def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]:
result: list[TabPosition] = []
for fret in range(0, 13):
midi = open_midi + fret
if mod12(midi) in pitch_classes:
result.append(TabPosition(string_number, fret, midi))
return result
def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int, ...]) -> int:
fretted = [position.fret for position in candidate if position.fret > 0]
if not fretted:
span = 0
low = 0
else:
span = max(fretted) - min(fretted)
low = min(fretted)
covered = {mod12(position.midi) for position in candidate}
missing = len(set(pitch_classes) - covered)
open_penalty = sum(1 for position in candidate if position.fret == 0)
return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate)
def find_playable_voicing(symbol: str) -> list[TabPosition]:
pitch_classes = chord_pitch_classes(symbol)
if not pitch_classes:
return []
string_sets = ((6, 4, 3, 2), (5, 4, 3, 2), (4, 3, 2, 1), (6, 5, 4, 3), (5, 3, 2, 1))
by_string = {string_number: positions_for_string(string_number, open_midi, pitch_classes) for string_number, open_midi in STANDARD_TUNING}
best: tuple[TabPosition, ...] | None = None
best_score = 10**9
for string_set in string_sets:
options = [by_string[string_number][:8] for string_number in string_set]
if any(not item for item in options):
continue
for candidate in product(*options):
fretted = [position.fret for position in candidate if position.fret > 0]
if fretted and max(fretted) - min(fretted) > 4:
continue
covered = {mod12(position.midi) for position in candidate}
if len(covered & set(pitch_classes)) < min(3, len(set(pitch_classes))):
continue
score = candidate_score(candidate, pitch_classes)
if score < best_score:
best = candidate
best_score = score
return sorted(best or [], key=lambda item: item.midi)
def pitch_from_midi(midi: int) -> dict[str, int | str]:
names = (
("c", "n"),
("c", "#"),
("d", "n"),
("e", "b"),
("e", "n"),
("f", "n"),
("f", "#"),
("g", "n"),
("a", "b"),
("a", "n"),
("b", "b"),
("b", "n"),
)
letter, accidental = names[mod12(midi)]
return {"letter": letter, "accidental": accidental, "octave": midi // 12 - 1}
def pitches_for_voicing(symbol: str) -> list[dict[str, int | str]]:
return [pitch_from_midi(position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET) for position in find_playable_voicing(symbol)]
+272
View File
@@ -0,0 +1,272 @@
from __future__ import annotations
from pathlib import Path
from generator.arrange.form import ArrangementSection, full_arrangement_sections
from generator.arrange.fretboard import pitches_for_voicing
from generator.score import (
QUARTER_TICKS,
apply_source_chord_markers,
active_chord_at_offset,
chord_markers,
chord_symbol_at_offset,
copy_source_voice,
first_chord_symbol,
first_voice,
leading_pickup_measure_index,
load_json,
make_arranged_document,
make_measure,
measure_capacity_ticks,
renderable_duration,
renderable_rest_notes,
rest_note,
sanitized_note,
slash_note,
slugify,
source_staff,
source_note_attack_at,
write_json,
)
PART_TYPES = ("acoustic", "electric", "vocal")
def propagated_chords(measures: list[dict]) -> list[str]:
result: list[str] = []
current = ""
for measure in measures:
current = first_chord_symbol(measure, current)
result.append(current)
return result
def playable_chord_note(note_id: str, duration_ticks: int, voicing_chord_symbol: str, visible_chord_symbol: str = "") -> dict:
note = {
"id": note_id,
"kind": "note",
"duration_ticks": duration_ticks,
"pitches": pitches_for_voicing(voicing_chord_symbol),
"hidden": False,
}
if not note["pitches"]:
return rest_note(note_id, duration_ticks, visible_chord_symbol)
if visible_chord_symbol:
note["chord_symbol"] = visible_chord_symbol
return note
def rhythm_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
hit_offsets = (0, QUARTER_TICKS + QUARTER_TICKS // 2, QUARTER_TICKS * 2, QUARTER_TICKS * 2 + QUARTER_TICKS // 2, QUARTER_TICKS * 3 + QUARTER_TICKS // 2)
hit_duration = QUARTER_TICKS // 2
notes: list[dict] = []
cursor = 0
for offset in hit_offsets:
if offset >= capacity:
break
if offset > cursor:
rests, sequence = renderable_rest_notes("arr-rest", offset - cursor, sequence)
notes.extend(rests)
cursor = offset
active_chord = active_chord_at_offset(measure, offset, chord_symbol)
visible_chord = chord_symbol_at_offset(measure, offset)
sequence += 1
notes.append(playable_chord_note(f"arr-note-{sequence}", min(hit_duration, capacity - offset), active_chord, visible_chord))
cursor = offset + min(hit_duration, capacity - offset)
if cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return apply_source_chord_markers(notes, measure), sequence
def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = []
lead_rest = min(QUARTER_TICKS // 2, capacity)
if lead_rest:
rests, sequence = renderable_rest_notes("arr-rest", lead_rest, sequence, chord_symbol_at_offset(measure, 0))
notes.extend(rests)
if lead_rest < capacity:
hit = min(QUARTER_TICKS // 2, capacity - lead_rest)
visible_chord = chord_symbol_at_offset(measure, lead_rest)
active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol)
sequence += 1
if source_note_attack_at(measure, lead_rest):
notes.append(rest_note(f"arr-rest-{sequence}", hit, visible_chord))
else:
notes.append(playable_chord_note(f"arr-note-{sequence}", hit, active_chord, visible_chord))
cursor = lead_rest + hit
if cursor < capacity:
markers = [marker for marker in chord_markers(measure) if marker[0] >= cursor]
if markers:
segment_cursor = cursor
for index, (start, _, chord) in enumerate(markers):
if start > segment_cursor:
rests, sequence = renderable_rest_notes("arr-rest", start - segment_cursor, sequence)
notes.extend(rests)
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
notes.extend(rests)
segment_cursor = next_start
if segment_cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - segment_cursor, sequence)
notes.extend(rests)
else:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return apply_source_chord_markers(notes, measure), sequence
def slash_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = []
cursor = 0
while cursor < capacity:
sequence += 1
duration = min(QUARTER_TICKS, capacity - cursor)
notes.append(slash_note(f"arr-slash-{sequence}", duration))
cursor += duration
return notes, sequence
def rest_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
markers = chord_markers(measure)
if not markers:
notes, sequence = renderable_rest_notes("arr-rest", capacity, sequence)
return notes, sequence
notes: list[dict] = []
cursor = 0
for index, (start, _, chord) in enumerate(markers):
if start > cursor:
if not renderable_duration(start - cursor):
return source_rest_skeleton_notes(measure, sequence)
rests, sequence = renderable_rest_notes("arr-rest", start - cursor, sequence)
notes.extend(rests)
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
if not renderable_duration(next_start - start):
return source_rest_skeleton_notes(measure, sequence)
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
notes.extend(rests)
cursor = next_start
if cursor < capacity:
if not renderable_duration(capacity - cursor):
return source_rest_skeleton_notes(measure, sequence)
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return notes, sequence
def source_rest_skeleton_notes(measure: dict, sequence: int) -> tuple[list[dict], int]:
notes: list[dict] = []
for source_note in first_voice(measure).get("notes", []):
if not isinstance(source_note, dict):
continue
sequence += 1
note = rest_note(f"arr-rest-{sequence}", int(source_note.get("duration_ticks", QUARTER_TICKS)), str(source_note.get("chord_symbol", "")))
if "display_ticks" in source_note:
note["display_ticks"] = source_note["display_ticks"]
if "tuplet_id" in source_note:
note["tuplet_id"] = source_note["tuplet_id"]
notes.append(note)
if not notes:
sequence += 1
notes.append(rest_note(f"arr-rest-{sequence}", measure_capacity_ticks(str(measure.get("time_signature", "4/4")))))
return notes, sequence
def melody_voice(measure: dict, *, strip_lyrics: bool) -> dict:
voice = copy_source_voice(measure, strip_lyrics=strip_lyrics)
voice["id"] = "voice-arranged-source"
voice["notes"] = [sanitized_note(note, strip_lyrics=strip_lyrics) for note in first_voice(measure).get("notes", []) if isinstance(note, dict)]
return voice
def apply_vocal_pickup_overlap(output: list[dict], source_measures: list[dict], section: ArrangementSection) -> None:
pickup = leading_pickup_measure_index(source_measures)
if pickup is None or section.source_measure_index != pickup + 1 or not output:
return
voice = copy_source_voice(source_measures[pickup], strip_lyrics=False)
for note in voice["notes"]:
if isinstance(note, dict):
note.pop("chord_symbol", None)
output[-1]["voices"][0]["id"] = "voice-arranged-vocal"
output[-1]["voices"][0]["tuplets"] = voice["tuplets"]
output[-1]["voices"][0]["notes"] = voice["notes"]
def voice_for_section(section: ArrangementSection, measure: dict, chord_symbol: str, sequence: int) -> tuple[dict, int]:
if section.role == "vocal":
return melody_voice(measure, strip_lyrics=False), sequence
if section.role == "melody":
return melody_voice(measure, strip_lyrics=True), sequence
if section.role == "rhythm":
notes, sequence = rhythm_notes(measure, chord_symbol, sequence)
elif section.role == "accompaniment":
notes, sequence = accompaniment_notes(measure, chord_symbol, sequence)
elif section.role == "slash":
notes, sequence = slash_notes(measure, chord_symbol, sequence)
else:
notes, sequence = rest_notes(measure, chord_symbol, sequence)
return {"id": "voice-arranged-guitar", "tuplets": [], "notes": notes}, sequence
def arranged_measures(source_measures: list[dict], part_type: str) -> list[dict]:
sections = full_arrangement_sections(source_measures, part_type)
chords = propagated_chords(source_measures)
output: list[dict] = []
sequence = 0
for section in sections:
if part_type == "vocal" and section.role == "vocal":
apply_vocal_pickup_overlap(output, source_measures, section)
for offset in range(section.measure_count):
source_index = section.source_measure_index + offset
source_measure = source_measures[source_index]
chord_symbol = chords[source_index] if source_index < len(chords) else ""
voice, sequence = voice_for_section(section, source_measure, chord_symbol, sequence)
output_measure = make_measure(
f"measure-{len(output) + 1}",
source_measure,
voice.get("notes", []),
section_label=section.label if offset == 0 else "",
tuplets=voice.get("tuplets", []),
)
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
output.append(output_measure)
return output
def arrange_document(document: dict, part_type: str) -> dict:
staff = source_staff(document)
measures = staff.get("measures")
if not isinstance(measures, list) or not measures:
raise ValueError("performance arrangement requires at least one source measure")
output = make_arranged_document(
document,
staff,
staff_id="staff-arranged-vocal" if part_type == "vocal" else "staff-arranged-guitar",
staff_name=part_type.capitalize(),
role="vocal" if part_type == "vocal" else "guitar",
arrangement_part_type=part_type,
measures=arranged_measures([measure for measure in measures if isinstance(measure, dict)], part_type),
)
metadata = output.setdefault("metadata", {})
if isinstance(metadata, dict):
metadata.setdefault("tempo", 124)
return output
def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
if not source_dir.exists():
raise ValueError(f"performance source directory not found: {source_dir}")
count = 0
for source_path in sorted(source_dir.glob("*.json")):
document = load_json(source_path)
for part_type in PART_TYPES:
output = arrange_document(document, part_type)
target = output_dir / f"{slugify(source_path.stem)}-{part_type}.json"
write_json(target, output)
count += 1
return count