diff --git a/README.md b/README.md index 6a15191..0585edb 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,13 @@ Identical notation bodies share layout and body rasterization even when titles o The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated-tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Four-digit numbered sources in `data/practice/` (currently `0001.xml` through `0366.xml`; `0000.xml` is also valid) produce one pass each of **acoustic accompaniment**, **electric melody**, and **vocal melody**. Both categories use the authored musical key, with no key variants or key-selection menu. Duplicate titles never merge exercises. -Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic provides the foundation; electric accompaniment responds to the melody and acoustic attacks. Selection considers harmonic coverage, density, collisions, and available space, and restarts at section or meter changes. Rhythm snippets supply attacks, rests, and written durations; structured harmony supplies chord tones. N.C. and absent harmony remain silent. Authored melody, solo slashes, and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly. +Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independent bass and upper-chord rhythms, including simultaneous attacks, sharing a playable grip. Bass alternates root/fifth unless an explicit bass or playability requires otherwise. Electric accompaniment responds more sparsely with stabs lasting at most a quarter note. Rhythm snippets supply attacks and rests; structured harmony supplies chord tones. N.C. and absent harmony remain silent. Solo slashes and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly. There are no musical genre presets, genre defaults, or genre metadata. Obsolete `vamp:style` hints are rejected; visual styles, fonts, colors, and barline notation remain supported. Phrase cycles are written into the generated score and played once in reading order. -Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 0-20, and a maximum four-fret span excluding open strings. They render standard notation plus generated TAB. Practice sources provide a monophonic melody and optional chord symbols; the vocal part adds deterministic scat syllables without changing rhythm or written register. Acoustic, electric, and vocal parts all sound one octave below their notation, for both songs and practice. Explicit MusicXML instrument transposition is honored once; an unplayable passage fails rather than being shifted to fit. +Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 0-20, and a maximum four-fret span excluding open strings. Acoustic uses a bass on string 5 or 6 and two or three consecutive upper strings within 2-4, with zero or one unused string between them. Electric chords use strings 1-3 or 1-4 and may omit the root. Electric melody notes longer than a quarter note, including full tied runs, receive chords when playable; chord changes create fresh attacks even for short remaining fragments. Song electric melodies are raised one octave; practice and vocal registers stay unchanged. All parts still sound one octave below their generated notation, and explicit MusicXML transposition is honored once. Unplayable melody harmonizations remain single notes with a diagnostic; unplayable accompaniment fails generation. + +All parts use two music systems per page, with a single system only when the score ends on an odd system. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product. Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending. @@ -121,7 +123,7 @@ Use American English in project-owned identifiers, filenames, messages, and docu See [Score Generation](TECHNICAL_GUIDE.md#score-generation) for supported MusicXML notation, Vamp properties, schema details, and diagnostics. There is no compact-JSON source format or legacy runtime adapter. Unsupported notation fails generation before existing assets are replaced. -Build-time engraving is implemented in Python in `generator/engraving`, using the bundled Petaluma OTF fonts and SMuFL metadata. MuseScore is an optional authoring tool, not a build dependency or rendering backend. The managed image and CI install the pinned Python requirements automatically; direct host builds use `pip install -r generator/requirements.txt`. Exact musical timing is retained through layout, and dense measures can continue across systems without shrinking the notation. Missing glyphs, unrepresentable page geometry, and failed notation audits stop generation before replacing existing assets. +Build-time engraving is implemented in Python in `generator/engraving`, using the bundled Petaluma OTF fonts and SMuFL metadata. MuseScore is an optional authoring tool, not a build dependency or rendering backend. The managed image and CI install the pinned Python requirements automatically; direct host builds use `pip install -r generator/requirements.txt`. Exact musical timing is retained through layout, and dense measures can continue across systems. Missing glyphs, unrepresentable page geometry, and failed notation audits stop generation before replacing existing assets. Petaluma is from Steinberg under the SIL Open Font License. Petaluma supplies music glyphs, and Petaluma Script supplies all visible text on score pages and both device interfaces. The Zero uses packaged `PetalumaScript.otf` for dynamic UI text; the Pico displays prerendered Petaluma Script artwork. Runtime packages retain `OFL.txt`; the music font and engraver stay build-only. diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 0d4820c..6ecdc75 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -66,9 +66,9 @@ The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model and `generator.rhythm.load_library()` parses the library once per asset generation. Immutable snippets retain exact event onsets, durations, written values, rests, beams, and continuations. Their placeholder pitches and tempo do not transpose chords or select playback BPM. A snippet must contain one or two complete sequential bars in one supported meter and voice, without grace notes, tuplets, time modifications, tremolo, or meter changes. -`generator.accompaniment` selects a two-bar motif, either a complete two-bar snippet or a pair of one-bar snippets, and realizes a four-bar `A, B, A, B'` cycle. The fourth bar varies one beat when a suitable candidate exists. The cycle repeats through the section, restarting at section or meter boundaries; a final partial cycle uses only the required bars. Acoustic establishes the foundation, while electric accompaniment is planned against both the authored melody and that acoustic plan. Selection ranks harmonic coverage and density, then attack collisions, occupied melody time, and pulse, with source identity breaking ties deterministically. It does not enumerate every pair of one-bar snippets or introduce randomness. +`generator.accompaniment` selects a two-bar motif, either a complete two-bar snippet or a pair of one-bar snippets, and realizes a four-bar `A, B, A, B'` cycle. The fourth bar varies one beat when a suitable candidate exists. The cycle repeats through the section, restarting at section or meter boundaries; a final partial cycle uses only the required bars. The immutable plan has separate `acoustic_bass`, `acoustic_upper`, and `electric` lanes. Density targets are one bass attack per two quarter-note beats, one upper-chord attack per beat, and one electric attack per four beats. Harmonic coverage and pulse support take priority for acoustic, while electric favors sparse responses around melody and acoustic attacks. Silent electric bars are allowed. Simultaneous bass and upper attacks are allowed, as are collisions with the melody. Source identity breaks ties deterministically; selection does not enumerate every pair of one-bar snippets or introduce randomness. -Pickups use the corresponding tail of a full-meter rhythm bar. Harmonic state carries across bars until changed or canceled by N.C.; a snippet attack sounds only when a chord is active. A held generated chord stops before a different harmony or N.C., leaving silence until the next selected attack. Melody attacks influence selection but do not directly delete acoustic attacks. Missing rhythm material, unsupported accompaniment meters, and mid-measure meter changes produce errors. The broader import and engraving model still accepts the meters described below. +Pickups use the corresponding tail of a full-meter rhythm bar. Harmonic state carries across bars until changed or canceled by N.C.; a snippet attack sounds only when a chord is active. A held generated accompaniment chord stops before a different harmony or N.C., leaving silence until the next selected attack. Electric stabs last at most one quarter note and never continue a snippet tie; their remaining duration becomes rests. Melody attacks influence selection but do not directly delete acoustic attacks. Missing rhythm material, unsupported accompaniment meters, and mid-measure meter changes produce errors. The broader import and engraving model still accepts the meters described below. ### MusicXML Contract @@ -81,7 +81,7 @@ Pickups use the corresponding tail of a full-meter rhythm bar. Harmonic state ca | Small notes | Grace notes with zero metric duration, cue notes, and measured single/paired tremolos. | | Staff state | G/F/C, percussion, and no-clef staves; octave clefs and octave-shift spans; major/minor/modal keys or custom seven-letter alterations; timed key, clef, and meter changes. Authored TAB and technical fingering instructions are rejected; TAB is generated from pitched guitar parts. | | Meter | Simple, compound, additive, mixed-denominator, and unmetered timelines. A partial measure must be marked `implicit="yes"`; an initial implicit measure is the arrangement pickup. Leading rests are retained. | -| Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Their positions never split or shorten authored melody notes. | +| Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Electric melody generation intentionally reattacks eligible long notes at changed harmonies; import preserves the authored notes. | | Text | Title, composer, part name, multiple lyric verses with syllabification, elisions, and extenders, rehearsal labels at measure starts, and Vamp directives. | | Tempo | Metronome directions and positive quarter-note BPM from ``, either within a direction or directly in a measure. Playback/navigation attributes on `sound` are rejected. | | Reading order | All measures are written out. Repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. Ordinary barlines and contiguous ties remain supported. | @@ -122,19 +122,23 @@ Measure durations, event onsets/durations, harmony offsets, span times, and pitc ### Shared Part Generation -`parts.py` owns shared source validation, melody-copying, measure construction, rests, and solo slashes. `rhythm.py` loads snippets; `accompaniment.py` plans phrase cycles and realizes their chord events. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. The former generic twelve-key transposer and independent practice/arrangement fretboard implementations are removed. +`parts.py` owns shared source validation, melody-copying, measure construction, rests, and solo slashes. `rhythm.py` loads snippets; `accompaniment.py` plans phrase cycles and realizes independent bass/upper voices or electric stabs. `melody.py` identifies long tied melody runs and splits them at harmony changes before guitar placement. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. There is no generic twelve-key transposer or separate practice fretboard solver. -Practice acoustic accompaniment is derived from structured harmony roots, kinds, degrees, inversions, and slash basses using the shared accompaniment rhythm. A missing harmony or N.C. produces rests, not guessed chords. Electric retains the exact melody, spelling, rhythm, ties, and tuplets; vocal retains those same authored notes and receives scat lyrics. Song vocal lyrics are unchanged. Practice optional-turnaround harmonies are played once, without creating extra sections. +Practice acoustic accompaniment is derived from structured harmony roots, kinds, degrees, inversions, and slash basses using the same generation rules as songs. A missing harmony or N.C. produces rests, not guessed chords. Electric retains the melody's register and spelling while adding playable chords to long notes. Vocal retains authored notes and receives scat lyrics. Song vocal lyrics are unchanged. Practice optional-turnaround harmonies are played once, without creating extra sections. -Acoustic accompaniment in songs and practice uses the planned foundation. Electric accompaniment sections in songs use its response plan; electric melody sections still copy the melody. Song sections remain Rhythm Lead-in, Vocal Melody, Guitar Melody, Guitar Solo, Guitar Solo, Vocal Melody, and Ending, with each part retaining its melody, accompaniment, rest, or solo-slash role. Optional turnarounds are removed before planning the final ending. The generated phrase cycles contain explicit events in reading order, not runtime loops or repeat signs. +Acoustic accompaniment in songs and practice uses the planned foundation. Electric accompaniment sections in songs use the response plan; song electric melody sections are raised one octave before harmonization. Song sections remain Rhythm Lead-in, Vocal Melody, Guitar Melody, Guitar Solo, Guitar Solo, Vocal Melody, and Ending, with each part retaining its melody, accompaniment, rest, or solo-slash role. Optional turnarounds are removed before planning the final ending. The generated phrase cycles contain explicit events in reading order, not runtime loops or repeat signs. Each source is parsed, validated, and normalized once for all three parts. The batch generator computes shared accompaniment plans before building those parts. Public `arrange_document`, `practice_document`, `generate_performance_scores`, and `generate_practice_scores` calls require a keyword `rhythms` argument holding a `generator.rhythm.Library`; internal part builders receive precomputed plans. Builders treat the prepared source and plans as read-only and own their generated notes and spans. Source-path checks reject colliding song slugs before writing parts: for example, `one-two.xml` and `one_two.xml` would both produce `one-two-acoustic.json` and cannot coexist. -All three parts, for both songs and practice, use octave-transposing notation: written notes sound one octave lower. This includes the vocal part for the singer's range. Unmarked source notation uses that convention by default; copied melodies retain their written pitches and octave numbers. For example, written C4 sounds C3 (A-string fret 3), and written C#6 sounds C#5, within the 0-20 fret range. Generated accompaniment is notated using the same convention. +All three parts, for both songs and practice, use octave-transposing notation: written notes sound one octave lower. This includes the vocal part for the singer's range. Unmarked source notation uses that convention by default. Practice and vocal melodies retain their written pitches and octave numbers. Song electric melody sections add twelve semitones to both written and sounding pitch; the guitar's octave-transposing convention still applies. For practice, written C4 sounds C3 (A-string fret 3), and written C#6 sounds C#5, within the 0-20 fret range. Generated accompaniment is notated using the same convention. -`pitch.py` resolves explicit MusicXML instrument transposition once before part generation. Explicit zero transposition is also authoritative. Octave-clef exports already encode sounding pitch in their note values, so they do not receive another implicit octave subtraction. Generated parts carry an explicit `transpose.octave_change: -1`; guitar placement and audio read that shared state. Internal concert-pitch normalization and written-pitch conversion cancel for ordinary source notation. There is no octave fitting, vocal-specific range shift, or key transposition. +`pitch.py` resolves explicit MusicXML instrument transposition once before part generation. Explicit zero transposition is also authoritative. Octave-clef exports already encode sounding pitch in their note values, so they do not receive another implicit octave subtraction. Generated parts carry an explicit `transpose.octave_change: -1`; guitar placement and audio read that shared state. Internal concert-pitch normalization and written-pitch conversion cancel for ordinary source notation except for the intentional song electric melody octave increase. There is no automatic octave fitting, vocal-specific range shift, or key transposition. -`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 0-20 inclusive, with at most four frets between fretted positions; open strings do not count toward that span. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Harmonic candidates retain root and defining/altered/added degrees and requested bass; an unaltered fifth in extended chords and unaltered intermediate ninth/eleventh extensions may be omitted. Pitch classes are not doubled. +`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 0-20 inclusive, with at most four frets between fretted positions; open strings do not count toward that span. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Bass alternates root and chord fifth, restarting at harmony/section changes; explicit basses/inversions remain fixed. A fifth-bass choice falls back to the root if it cannot preserve chord identity and the held strings. + +Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voicings and octave doublings are allowed; the third or suspension, sixth/seventh, alterations, and named extensions remain required. An unaltered fifth in extended chords and unaltered intermediate ninth/eleventh extensions may be omitted. Melody notes can lie below added chord tones, including non-chord melody pitches that must remain unchanged. + +`melody.py` treats the full tied duration as one note when testing whether it exceeds one quarter-note beat. Qualifying notes receive a chord at their initial attack and at each subsequent changed harmony, even if a resulting fragment is less than one beat. Changed harmonies break ties and reattack the melody; repeated identical harmonies do not. N.C. releases the added voices while preserving the melody's sustain. Added tones through unchanged harmony retain their own ties. The quarter-note cap on electric stabs does not apply to these melody chords. If no valid chord fits, the exact melody remains with a diagnostic; it is not octave-wrapped or omitted. Placement minimizes total movement of fret/string position centers, then fretted span and omitted optional degrees, with deterministic ties. Independent attacks use an exact small-grid distance transform; when notes sustain across attacks the search retains actual occupied strings. There is no finger assignment, barre logic, or predefined chord-shape dictionary. These are proximity and fretboard-feasibility constraints, not a guarantee of comfortable fingering. Nonstandard tuning, capos, microtonal frets, and unplayable passages fail with source/part/event context; there is no slash fallback for a failed chord. Existing assets remain untouched after failure. @@ -170,13 +174,15 @@ Reuse has two levels. A full-score hit restores complete page images for the sam Voices retain separate stems and collision offsets; chord tones retain their own written durations and per-tone ties. Pitched and percussion material use their own staff positions. Unknown exact microtonal alterations use a rational semitone annotation rather than a rounded accidental; an unavailable explicit SMuFL glyph is an error. The broader model/engraver supports this notation, while automatic guitar and practice generation enforce the source contracts above. -Generated guitar tones carry `tablature: {string, fret}`; their staff is marked `tablature: true`. `engraving/tablature.py` draws a six-line TAB view below standard notation, sharing onset columns, barlines, and page breaks. Both views belong to one system and never duplicate the semantic timeline or playback events. Standard notation carries rhythm and tuplets; TAB carries positions, rests/slashes, and tied continuations. Strings and frets are validated against sounding pitch, and the layout audit checks each visible TAB numeral's position and alignment. +Generated guitar tones carry `tablature: {string, fret}`; their staff is marked `tablature: true`. `engraving/tablature.py` draws a six-line TAB view below standard notation, sharing onset columns, barlines, and page breaks. Both views belong to one system and never duplicate the semantic timeline or playback events. Standard notation carries rhythm, rests, and tuplets; TAB carries positions, solo slashes, and tied continuations without rest glyphs. Strings and frets are validated against sounding pitch, and the layout audit checks each visible TAB numeral's position and alignment. + +Every part uses two music systems per page; only an odd final system occupies a row alone. Pagination reserves two rows below the header, keeping a fixed gap and the footer clear. Both systems on a page share a uniform scale when their measured height exceeds a row; notation, TAB, text, and anchors scale together without changing musical content. Guitar layout starts with 10-pixel notation spacing, 10.5-pixel TAB spacing, 12-pixel fret labels, and a 14-pixel gap between systems. The unscaled TAB spacing accommodates Petaluma numerals up to 9.75 pixels tall. The layout audit accounts for each system's scale when verifying pitch and TAB alignment. Existing `.pages/*.png` preview files are the exact packaged page textures. `audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies, tempos and spans. It rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite. Bundles preserve the complete `music` object. Presentation identifies the `Vamp Python` engraver; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Scores, playback manifests, and the catalog have no numbered schema labels. Required fields, types, relationships, dimensions, and checksums determine whether generated data is usable. Incompatible data is rejected, never migrated or guessed. -Pages are 976x484 pixels with 12-pixel staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. Dense measures continue across systems/pages; sustained notes become exact, tied presentation fragments and rests become exact rest fragments. These fragments never change the semantic JSON. Irreducible simultaneous content that cannot fit a fixed page fails contextually rather than being clipped or shrunk. Blank pages, incorrect dimensions, and ink reaching the page edge are rejected. Runtime letterboxing preserves aspect ratio. Luma's `load_texture_uncached()` lets Product Vamp retain at most two textures without a hidden strong asset-cache reference. The UI reports missing/corrupt assets instead of falling back to the removed renderer. Runtime validates bundle metadata, paths, dimensions, and page checksums; the build validates musical semantics. +Pages are 976x484 pixels with 10-pixel guitar or 12-pixel vocal staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. The two acoustic rest lanes sit closer to the staff, with collision offsets preserving their separation. Dense measures continue across systems/pages; sustained notes become exact, tied presentation fragments and rests become exact rest fragments. These fragments never change the semantic JSON. Irreducible simultaneous content that cannot fit a fixed page fails contextually rather than being clipped or shrunk. Blank pages, incorrect dimensions, and ink reaching the page edge are rejected. Runtime letterboxing preserves aspect ratio. Luma's `load_texture_uncached()` lets Product Vamp retain at most two textures without a hidden strong asset-cache reference. The UI reports missing/corrupt assets instead of falling back to the removed renderer. Runtime validates bundle metadata, paths, dimensions, and page checksums; the build validates musical semantics. ### Validation And Publication diff --git a/generator/accompaniment.py b/generator/accompaniment.py index 6c22d06..e43290c 100644 --- a/generator/accompaniment.py +++ b/generator/accompaniment.py @@ -1,9 +1,12 @@ from __future__ import annotations -import copy import dataclasses import fractions +import functools +import itertools +import generator.guitar +import generator.harmony import generator.model import generator.rhythm @@ -18,7 +21,8 @@ class Context: @dataclasses.dataclass(frozen=True) class Plan: - acoustic: tuple[generator.rhythm.Bar, ...] + acoustic_bass: tuple[generator.rhythm.Bar, ...] + acoustic_upper: tuple[generator.rhythm.Bar, ...] electric: tuple[generator.rhythm.Bar, ...] def active_chord(measure: dict, onset: fractions.Fraction, previous: dict | None) -> dict | None: @@ -56,37 +60,48 @@ def context(measure: dict, previous: dict | None) -> Context: occupied.append((start, end)) return Context(measure, previous, offset, intervals, frozenset(attacks), tuple(occupied)) +@functools.lru_cache(maxsize=4096) +def sounding_rhythm(bar, offset, intervals): + attacks = tuple(t - offset for t in bar.attacks() if any(a <= t - offset < b for a, b in intervals)) + spans = tuple((max(event.onset - offset, a), min(event.onset + event.duration - offset, b)) for event in bar.events if event.sounding and event.onset + event.duration > offset for a, b in intervals if a <= max(event.onset - offset, 0) < b) + return attacks, spans + def sounding_attacks(bar: generator.rhythm.Bar, item: Context) -> tuple[fractions.Fraction, ...]: - return tuple(t - item.offset for t in bar.attacks() if any(a <= t - item.offset < b for a, b in item.intervals)) + return sounding_rhythm(bar, item.offset, item.intervals)[0] def sounding_spans(bar: generator.rhythm.Bar, item: Context) -> tuple[tuple[fractions.Fraction, fractions.Fraction], ...]: - return tuple((max(event.onset - item.offset, a), min(event.onset + event.duration - item.offset, b)) for event in bar.events if event.sounding and event.onset + event.duration > item.offset for a, b in item.intervals if a <= max(event.onset - item.offset, 0) < b) + return sounding_rhythm(bar, item.offset, item.intervals)[1] -def rank(bar: generator.rhythm.Bar, contexts: list[tuple[Context, tuple[fractions.Fraction, ...]]], electric: bool) -> tuple: - missing_bars, uncovered, density, collisions, occupied, pulse = 0, 0, fractions.Fraction(), 0, 0, 0 - for item, acoustic in contexts: - attacks = sounding_attacks(bar, item) - spans = sounding_spans(bar, item) - missing_bars += bool(item.intervals) and not spans and not electric - uncovered += sum(not any(start < b and end > a for start, end in spans) for a, b in item.intervals) - duration = sum((b - a for a, b in item.intervals), fractions.Fraction()) - density += abs(len(attacks) - duration / (4 if electric else 2)) - collisions += sum((t in item.attacks) + (t in acoustic) for t in attacks) - occupied += sum(any(a <= t < b for a, b in item.occupied) for t in attacks) - pulse -= sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks) - return missing_bars, uncovered, density, collisions, occupied, pulse +@functools.lru_cache(maxsize=16384) +def measure_rank(bar, offset, intervals, melody_attacks, melody_spans, companions, role): + attacks, spans = sounding_rhythm(bar, offset, intervals) + missing_bars = int(bool(intervals) and not spans and role != 'electric') + uncovered = sum(not any(start < b and end > a for start, end in spans) for a, b in intervals) + duration = sum((b - a for a, b in intervals), fractions.Fraction()) + density = abs(len(attacks) - duration / {'electric': 4, 'bass': 2, 'upper': 1}[role]) + collisions = sum((t in melody_attacks) + (t in companions) for t in attacks) + occupied = sum(any(a <= t < b for a, b in melody_spans) for t in attacks) + pulse = -sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks) + if role == 'electric': + return missing_bars, density, collisions, occupied, uncovered, pulse + return missing_bars, uncovered, density, pulse if role == 'bass' else collisions, collisions if role == 'bass' else pulse, occupied + +def rank(bar: generator.rhythm.Bar, contexts: list[tuple[Context, tuple[fractions.Fraction, ...]]], role: str) -> tuple: + result = (0, 0, 0, 0, 0, 0) + for item, companions in contexts: + result = add_ranks(result, measure_rank(bar, item.offset, item.intervals, item.attacks, item.occupied, companions, role)) + return result def add_ranks(left: tuple, right: tuple) -> tuple: return tuple(a + b for a, b in zip(left, right)) -def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context], acoustic: list[generator.rhythm.Bar] | None = None) -> tuple[generator.rhythm.Bar, ...]: - electric = acoustic is not None +def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context], role: str, companions: list[tuple[fractions.Fraction, ...]] | None = None) -> tuple[generator.rhythm.Bar, ...]: bars = {} for snippet in snippets: for index, bar in enumerate(snippet.bars): bars.setdefault(bar, f'{snippet.id}:{index}') - phases = [[(item, sounding_attacks(acoustic[i], item) if acoustic else ()) for i, item in enumerate(contexts) if i % 2 == phase] for phase in range(2)] - scores = [{bar: rank(bar, phase, electric) for bar in bars} for phase in phases] + phases = [[(item, companions[i] if companions else ()) for i, item in enumerate(contexts) if i % 2 == phase] for phase in range(2)] + scores = [{bar: rank(bar, phase, role) for bar in bars} for phase in phases] candidates = [(add_ranks(scores[0][s.bars[0]], scores[1][s.bars[1]]), s.id, s.bars) for s in snippets if len(s.bars) == 2] single = [s for s in snippets if len(s.bars) == 1] if single: @@ -96,20 +111,20 @@ def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context if score[0]: failing = next(item.measure for i, item in enumerate(contexts) if item.intervals and not sounding_spans(pair[i % 2], item)) raise ValueError(f'measure {failing["number"]}: no rhythm motif provides acoustic accompaniment in every harmonically active bar') - fourth = [(item, sounding_attacks(acoustic[i], item) if acoustic else ()) for i, item in enumerate(contexts) if i % 4 == 3] + fourth = [(item, companions[i] if companions else ()) for i, item in enumerate(contexts) if i % 4 == 3] second = pair[1] alternatives = [bar for bar in bars if sum(bar.beat(n) != second.beat(n) for n in range(int(second.length))) == 1] - variation = min(alternatives, key=lambda bar: (rank(bar, fourth, electric), bars[bar])) if alternatives and fourth else second - if rank(variation, fourth, electric)[0]: + variation = min(alternatives, key=lambda bar: (rank(bar, fourth, role), bars[bar])) if alternatives and fourth else second + if rank(variation, fourth, role)[0]: variation = second result = (pair[0], second, pair[0], variation) - if electric and any(item.intervals for item in contexts) and not any(sounding_spans(result[i % 4], item) for i, item in enumerate(contexts)): + if role == 'electric' and any(item.intervals for item in contexts) and not any(sounding_spans(result[i % 4], item) for i, item in enumerate(contexts)): raise ValueError(f'measure {contexts[0].measure["number"]}: rhythm library cannot provide an audible electric response') return result def plan_section(measures: list[dict], library: generator.rhythm.Library, *, initial: dict | None = None, electric: bool = True) -> Plan: previous = carried_chords(measures, initial) - acoustic, responses = [], [] + basses, uppers, responses = [], [], [] start = 0 while start < len(measures): meter = measures[start]['time_signature'] @@ -122,13 +137,15 @@ def plan_section(measures: list[dict], library: generator.rhythm.Library, *, ini if any('meter' in change and generator.model.fraction(change['onset']) != 0 for change in measure.get('attributes', [])): raise ValueError(f'measure {measure["number"]}: accompaniment does not support mid-measure meter changes') contexts = [context(m, previous[start + i]) for i, m in enumerate(segment)] - foundation = cycle(snippets, contexts) - chosen = [foundation[i % 4] for i in range(len(segment))] - response = cycle(snippets, contexts, chosen) if electric else foundation - acoustic.extend(chosen) + bass = cycle(snippets, contexts, 'bass') + upper = cycle(snippets, contexts, 'upper') + companions = [tuple(sorted(set(sounding_attacks(bass[i % 4], item)) | set(sounding_attacks(upper[i % 4], item)))) for i, item in enumerate(contexts)] + response = cycle(snippets, contexts, 'electric', companions) if electric else upper + basses.extend(bass[i % 4] for i in range(len(segment))) + uppers.extend(upper[i % 4] for i in range(len(segment))) responses.extend(response[i % 4] for i in range(len(segment))) start = end - return Plan(tuple(acoustic), tuple(responses)) + return Plan(tuple(basses), tuple(uppers), tuple(responses)) def same_chord(left: dict | None, right: dict | None) -> bool: return left is not None and right is not None and all(left.get(k) == right.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion')) @@ -159,7 +176,7 @@ def normalize_beams(events: list[dict]) -> None: if not event.get('beams'): event.pop('beams', None) -def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None) -> list[dict]: +def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *, lane: str = '') -> list[dict]: item = context(measure, previous) length = generator.model.fraction(measure['duration']) events = [] @@ -178,44 +195,74 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None) -> if not same_chord(chord, preceding): chord = None sounding_end = end + if chord and lane == 'electric': + if continuation: + chord = None + else: + sounding_end = min(end, start + 1) for change in measure['harmonies']: time = generator.model.fraction(change['onset']) if chord and start < time < sounding_end and not same_chord(chord, active_chord(measure, time, previous)): sounding_end = time written = {'type': source.written_type, 'dots': source.dots} if end - start == source.duration and sounding_end == end else None - event = generator.model.make_event(f'{measure["id"]}-rhythm-{len(events)}', start, sounding_end - start, 'note' if chord else 'rest', written=written) + event = generator.model.make_event(f'{measure["id"]}-rhythm-{lane}-{len(events)}', start, sounding_end - start, 'note' if chord else 'rest', written=written) if chord: event['_harmony'] = chord + if lane: + event.update(_guitar_profile='electric' if lane == 'electric' else 'acoustic', _guitar_lane=lane) if continuation: event['_rhythm_continuation'] = True if source.beams and written and chord: event['beams'] = [{'level': level, 'type': kind} for level, kind in source.beams] events.append(event) if sounding_end < end: - events.append(generator.model.make_event(f'{measure["id"]}-rhythm-{len(events)}', sounding_end, end - sounding_end, 'rest')) + events.append(generator.model.make_event(f'{measure["id"]}-rhythm-{lane}-{len(events)}', sounding_end, end - sounding_end, 'rest')) normalize_beams(events) return events def connect_continuations(document: dict) -> bool: - previous = None + previous = {} changed = False for measure in document['staves'][0]['measures']: if measure.get('section_label'): - previous = None + previous.clear() for voice in measure['voices']: for note in voice['notes']: continuation = note.pop('_rhythm_continuation', False) - if continuation and previous and previous['pitches'] and note['pitches']: - note['pitches'] = copy.deepcopy(previous['pitches']) - for index, tone in enumerate(note['pitches']): - source = previous['pitches'][index] - tone.update(id=f'{note["id"]}-p{index + 1}', duration=copy.deepcopy(note['duration']), written=copy.deepcopy(note['written']), tie_from=source['id']) - tone.pop('tie_to', None) + source_note = previous.get(voice['id']) + if continuation and source_note and source_note['pitches'] and note['pitches']: + old = {(p['tablature']['string'], p['tablature']['fret']): p for p in source_note['pitches']} + for tone in note['pitches']: + source = old[(tone['tablature']['string'], tone['tablature']['fret'])] + tone['tie_from'] = source['id'] source['tie_to'] = tone['id'] changed = True elif continuation: note.update(kind='rest', pitches=[]) note.pop('beams', None) changed = True - previous = note + previous[voice['id']] = note return changed + +def prepare_basses(document: dict) -> None: + previous, attack, current_bass = None, 0, None + for measure in document['staves'][0]['measures']: + if measure.get('section_label'): + previous, attack = None, 0 + notes = sorted((n for v in measure['voices'] for n in v['notes'] if n.get('_guitar_profile') == 'acoustic'), key=lambda n: generator.model.fraction(n['onset'])) + for _, group in itertools.groupby(notes, key=lambda n: generator.model.fraction(n['onset'])): + group = list(group) + chord = group[0]['_harmony'] + root = (generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])) % 12 + if not same_chord(chord, previous): + attack, current_bass = 0, root + explicit = 'bass' in chord or 'inversion' in chord + if any(n['_guitar_lane'] == 'bass' and not n.get('_rhythm_continuation') for n in group): + degrees = generator.harmony.degrees(chord) + current_bass = (root + generator.harmony.degree_semitones(5, degrees[5])) % 12 if not explicit and attack % 2 and 5 in degrees else root + attack += 1 + if explicit: + current_bass = generator.guitar.chord_requirements(chord, 'acoustic')[2] + for note in group: + note['_bass_pc'] = current_bass + previous = chord diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 57009f9..b97bdc1 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -7,6 +7,7 @@ import pathlib import generator.accompaniment import generator.arrange.form import generator.guitar +import generator.melody import generator.model import generator.musicxml import generator.parts @@ -62,8 +63,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator rhythm = None if section.role == 'accompaniment': plan = plans[section.section_index] - rhythm = (plan.acoustic if part_type == 'acoustic' else plan.electric)[len(segment)] - result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm) + rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)] + result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm, melody_octave=True) segment.append(result) identity_segment = segment segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction()) @@ -112,6 +113,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator 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) if part_type != 'vocal': + generator.accompaniment.prepare_basses(result) + generator.melody.harmonize(result) generator.guitar.place_guitar(result) if generator.accompaniment.connect_continuations(result): generator.guitar.place_guitar(result) diff --git a/generator/engraving/audit.py b/generator/engraving/audit.py index 23d1d86..81b0ba3 100644 --- a/generator/engraving/audit.py +++ b/generator/engraving/audit.py @@ -24,6 +24,10 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen fragments, primitives = {}, {} previous_end = fractions.Fraction() for page in layout.pages: + if len(page.systems) != 2 and (page is not layout.pages[-1] or len(page.systems) != 1): + raise generator.engraving.types.EngravingError('pages must contain two music systems, except an odd final system') + if len(page.systems) == 2 and page.systems[0].box.bottom + layout.style.system_gap > page.systems[1].box.top + 0.01: + raise generator.engraving.types.EngravingError('music systems overlap their page rows') audit_primitives(page.primitives, page.width, page.height, fonts) for item in page.primitives: for source in item.sources: @@ -36,7 +40,7 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen for anchor in system.anchors: if anchor.fragment.kind == 'tone': expected = index.pitch_position(index.tones[anchor.source][1], anchor.time) - if abs(anchor.y - (baseline - expected * layout.style.space / 2)) > 0.01: + if abs(anchor.y - (baseline - expected * layout.style.space * system.scale / 2)) > 0.01: raise generator.engraving.types.EngravingError(f'{anchor.source}: displayed staff position changed pitch') role = 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind candidates = [p for p in system.primitives if p.role == role and anchor.source in p.sources and abs(p.points[0][0] - anchor.x) < 0.01] @@ -51,7 +55,7 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen tone = index.tones[anchor.source][1] tab = tone['tablature'] tab_baseline = max(p.points[0][1] for p in system.primitives if p.role == 'tab-staff') - expected_y = tab_baseline - (6 - tab['string']) * layout.style.space + 5 + expected_y = tab_baseline + (4 - (6 - tab['string']) * layout.style.tab_space) * system.scale expected_x = min(a.x for a in system.anchors if a.event == anchor.event and a.time == anchor.time) tabs = [p for p in system.primitives if p.role == 'tab' and anchor.source in p.sources] if len(tabs) != 1 or tabs[0].value != str(tab['fret']) or abs(tabs[0].points[0][1] - expected_y) > 0.01 or abs(tabs[0].points[0][0] - expected_x) > 0.01: @@ -67,7 +71,7 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen continue if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}: continue - if item.box.overlaps(other.box, 0.1): + if item.box.overlaps(other.box, 0.1 * system.scale): raise generator.engraving.types.EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}') if previous_end != index.end: raise generator.engraving.types.EngravingError('layout omits the end of the score') diff --git a/generator/engraving/layout.py b/generator/engraving/layout.py index 2f1277a..2965eed 100644 --- a/generator/engraving/layout.py +++ b/generator/engraving/layout.py @@ -53,14 +53,14 @@ def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fract measure_start, _, measure = index.measure_at(time) for owner, _, item in index.harmonies_by_onset.get(time, []): if owner is measure: - items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 21, 'harmony', (item['id'],))) + items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],))) for owner, i, value in index.tempos_by_onset.get(time, []): if owner is measure: items.extend(generator.engraving.attributes.tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}')) if time == measure_start: items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure-number', (measure['id'],))) if label := measure.get('section_label'): - items.append(fonts.text(label, 0, 0, 19, 'section', (measure['id'] + ':section',))) + items.append(fonts.text(label, 0, 0, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',))) return items, anchors, fragments def column_metrics(index, time, start, end, fonts, style, columns): @@ -157,14 +157,12 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[generator.engraving.types.Page, ...]: pages = [] - placed = [] - header = [] - cursor = style.margin_y - first = True + guitar = document['staves'][0].get('tablature', False) + header_gap = 14 if guitar else 24 def page_header(number): title = document['metadata']['title'] - size = 26 if number == 1 else 17 + size = (20 if number == 1 else 16) if guitar else (26 if number == 1 else 17) item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ()) while item.box.right > style.width - style.margin_x and size > 12: size -= 1 @@ -175,32 +173,23 @@ def paginate(systems: list[generator.engraving.types.System], document: dict, fo if number == 1: subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value) if subtitle: - result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + 22, 16, 'subtitle', ())) + result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + (15 if guitar else 22), 12 if guitar else 16, 'subtitle', ())) return result - for system in systems: - height = system.box.bottom - system.box.top - if first: - header = page_header(len(pages) + 1) - cursor = generator.engraving.types.bounds(header).bottom + 24 - first = False - if cursor + height > style.height - style.margin_y - 18: - if not placed: - raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: simultaneous notation needs {height:.1f}px vertically; fixed-size page cannot fit it') - number = len(pages) + 1 - footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) - pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer]))) - placed = [] - header = page_header(len(pages) + 1) - cursor = generator.engraving.types.bounds(header).bottom + 24 - if cursor + height > style.height - style.margin_y - 18: - raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: simultaneous notation is too tall for a page') - dx, dy = style.margin_x + 1, cursor - system.box.top - moved = dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)) - placed.append(moved) - cursor += height + style.system_gap - if placed: + for first in range(0, len(systems), 2): number = len(pages) + 1 + header = page_header(number) + top = generator.engraving.types.bounds(header).bottom + header_gap + row_height = (style.height - style.margin_y - 24 - top - style.system_gap) / 2 + if row_height <= 0: + raise generator.engraving.types.EngravingError('page header leaves no room for two music systems') + pair = systems[first:first + 2] + scale = min(1, row_height / max(s.box.bottom - s.box.top for s in pair)) + placed = [] + for row, system in enumerate(pair): + system = system.scaled(scale) + dx, dy = style.margin_x + 1, top + row * (row_height + style.system_gap) - system.box.top + placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy))) footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer]))) return tuple(pages) @@ -240,6 +229,7 @@ def layout_systems(document: dict, style: generator.engraving.types.Style, fonts return tuple(systems) def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument: + style = generator.engraving.types.score_style(document, style) systems = layout_systems(document, style, fonts) layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style)) generator.engraving.audit.audit_layout(document, layout, fonts) diff --git a/generator/engraving/notes.py b/generator/engraving/notes.py index bcaebd9..5273b9b 100644 --- a/generator/engraving/notes.py +++ b/generator/engraving/notes.py @@ -39,7 +39,8 @@ def draw_event(index: generator.engraving.notation.ScoreIndex, event: generator. if position := note.get('rest_position'): y = -index.pitch_position(position, onset) * style.space / 2 elif event.lanes > 1: - y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * 3 + separation = 1 if index.staff.get('tablature') and event.lanes == 2 else 3 + y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * separation kind = 'slash' if note.get('notehead') == 'slash' else 'rest' fragment = generator.engraving.types.Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None) fragments.append(fragment) diff --git a/generator/engraving/pipeline.py b/generator/engraving/pipeline.py index 0ccd7e1..008a213 100644 --- a/generator/engraving/pipeline.py +++ b/generator/engraving/pipeline.py @@ -120,6 +120,7 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog for path, score_key in paths: try: document = json.loads(path.read_text(encoding='utf-8')) + style = generator.engraving.types.score_style(document, generator.engraving.types.Style()) destination = path.with_suffix('.pages') clear_pages(destination) destination.mkdir() diff --git a/generator/engraving/tablature.py b/generator/engraving/tablature.py index 62f4928..4a4ff67 100644 --- a/generator/engraving/tablature.py +++ b/generator/engraving/tablature.py @@ -11,14 +11,13 @@ def column(index, event, anchors, fonts, style): sources = (event.value['id'],) if not event.value['pitches']: anchor = anchors[0] - glyph = 'noteheadSlashHorizontalEnds' if anchor.fragment.kind == 'slash' else generator.engraving.notes.rest_glyph(anchor.fragment.written_type) - return [fonts.glyph(glyph, x, -2 * style.space, style.space, 'tab-' + anchor.fragment.kind, sources)] + return [fonts.glyph('noteheadSlashHorizontalEnds', x, -2 * style.tab_space, style.space, 'tab-slash', sources)] if anchor.fragment.kind == 'slash' else [] result = [] for anchor in anchors: tone = index.tones[anchor.source][1] tab = tone['tablature'] - y = -(6 - tab['string']) * style.space - result.append(fonts.text(str(tab['fret']), x, y + 5, style.space * 1.2, 'tab', (event.value['id'], tone['id']))) + y = -(6 - tab['string']) * style.tab_space + result.append(fonts.text(str(tab['fret']), x, y + 4, style.tab_font_size, 'tab', (event.value['id'], tone['id']))) return result def staff(index, items, start, end, left, right, fonts, style): @@ -26,12 +25,12 @@ def staff(index, items, start, end, left, right, fonts, style): numbers = [p for p in items if p.role.startswith('tab')] if not index.staff.get('tablature'): return items - offset = generator.engraving.types.bounds(notation).bottom + 32 + 5 * style.space + offset = generator.engraving.types.bounds(notation).bottom + style.tab_gap + 5 * style.tab_space numbers = [p.moved(0, offset) for p in numbers] drawing = list(numbers) - drawing.append(fonts.text('TAB', 0, offset - 2 * style.space, 15, 'tab-label', ())) + drawing.append(fonts.text('TAB', 0, offset - 2 * style.tab_space, style.tab_font_size, 'tab-label', ())) for string in range(1, 7): - y = offset - (6 - string) * style.space + y = offset - (6 - string) * style.tab_space cuts = sorted((max(0, p.box.left - 2), min(right, p.box.right + 2)) for p in numbers if p.box.top <= y <= p.box.bottom) cursor = 0 for a, b in cuts + [(right, right)]: @@ -39,7 +38,7 @@ def staff(index, items, start, end, left, right, fonts, style): drawing.append(generator.engraving.types.line('tab-staff', (), ((cursor, y), (a, y)), style.space * 0.13)) cursor = max(cursor, b) for bar in (p for p in notation if p.role == 'barline'): - drawing.append(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 / 4) for x, y in bar.points), bar.thickness)) + drawing.append(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 * style.tab_space / (4 * style.space)) for x, y in bar.points), bar.thickness)) by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]} for identifier, (event, tone) in index.tones.items(): target = tone.get('tie_to') @@ -53,7 +52,7 @@ def staff(index, items, start, end, left, right, fonts, style): first, last = by_source.get(identifier), by_source.get(target) x1 = first.box.right + 2 if first else left x2 = last.box.left - 2 if last else right - y = offset - (6 - tone['tablature']['string']) * style.space + 8 + y = offset - (6 - tone['tablature']['string']) * style.tab_space + 6 if x2 > x1: drawing.append(generator.engraving.types.curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y)))) return notation + drawing diff --git a/generator/engraving/types.py b/generator/engraving/types.py index db79d33..9978040 100644 --- a/generator/engraving/types.py +++ b/generator/engraving/types.py @@ -17,6 +17,13 @@ class Style: supersampling: int = 4 column_gap: float = 10 system_gap: float = 26 + guitar_space: float = 10 + tab_space: float = 10.5 + tab_gap: float = 12 + tab_font_size: float = 12 + +def score_style(document: dict, style: Style) -> Style: + return dataclasses.replace(style, space=style.guitar_space, system_gap=14) if document['staves'][0].get('tablature') else style @dataclasses.dataclass(frozen=True) class Box: @@ -26,6 +33,8 @@ class Box: bottom: float def moved(self, x: float, y: float) -> Box: return Box(self.left + x, self.top + y, self.right + x, self.bottom + y) + def scaled(self, factor: float) -> Box: + return Box(self.left * factor, self.top * factor, self.right * factor, self.bottom * factor) def overlaps(self, other: Box, clearance: float = 0) -> bool: return self.left < other.right + clearance and other.left < self.right + clearance and self.top < other.bottom + clearance and other.top < self.bottom + clearance def finite(self) -> bool: @@ -43,6 +52,8 @@ class Primitive: thickness: float = 0 def moved(self, x: float, y: float) -> Primitive: return dataclasses.replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points)) + def scaled(self, factor: float) -> Primitive: + return dataclasses.replace(self, box=self.box.scaled(factor), points=tuple((x * factor, y * factor) for x, y in self.points), size=self.size * factor, thickness=self.thickness * factor) @dataclasses.dataclass(frozen=True) class Fragment: @@ -75,6 +86,8 @@ class Anchor: fragment: Fragment def moved(self, x: float, y: float) -> Anchor: return dataclasses.replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y) + def scaled(self, factor: float) -> Anchor: + return dataclasses.replace(self, x=self.x * factor, y=self.y * factor, stem_x=self.stem_x * factor, stem_y=self.stem_y * factor) @dataclasses.dataclass(frozen=True) class System: @@ -84,6 +97,9 @@ class System: anchors: tuple[Anchor, ...] fragments: tuple[Fragment, ...] box: Box + scale: float = 1 + def scaled(self, factor: float) -> System: + return dataclasses.replace(self, primitives=tuple(p.scaled(factor) for p in self.primitives), anchors=tuple(a.scaled(factor) for a in self.anchors), box=self.box.scaled(factor), scale=self.scale * factor) @dataclasses.dataclass(frozen=True) class Page: diff --git a/generator/guitar.py b/generator/guitar.py index 241a884..d4559c1 100644 --- a/generator/guitar.py +++ b/generator/guitar.py @@ -4,6 +4,7 @@ import dataclasses import fractions import functools import itertools +import logging import typing import generator.harmony @@ -15,6 +16,8 @@ MIN_FRET = 0 MAX_FRET = 20 MAX_FRET_SPAN = 4 WRITTEN_GUITAR_OCTAVE_OFFSET = 12 +ACOUSTIC_STRINGS = ((6, 4, 3), (6, 4, 3, 2), (5, 4, 3), (5, 3, 2), (5, 4, 3, 2)) +ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1)) class TabPosition(typing.NamedTuple): string_number: int @@ -39,14 +42,14 @@ def validate_tuning(state: dict, location: str) -> None: if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected): raise ValueError(f'{location}: guitar generation requires standard six-string tuning') -def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ...], int | None]: +def chord_requirements(definition: dict, profile: str = '') -> tuple[tuple[int, ...], tuple[int, ...], int | None]: degrees = generator.harmony.degrees(definition) root = definition['root'] root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'} optional = {d for d, a in degrees.items() if not a and d not in explicit and (d == 5 and len(degrees) > 3 or d in (9, 11) and d < max(degrees))} classes = {d: (root_pc + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()} - required = {value for d, value in classes.items() if d not in optional} + required = {value for d, value in classes.items() if d not in optional and not (profile == 'electric' and d == 1)} allowed = set(classes.values()) bass = definition.get('bass') bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None @@ -56,15 +59,19 @@ def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, .. if not 0 <= inversion < len(ordered): raise ValueError('harmony inversion exceeds its chord degrees') bass_pc = classes[ordered[inversion]] + if profile == 'electric': + bass_pc = None if bass_pc is not None: allowed.add(bass_pc) required.add(bass_pc) + if profile == 'acoustic' and bass_pc is None: + bass_pc = root_pc % 12 return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc @functools.lru_cache(maxsize=256) -def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True) -> tuple[tuple[TabPosition, ...], ...]: +def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[TabPosition, ...], ...]: result = [] - options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed) for string, open_midi in STANDARD_TUNING] + options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed or open_midi + fret in melody) for string, open_midi in STANDARD_TUNING] def visit(index, selected, covered): if len(set(required) - covered) > len(options) - index: @@ -78,7 +85,17 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i if position.midi % 12 not in covered and within_fret_span((*selected, position)): visit(index + 1, (*selected, position), covered | {position.midi % 12}) - visit(0, (), set()) + if profile: + by_string = {string: options[i] for i, (string, _) in enumerate(STANDARD_TUNING)} + for strings in ACOUSTIC_STRINGS if profile == 'acoustic' else ELECTRIC_STRINGS: + for selected in itertools.product(*(by_string[s] for s in strings)): + if not within_fret_span(selected) or not set(required) <= {p.midi % 12 for p in selected} or not set(melody) <= {p.midi for p in selected}: + continue + if bass is not None and (selected[0].midi % 12 != bass or selected[0].midi != min(p.midi for p in selected)): + continue + result.append(selected) + else: + visit(0, (), set()) if not prune: return tuple(sorted(result)) # Independent attacks with equal centers have identical movement costs. @@ -183,34 +200,83 @@ class AttackCandidate: shape: tuple[TabPosition, ...] missing: int -def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool): +@dataclasses.dataclass(frozen=True) +class EventPlacement: + note: dict + exact: tuple + added: tuple[TabPosition, ...] = () + +def continued_sources(notes): + result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p} + result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in STANDARD_TUNING) + return result + +def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False): pending = [] chords = [n for n in notes if '_harmony' in n] - if len(chords) > 1: - raise ValueError('generated accompaniment requires one harmonic event per onset') chord = chords[0] if chords else None + profile = chord.get('_guitar_profile', '') if chord else '' + if len(chords) > 1 and (profile != 'acoustic' or any(n['_harmony'] != chord['_harmony'] for n in chords)): + raise ValueError('simultaneous generated voices must share one acoustic harmony') for note in notes: for tone in note['pitches']: options = tones[tone['id']].positions if source := tone.get('tie_from'): options = tuple(p for p in options if p == active.get(source)) pending.append((tone['id'], options)) - requirements = chord_requirements(chord['_harmony']) if chord else ((), (), None) - candidates = chord_positions(*requirements, prune=not (preserve_strings or held or pending)) if chord else [()] - for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))): - for positions in candidates: - shape = tuple(p for _, p in exact) + positions - if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape): - continue - assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact + if fallback and chord and chord.get('_harmonize'): + chords, chord, profile = [], None, '' + requirements = chord_requirements(chord['_harmony'], profile) if chord else ((), (), None) + melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else () + basses = [chord.get('_bass_pc', requirements[2])] if chord else [None] + if profile == 'acoustic' and requirements[2] not in basses: + basses.append(requirements[2]) + for bass in basses: + candidates = chord_positions(requirements[0], requirements[1], bass, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody) if chord else [()] + found = False + for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))): exact_positions = dict(exact) - choices = tuple((n, positions if n is chord else tuple((p, exact_positions[p['id']]) for p in n['pitches'])) for n in notes) - yield AttackCandidate(assignment, choices, shape, len(set(requirements[1]) - {p.midi % 12 for p in positions})) + for positions in candidates: + if profile: + if any(p not in positions for p in exact_positions.values()): + continue + assignment = dict(exact) + choices = [] + compatible = True + for note in notes: + added = tuple(p for p in positions if p not in exact_positions.values() and (note in chords and (profile != 'acoustic' or (p.string_number >= 5) == (note['_guitar_lane'] == 'bass')))) + if any(p.midi % 12 not in requirements[1] for p in added): + compatible = False + break + if '_continue_from' in note: + previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '-s')} + if previous != set(added): + compatible = False + break + assignment.update((f'{note["id"]}-s{p.string_number}', p) for p in added) + choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added)) + if not compatible: + continue + found = True + yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, len(set(requirements[1]) - {p.midi % 12 for p in positions})) + else: + shape = tuple(p for _, p in exact) + positions + if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape): + continue + assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact + choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes) + found = True + yield AttackCandidate(assignment, choices, shape, len(set(requirements[1]) - {p.midi % 12 for p in positions})) + if found: + break def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]: events, tones = [], {} absolute = fractions.Fraction() + previous = {} for measure in document['staves'][0]['measures']: + if measure.get('section_label'): + previous.clear() changes = sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset'])) state = {k: measure[k] for k in ('transpose', 'staff_details')} for change in [state, *changes]: @@ -218,6 +284,16 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]: validate_tuning(state, measure['id']) for voice in measure['voices']: for order, note in enumerate(voice['notes']): + if note.get('_rhythm_continuation'): + source = previous.get(voice['id']) + if source and '_harmony' in source: + note['_continue_from'] = source['id'] + for string, _ in STANDARD_TUNING: + tones[f'{source["id"]}-s{string}'].pitch['tie_to'] = f'{note["id"]}-s{string}' + elif not note['pitches']: + note.pop('_harmony', None) + note['kind'] = 'rest' + previous[voice['id']] = note onset = generator.model.fraction(note['onset']) state = {k: measure[k] for k in ('transpose', 'staff_details')} for change in changes: @@ -226,6 +302,8 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]: for tone in note['pitches']: sounding = generator.pitch.sounding_pitch(tone, state) options = positions_for_pitch(sounding) + if tab := tone.get('tablature'): + options = tuple(p for p in options if (p.string_number, p.fret) == (tab['string'], tab['fret'])) if not options: raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {MIN_FRET}-{MAX_FRET}') tones[tone['id']] = GuitarTone(tone, absolute + onset + generator.model.fraction(tone['duration']), options) @@ -240,8 +318,8 @@ 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 advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict: - continued = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p} +def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict: + continued = continued_sources(notes) held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)} result = {} @@ -252,7 +330,7 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[ if not held_ids and not continued: nearest = nearest_positions(states) - for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings): + for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings, fallback): x, y = position_center(candidate.shape) cost, _, previous = nearest[x][y] keep(candidate, previous, cost) @@ -260,10 +338,12 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[ for previous in states.values(): 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): + for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback): distance = movement(tuple(p for _, p in previous.active), candidate.shape) keep(candidate, previous, (previous.cost[0] + distance, previous.cost[1], previous.cost[2])) 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) raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement within frets {MIN_FRET}-{MAX_FRET} and four-fret span; held/tied strings must remain fixed') return result @@ -277,12 +357,23 @@ def place_guitar(document: dict) -> 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) selected = min(states.values(), key=lambda s: (s.cost, s.active)) + skipped = [] while selected.previous is not None: - for note, choices in selected.choices: + for choice in selected.choices: + note = choice.note + for tone, position in choice.exact: + tone['tablature'] = {'string': position.string_number, 'fret': position.fret} if definition := note.pop('_harmony', None): - note['pitches'] = generator.model.make_event(note['id'], generator.model.fraction(note['onset']), generator.model.fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches'] - else: - for tone, position in choices: - tone['tablature'] = {'string': position.string_number, 'fret': position.fret} + for pitch in chord_pitches(definition, choice.added): + note['pitches'].append({**pitch, 'id': f'{note["id"]}-s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']}) + if note.get('_harmonize') and not choice.added and not note.get('_rhythm_continuation'): + skipped.append(note['id']) selected = selected.previous + for measure in document['staves'][0]['measures']: + for voice in measure['voices']: + for note in voice['notes']: + for field in ('_guitar_profile', '_guitar_lane', '_bass_pc', '_harmonize', '_continue_from'): + note.pop(field, None) + if skipped: + 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 diff --git a/generator/melody.py b/generator/melody.py new file mode 100644 index 0000000..c15162e --- /dev/null +++ b/generator/melody.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import bisect +import copy +import fractions + +import generator.accompaniment +import generator.model + +def harmonize(document: dict) -> None: + """Split long melody runs at harmony changes before choosing guitar positions.""" + staff = document['staves'][0] + tones, events, changes = {}, [], [] + absolute = fractions.Fraction() + previous = None + for measure in staff['measures']: + for chord in measure['harmonies']: + value = chord if chord['kind'] != 'none' else None + if value != previous and not generator.accompaniment.same_chord(value, previous): + time = absolute + generator.model.fraction(chord['onset']) + if changes and changes[-1][0] == time: + changes[-1] = (time, value) + else: + changes.append((time, value)) + previous = value + for voice in measure['voices']: + for note in voice['notes']: + start = absolute + generator.model.fraction(note['onset']) + events.append((note, start)) + for tone in note['pitches']: + tones[tone['id']] = tone + absolute += generator.model.fraction(measure['duration']) + eligible = set() + visited = set() + for note, _ in events: + if not note.get('_melody') or note.get('grace') or note.get('cue') or len(note['pitches']) != 1 or note['pitches'][0].get('cue'): + continue + tone = note['pitches'][0] + if tone['id'] in visited: + continue + while tone.get('tie_from'): + tone = tones[tone['tie_from']] + chain = [] + while True: + chain.append(tone) + if not tone.get('tie_to'): + break + tone = tones[tone['tie_to']] + visited.update(p['id'] for p in chain) + if sum((generator.model.fraction(p['duration']) for p in chain), fractions.Fraction()) > 1: + eligible.update(p['id'] for p in chain) + times = [time for time, _ in changes] + reattacks = {time for time, chord in changes if chord} + replacements, tone_pieces = {}, {} + for note, start in events: + melodic = note.pop('_melody', False) + if not melodic or not note['pitches'] or note['pitches'][0]['id'] not in eligible: + continue + end = start + generator.model.fraction(note['duration']) + cuts = [start, *times[bisect.bisect_right(times, start):bisect.bisect_left(times, end)], end] + pieces = [] + for index, (a, b) in enumerate(zip(cuts, cuts[1:])): + fragment = copy.deepcopy(note) + fragment['id'] = note['id'] if index == 0 else f'{note["id"]}-h{index}' + fragment['onset'] = generator.model.rational(generator.model.fraction(note['onset']) + a - start) + fragment['duration'] = generator.model.rational(b - a) + if len(cuts) > 2: + fragment['written'] = generator.model.written_for(b - a) + fragment.pop('beams', None) + for tone in fragment['pitches']: + original = tone['id'] + tone['id'] = original if index == 0 else f'{original}-h{index}' + tone['duration'], tone['written'] = fragment['duration'], copy.deepcopy(fragment['written']) + tone.pop('tie_from', None) + tone.pop('tie_to', None) + tone_pieces.setdefault(original, []).append((tone, a)) + if index: + fragment.pop('lyrics', None) + current = bisect.bisect_right(times, a) - 1 + chord = changes[current][1] if current >= 0 else None + if chord: + fragment.update(_harmony=chord, _guitar_profile='electric', _harmonize=True) + pieces.append(fragment) + replacements[note['id']] = pieces + for identifier, pieces in tone_pieces.items(): + original = tones[identifier] + links = list(zip(pieces, pieces[1:])) + if source := original.get('tie_from'): + previous_piece = tone_pieces[source][-1] if source in tone_pieces else (tones[source], None) + links.insert(0, (previous_piece, pieces[0])) + previous_piece[0].pop('tie_to', None) + if target := original.get('tie_to'): + if target not in tone_pieces: + pieces[-1][0]['tie_to'] = target + tones[target]['tie_from'] = pieces[-1][0]['id'] + for (first, _), (second, start) in links: + if start not in reattacks: + first['tie_to'], second['tie_from'] = second['id'], first['id'] + by_tone = {p['id']: note for original, _ in events for note in replacements.get(original['id'], [original]) for p in note['pitches']} + for measure in staff['measures']: + for voice in measure['voices']: + voice['notes'] = [piece for note in voice['notes'] for piece in replacements.get(note['id'], [note])] + for note in voice['notes']: + if note.get('_harmonize') and (source := note['pitches'][0].get('tie_from')): + earlier = by_tone[source] + if generator.accompaniment.same_chord(earlier.get('_harmony'), note['_harmony']): + note['_rhythm_continuation'] = True + generator.accompaniment.normalize_beams(voice['notes']) + for tuplet in staff.get('tuplets', []): + if tuplet['end'] in replacements: + tuplet['end'] = replacements[tuplet['end']][-1]['id'] diff --git a/generator/parts.py b/generator/parts.py index 947ed29..578e7a8 100644 --- a/generator/parts.py +++ b/generator/parts.py @@ -73,12 +73,20 @@ def validate_source(document: dict, *, practice: bool = False) -> None: if practice and len(voices) > 1: raise ValueError('practice requires one monophonic voice') -def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '', rhythm: generator.rhythm.Bar | None = None) -> dict: +def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '', rhythm: generator.rhythm.Bar | tuple[generator.rhythm.Bar, generator.rhythm.Bar] | None = None, *, melody_octave: bool = False) -> dict: voices = generator.score.copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None - notes = [n for v in voices for n in v['notes']] if voices else generator.accompaniment.realize(source, rhythm, previous) if rhythm is not None else generated_voice(source, role, previous) + if role == 'accompaniment' and part == 'acoustic': + voices = [{'id': lane, 'notes': generator.accompaniment.realize(source, bar, previous, lane=lane)} for lane, bar in zip(('upper', 'bass'), (rhythm[1], rhythm[0]))] + for voice in voices: + for note in voice['notes']: + note.update(voice=voice['id'], stem='down' if voice['id'] == 'bass' else 'up') + notes = [n for v in voices for n in v['notes']] if voices else generator.accompaniment.realize(source, rhythm, previous, lane='electric') if rhythm is not None else generated_voice(source, role, previous) if role in ('melody', 'vocal'): for note in notes: - note['pitches'] = [generator.pitch.shift_pitch(p, 7, 12) for p in note['pitches']] + octaves = 2 if melody_octave and role == 'melody' else 1 + note['pitches'] = [generator.pitch.shift_pitch(p, 7 * octaves, 12 * octaves) for p in note['pitches']] + if role == 'melody': + note['_melody'] = True result = generator.score.make_measure(identifier, source, [] if voices else notes, section_label=label) if voices: result['voices'] = voices diff --git a/generator/practice/generate.py b/generator/practice/generate.py index a7856f5..9ee713d 100644 --- a/generator/practice/generate.py +++ b/generator/practice/generate.py @@ -4,6 +4,7 @@ import pathlib import generator.accompaniment import generator.guitar +import generator.melody import generator.musicxml import generator.parts import generator.rhythm @@ -23,7 +24,7 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type] if part_type == 'acoustic' and plan is None: raise ValueError('acoustic practice requires a rhythm plan') - measures = [generator.parts.build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', ''), plan.acoustic[i] if part_type == 'acoustic' else None) for i, m in enumerate(staff['measures'])] + measures = [generator.parts.build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', ''), (plan.acoustic_bass[i], plan.acoustic_upper[i]) if part_type == 'acoustic' else None) for i, m in enumerate(staff['measures'])] for measure in measures: for chord in measure['harmonies']: chord.pop('chord_optional', None) @@ -32,6 +33,8 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani if part_type == 'vocal': generator.scat.add_scat(result) else: + generator.accompaniment.prepare_basses(result) + generator.melody.harmonize(result) generator.guitar.place_guitar(result) if generator.accompaniment.connect_continuations(result): generator.guitar.place_guitar(result) diff --git a/tests/fixtures/engraving-python.png b/tests/fixtures/engraving-python.png index 2f3166a..47a74c8 100644 Binary files a/tests/fixtures/engraving-python.png and b/tests/fixtures/engraving-python.png differ diff --git a/tests/fixtures/notation-python.png b/tests/fixtures/notation-python.png index 81b4ae2..dbccc64 100644 Binary files a/tests/fixtures/notation-python.png and b/tests/fixtures/notation-python.png differ diff --git a/tests/test_accompaniment.py b/tests/test_accompaniment.py index eae8002..52bf0d4 100644 --- a/tests/test_accompaniment.py +++ b/tests/test_accompaniment.py @@ -161,7 +161,7 @@ class AccompanimentPlanningTests(unittest.TestCase): item = generator.accompaniment.context(measure, None) bar = quarter_bar(4, {0, 1}) self.assertEqual(generator.accompaniment.sounding_spans(bar, item), ()) - self.assertEqual(generator.accompaniment.rank(bar, [(item, ())], False)[0], 1) + self.assertEqual(generator.accompaniment.rank(bar, [(item, ())], 'upper')[0], 1) def test_melody_context_uses_audible_hidden_notes_but_not_cues(self): measure = melody_measures(1)[0] notes = measure['voices'][0]['notes'] @@ -177,7 +177,7 @@ class AccompanimentPlanningTests(unittest.TestCase): content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note() measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures'] plan = generator.accompaniment.plan_section(measures, library) - self.assertEqual(plan.acoustic, (sounding,) * 4) + self.assertEqual(plan.acoustic_upper, (sounding,) * 4) self.assertEqual(plan.electric, (silent,) * 4) def test_section_repeats_four_bar_motif_with_at_most_one_beat_variation(self): for beats in (2, 3, 4, 5): @@ -185,7 +185,7 @@ class AccompanimentPlanningTests(unittest.TestCase): before = copy.deepcopy(measures) library = tests.rhythm_fixtures.library() plan = generator.accompaniment.plan_section(measures, library) - for part in (plan.acoustic, plan.electric): + for part in (plan.acoustic_bass, plan.acoustic_upper, plan.electric): with self.subTest(beats=beats, part=part): self.assertEqual(len(part), 12) self.assertEqual(part[:4], part[4:8]) @@ -201,7 +201,7 @@ class AccompanimentPlanningTests(unittest.TestCase): measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures'] plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library()) for index in range(3): - acoustic, electric = set(plan.acoustic[index].attacks()), set(plan.electric[index].attacks()) + acoustic, electric = set(plan.acoustic_upper[index].attacks()), set(plan.electric[index].attacks()) self.assertTrue(acoustic) self.assertTrue(electric) self.assertFalse(acoustic & electric) @@ -212,11 +212,13 @@ class AccompanimentPlanningTests(unittest.TestCase): plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library()) for index, measure in enumerate(measures): melody = {generator.model.fraction(n['onset']) for n in measure['voices'][0]['notes']} - for bars in (plan.acoustic, plan.electric): + for bars in (plan.acoustic_bass, plan.acoustic_upper, plan.electric): events = generator.accompaniment.realize(measure, bars[index], None) attacks = {generator.model.fraction(n['onset']) for n in events if n['kind'] == 'note'} - self.assertTrue(attacks, (index, bars)) - self.assertTrue(attacks & melody) + if bars is not plan.electric: + self.assertTrue(attacks, (index, bars)) + self.assertTrue(attacks <= melody) + self.assertTrue(any(bar.attacks() for bar in plan.electric)) def test_section_and_local_meter_changes_restart_the_motif(self): library = generator.rhythm.Library(tuple(generator.rhythm.Snippet(f'{beats}-4/pair', f'{beats}/4', (quarter_bar(beats, {0}), quarter_bar(beats, {1}))) for beats in (3, 4))) segments = [melody_measures(3), melody_measures(2, beats=3), melody_measures(3), melody_measures(4)] @@ -226,9 +228,10 @@ class AccompanimentPlanningTests(unittest.TestCase): start = 0 for segment in segments: expected = generator.accompaniment.plan_section(segment, library) - self.assertEqual(plan.acoustic[start:start + len(segment)], expected.acoustic) + self.assertEqual(plan.acoustic_upper[start:start + len(segment)], expected.acoustic_upper) + self.assertEqual(plan.acoustic_bass[start:start + len(segment)], expected.acoustic_bass) self.assertEqual(plan.electric[start:start + len(segment)], expected.electric) - self.assertEqual(plan.acoustic[start].attacks(), (0,)) + self.assertEqual(plan.acoustic_upper[start].attacks(), (0,)) start += len(segment) def test_mid_measure_meter_changes_and_unsupported_meters_fail(self): content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=1, kind='quarter') + '' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='') @@ -248,8 +251,8 @@ class AccompanimentPlanningTests(unittest.TestCase): contexts = [generator.accompaniment.context(m, chord) for m, chord in zip(measures, previous)] self.assertEqual([item.intervals for item in contexts], [((0, 4),), ((0, 2),), ()]) plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library(), initial=initial) - self.assertTrue(generator.accompaniment.sounding_attacks(plan.acoustic[0], contexts[0])) - self.assertEqual(generator.accompaniment.sounding_attacks(plan.acoustic[2], contexts[2]), ()) + self.assertTrue(generator.accompaniment.sounding_attacks(plan.acoustic_upper[0], contexts[0])) + self.assertEqual(generator.accompaniment.sounding_attacks(plan.acoustic_upper[2], contexts[2]), ()) class AccompanimentRealizationTests(unittest.TestCase): def test_harmonic_gating_removes_dangling_beams(self): @@ -294,7 +297,8 @@ class AccompanimentRealizationTests(unittest.TestCase): self.assertEqual(events[1]['written'], {'type': 'quarter', 'dots': 0}) library = generator.rhythm.Library((generator.rhythm.Snippet('pickup', '4/4', (bar,)),)) plan = generator.accompaniment.plan_section([measure], library) - self.assertEqual(plan.acoustic, (bar,)) + self.assertEqual(plan.acoustic_upper, (bar,)) + self.assertEqual(plan.acoustic_bass, (bar,)) self.assertEqual(plan.electric, (bar,)) class TiedAccompanimentTests(unittest.TestCase): @@ -326,7 +330,8 @@ class TiedAccompanimentTests(unittest.TestCase): bars = library.for_meter('4/4')[0].bars self.assertEqual(bars[1].attacks(), ()) plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library) - self.assertEqual(plan.acoustic, bars) + self.assertEqual(plan.acoustic_upper, bars) + self.assertEqual(plan.acoustic_bass, bars) self.assertEqual(plan.electric, bars) result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=library) first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1) @@ -337,11 +342,13 @@ class TiedAccompanimentTests(unittest.TestCase): self.assertEqual(second[1]['pitches'], []) self.assertEqual(timing(second), expected) self.assert_tied_chord(first[0], second[0]) - self.assertEqual({generator.model.pitch_number(p) % 12 for p in first[0]['pitches']}, {0, 4, 7}) + first_tones = [p for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'] for p in n['pitches']] + self.assertEqual({generator.model.pitch_number(p) % 12 for p in first_tones}, {0, 4, 7}) + self.assert_tied_chord(result['staves'][0]['measures'][0]['voices'][1]['notes'][0], result['staves'][0]['measures'][1]['voices'][1]['notes'][0]) generator.model.validate_document(result) timeline = generator.playback.compile_timeline(result) self.assertEqual(generator.model.fraction(timeline['duration']), 8) - self.assertEqual(len(timeline['events']), len(first[0]['pitches'])) + self.assertEqual(len(timeline['events']), len(first_tones)) self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 + continuation_duration for e in timeline['events'])) self.assertEqual(source, before) self.assertEqual(library.for_meter('4/4')[0].bars, bars) @@ -354,7 +361,7 @@ class TiedAccompanimentTests(unittest.TestCase): first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1) self.assertEqual(timing(first), [(0, 4, 'note')]) self.assertEqual(timing(second), [(0, 2, 'rest'), (2, 2, 'rest')]) - self.assertEqual({generator.model.pitch_number(p) % 12 for p in first[0]['pitches']}, {0, 4, 7}) + self.assertEqual({generator.model.pitch_number(p) % 12 for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'] for p in n['pitches']}, {0, 4, 7}) self.assertEqual(second[0]['pitches'], []) for note in first + second: for pitch in note['pitches']: @@ -374,5 +381,5 @@ class TiedAccompanimentTests(unittest.TestCase): self.assert_tied_chord(first[0], second[0]) generator.model.validate_document(result) events = generator.playback.compile_timeline(result)['events'] - self.assertEqual(len(events), len(first[0]['pitches'])) + self.assertEqual(len(events), sum(len(n['pitches']) for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'])) self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 5 for e in events)) diff --git a/tests/test_engraving.py b/tests/test_engraving.py index 6b4cfe7..a457363 100644 --- a/tests/test_engraving.py +++ b/tests/test_engraving.py @@ -23,6 +23,7 @@ import generator.musicxml import generator.playback import generator.practice.generate import tests.rhythm_fixtures +import tests.test_accompaniment import tests.test_musicxml class EngravingTests(unittest.TestCase): @@ -36,6 +37,65 @@ class EngravingTests(unittest.TestCase): return layout def primitives(self, layout, role): return [p for page in layout.pages for p in page.primitives if p.role == role] + def test_tab_omits_rests_but_keeps_standard_notation(self): + content = tests.test_musicxml.xml_note('E', 2, 'half', octave=5) + '2half' + source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) + document = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) + layout = self.layout(document) + self.assertEqual(len(self.primitives(layout, 'rest')), 1) + self.assertEqual(self.primitives(layout, 'tab-rest'), []) + self.assertEqual(len(self.primitives(layout, 'tab')), 1) + def assert_paired_pages(self, layout): + self.assertTrue(all(len(page.systems) == 2 for page in layout.pages[:-1])) + self.assertIn(len(layout.pages[-1].systems), (1, 2)) + for page in layout.pages: + self.assertEqual(len({system.scale for system in page.systems}), 1) + for first, second in zip(page.systems, page.systems[1:]): + self.assertGreaterEqual(second.box.top - first.box.bottom, layout.style.system_gap - 0.001) + def test_guitar_pages_always_pair_systems_including_tall_passages(self): + content = tests.test_musicxml.xml_note('E', octave=5) + source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 32)) + document = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) + layout = self.layout(document) + self.assert_paired_pages(layout) + self.assertEqual(layout.style.space, 10) + self.assertEqual(layout.style.tab_font_size, 12) + wide = tests.test_musicxml.xml_note('E', octave=3) + tests.test_musicxml.xml_note('C', octave=7, prefix='') + document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([wide] * 32)) + for measure in document['staves'][0]['measures']: + measure['transpose']['octave_change'] = -1 + generator.guitar.place_guitar(document) + tall = self.layout(document) + self.assertGreater(len(tall.pages), 1) + self.assert_paired_pages(tall) + self.assertTrue(all(system.scale < 1 for page in tall.pages for system in page.systems)) + self.assertEqual(tall.style, layout.style) + def test_vocal_pages_have_two_systems_and_an_odd_final_system(self): + content = tests.test_musicxml.xml_note(extra='singledoo') + source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 32)) + style = generator.engraving.types.Style() + systems = generator.engraving.layout.layout_systems(source, style, self.fonts) + self.assertGreaterEqual(len(systems), 3) + pages = generator.engraving.layout.paginate(systems[:3], source, self.fonts, style) + self.assertEqual([len(page.systems) for page in pages], [2, 1]) + self.assert_paired_pages(self.layout(source)) + def test_two_voice_acoustic_systems_can_share_a_page(self): + harmony = 'Emajor' + content = harmony + tests.test_musicxml.xml_note('G', 2, 'half', alter=1) + tests.test_musicxml.xml_note('B', 2, 'half', prefix='') + '2half' + content += '4' + tests.test_musicxml.xml_note('E', extra='2') + document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 16)) + positions = {'e': {'string': 5, 'fret': 7}, 'g': {'string': 4, 'fret': 6}, 'b': {'string': 3, 'fret': 4}} + for measure in document['staves'][0]['measures']: + measure['transpose']['octave_change'] = -1 + for voice in measure['voices']: + for note in voice['notes']: + for pitch in note['pitches']: + pitch['tablature'] = positions[pitch['letter']] + generator.guitar.place_guitar(document) + layout = self.layout(document) + self.assert_paired_pages(layout) + self.assertTrue(self.primitives(layout, 'rest')) + self.assertFalse(self.primitives(layout, 'tab-rest')) def test_notation_fixture_visual_golden(self): layout = self.layout(generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml'))) self.assertEqual(len(layout.pages), 1) @@ -206,7 +266,7 @@ class EngravingTests(unittest.TestCase): def test_wide_pitch_range_can_break_inside_a_measure_vertically(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', octave=10) + tests.test_musicxml.xml_note(duration=2, kind='half', octave=-1))) layout = self.layout(source) - self.assertEqual(len(layout.pages), 2) + self.assertEqual([len(page.systems) for page in layout.pages], [2]) self.assertEqual(len(self.primitives(layout, 'head')), 2) def test_cue_grace_and_tremolo_are_drawn(self): grace = 'D416th' @@ -251,7 +311,7 @@ class EngravingTests(unittest.TestCase): for bar, tab in zip(notation, tabs): for (x1, y1), (x2, y2) in zip(bar.points, tab.points): self.assertAlmostEqual(x1, x2) - self.assertAlmostEqual((y1 - baselines['staff']) * 5 / 4, y2 - baselines['tab-staff']) + self.assertAlmostEqual((y1 - baselines['staff']) * 5 * layout.style.tab_space / (4 * layout.style.space), y2 - baselines['tab-staff']) def test_adjacent_string_tab_numerals_do_not_overlap(self): content = ''.join(tests.test_musicxml.xml_note(step, octave=octave, prefix='' if index else '') for index, (step, octave) in enumerate((('G', 3), ('C', 4), ('E', 4), ('G', 4), ('C', 5), ('E', 5)))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) diff --git a/tests/test_guitar_parts.py b/tests/test_guitar_parts.py new file mode 100644 index 0000000..a83e2af --- /dev/null +++ b/tests/test_guitar_parts.py @@ -0,0 +1,176 @@ +import copy +import fractions +import unittest + +import generator.accompaniment +import generator.arrange.generate +import generator.guitar +import generator.harmony +import generator.model +import generator.parts +import generator.playback +import generator.practice.generate +import generator.rhythm +import tests.rhythm_fixtures +import tests.test_accompaniment +import tests.test_musicxml +import tests.test_parts + +class GuitarPartTests(unittest.TestCase): + def practice(self, content, part='electric', divisions=1): + source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=divisions)) + result = generator.practice.generate.practice_document(source, part, rhythms=tests.rhythm_fixtures.library()) + generator.model.validate_document(result) + return result + def test_profile_shapes_cover_defining_chord_tones(self): + for symbol in ('CM', 'Cm', 'C7', 'CmM7', 'Csus', 'Cdim7', 'Caug7', 'C7b9', 'C13', 'C5', 'CM/E'): + for profile in ('acoustic', 'electric'): + with self.subTest(symbol=symbol, profile=profile): + definition = generator.harmony.parse_symbol(symbol) + required, allowed, bass = generator.guitar.chord_requirements(definition, profile) + candidates = generator.guitar.chord_positions(required, allowed, bass, profile=profile) + self.assertTrue(candidates) + for candidate in candidates: + strings = tuple(p.string_number for p in candidate) + self.assertIn(strings, generator.guitar.ACOUSTIC_STRINGS if profile == 'acoustic' else generator.guitar.ELECTRIC_STRINGS) + self.assertTrue(set(required) <= {p.midi % 12 for p in candidate} <= set(allowed)) + self.assertTrue(generator.guitar.within_fret_span(candidate)) + chord = generator.harmony.parse_symbol('C7') + self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric')[0]) + candidates = generator.guitar.chord_positions(*generator.guitar.chord_requirements(chord, 'electric'), profile='electric') + self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates)) + def test_acoustic_has_independent_voices_and_alternating_bass(self): + result = self.practice(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic') + measure = result['staves'][0]['measures'][0] + upper, bass = measure['voices'] + self.assertEqual((upper['id'], bass['id']), ('upper', 'bass')) + self.assertNotEqual(tests.test_accompaniment.timing(upper['notes']), tests.test_accompaniment.timing(bass['notes'])) + attacks = [n for n in bass['notes'] if n['pitches']] + self.assertEqual([generator.model.pitch_number(n['pitches'][0]) % 12 for n in attacks], [0, 7]) + for voice in (upper, bass): + self.assertEqual(sum(generator.model.fraction(n['duration']) for n in voice['notes']), 4) + for note in voice['notes']: + self.assertEqual(note['stem'], 'up' if voice is upper else 'down') + strings = sorted(p['tablature']['string'] for p in note['pitches']) + if strings: + if voice is bass: + self.assertEqual(len(strings), 1) + self.assertIn(strings[0], (5, 6)) + else: + self.assertIn(strings, ([2, 3], [3, 4], [2, 3, 4])) + timeline = generator.playback.compile_timeline(result)['events'] + by_id = {p['id']: p for v in measure['voices'] for n in v['notes'] for p in n['pitches']} + for event in timeline: + start = generator.model.fraction(event['onset']) + simultaneous = [e for e in timeline if generator.model.fraction(e['onset']) <= start < generator.model.fraction(e['onset']) + generator.model.fraction(e['duration'])] + positions = [generator.guitar.TabPosition(by_id[e['id']]['tablature']['string'], by_id[e['id']]['tablature']['fret'], int(generator.model.fraction(e['pitch']))) for e in simultaneous] + self.assertEqual(len(positions), len({p.string_number for p in positions})) + self.assertTrue(generator.guitar.within_fret_span(positions)) + def test_explicit_bass_and_fifth_fallback(self): + for symbol, expected in (('CM/E', 4), ('C7b9', 0)): + source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) + chord = generator.harmony.parse_symbol(symbol) + source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}] + result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library()) + bass = result['staves'][0]['measures'][0]['voices'][1] + self.assertEqual({generator.model.pitch_number(p) % 12 for n in bass['notes'] for p in n['pitches']}, {expected}) + def test_acoustic_bass_and_upper_chord_can_attack_together(self): + source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note())) + prepared = generator.parts.prepare_document(source, practice=True) + bar = tests.test_accompaniment.quarter_bar(4, {0, 2}) + plan = generator.accompaniment.Plan((bar,), (bar,), ()) + result = generator.practice.generate._practice_document(prepared, 'acoustic', plan) + generator.model.validate_document(result) + upper, bass = result['staves'][0]['measures'][0]['voices'] + self.assertEqual([generator.model.fraction(n['onset']) for n in upper['notes'] if n['pitches']], [0, 2]) + self.assertEqual([generator.model.fraction(n['onset']) for n in bass['notes'] if n['pitches']], [0, 2]) + for onset in (0, 2): + pitches = [p for voice in (upper, bass) for n in voice['notes'] if generator.model.fraction(n['onset']) == onset for p in n['pitches']] + positions = [generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p) - 12)) for p in pitches] + self.assertIn(tuple(sorted((p.string_number for p in positions), reverse=True)), generator.guitar.ACOUSTIC_STRINGS) + self.assertTrue(generator.guitar.within_fret_span(positions)) + def test_long_melody_chords_and_short_notes(self): + content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 2, 'half', octave=5) + tests.test_musicxml.xml_note('G', 1, 'quarter', octave=5) + tests.test_musicxml.xml_note('C', 1, 'quarter', octave=6) + notes = tests.test_musicxml.notes(self.practice(content)) + self.assertIn(len(notes[0]['pitches']), (3, 4)) + self.assertEqual([len(n['pitches']) for n in notes[1:]], [1, 1]) + self.assertEqual(generator.model.pitch_number(notes[0]['pitches'][0]), 76) + self.assertIn(sorted(p['tablature']['string'] for p in notes[0]['pitches']), ([1, 2, 3], [1, 2, 3, 4])) + def test_tied_quarters_qualify_and_keep_all_strings(self): + content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5, extra='') + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5, extra='') + '2half' + result = self.practice(content) + first, second = tests.test_musicxml.notes(result)[:2] + self.assertIn(len(first['pitches']), (3, 4)) + self.assertEqual(len(first['pitches']), len(second['pitches'])) + for before, after in zip(first['pitches'], second['pitches']): + self.assertEqual(before['tie_to'], after['id']) + self.assertEqual(after['tablature'], before['tablature']) + self.assertEqual({generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(result)['events']}, {2}) + def test_chord_change_reattacks_even_for_final_eighth(self): + content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=7) + tests.test_musicxml.xml_note('G', 8, 'whole', octave=5) + result = self.practice(content, divisions=2) + notes = tests.test_musicxml.notes(result) + self.assertEqual([generator.model.fraction(n['duration']) for n in notes], [fractions.Fraction(7, 2), fractions.Fraction(1, 2)]) + for note in notes: + self.assertIn(len(note['pitches']), (3, 4)) + self.assertFalse(any('tie_from' in p or 'tie_to' in p for p in note['pitches'])) + self.assertEqual(generator.model.pitch_number(note['pitches'][0]), 79) + self.assertEqual({generator.model.fraction(e['onset']) for e in generator.playback.compile_timeline(result)['events']}, {0, fractions.Fraction(7, 2)}) + def test_cross_measure_ties_continue_only_without_a_chord_change(self): + for root in ('C', 'F'): + with self.subTest(root=root): + first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', octave=5, extra='') + second = tests.test_parts.xml_harmony(root) + tests.test_musicxml.xml_note('E', octave=5, extra='') + source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([first, second])) + result = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) + generator.model.validate_document(result) + notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']] + self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes)) + self.assertEqual(bool(notes[0]['pitches'][0].get('tie_to')), root == 'C') + self.assertEqual({generator.model.fraction(e['onset']) for e in generator.playback.compile_timeline(result)['events']}, {0} if root == 'C' else {0, 4}) + def test_split_tuplet_keeps_exact_timing_and_span_end(self): + content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=10) + for index in range(3): + extra = '32half' + if index != 1: + extra += '' + content += tests.test_musicxml.xml_note('E', 4, 'half', octave=5, extra=extra) + result = self.practice(content, divisions=3) + notes = tests.test_musicxml.notes(result) + self.assertEqual([generator.model.fraction(n['duration']) for n in notes], [fractions.Fraction(4, 3)] * 2 + [fractions.Fraction(2, 3)] * 2) + self.assertEqual(result['staves'][0]['tuplets'][0]['end'], notes[-1]['id']) + self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes)) + self.assertFalse(notes[-1]['pitches'][0].get('tie_from')) + def test_no_chord_releases_harmony_without_reattacking_melody(self): + content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony(kind='none', offset=2) + tests.test_musicxml.xml_note('E', octave=5) + result = self.practice(content) + first, second = tests.test_musicxml.notes(result) + self.assertIn(len(first['pitches']), (3, 4)) + self.assertEqual(len(second['pitches']), 1) + self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id']) + timeline = generator.playback.compile_timeline(result)['events'] + self.assertEqual(sum(generator.model.fraction(e['duration']) == 4 for e in timeline), 1) + self.assertTrue(all(generator.model.fraction(e['onset']) == 0 for e in timeline)) + def test_unplayable_harmonization_preserves_low_melody(self): + content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('C', octave=4) + with self.assertLogs('generator.guitar', level='WARNING'): + result = self.practice(content) + note = tests.test_musicxml.notes(result)[0] + self.assertEqual(len(note['pitches']), 1) + self.assertEqual(note['pitches'][0]['tablature'], {'string': 5, 'fret': 3}) + def test_song_melody_only_is_raised_an_octave(self): + source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4))) + before = copy.deepcopy(source) + for builder in (generator.practice.generate.practice_document, generator.arrange.generate.arrange_document): + for part in ('electric', 'vocal'): + result = builder(source, part, rhythms=tests.rhythm_fixtures.library()) + notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']] + expected = 76 if part == 'electric' and builder is generator.arrange.generate.arrange_document else 64 + self.assertEqual({generator.model.pitch_number(n['pitches'][0]) for n in notes}, {expected}) + self.assertEqual(source, before) + def test_electric_stabs_are_capped_and_do_not_continue(self): + source = tests.test_accompaniment.melody_measures(1)[0] + for continuation in (False, True): + bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole', continuation=continuation),), fractions.Fraction(4)) + events = generator.accompaniment.realize(source, bar, None, lane='electric') + self.assertEqual(tests.test_accompaniment.timing(events), [(0, 4, 'rest')] if continuation else [(0, 1, 'note'), (1, 3, 'rest')]) diff --git a/tests/test_parts.py b/tests/test_parts.py index 0642698..48476dd 100644 --- a/tests/test_parts.py +++ b/tests/test_parts.py @@ -73,7 +73,7 @@ class PartGenerationTests(unittest.TestCase): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + xml_harmony('F') + tests.test_musicxml.xml_note(duration=duration, kind=kind, extra='' if duration == 3 else ''), attributes, implicit=implicit)) measure = source['staves'][0]['measures'][0] plan = generator.accompaniment.plan_section([measure], tests.rhythm_fixtures.library(), electric=False) - events = generator.accompaniment.realize(measure, plan.acoustic[0], None) + events = generator.accompaniment.realize(measure, plan.acoustic_upper[0], None) self.assertEqual(sum(generator.model.fraction(n['duration']) for n in events), duration) sounding = [n for n in events if n['kind'] == 'note'] self.assertTrue(sounding) @@ -115,7 +115,7 @@ class PartGenerationTests(unittest.TestCase): self.assertEqual(prepared, before) self.assertEqual(source, original) def test_melody_written_pitch_rhythm_rests_and_cross_bar_ties_are_preserved(self): - content = xml_harmony() + xml_harmony('F', offset=3) + content = '' content += tests.test_musicxml.xml_note(duration=2, kind='quarter', alter=1, extra='') content += tests.test_musicxml.xml_note(duration=6, kind='half', alter=1, extra='') root = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(content, divisions=2)) @@ -142,7 +142,8 @@ class PartGenerationTests(unittest.TestCase): self.assertEqual(generated[key], original[key]) self.assertEqual(len(generated['pitches']), len(original['pitches'])) for pitch, tone in zip(original['pitches'], generated['pitches']): - self.assertEqual(generator.model.pitch_number(tone), generator.model.pitch_number(pitch)) + octave = 12 if part == 'electric' and builder is generator.arrange.generate.arrange_document else 0 + self.assertEqual(generator.model.pitch_number(tone), generator.model.pitch_number(pitch) + octave) self.assertEqual(tone['written'], pitch['written']) for key in ('tie_from', 'tie_to'): self.assertEqual(key in tone, key in pitch)