Sepearate Generation

This commit is contained in:
khodowany
2026-10-04 09:05:25 -05:00
parent e8ac7b5edd
commit 8fdd736e13
56 changed files with 1739 additions and 341 deletions
+8 -34
View File
@@ -93,39 +93,15 @@ Artifacts are published to the khodo generic package registry's required `latest
Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live in `data/`. Generated score JSON is not committed. Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live in `data/`. Generated score JSON is not committed.
`data/songs` supplies the authored music. `data/rhythms/<meter>/<series>/` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. All three series are active: the original quarter/eighth patterns plus series `3` with half, dotted-half, and whole notes/rests. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts. `data/songs` supplies the original melody and harmony. The 21 scale patterns, 2,954 relative interval paths, meter-specific rhythm catalogs, and versioned rules in `data/composition` supply production vocabulary. Audition references are outside that path and never participate in construction, scoring, caching or selection.
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. Build-time Python uses each song's actual melody, its mapped harmony, `data/sequences`, and `data/rhythms` to compose recognizable acoustic and electric variations. The standalone `data/lead_phrases` material is an audition control, not a production seed. No solo assignments are stored in song XML, and the runtime format is unchanged. Six roles generate independently from immutable source analysis: acoustic accompaniment/solo; electric accompaniment/melody/solo; vocal melody. A bounded process pool composes roles in parallel, with fully keyed per-role caches for incremental builds. `--composition-jobs 1` provides the serial equivalent; the default is two. There is no joint acoustic/electric selection, generated-solo-aware backing, or reuse of generated melody grips. Assembly respects source-defined role windows and the returning vocal pickup.
The arrangement reuses six musical parts: acoustic accompaniment and solo; electric accompaniment, melody and solo; and vocal melody. Each guitar learns one accompaniment over the full form. Corresponding bars reuse identical rhythms, durations, pitches and TAB fingerings, including behind solos. Only authored harmony changes and protected pickup/ending boundaries create local exceptions. Each solo develops one realized theme from selected features across the actual head. Complete phrases coordinate chord-tone destinations, scale routes, mixed rhythms, space and binding sustained arrivals. Both solos can use all six strings at frets 1–12 without open strings, with different soft register and thematic preferences. Ordinary movement remains strictly mapped. Target-driven approaches, colors, anticipations and conservative blues treatments require explicit validated timing, harmonic and resolution contracts. Direct approaches remain available. No dynamics, accents or humanization have been added.
Source analysis retains complete sounding notes, rests, pickups and cross-bar holds. Production uses **thematic recomposition**: a short rhythm, contour, opening, or characteristic interval from the head initializes a new solo theme. Later phrases develop that realization through harmonic adaptation, extension, answers, or optional brief callbacks. Each instrument establishes a distinct related theme instead of restarting from the corresponding head phrase at every source boundary. Close paraphrase remains available through `composer.compose(..., mode='paraphrase')` for comparison. Each accompaniment is a source-derived canonical part, reused unchanged at corresponding form positions. Sparse shells/dyads and compact voicings retain essential chord information, with deliberate releases and varied rhythms. Neither backing changes in response to generated solos. Source vocals and electric melody retain their established behavior.
Treatment-specific contracts preserve the selected features only. Whole-source deviation applies to close paraphrase, including sounding durations; it does not penalize recomposition for changing unrelated notes. Before optional runs, realized pitch/time overlap and interval/rhythm fragments reject substantial near-copies (initial settings: 80% agreement across a two-bar window with at least four source attacks). Octave shifts, reattacks and extra attacks cannot dilute the duration comparison. Additional distance earns no bonus. The original source reference and the solo's realized theme remain separate in state and reports. See [Independent Musical Generation](TECHNICAL_GUIDE.md#independent-musical-generation) for the dependency graph, data inventory/provenance, algorithms, constraints, single-role CLI, cache behavior and optional audition/reference commands. Full comparison artifacts for this update are under `build/independent_generation_comparison/`; the baseline is never a production input.
Scale permission and chord function remain distinct. Ordinary melody and runs must belong to all mapped collections they overlap; mapped accidentals such as C-sharp in D harmonic minor remain valid. A separate, complete enclosure contract can authorize one brief lower approach outside that map. Generic chromatic substitutions remain disabled. Basic chord members respect suspensions, alterations and omissions; slash-bass requirements do not automatically become melodic targets. Ordinary sounds lasting at least two quarter-note beats must remain chord members across every overlapping chord, unless an explicit valid color or prepared suspension applies. Tied notation is merged before evaluation. Terminal anchors must actually complete, and protected quotations retain their contracts.
Rhythm snippets and complete interval routes realize one- or two-bar edits jointly with chord-tone destinations. Routes retain their entry, complete timing, pitch path and arrival across barlines and collection changes. Later candidates receive the incoming note, position and realized theme before pitch generation. Run entries may extend an existing note without reattack. Intermediate arrivals continue the thought; the final destination has a closed-ending contract. Missing harmony creates explicit silence while preserving thematic memory. Short fragments around missing harmony are explicitly reduced and do not claim full motif fidelity.
Destination retrieval retains representatives of the actual chord degrees before narrowing timing/register choices. Thirds, sevenths, specified sixths, suspensions and altered fifths receive a small harmonic-function preference; roots and fifths remain eligible. The same bounded target cost considers incoming motion, recent destinations, the theme's direction and one following harmony. Terminal roots of the mapped tonic also remain competitive. A Dm triad does not acquire Dm7's seventh, and a non-chord slash bass does not become a melody target.
After selecting the strict acoustic/electric pair and its mapped runs, `solo/enclosures.py` compares sparse local ornaments with direct arrivals. The initial vocabulary is a mapped upper neighbor followed by a lower semitone neighbor, each an eighth note, then the exact preselected chord-tone target. The upper is at most three semitones above the target. The lower can already be mapped; chromatic status earns no extra reward. Each candidate replaces a continuously sounding beat before an existing intermediate stop or terminal hold, optionally revising a complete mapped route up to two beats earlier. Identifying features, protected quotations, rests, target timing and the whole required hold remain intact. No target is reattacked merely to make an ornament possible.
Validation checks the complete sounded gesture, all crossed harmony/scale spans, exact pitch and octave, arrival and release, and actual preparation/resolution timing. Only the contracted lower event can receive an exception. Missing, changed, clipped or unresolved targets invalidate the entire gesture. Tied splits do not change classification. A small chorus frontier retains direct options, with at most one enclosure per phrase and four per 32 bars; sparse use is a soft preference, not a quota. The home theme and development history stay strict. A previously sounding mapped upper neighbor may supply the final eighth-note approach segment without a new attack, but its complete duration still obeys ordinary sustained-note rules. Incidental repeated upper-neighbor attacks carry a local cost; deliberate repeated attacks remain available. Placement is resolved across the final solo; ornamentation never changes accompaniment. Build-time `enclosures_enabled=False` on compose/compose_pair/arrangement skips only this final pass; no runtime fields or performance expression are added.
The plain recomposed theme is exported independently of optional runs. Activity retains a soft 4–6 attacks-per-four-beats preference; occupancy remains diagnostic. No accents, dynamics, swing or other performance-expression changes are applied.
Search retains eight phrase alternatives and an eight-state chorus frontier, with two intermediate continuations per established theme. Up to 64 acoustic/electric pairs compete; equivalent themes after transposition are excluded while individual musical costs and pair similarity remain separate. Concrete caches include incoming state, harmony, timing and vocabulary. One bounded replan broadens eligible start and destination pitches. Exhaustion identifies the failed window without invoking a generic composer. Stable ordering and final tie-breaking preserve determinism.
Electric solos use strings 1–4 and favor roughly the electric melody's sounding register. Acoustic solos use strings 2–6 and favor about one octave below that melody. Both require frets 1–12 with no open notes. The register reference uses the original melody voice, excluding added chord voices, with a four-semitone median deadband and bounded preference; it is not a hard pitch corridor. Instrument profiles constrain pitch/anchor retrieval, complete routes, placement and validation, with cache keys separated by profile. Whole phrases can explore and return without a string-use quota. The four-fret hand-position model retains alternative endpoints and uses available time without claiming a maximum playable speed. Chords retain their existing 1–12 anchor policy, and the returning vocal pickup remains protected.
A clear return refers to the introduced theme's stored opening signature (normally three to five attacks with rhythm/rests), rather than selecting a different later three-note turn. Freer development retains a smaller core. Bounded gesture revisions can reconnect editable isolated notes without changing pitch order, attack count, or required arrivals. Existing anchors distinguish local settling, cadential resolution and handoff; a local settle carries the next harmonic destination into subsequent selection. These distinctions affect the solo, never its accompaniment.
Build reports in `generated_assets/analysis/` include selected source features, recurring solo themes, development lineage, anchors, complete routes, plain-versus-head and elaborated-versus-head comparisons, candidate diagnostics, destination degrees and complete enclosure contracts. Out-of-map approach notes are reported separately. Audition reports additionally show string use weighted by sounding duration and distinguish forced first-string notes from optional placements. These measurements establish technical behavior, not musical quality.
For comparisons, preserve the previous `elaborated/snapshot.json` under `build/solo_melody_auditions/paraphrase_baseline/`, then run `./modulus.py python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_melody_auditions/index.html` for the original source, previous close paraphrase, new theme and theme with runs, alone and with fixed backing. Historical mixes retain their original backing; new production uses canonical accompaniment. Source controls preserve the imported melody and may expose original harmonic tensions; production variations must pass strict validation. The temporary reference archive remains ignored at `build/lead_melody_reference.zip`.
The current comparison is under `build/solo_foreground_auditions/index.html`: preserved production, a whole-phrase register/string-only control, the new plain theme, the selected strict theme before rhythmic revision and enclosures, and the final solo. Fixed comparisons use the same canonical backing and neutral playback. Full, opening-eight-bar and closing-four-bar comparisons include notation and passage reports; full solos also have unaccompanied versions. `before_enclosure` retains the exact selected pre-ornament realization, and `before_rhythm` isolates the timing revision. Preserve a previous snapshot as `before/snapshot.json`, then run `./modulus.py python -m generator.solo.audition --output build/solo_foreground_auditions --capture after` and `--render`. `--no-enclosures` provides a strict capture. Artifacts remain ignored by Git. Older enclosure comparisons remain under `build/solo_enclosure_auditions/`.
To inspect generated assets without building the full package, run: To inspect generated assets without building the full package, run:
@@ -136,17 +112,15 @@ To inspect generated assets without building the full package, run:
The `--check` path compares generation invariants against `data/expected/performance_summary.json`. The `--check` path compares generation invariants against `data/expected/performance_summary.json`.
Modulus owns the pinned build-only dependencies in its optional `assets` group, selected by `tooling.python.groups` in `modulus.json`. Configure prepares `build/build_common/python`; `./modulus.py python` and CMake use that environment and the exported Modulus modules. Builds never install Python dependencies. After dependency pins change, run configure again. C++-only libraries do not opt in, and Python is not packaged for the Pi. Modulus owns the pinned build-only dependencies in its optional `assets` group, selected by `tooling.python.groups` in `modulus.json`. Configure prepares `build/build_common/python`; `./modulus.py python` and CMake use that environment and the exported Modulus modules. Builds never install Python dependencies. After dependency pins change, run configure again. C++-only libraries do not opt in, and Python is not packaged for the Pi.
CMake builds engrave with two Python workers and share a disposable page cache under `build/build_common/engraving_cache`. Native and ARM packaging reuse unchanged pages; CI also retains this cache between runs. Music, playback, and catalog validation still run for every generation. Set `VAMP_ENGRAVING_JOBS` (1-16) to adjust concurrency, or set `VAMP_ENGRAVING_CACHE_DIR` to an empty CMake value to disable caching. Direct generator runs can opt in with `--jobs 2 --engraving-cache build/build_common/engraving_cache --progress`. CMake builds engrave with two Python workers and share a disposable page cache under `build/build_common/engraving_cache`. Native and ARM packaging reuse unchanged pages; CI also retains this cache between runs. Cached musical roles are reused independently; music, playback, and catalog validation still run for every generation. Set `VAMP_ENGRAVING_JOBS` (1-16) to adjust concurrency, or set `VAMP_ENGRAVING_CACHE_DIR` to an empty CMake value to disable caching. Direct generator runs can opt in with `--jobs 2 --engraving-cache build/build_common/engraving_cache --progress`.
Identical notation bodies share layout and body rasterization even when titles or score identities differ; each score retains its own header, pages, music, and catalog identity. A `cold namespace` means no cache directory exists for the current renderer fingerprint. It does not mean that every score must be rendered independently: repeated bodies can still be reused within that build, including with persistent caching disabled. Progress separates full-score hits, body hits, newly rasterized bodies, and composed pages. Identical notation bodies share layout and body rasterization even when titles or score identities differ; each score retains its own header, pages, music, and catalog identity. A `cold namespace` means no cache directory exists for the current renderer fingerprint. It does not mean that every score must be rendered independently: repeated bodies can still be reused within that build, including with persistent caching disabled. Progress separates full-score hits, body hits, newly rasterized bodies, and composed pages.
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. Songs generate Acoustic, Electric, and Vocal parts in their authored key, without key variants or a key-selection menu. Measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure, and exported excerpts retain their arrangement measure labels. 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. Songs generate Acoustic, Electric, and Vocal parts in their authored key, without key variants or a key-selection menu. Measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure, and exported excerpts retain their arrangement measure labels.
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 separately planned bass and upper-chord rhythms, including simultaneous attacks, sharing one fixed playable voicing until the harmony changes in the canonical form. That complete part repeats unchanged across arrangement iterations. Bass, upper voices, and TAB positions stay constant across rests, barlines, and repeated chord symbols. Any defined chord tone can serve as the bass; explicit slash basses and inversions remain authoritative. The rhythms combine into one notated voice with no ties: each attack releases by the next attack or barline, simultaneous tones share a stem and duration, and rests appear only when the entire part is silent. Held notes are not reattacked. Electric accompaniment targets about two attacks per 4/4 bar, scaled for other meters. The original song informs its one-time rhythm choice; generated solos do not alter it. Its notes retain the selected rhythm's duration without a one-beat cap, stopping at chord changes or barlines and reusing the corresponding melody passage's chord shape when available and scale-compatible. Both parts remain tie-free; clipped binary durations that need ties shorten to one conventional note followed by silence. Rhythm snippets supply attacks and rests; tied continuations do not create accompaniment attacks. Structured harmony supplies chord tones. N.C. and absent harmony remain silent. The seven-section form is unchanged; soloing guitars play single-note phrases without self-accompaniment. Unsupported accompaniment meters and mid-measure meter changes fail explicitly. All generated monophonic guitar material uses frets 1–12 without open strings. Accompaniment uses all legal compact positions; a chord can extend to fret 20 while retaining at least one sounding anchor at fret 12 or below and the existing three-fret span restriction. Electric source-melody harmonization retains its existing placement policy. Exact source pitches and ties are preserved; unplayable or unsupported input fails explicitly rather than being silently octave-folded.
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. There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged.
Guitar parts share standard tuning and chord-tone helpers but use separate acoustic and electric placement systems. Single notes in every guitar part, including solos, melodies, bass notes, and harmonization fallbacks, must use frets 1-12. Chords may extend above fret 12, exclude open strings, and fit within three consecutive fret positions. Frets 7-9 qualify; frets 7-10 do not. Each played acoustic or electric chord requires at least one occupied string at fret 12 or below; this anchor is an eligibility check, not a penalty for higher notes within the grip. Acoustic requires a bass on string 5 or 6 plus all three upper strings 4, 3, and 2. Electric uses chords on strings 1-3 or 1-4. 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. Electric melodies are raised one octave; 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 or exact melody pitches fail generation. Acoustic bass attacks and upper-chord attacks qualify independently; an unsounded member of the underlying grip cannot supply the anchor. Held chord tones must retain a sounding anchor, including across notation ties. Solo registers, anchors, pitch paths, and placement all respect the single-note limit before selection. Unreachable exact pitches or explicit TAB fail without silent octave fitting.
All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. 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. All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. 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.
+56 -14
View File
@@ -188,33 +188,75 @@ Only the soloing part receives compact `scale_spans`, with absolute rational sta
Scale labels appear above chord symbols at collection changes and system starts. Detailed parent collections, source and arranged positions, chord-root modes, degrees, chord-tone targets, and selection reasons are written to `generated_assets/analysis/<song>.txt`. These reports are published transactionally with the build output but excluded from both installation and Docker runtime assets. The C++ runtime only displays the generated pages; it does not load the scale library or run harmonic analysis. Scale labels appear above chord symbols at collection changes and system starts. Detailed parent collections, source and arranged positions, chord-root modes, degrees, chord-tone targets, and selection reasons are written to `generated_assets/analysis/<song>.txt`. These reports are published transactionally with the build output but excluded from both installation and Docker runtime assets. The C++ runtime only displays the generated pages; it does not load the scale library or run harmonic analysis.
### Phrase Composition ### Independent Musical Generation
The musical arrangement has six reusable parts: acoustic accompaniment/solo, electric accompaniment/melody/solo, and vocal melody. `arrange.accompaniment.install` builds each accompaniment once and copies its events at matching authored form positions, retaining cycle phase in lead-in/ending excerpts. It preserves the actual guitar pickup's event identities, ties and tuplets. Changed effective harmony is revoiced locally; a structural audit rejects unexplained differences among repeated bars. `solo.arrangement.apply` installs only the soloist's notes and validates the unchanged supporting part against the solo scale map. No solo-dependent accompaniment planning, voicing, or dynamics remains in production. The arrangement has six independently generated roles: acoustic accompaniment and solo; electric accompaniment, melody and solo; vocal melody. `generator.production.SourceAnalysis` contains a canonical immutable copy of the original song. Its harmony, melody, form, meter, tempo, and source-defined role windows are the only shared musical context. A role never reads another role's candidate pool, chosen notes, attacks, register, or grips. Assembly unions their reserved windows; it does not compose, reselect, or rewrite music. The original vocal pickup reserves time before solo composition.
Solo `profiles.Instrument` carries hard string limits, the sounding register, and a melody-derived preferred center. The electric reference is the prepared concert melody plus twelve semitones; acoustic is twelve semitones below electric. This reproduces the existing electric-melody sounding transformation without using the highest harmonization tone. The soft center has a four-semitone deadband, and excursions remain possible. Concrete search and placement caches include the profile. `generator.production.generate_role` is the production entry point for each role. The ordinary build schedules these jobs in a bounded **spawn process pool**, default two workers. `--composition-jobs 1` is the serial reference; values through 16 are accepted. CMake exposes `VAMP_COMPOSITION_JOBS` separately from `VAMP_ENGRAVING_JOBS`. More workers can increase memory use and need not be faster on a small machine.
`gesture_rhythm.revise` compares up to eight alternatives in each of two editable windows, retaining the same pitch sequence and attack count. Feature contracts, complete-duration harmony checks, anchor checks and required tails all remain binding. `Phrase.before_rhythm` preserves the exact pre-revision candidate for comparison. Anchor `effect` distinguishes local settling, cadential arrival and handoff; local settling carries a small next-destination preference into the following phrase. An enclosure may reuse a contiguous mapped upper note through an explicit approach segment; extending that note does not waive its ordinary duration-sensitive harmonic policy. Composition caches contain fully keyed role results under `build/build_common/composition_cache`. Keys cover canonical source content, role settings, relevant production data, algorithm version, and implementation files. Role-specific content hashes and named decision domains determine equivalent-choice tie breaks; output names, title, request order, references, previous scores, and wall-clock budgets do not determine musical choices. Concrete route caches include incoming state and endpoints. Rhythm-window indexing and per-worker parsed catalogs remove repeated full-catalog scans. Cache corruption is rejected, and clean generation remains available without a cache. Unchanged songs/roles can reuse their results; rendering and assembly remain validated. Trace records accompany completed output in `analysis/<song>.roles`; elapsed timings are progress output, not part of deterministic published scores.
`generator/solo/` is build-only. The active path is `arrangement.apply` → `composer.compose_pair` → `leads.thematic_options` → `themes.phrase_candidates` → `realization.checked_phrase`. The song's actual melody supplies a selected rhythmic, contour, opening, or interval seed. Each instrument introduces its own realized solo theme; later development refers to that heard identity, not each successive phrase of the head. Close paraphrase remains available through `mode='paraphrase'` for comparisons. The older cell-level planner/refiner is not the production theme constructor. The active solo construction is `composer.compose` → `leads.thematic_options` → `themes.phrase_candidates` → `realization.checked_phrase`. Source-feature retrieval now covers the complete head, including characteristic rhythm, rests, interval relationships and turns, rather than only its first eight bars. One realized theme drives later development and selected clear returns. The acoustic and electric policies independently prefer different source-feature treatments and soft, overlapping register trajectories. Pairwise uniqueness is diagnostic, never a selection constraint. All six strings are eligible for either solo; sounding notes require frets 1–12 without open strings. Placement compares complete hand-region paths, time-aware shifts and crossings, retaining ties in the same position. Electric melody/vocal transformations and the separate chord anchor policy remain unchanged.
`phrasing.py` proposes four-, five-, six-, and eight-bar endpoints from actual measure boundaries and harmony, with explicit missing-harmony interruptions and a trimmed final handoff. Two promising endpoint alternatives compete through realization from each incoming history. A soft preference aims for approximately six substantial closes per 32 bars (five to seven is the initial range). Ending alternatives choose a chord-valid pitch, arrival, two-to-four-beat sounding interval, and optional release together before constructing the approach. Intermediate search units do not close the thought. A held target must remain one sounding event for the promised interval; repeated attacks or touching and abandoning it do not fulfill the contract. Cross-bar holds must remain valid across every chord and mapped collection they overlap. Complete phrase alternatives combine an identifying feature, continuation, harmonic destination, exact rhythm and sounding tail. Structural notes come from the written chord; mapped scale routes connect them with octave-aware indices. Complete paths retain their destination across cells, barlines and scale changes. The search remains bounded, with diverse rhythm/endpoint representatives before expensive realization and a limited replan on failure. An exhausted search reports its failed window; there is no generic fallback tune. Quarter/eighth movement, delayed entrances, offbeat/dotted values, rests, cross-bar holds and selected triplets or sixteenths come from catalogs plus independently authored general rhythm cells. Faster cells obey tempo/difficulty limits. Sustained endings remain binding, but there are no chorus quotas for attacks, closes, returns, pitch distribution or strings. Occupancy is diagnostic.
Theme appearances distinguish clear returns, developed appearances, and freer continuation. A clear return preserves an identifying melodic relationship with its timing; development can preserve melodic intervals while altering rhythm, or a characteristic rhythm while recomposing pitches. A stored opening signature from the realized introduction supplies clear returns, including its rhythm and rests; development may use its smaller three-note core. Ordinary generated events outside that declared signature remain editable. A uniform quarter-note rhythm alone is not sufficient identity for a rhythmic development. Approximately two clear returns are preferred, independently of phrase closures. Once a feature contract is fulfilled, additional copying is not rewarded. Typed expression policies distinguish optional colors, semitone approaches from either side, both enclosure orders, short chromatic connections, delayed resolutions, anticipations and two conservative blues inflections. They are local alternatives to a direct approach around existing destinations, with complete timing and pitch contracts. Optional colors are conditioned on chord quality/function, duration and actual continuation; altered dominant ninths/thirteenths require a resolving dominant. A natural minor sixth needs tonic/predominant context and stepwise resolution. These policies do not rewrite the chord or force the backing to voice a color. Explicit alterations/omissions take precedence. Written extensions remain distinct from optional colors.
`sequences.py` validates relative paths in `data/sequences` and chains them into complete routes using octave-aware scale indices. `themes.py` combines these with exact rhythms from `data/rhythms`, including rhythmic alteration, diminution, displacement already present in catalog windows, and sustained endings. Concrete routes retain their complete entry, pitch path, timing, and destination. Up to eight rhythm alternatives and six destinations are retrieved; sixteen joint rhythm/start/destination jobs preserve coverage before expensive realization, expanding to thirty-two during a bounded replan. Eight intermediate, phrase, and chorus alternatives are retained. Cache keys include the actual musical state used in construction; history-dependent ranking occurs after shared concrete retrieval. Ordinary material still belongs to every mapped collection it overlaps. Only a validated expression can authorize an exception. Validation checks actual event IDs, exact pitch/octave, harmonic time, every overlapped chord/scale, preparation where required, resolution deadline, guitar placement, protected identity and terminal hold. An annotation or role label alone authorizes nothing. Ties merge before duration treatment; repeated attacks do not satisfy a hold. Missing harmony yields explicit space, unsupported chords fail visibly, and the 21 collections are not claimed to cover every possible chord. Expression selection is a bounded local pass over a valid theme, not a global search over all possible tensions. It may select no decoration.
A rolling sixteen-bar history measures exact and transposed pitch/rhythm recurrence, repeated oscillations, and cadence formulas. Recent reuse receives more pressure than distant reuse, and deliberate returns receive a discount rather than immunity. Ties collapse to one attack. Register goals offer wider acoustic development and lower electric answers after high passages. These are musical preferences, not range or string quotas. Phrase activity retains a broad preference of four to six attacks per four quarter-note beats, including its held ending; individual closing cells have no attack quota. Occupancy is diagnostic. Explicit active, sustained-arrival, and space spans prevent a silent candidate from satisfying a moving passage. `generator.comping.Canonical` constructs each accompaniment independently from source harmony, original melody attacks, form position and static policy. It considers dyads, shells and compact voicings, preserving defining written degrees and explicit bass requirements; complex written extensions can require more tones. Acoustic carries primary bass responsibility. Electric supplies bass at essential source-defined positions. Context selects releases, delayed shells, held figures or space; there is no compulsory four-bar loop. The canonical form is reused exactly when that instrument accompanies, including behind solos. It neither borrows electric melody grips nor follows generated solo gaps. Role windows prevent self-accompaniment. Ensemble overlap is reported downstream and never feeds selection.
`leads.elaborate` may replace a complete internal span of shorter notes, as well as an editable portion of a hold. Feature contracts and protected sounding tails are rechecked against the complete result. Exact source quotations remain immutable. The plain theme is a complete playable line, and elaboration is optional. Harmonic validity, exact timing, scale membership, monophony, required holds, and guitar placement remain hard constraints. A bounded replan may reconsider eligible ending alternatives; exhausted search reports its failed passage without claiming mathematical incompatibility or falling back to generic material. No accents, dynamics, velocity shaping, new swing interpretation or random timing have been added. Runtime JSON, notation/TAB semantics, playback synthesis and transactional publication are unchanged. Source melody and vocal compatibility remain checked separately from generated music.
Acoustic/electric selection compares complete alternatives, preserving different related themes and discouraging unintended long copies. Placement uses sounding pitches on strings 2–6 for acoustic solos and 1–4 for electric solos, frets 1–12 without open strings; written guitar pitch is one octave above sound exactly once. Ties retain string and fret. The first declared source tempo, or 120 quarter notes/minute, informs movement costs; runtime tempo remains adjustable. No out-of-collection chromatic substitutions, accents, dynamics, velocity shaping, or swing changes are part of this composer. #### Data inventory and consumers
`arrangement.py` analyzes each song once and replaces only the two solo-section windows after normal head/accompaniment placement. Electric backs Acoustic Solo; acoustic backs Electric Solo. Neither guitarist accompanies itself. Supporting parts retain the canonical accompaniment chosen from the original song, including its exact rhythm, pitches and fingerings. The electric canonical part may reuse a corresponding melody grip only when it satisfies the written chord requirements and contains chord tones. Solo installation validates the existing backing against the mapped collections and audits repeated form positions; it does not regenerate backing from foreground activity. The complete vocal part remains unchanged, and its pickup cutoff is reserved before composing the electric ending. | Resource | Action and purpose | Active consumer |
|---|---|---|
| `data/songs/*.xml` (2 current sources) | Keep original songs; no reference-derived substitute sources | Source analysis, form, each role |
| `data/scales/` (21 patterns) | Keep major, melodic-minor and harmonic-minor modes | Scale mapper, strict route validation |
| `data/sequences/` (2,954 relative paths) | Keep combinatorial intervals; they are routes, not complete curated tunes | Endpoint-indexed solo routes |
| `data/rhythms/` (3,538 snippets) | Keep all meters: 34 in 2/4, 146 in 3/4, 630 in 4/4, 2,728 in 5/4; retrieval is meter-specific, never weighted by file count | Solo rhythm windows; legacy rhythm regression fixtures |
| `data/composition/roles.json` | Add versioned role preferences, register, bass and moderate difficulty policy | Independent role choices, placement preferences, fast-cell eligibility |
| `data/composition/grammar.json` | Add transposable feature-preservation contracts and hold/release alternatives | Theme development and ending construction |
| `data/composition/harmony.json` | Add explicit optional tension/function/duration/resolution rules | Expression candidates and validation |
| `data/composition/lead_ins.json` | Add generic target-relative formulas and blues resolutions | Complete expression candidates and validation |
| `data/composition/rhythms.json` | Add purpose-bearing melodic cells and sparse comping figures | Solo joint realization, canonical comping |
| `data/lead_phrases/original_lead_theme.json` | Migrate unchanged to `audition/controls/`; remove from production data/build dependencies | Optional historical audition controls only |
| `data/expected/performance_summary.json` | Keep validation-only form/harmony/vocal invariants; never used in selection | Post-generation compatibility check |
| `build/*solo-[12].xml`, old generated assets, audition snapshots | Keep untouched, outside all musical input traces and caches | Explicit optional review only |
`notation.py` converts placed attack spans into ordinary score events, rests, and tied continuations across notation/bar boundaries. Engraving, playback and C++ consumption retain their existing formats. `report.py` appends motif/source IDs, transformation contracts, activity and landmark plans, target windows, sounding pitches, TAB decisions and search diagnostics. Generation still stages and validates the complete output before publication. Listening and playing remain necessary to accept the musical result. The five added JSON resources carry stable IDs, version 1, independent authorship provenance and MIT licensing. They were written from the musical brief and general principles, without extracting target notes, rhythm fingerprints, statistics or fitted weights. Existing catalog provenance is unchanged. No songs or useful combinatorial families were deleted. Legacy cell composition utilities remain for regression/explicit historical comparison; they are not a fallback production path.
`python -m generator.solo.audition --output build/solo_foreground_auditions --capture before` preserves the current implementation, and `--capture after` captures its successor. Existing snapshots are never overwritten. `--render` exports full solos, opening eight bars, and closing four bars as MusicXML and WAV, with the final canonical backing held constant. The published arrangement reuses that same backing instead of recomposing around the foreground. It also exports whole-phrase register transfers of the baseline, the selected theme before rhythmic revision, the new plain theme and unaccompanied plain/elaborated full solos. Source hashes, implementation/policy fingerprints, tempo, and shared mix gain are recorded. Reports distinguish completed sustained endings from ordinary long notes, show retained theme features, recurrence counts, movement/arrival activity, local register distribution, and forced versus optional first-string use. These diagnostics establish behavior; listening is still needed to judge musical quality. #### Commands and validation boundary
```sh
# One role; no other role is composed. A JSON settings file can override its policy.
./modulus.py python -m generator.compose --song data/songs/autumn_leaves.xml \
--role acoustic_solo --data-dir data --cache-dir build/build_common/composition_cache \
--output build/acoustic-role.json
# Full production build, bounded processes and persistent role/page caches.
./modulus.py python -m generator --data-dir data --output-dir build/build_x86_debug/generated_assets \
--composition-jobs 2 --composition-cache build/build_common/composition_cache \
--jobs 2 --engraving-cache build/build_common/engraving_cache --check --progress
# Auditions read completed output and an optional preserved baseline; never compose.
./modulus.py python -m audition.production_review \
--assets build/independent_generation_comparison/after \
--baseline build/independent_generation_comparison/baseline \
--output build/independent_generation_comparison/auditions
# Optional reference review: role association is explicit, not inferred from "Vocal".
./modulus.py python -m audition.reference_review \
--reference 'autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml' \
--form-start autumn_leaves/acoustic=2 --output build/reference-review.json
```
The actual four optional paths are `build/all-of-me-solo-1.xml`, `build/all-of-me-solo-2.xml`, `build/autumn-leaves-solo-1.xml`, and `build/autumn-leaves-solo-2.xml`. Solo 1 maps to acoustic; Solo 2 maps to electric for review only. All of Me has 32 notated measures. Autumn Leaves has 33, with its first Gm7 at measure 2; its initial material is retained without invented harmony. The reviewer records encoded pitches, literal annotations, rational offsets, carried harmony, tuplets, ties, sounding events and uncertain annotation relationships. No guitar-octave interpretation or repairs are applied. Encoded pitches outside the legal solo domain remain recorded as such. This optional command is never run by the build or required tests.
Auditions export full solos plus opening/ending excerpts, solo stems, a common fixed backing for comparisons and the final assembled backing. Plain themes and selected pre-expression themes are included. Source controls are explicitly placed in a legal guitar register for audition only. Neutral rendering and gains are shared; score overlap diagnostics are not claims of perceived masking or musical improvement. Required tests use independent synthetic musical fixtures and cover serial/parallel/order equality, role settings, clean/cache equality, reference isolation, exact timing, ordinary scale motion, typed exceptions, preserved holds, notation and placement. Listening and playing are still necessary to judge the result.
Current boundaries: source meter vocabulary is 2/4–5/4; unsupported meters or unrepresentable notation fail explicitly. The bounded composer cannot prove impossibility when it exhausts its budget. Expression vocabulary and local editing are deliberately finite, and the source-derived seven-section arrangement remains the existing product form. Independent parts can still collide or sound similar; diagnostics cannot automatically reselect either part. Playback remains one neutral mono tone profile, not a simulation of acoustic/electric timbre.
### Engraving Boundary ### Engraving Boundary
+1
View File
@@ -0,0 +1 @@
"""Optional, downstream audition tools. Never imported by production composers."""
+145
View File
@@ -0,0 +1,145 @@
"""Export auditions from completed assets; this command never composes music."""
from __future__ import annotations
import argparse
import array
import copy
import dataclasses
import html
import json
from pathlib import Path
from fractions import Fraction as F
from generator import model, musicxml_writer, playback, score
from generator.solo import notation, placement, types, similarity
from generator.solo.audition import excerpt, mix
def region(document, part):
measures=document['staves'][0]['measures']
first=next(i for i,m in enumerate(measures) if m.get('section_label')==part.capitalize()+' Solo')
finish=next((i for i in range(first+1,len(measures)) if measures[i].get('section_label')),len(measures))
return first,finish-first
def staged(document, details, stage, tempo):
notes=[]
for phrase in details['phrases']:
for n in phrase['lead_stages'].get(stage,{}).get('notes',[]):
p=n['pitch']
notes.append(types.Note(F(n['start']),F(n['end']),types.Pitch(p['letter'],F(p['alter']),p['octave']),
tuple(n['sources']),n['role'],continuation=n['continuation'],event_id=n['event_id']))
notes=types.sounding_notes(tuple(notes))
placed=placement.place(notes,tempo)
if notes and placed is None:raise ValueError(stage+': no legal audition placement')
result=copy.deepcopy(document)
notation.replace_region(result,0,notation.measures(result['staves'][0]['measures'],placed[1] if placed else (),stage))
model.validate_document(result)
return result
def overlap(lead, backing):
a=playback.compile_timeline(lead)['events'];b=playback.compile_timeline(backing)['events']
total=covered=above=0.
for n in a:
start=model.fraction(n['onset']);end=start+model.fraction(n['duration']);pitch=model.fraction(n['pitch'])
cuts={start,end}
for other in b:
s=model.fraction(other['onset']);e=s+model.fraction(other['duration'])
if s<end and e>start:cuts.update((max(s,start),min(e,end)))
points=sorted(cuts)
for x,y in zip(points,points[1:]):
simultaneous=[o for o in b if model.fraction(o['onset'])<=x<model.fraction(o['onset'])+model.fraction(o['duration'])]
total+=float(y-x)
if simultaneous:covered+=float(y-x)
if any(model.fraction(o['pitch'])>pitch for o in simultaneous):above+=float(y-x)
return {'sounding_beats':total,'backing_overlap_fraction':covered/max(.001,total),'backing_above_lead_fraction':above/max(.001,total),
'interpretation':'score overlap only; not a perceptual masking measurement; no feedback to composition'}
def export(assets,baseline,output):
output.mkdir(parents=True,exist_ok=True)
manifest={'composition_inputs':False,'gain':.65,'backing_gain':.55,'playback':'unchanged neutral constant-level synthesis',
'listening_status':'not auditioned by the agent; score validation and rendered signal checks only','solos':{}}
tone_cache={};clips=[];notes_report=[]
for roles_path in sorted((assets/'analysis').glob('*.roles')):
song=roles_path.stem;roles=json.loads(roles_path.read_text())
for part in ('acoustic','electric'):
print('Audition: '+song+'/'+part,flush=True)
other='electric' if part=='acoustic' else 'acoustic'
assembled=score.load_json(assets/'songs'/f'{song}_{part}.json')
backing=score.load_json(assets/'songs'/f'{song}_{other}.json')
first,count=region(assembled,part)
lead=excerpt(assembled,first,count);backing=excerpt(backing,first,count)
detail=roles[part+'_solo']['details']
tempo=next((model.fraction(t['bpm'])*model.written_length(t['written']) for m in assembled['staves'][0]['measures'] for t in m.get('tempos',[]) if 'bpm' in t),F(120))
versions={'final':lead, 'plain':staged(lead,detail,'refined',tempo), 'strict':staged(lead,detail,'before_enclosure',tempo), 'source':staged(lead,detail,'initial',tempo)}
baseline_backing=None
old=baseline/'songs'/f'{song}_{part}.json' if baseline else None
if old and old.is_file():
original=score.load_json(old);a,b=region(original,part)
versions['baseline']=excerpt(original,a,b)
baseline_backing=excerpt(score.load_json(baseline/'songs'/f'{song}_{other}.json'),a,b)
key=song+'_'+part
manifest['solos'][key]={'tempo':str(tempo),'source_form_measures':count,'details':detail,
'input_trace':roles[part+'_solo']['trace'],'ensemble':overlap(lead,backing)}
notes_report.append(key+' — solo-relative rational quarter-note time; sounding pitches\n')
for phrase in detail['phrases']:
notes_report.append(f"{phrase['id']}: {phrase['start']}–{phrase['end']}; {phrase['ending']}; completed={phrase['ending_completed']}\n")
for n in phrase['draft']['notes']:
p=n['pitch'];tone=types.Pitch(p['letter'],F(p['alter']),p['octave'])
pos=n['position'];notes_report.append(f" {n['start']}–{n['end']} {tone.label()} {n['role']} string {pos[0]} fret {pos[1]}\n")
back_audio=mix([backing],tempo,output,tone_cache)
for name,document in versions.items():
label=key+'_'+name
score.write_json(output/(label+'.json'),document)
musicxml_writer.write_musicxml(output/(label+'.xml'),document)
solo_audio=mix([document],tempo,output,tone_cache)
combined=array.array('f',(v+.55*(back_audio[i] if i<len(back_audio) else 0) for i,v in enumerate(solo_audio)))
variants={'solo':solo_audio,'fixed_backing':combined}
if name=='final':variants['final_arrangement']=combined
if name=='baseline' and baseline_backing:
oldback=mix([baseline_backing],tempo,output,tone_cache)
variants['original_backing']=array.array('f',(v+.55*(oldback[i] if i<len(oldback) else 0) for i,v in enumerate(solo_audio)))
for context,values in variants.items():
peak=max(map(abs,values),default=0)*manifest['gain']
if peak>=1:raise ValueError('Audition gain would clip; choose a lower shared gain')
path=label+'_'+context+'.wav'
playback.write_pcm(output/path,(v*manifest['gain'] for v in values))
clips.append({'file':path,'song':song,'part':part,'version':name,'context':context,'peak':peak})
if context=='fixed_backing':
# Exact slices retain the same backing and gain. Counts come from
# the completed source form, not a presumed 32-bar chorus.
measures=document['staves'][0]['measures'];durations=[model.fraction(m['duration']) for m in measures]
for excerpt_name,start,end in (('opening',F(),sum(durations[:8],F())),('ending',sum(durations[:-4],F()),sum(durations,F()))):
factor=F(60*playback.SAMPLE_RATE)/tempo
selection=values[round(start*factor):round(end*factor)]
clip=label+'_'+excerpt_name+'.wav'
playback.write_pcm(output/clip,(v*manifest['gain'] for v in selection))
clips.append({'file':clip,'song':song,'part':part,'version':name,'context':excerpt_name,'peak':peak})
manifest['pair_comparisons']={}
for song in sorted({c['song'] for c in clips}):
def events(part):
detail=manifest['solos'][song+'_'+part]['details']
return tuple((float(F(n['start'])),float(F(n['end'])),types.Pitch(n['pitch']['letter'],F(n['pitch']['alter']),n['pitch']['octave']).number())
for p in detail['phrases'] for n in p['draft']['notes'])
manifest['pair_comparisons'][song]=similarity.compare_events(events('electric'),events('acoustic'))
manifest['clips']=clips
(output/'report.json').write_text(json.dumps(manifest,indent=2))
(output/'notes.txt').write_text(''.join(notes_report))
rows=['<!doctype html><meta charset="utf-8"><title>Independent generation auditions</title><style>body{max-width:1100px;margin:2rem auto;font:16px system-ui}audio{width:420px}td{padding:.5rem}details{margin:1rem}</style>',
'<h1>Independent generation auditions</h1><p>Baseline and new music, same neutral rendering and fixed comparison backing. All files share gain 0.65; backing gain 0.55. Final arrangements use the new independent canonical backing. No subjective listening verdict has been made.</p><p><a href="report.json">Full roles, contracts and diagnostics</a> · <a href="notes.txt">Sounding note report</a></p>']
for key,detail in manifest['solos'].items():
rows.append('<h2>'+html.escape(key)+'</h2><table>')
for c in clips:
if c['song']+'_'+c['part']!=key:continue
label=c['version']+' / '+c['context']
rows.append('<tr><td>'+html.escape(label)+'</td><td><audio controls preload="none" src="'+c['file']+'"></audio></td><td><a href="'+c['file']+'">WAV</a></td></tr>')
rows.append('</table>')
(output/'index.html').write_text('\n'.join(rows))
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--assets',type=Path,required=True);p.add_argument('--baseline',type=Path);p.add_argument('--output',type=Path,required=True)
args=p.parse_args();export(args.assets,args.baseline,args.output)
if __name__=='__main__':main()
+127
View File
@@ -0,0 +1,127 @@
"""Read optional reference MusicXML literally, without repairing it or composing."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from fractions import Fraction as F
import xml.etree.ElementTree as ET
def read(path, role, form_start=None):
raw=Path(path).read_bytes();tree=ET.fromstring(raw)
result={'path':str(Path(path).resolve()),'sha256':hashlib.sha256(raw).hexdigest(),'validation_role':role,
'pitch_convention':'encoded MusicXML; no inferred guitar octave','part_names':[n.text for n in tree.findall('./part-list/score-part/part-name')],
'measures':[],'notes':[],'harmonies':[],'annotations':[],'discrepancies':[]}
divisions=F(1);absolute=F();nominal=F(4);pending={};active=None
for index,m in enumerate(tree.findall('./part/measure')):
cursor=F();extent=F();last_start=F();last_duration=F()
if (value:=m.findtext('attributes/divisions')) is not None:divisions=F(value)
meter=m.find('attributes/time')
if meter is not None and meter.findtext('beats'):
nominal=sum((F(v) for v in meter.findtext('beats').split('+')),F())*4/F(meter.findtext('beat-type'))
for item in m:
offset=F(item.findtext('offset','0'))/divisions
if item.tag=='backup':cursor-=F(item.findtext('duration'))/divisions
elif item.tag=='forward':cursor+=F(item.findtext('duration'))/divisions;extent=max(extent,cursor)
elif item.tag=='harmony':
active={'time':str(absolute+cursor+offset),'measure':index+1,'offset':str(cursor+offset),
'root':item.findtext('root/root-step'),'alter':item.findtext('root/root-alter','0'),
'kind':item.findtext('kind'),'display':item.find('kind').get('text') if item.find('kind') is not None else None,
'bass':ET.tostring(item.find('bass'),encoding='unicode') if item.find('bass') is not None else None,
'degrees':[{'value':d.findtext('degree-value'),'alter':d.findtext('degree-alter'),'type':d.findtext('degree-type')} for d in item.findall('degree')]}
result['harmonies'].append(active)
elif item.tag=='direction':
for words in item.findall('direction-type/words'):
result['annotations'].append({'time':str(absolute+cursor+offset),'measure':index+1,'text':''.join(words.itertext()),'interpretation':'descriptive; no production permission'})
elif item.tag=='note':
duration=F(item.findtext('duration','0'))/divisions
start=last_start if item.find('chord') is not None else cursor
end=start+duration;voice=item.findtext('voice','1');p=item.find('pitch')
encoded={'step':p.findtext('step'),'alter':p.findtext('alter','0'),'octave':int(p.findtext('octave'))} if p is not None else None
kinds={t.get('type') for t in item.findall('tie')} | {t.get('type') for t in item.findall('notations/tied')}
event={'start':str(absolute+start),'end':str(absolute+end),'measure':index+1,'offset':str(start),'pitch':encoded,'rest':item.find('rest') is not None,
'written_type':item.findtext('type'),'dots':len(item.findall('dot')),'voice':voice,'ties':sorted(kinds),
'time_modification':ET.tostring(item.find('time-modification'),encoding='unicode') if item.find('time-modification') is not None else None,
'tuplets':[dict(t.attrib) for t in item.findall('notations/tuplet')],'grace':item.find('grace') is not None,'new_attack':'stop' not in kinds and p is not None}
key=(voice,json.dumps(encoded,sort_keys=True))
if 'stop' in kinds:
old=pending.get(key)
if old is None or result['notes'][old]['end']!=event['start']:
result['discrepancies'].append({'measure':index+1,'issue':'unmatched/noncontiguous tie; retained literally'})
else:event['continuation_of']=old
if 'start' not in kinds:pending.pop(key,None)
if 'start' in kinds:pending[key]=len(result['notes'])
result['notes'].append(event)
if item.find('chord') is None and item.find('grace') is None:cursor=end
last_start,last_duration=start,duration;extent=max(extent,end,cursor)
if extent!=nominal and m.get('implicit')!='yes':result['discrepancies'].append({'measure':index+1,'issue':f'encoded span {extent} differs from nominal {nominal}; no padding applied'})
result['measures'].append({'number':m.get('number'),'start':str(absolute),'duration':str(extent),'implicit':m.get('implicit'),'initial_harmony_known':any(F(h['time'])<=absolute for h in result['harmonies'])})
absolute+=extent
if pending:result['discrepancies'].append({'issue':'unterminated ties retained'})
# Do not run the strict score importer over these references: annotations,
# unusual timing and high encoded pitches are evidence, not repair requests.
from generator import harmony, model, fretboard
def semantic(h):
return {'root':{'letter':h['root'].lower(),'alter':model.rational(F(h['alter']))},'kind':h['kind'],
'degrees':[{'value':int(d['value']),'alter':model.rational(F(d['alter'])),'type':d['type']} for d in h['degrees']]}
for event in result['notes']:
if not event['pitch']:continue
p=event['pitch'];number=12*(p['octave']+1)+model.NATURAL[p['step'].lower()]+F(p['alter'])
event['encoded_midi']=str(number)
event['legal_positions_at_encoded_pitch']=len(fretboard.single_positions_for_pitch(number))
event['harmonic_spans']=[]
start,end=F(event['start']),F(event['end'])
changes=[F(h['time']) for h in result['harmonies'] if start<F(h['time'])<end]
for a,b in zip([start,*changes],[*changes,end]):
current=next((h for h in reversed(result['harmonies']) if F(h['time'])<=a),None)
info={'start':str(a),'end':str(b),'chord':current,'chord_degrees':[]}
if current and current['root']:
try:
chord=semantic(current);root=model.NATURAL[chord['root']['letter']]+F(current['alter'])
info['chord_degrees']=[d for d,alter in harmony.degrees(chord).items() if (root+harmony.degree_semitones(d,alter)-number)%12==0]
info['root_relative_semitones']=str((number-root)%12)
info['spelled_simple_degree']=(model.LETTERS.index(p['step'].lower())-model.LETTERS.index(chord['root']['letter']))%7+1
except (ValueError,KeyError,AttributeError) as error:
info['uncertainty']=str(error)
else:info['uncertainty']='No written/carried harmony; none invented.'
event['harmonic_spans'].append(info)
sounded=[];tie_roots={}
for i,n in enumerate(result['notes']):
if not n['pitch']:continue
previous=n.get('continuation_of')
if previous is not None and previous in tie_roots:
root=tie_roots[previous];sounded[root]['end']=n['end'];sounded[root]['notation_events'].append(i);tie_roots[i]=root
else:
tie_roots[i]=len(sounded);sounded.append({'start':n['start'],'end':n['end'],'pitch':n['pitch'],'notation_events':[i]})
result['sounding_events']=sounded
first=form_start or next((h['measure'] for h in result['harmonies']),None)
offset=F(result['measures'][first-1]['start']) if first else None
result['alignment']={'form_start_measure':first,'encoded_offset':str(offset) if offset is not None else None,'basis':'explicit override' if form_start else 'first written harmony; review required','preceding_material':'retained, without invented harmony'}
for label in result['annotations']:
t=F(label['time']); current=[h for h in result['harmonies'] if F(h['time'])<=t]
future=next((h for h in result['harmonies'] if F(h['time'])>t),None)
label['current_harmony']=current[-1] if current else None
label['following_harmony']=future
label['events']=[n for n in result['notes'] if F(n['start'])<=t<F(n['end']) or t<=F(n['start'])<t+1]
label['uncertainty']='Label meaning must be assessed from these events and harmonic time; label preserved verbatim.'
expected={'b9':F(1),'#9':F(3),'b13':F(8),'#11':F(6),'6':F(9)}.get(label['text'].strip())
if expected is not None:
relationships=[s.get('root_relative_semitones') for n in label['events'] for s in n.get('harmonic_spans',[])]
label['literal_interval_present']=str(expected) in relationships
if not label['literal_interval_present']:label['uncertainty']='No matching interval in the immediate annotated context; label retained, intended scope uncertain.'
# Lead in and Bluesy stay contextual descriptions, never enclosure permits.
return result
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--reference',action='append',default=[],metavar='ROLE=PATH')
p.add_argument('--form-start',action='append',default=[],metavar='ROLE=MEASURE')
p.add_argument('--output',type=Path,required=True)
args=p.parse_args(); starts={k:int(v) for k,v in (x.split('=',1) for x in args.form_start)}
results=[read(path,role,starts.get(role)) for role,path in (x.split('=',1) for x in args.reference)]
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps({'optional_validation_only':True,'references':results},indent=2))
if __name__=='__main__':main()
+4 -1
View File
@@ -137,7 +137,7 @@ configure_file(package/manifest.json.in ${VAMP_MANIFEST} @ONLY)
file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/data/*.xml ${CMAKE_CURRENT_SOURCE_DIR}/data/*.xml
${CMAKE_CURRENT_SOURCE_DIR}/data/expected/*.json ${CMAKE_CURRENT_SOURCE_DIR}/data/expected/*.json
${CMAKE_CURRENT_SOURCE_DIR}/data/lead_phrases/*.json ${CMAKE_CURRENT_SOURCE_DIR}/data/composition/*.json
${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py ${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py ${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
${VAMP_SHARED_PYTHON_INPUTS} ${VAMP_SHARED_PYTHON_INPUTS}
@@ -151,6 +151,7 @@ file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
set(VAMP_GENERATED_ASSETS_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated_assets) set(VAMP_GENERATED_ASSETS_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated_assets)
set(VAMP_GENERATED_ASSETS_STAMP ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.stamp) set(VAMP_GENERATED_ASSETS_STAMP ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.stamp)
set(VAMP_ENGRAVING_JOBS 2 CACHE STRING "Maximum parallel Python engraving workers (1-16)") set(VAMP_ENGRAVING_JOBS 2 CACHE STRING "Maximum parallel Python engraving workers (1-16)")
set(VAMP_COMPOSITION_JOBS 2 CACHE STRING "Maximum independent musical role workers (1-16)")
set(VAMP_ENGRAVING_CACHE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/engraving_cache" CACHE PATH "Shared build-only page cache; empty disables caching") set(VAMP_ENGRAVING_CACHE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/engraving_cache" CACHE PATH "Shared build-only page cache; empty disables caching")
set(VAMP_ENGRAVING_CACHE_ARGS) set(VAMP_ENGRAVING_CACHE_ARGS)
if(VAMP_ENGRAVING_CACHE_DIR) if(VAMP_ENGRAVING_CACHE_DIR)
@@ -169,6 +170,8 @@ add_custom_command(
--static-assets-dir ${CMAKE_CURRENT_SOURCE_DIR}/package/assets --static-assets-dir ${CMAKE_CURRENT_SOURCE_DIR}/package/assets
--check --check
--jobs ${VAMP_ENGRAVING_JOBS} --jobs ${VAMP_ENGRAVING_JOBS}
--composition-jobs ${VAMP_COMPOSITION_JOBS}
--composition-cache ${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/composition_cache
--progress --progress
${VAMP_ENGRAVING_CACHE_ARGS} ${VAMP_ENGRAVING_CACHE_ARGS}
COMMAND ${CMAKE_COMMAND} -E touch ${VAMP_GENERATED_ASSETS_STAMP} COMMAND ${CMAKE_COMMAND} -E touch ${VAMP_GENERATED_ASSETS_STAMP}
+78
View File
@@ -0,0 +1,78 @@
{
"version": 1,
"provenance": "Original transposable transformations authored from the musical brief, independently of audition references.",
"license": "MIT",
"transformations": [
{
"id": "rhythmic_seed",
"preserve": [
"rhythm"
],
"purpose": "introduce"
},
{
"id": "contour_seed",
"preserve": [
"contour"
],
"purpose": "introduce"
},
{
"id": "opening_fragment",
"preserve": [
"rhythm",
"pitches"
],
"purpose": "introduce"
},
{
"id": "characteristic_interval",
"preserve": [
"exact_intervals"
],
"purpose": "introduce"
},
{
"id": "return",
"preserve": [
"rhythm",
"intervals"
],
"purpose": "recognition"
},
{
"id": "develop_rhythm",
"preserve": [
"rhythm"
],
"purpose": "new pitch continuation"
},
{
"id": "develop_contour",
"preserve": [
"intervals"
],
"purpose": "rhythmic development"
},
{
"id": "continue",
"preserve": [
"contour"
],
"purpose": "extend heard material through a boundary"
}
],
"ending_holds": [
"2",
"5/2",
"3",
"7/2",
"4"
],
"ending_releases": [
"0",
"1/2",
"1",
"2"
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"version": 1,
"provenance": "Original conservative chord/function rules; not learned from supplied reference scores.",
"license": "MIT",
"colors": [
{"id": "major_ninth", "qualities": ["major"], "degree": 9, "alter": 0, "max_beats": "4", "outside": false},
{"id": "major_thirteenth", "qualities": ["major"], "degree": 13, "alter": 0, "max_beats": "4", "outside": false},
{"id": "lydian_color", "qualities": ["major", "dominant"], "degree": 11, "alter": 1, "max_beats": "2", "outside": true, "resolve": true},
{"id": "minor_ninth", "qualities": ["minor"], "degree": 9, "alter": 0, "max_beats": "4", "outside": false},
{"id": "minor_eleventh", "qualities": ["minor", "half_diminished"], "degree": 11, "alter": 0, "max_beats": "4", "outside": false},
{"id": "minor_sixth", "qualities": ["minor"], "degree": 6, "alter": 0, "max_beats": "2", "outside": true, "resolve": true, "function": "tonic_or_predominant"},
{"id": "dominant_ninth", "qualities": ["dominant"], "degree": 9, "alter": 0, "max_beats": "4", "outside": false},
{"id": "dominant_thirteenth", "qualities": ["dominant"], "degree": 13, "alter": 0, "max_beats": "4", "outside": false},
{"id": "dominant_flat_ninth", "qualities": ["dominant"], "degree": 9, "alter": -1, "max_beats": "2", "outside": true, "resolve": true, "function": "resolving_dominant"},
{"id": "dominant_sharp_ninth", "qualities": ["dominant"], "degree": 9, "alter": 1, "max_beats": "2", "outside": true, "resolve": true, "function": "resolving_dominant"},
{"id": "dominant_flat_thirteenth", "qualities": ["dominant"], "degree": 13, "alter": -1, "max_beats": "2", "outside": true, "resolve": true, "function": "resolving_dominant"}
],
"resolution_gap": "1",
"anticipation_max": "1"
}
+16
View File
@@ -0,0 +1,16 @@
{
"version": 1,
"provenance": "Original target-relative formulas; no reference-score licks or statistics.",
"license": "MIT",
"patterns": [
{"id": "below", "kind": "approach", "semitones": [-1], "durations": ["1/2"], "gap": "0"},
{"id": "above", "kind": "approach", "semitones": [1], "durations": ["1/2"], "gap": "0"},
{"id": "upper_lower", "kind": "enclosure", "semitones": [1, -1], "durations": ["1/2", "1/2"], "gap": "0"},
{"id": "lower_upper", "kind": "enclosure", "semitones": [-1, 1], "durations": ["1/2", "1/2"], "gap": "0"},
{"id": "ascending", "kind": "connection", "semitones": [-2, -1], "durations": ["1/2", "1/2"], "gap": "0"},
{"id": "descending", "kind": "connection", "semitones": [2, 1], "durations": ["1/2", "1/2"], "gap": "0"},
{"id": "delayed_below", "kind": "approach", "semitones": [-1], "durations": ["1/2"], "gap": "1/2"}
],
"blues": [{"id": "minor_to_major_third", "degree": 3, "alter": -1, "target_degree": 3}, {"id": "flat_fifth_to_fifth", "degree": 5, "alter": -1, "target_degree": 5}],
"max_approach": "3/2"
}
+24
View File
@@ -0,0 +1,24 @@
{
"version": 1,
"provenance": "Original rhythm cells authored as general transformations, not extracted from audition scores.",
"license": "MIT",
"cells": [
{"id": "pickup", "purpose": "entrance", "values": ["r:1/2", "1/2", "1"]},
{"id": "delayed", "purpose": "entrance", "values": ["r:1", "1/2", "1/2"]},
{"id": "offbeat_hold", "purpose": "arrival", "values": ["1/2", "3/2"]},
{"id": "short_answer", "purpose": "answer", "values": ["1/2", "1/2", "r:1"]},
{"id": "mixed_motion", "purpose": "continue", "values": ["1", "1/2", "1/2"]},
{"id": "eighth_motion", "purpose": "connect", "values": ["1/2", "1/2", "1/2", "1/2"]},
{"id": "broadening", "purpose": "arrival", "values": ["1", "2", "r:1"]},
{"id": "triplet_turn", "purpose": "connect", "values": ["1/3", "1/3", "1/3"], "difficulty": "triplet"},
{"id": "sixteenth_pickup", "purpose": "connect", "values": ["1/4", "1/4", "1/2"], "difficulty": "sixteenth"}
],
"comping": [
{"id": "establish_release", "attacks": [["0", "1/2"]]},
{"id": "broad_shell", "attacks": [["0", "3/2"]]},
{"id": "delayed_shell", "attacks": [["1/2", "1"]]},
{"id": "answer", "attacks": [["0", "1/2"], ["3/2", "1/2"]]},
{"id": "sustain", "attacks": [["0", "3"]]},
{"id": "space", "attacks": []}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"version": 1,
"provenance": "Independently authored general composition policy; no validation-score measurements or material.",
"license": "MIT",
"roles": {
"acoustic_solo": {"seed": "acoustic", "register_offset": 0, "preferred_features": ["rhythmic_seed", "opening_fragment", "characteristic_interval", "contour_seed"], "register_motion": [0, 5, -3, 2]},
"electric_solo": {"seed": "electric", "register_offset": 12, "preferred_features": ["characteristic_interval", "contour_seed", "rhythmic_seed", "opening_fragment"], "register_motion": [0, -4, 5, 0]},
"acoustic_accompaniment": {"seed": "acoustic_comp", "bass": "primary", "center": 53},
"electric_accompaniment": {"seed": "electric_comp", "bass": "essential", "center": 60},
"electric_melody": {"seed": "head"},
"vocal_melody": {"seed": "voice"}
},
"difficulty": {"name": "moderate", "triplet_max_bpm": 160, "sixteenth_max_bpm": 112, "burst_attacks": 4}
}
+9 -1
View File
@@ -41,6 +41,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--check", action="store_true") parser.add_argument("--check", action="store_true")
parser.add_argument("--engraving-cache", type=pathlib.Path) parser.add_argument("--engraving-cache", type=pathlib.Path)
parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16") parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16")
parser.add_argument("--composition-jobs", type=int, default=2, choices=range(1, 17), metavar="1-16")
parser.add_argument("--composition-cache", type=pathlib.Path)
parser.add_argument("--progress", action="store_true") parser.add_argument("--progress", action="store_true")
args = parser.parse_args() args = parser.parse_args()
if not args.renderer_fingerprint and (args.data_dir is None or args.output_dir is None): if not args.renderer_fingerprint and (args.data_dir is None or args.output_dir is None):
@@ -55,6 +57,11 @@ def generate_assets(args: argparse.Namespace) -> None:
cache_dir = cache_dir.resolve() if cache_dir else None cache_dir = cache_dir.resolve() if cache_dir else None
if cache_dir and (cache_dir.is_relative_to(output_dir) or output_dir.is_relative_to(cache_dir)): if cache_dir and (cache_dir.is_relative_to(output_dir) or output_dir.is_relative_to(cache_dir)):
raise ValueError('engraving cache and generated assets must not overlap') raise ValueError('engraving cache and generated assets must not overlap')
composition_cache = getattr(args,'composition_cache',None)
if composition_cache:
composition_cache=composition_cache.resolve()
if composition_cache.is_relative_to(output_dir) or output_dir.is_relative_to(composition_cache):
raise ValueError('composition cache and generated assets must not overlap')
progress = getattr(args, 'progress', False) progress = getattr(args, 'progress', False)
started = time.monotonic() started = time.monotonic()
@@ -81,7 +88,8 @@ def generate_assets(args: argparse.Namespace) -> None:
staged.mkdir(parents=True, exist_ok=True) staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged) copy_runtime_fonts(static_assets_dir, staged)
report('generating performance parts') report('generating performance parts')
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences) performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences,
composition_jobs=getattr(args,'composition_jobs',2), cache_dir=getattr(args,'composition_cache',None), progress=progress)
generator.score.validate_generated_tree(staged) generator.score.validate_generated_tree(staged)
if args.check: if args.check:
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs') generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
+3 -1
View File
@@ -139,7 +139,7 @@ def plan_section(measures: list[dict], library: generator.rhythm.Library, *, ini
bass = cycle(snippets, contexts, 'bass') bass = cycle(snippets, contexts, 'bass')
upper = cycle(snippets, contexts, 'upper') 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)] 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 acoustic_companions else None) if electric else upper response = cycle(snippets, contexts, 'electric') if electric else upper
basses.extend(bass[i % 4] for i in range(len(segment))) basses.extend(bass[i % 4] for i in range(len(segment)))
uppers.extend(upper[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))) responses.extend(response[i % 4] for i in range(len(segment)))
@@ -205,6 +205,8 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *,
unchanged = end - start == source.duration and sounding_end == end unchanged = end - start == source.duration and sounding_end == end
duration = sounding_end - start duration = sounding_end - start
written = {'type': source.written_type, 'dots': source.dots} if unchanged else generator.model.written_for(duration) written = {'type': source.written_type, 'dots': source.dots} if unchanged else generator.model.written_for(duration)
if unchanged and source.ratio != 1:
written['ratio'] = generator.model.rational(source.ratio)
if not unchanged and (lane == 'electric' or not chord): if not unchanged and (lane == 'electric' or not chord):
duration, written = next(written_durations(duration)) duration, written = next(written_durations(duration))
event = generator.model.make_event(f'{measure["id"]}_rhythm_{lane}_{len(events)}', start, duration, 'note' if chord else 'rest', written=written) event = generator.model.make_event(f'{measure["id"]}_rhythm_{lane}_{len(events)}', start, duration, 'note' if chord else 'rest', written=written)
-20
View File
@@ -35,26 +35,6 @@ def voice(source, document, measures, part, initial=None, source_offset=0):
generator.acoustic.combine(result) generator.acoustic.combine(result)
else: else:
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result)
# Melody grips remain optional candidates from the same source position.
# Only genuine chord tones are copied: exposed melody tensions must not
# become a repeated accompaniment tension behind every solo.
sections = form.full_arrangement_sections(generator.score.source_staff(source)['measures'], part)
first = sum(s.measure_count for s in sections if s.section_index < 2)
head = document['staves'][0]['measures'][first + source_offset:first + source_offset + len(measures)]
previous = generator.accompaniment.carried_chords(measures, initial)
links = []
for i, measure in enumerate(measures):
if i >= len(head): break
for note in generator.score.measure_notes(measure):
if not note['pitches']: continue
chord = generator.accompaniment.active_chord(measure, generator.model.fraction(note['onset']), previous[i])
requirements = generator.fretboard.chord_requirements(chord)
allowed = set(requirements.allowed)
matches = [n for n in generator.score.measure_notes(head[i]) if len(n['pitches']) >= 3
and {int(generator.model.pitch_number(p)) % 12 for p in n['pitches']} <= allowed
and requirements.covers({int(generator.model.pitch_number(p)) % 12 for p in n['pitches']})]
links.append(generator.electric.VoicingLink(note, tuple(matches)))
generator.electric.reuse_voicings(links)
generator.model.validate_document(result) generator.model.validate_document(result)
generator.fretboard.validate_document(result) generator.fretboard.validate_document(result)
return result['staves'][0]['measures'] return result['staves'][0]['measures']
+17 -35
View File
@@ -57,7 +57,7 @@ def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[
canonical.acoustic_upper[start:end], canonical.electric[start:end]) canonical.acoustic_upper[start:end], canonical.electric[start:end])
return result return result
def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan], *, scales: generator.scales.library.Library | None = None, reports: list[str] | None = None, source_name: str = '') -> dict: def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan], *, scales: generator.scales.library.Library | None = None, reports: list[str] | None = None, source_name: str = '', owned=None, accompaniment=None) -> dict:
if part_type not in generator.parts.PART_TYPES: if part_type not in generator.parts.PART_TYPES:
raise ValueError(f'unknown arrangement part: {part_type}') raise ValueError(f'unknown arrangement part: {part_type}')
staff = generator.score.source_staff(document) staff = generator.score.source_staff(document)
@@ -72,30 +72,36 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
source_starts.append(source_time) source_starts.append(source_time)
source_time += generator.model.fraction(m['duration']) source_time += generator.model.fraction(m['duration'])
for index, section in enumerate(sections): for index, section in enumerate(sections):
role = section.role if owned is None or section.role in owned else 'rest'
section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None) section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(section_measures) previous = generator.accompaniment.carried_chords(section_measures)
segment = [] segment = []
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count): for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
m = section_measures[si] m = section_measures[si]
rhythm = None rhythm = None
if section.role == 'accompaniment': if role == 'accompaniment' and accompaniment is None:
plan = plans[section.section_index] plan = plans[section.section_index]
rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)] 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) if role == 'accompaniment' and accompaniment is not None:
result = accompaniment(m, previous[si], si)
result['id'] = f'measure_{len(output) + len(segment) + 1}'
result['section_label'] = section.label if not segment else ''
else:
result = generator.parts.build_measure(m, f'measure_{len(output) + len(segment) + 1}', role, part_type, previous[si], section.label if not segment else '', rhythm)
segment.append(result) segment.append(result)
if part_type == 'electric' and section.role in ('melody', 'accompaniment'): if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result)) voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction()) segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
if section.role == 'slash': if section.role == 'slash':
solos.append(generator.scales.timeline.Solo(absolute, absolute + segment_length, section.label)) solos.append(generator.scales.timeline.Solo(absolute, absolute + segment_length, section.label))
if section.role in ('vocal', 'melody'): if role in ('vocal', 'melody'):
start = source_starts[section.source_measure_index] start = source_starts[section.source_measure_index]
for shift in staff.get('octave_shifts', []): for shift in staff.get('octave_shifts', []):
low, high = max(start, generator.model.fraction(shift['start'])), min(start + segment_length, generator.model.fraction(shift['end'])) low, high = max(start, generator.model.fraction(shift['start'])), min(start + segment_length, generator.model.fraction(shift['end']))
if low < high: if low < high:
octave_shifts.append({**shift, 'id': f'section_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)}) octave_shifts.append({**shift, 'id': f'section_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)})
pickup = None pickup = None
if section.role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and output: if role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and output:
pickup = generator.parts.build_measure(source[0], f'pickup_{section.section_index}', section.role, part_type, None) pickup = generator.parts.build_measure(source[0], f'pickup_{section.section_index}', section.role, part_type, None)
tuplets.extend(generator.score.reidentify(([pickup] if pickup else []) + segment, staff.get('tuplets', []))) tuplets.extend(generator.score.reidentify(([pickup] if pickup else []) + segment, staff.get('tuplets', [])))
if pickup: if pickup:
@@ -115,22 +121,20 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
tuplets = generator.arrange.pickup.retain_links(output, tuplets) tuplets = generator.arrange.pickup.retain_links(output, tuplets)
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) result = 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) result['metadata'].setdefault('tempo', 124)
if part_type == 'acoustic': if part_type == 'acoustic' and accompaniment is None and (owned is None or 'accompaniment' in owned):
generator.acoustic.prepare(result) generator.acoustic.prepare(result)
generator.acoustic_voicing.place(result) generator.acoustic_voicing.place(result)
generator.acoustic.combine(result) generator.acoustic.combine(result)
elif part_type == 'electric': elif part_type == 'electric' and accompaniment is None and (owned is None or 'melody' in owned or 'accompaniment' in owned):
generator.melody.harmonize(result) generator.melody.harmonize(result)
voicing_links = generator.electric.link_voicings(source, voicing_origins)
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result): if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result)
generator.electric.reuse_voicings(voicing_links)
# Source labels identify the head while arranging; published labels identify # Source labels identify the head while arranging; published labels identify
# the complete performance, including its lead-in and repeated choruses. # the complete performance, including its lead-in and repeated choruses.
for number, measure in enumerate(output, 1): for number, measure in enumerate(output, 1):
measure['number'] = str(number) measure['number'] = str(number)
if part_type != 'vocal': if part_type != 'vocal' and owned is None:
generator.arrange.accompaniment.install(document, result, plans) generator.arrange.accompaniment.install(document, result, plans)
generator.fretboard.validate_document(result) generator.fretboard.validate_document(result)
if scales and solos: if scales and solos:
@@ -139,28 +143,6 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
reports.append(report) reports.append(report)
return result return result
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library, sequences: generator.solo.sequences.Library, references=()) -> int: def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms=None, scales=None, sequences=None, composition_jobs=2, cache_dir=None, progress=False) -> int:
count = 0 from generator.production import generate_tree
for path in generator.parts.source_paths(source_dir): return generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress)
try:
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
plans = plan_arrangement(document, rhythms)
except ValueError as error:
raise ValueError(f'{path}: {error}') from error
parts = {}
for part in generator.parts.PART_TYPES:
try:
parts[part] = _arrange_document(document, part, plans)
except ValueError as error:
raise ValueError(f'{path}, part {part}: {error}') from error
try:
report = generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, references=references)
except ValueError as error:
raise ValueError(f'{path}: {error}') from error
for part, result in parts.items():
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}_{part}.json', result)
count += 1
report_path = output_dir.parent / 'analysis' / f'{generator.score.slugify(path.stem)}.txt'
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(document['metadata']['title'] + '\nSource: ' + path.name + '\n\n' + report, encoding='utf-8')
return count
+123
View File
@@ -0,0 +1,123 @@
"""Source-only sparse accompaniment, frozen once at each form position."""
from __future__ import annotations
import copy
import functools
import json
from fractions import Fraction as F
from generator import accompaniment, fretboard, guitar, harmony, model, parts, score, vocabulary
from generator.solo.policy import stable_rank
def semantic_key(chord):
return json.dumps({k: chord[k] for k in ('root', 'kind', 'degrees', 'bass', 'inversion') if k in chord}, sort_keys=True)
@functools.lru_cache(maxsize=2048)
def voicings(key, bass):
chord = json.loads(key)
degrees = harmony.degrees(chord)
root = int(model.NATURAL[chord['root']['letter']] + model.alteration(chord['root']['alter']))
classes = {d: (root + harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
required = {p for d, p in classes.items() if d in (2, 3, 4, 6, 7) or d == 5 and degrees[d] or d > 7}
full = fretboard.chord_requirements(chord)
required.update(full.basses)
if bass and 1 in classes:
required.add(classes[1])
if len(required) < 2:
required.update(classes[d] for d in (1, 5) if d in classes)
allowed = set(full.allowed)
requirements = fretboard.ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(allowed)), full.basses)
options = guitar.chord_positions(requirements, prune=False)
size = max(2, len(required))
options = [v for v in options if len(v) == size and fretboard.playable_attack((p, F(1)) for p in v)]
if not options:
raise ValueError(harmony.symbol(chord) + ': no legal sparse voicing preserving essential written degrees')
# Retain distinct hand regions and inversions, independent of enumeration.
best = {}
for v in options:
family = (min(p.fret for p in v), min(p.midi for p in v) % 12)
rank = (guitar.compactness(v), stable_rank('comp-voicing', v))
if family not in best or rank < best[family][0]:
best[family] = rank, v
return tuple(v for _, v in sorted(best.values()))
class Canonical:
def __init__(self, document, part, seed):
self.part, self.seed = part, seed
self.policy = vocabulary.role(part + '_accompaniment')
self.cache, self.previous, self.report = {}, (), []
measures = score.source_staff(document)['measures']
self.first_index = score.body_start_measure_index(measures)
self.last_index = len(measures)-1
carried = accompaniment.carried_chords(measures)
for index in range(score.body_start_measure_index(measures), len(measures)):
source = copy.deepcopy(measures[index])
for h in source['harmonies']:
h.pop('chord_optional', None)
self(source, carried[index], index)
def __call__(self, source, prior, index):
key = (index, tuple((model.fraction(h['onset']), semantic_key(h)) for h in source['harmonies']), semantic_key(prior) if prior else '', source['duration'].__repr__())
if key in self.cache:
return copy.deepcopy(self.cache[key])
length = model.fraction(source['duration'])
boundaries = sorted({F(), length, *(model.fraction(h['onset']) for h in source['harmonies'])})
attacks = []
source_attacks = {model.fraction(n['onset']) for n in score.measure_notes(source) if n['pitches'] and not all('tie_from' in p for p in n['pitches'])}
for start, end in zip(boundaries, boundaries[1:]):
chord = accompaniment.active_chord(source, start, prior)
if not chord:
continue
duration = end - start
# Essential bass is a source-derived form responsibility, independent
# of which other guitar happens to be playing or what it generated.
bass = self.policy.get('bass') == 'primary' or bool(chord.get('bass')) or index in (self.first_index,self.last_index) or start > 0
options = voicings(semantic_key(chord), bass)
center = self.policy.get('center', 55)
selected = min(options, key=lambda v: (
.08 * abs(sum(p.midi for p in v) / len(v) - center)
+ (.15 * guitar.movement(self.previous, v) if self.previous else 0)
+ .1 * guitar.compactness(v), stable_rank(self.seed, (index, start, v))))
self.previous = selected
patterns = []
for figure in vocabulary.data('rhythms')['comping']:
events = tuple((start+F(a), min(end, start+F(a)+F(d))) for a, d in figure['attacks'] if F(a) < duration)
# Space is available on held harmony, never an accidental empty winner.
if not events and (not prior or not accompaniment.same_chord(chord, prior) or start):
continue
conflicts = sum(a in source_attacks for a, _ in events)
target = 1 if duration <= 3 else 1.5
cost = .2*abs(len(events)-target) + .08*conflicts
if figure['id'] == 'sustain' and duration < 3:
cost += .2
patterns.append((cost, stable_rank(self.seed, (index, start, figure['id'])), events, figure['id']))
# Source-specific variation within a broad acceptability band, not a loop.
low = min(p[0] for p in patterns)
_, _, events, label = min((p for p in patterns if p[0] <= low+.25), key=lambda p:p[1])
for a, b in events:
attacks.append((a, b, selected, chord))
self.report.append({'source_index': index, 'onset': str(start), 'chord': harmony.symbol(chord), 'figure': label, 'tones': len(selected), 'bass': bass,
'fuller_voicing_reason': 'explicit written degrees/bass' if len(selected)>3 else ''})
notes, cursor = [], F()
def emit(a, b, positions=(), chord=None):
nonlocal cursor
previous = None
for duration, written in accompaniment.written_durations(b-a):
pitches = fretboard.chord_pitches(chord, positions) if positions else []
n = model.make_event(f'comp_{self.part}_{index}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
if previous:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n); previous = n; a += duration
cursor = b
for start, end, positions, chord in attacks:
if cursor < start: emit(cursor, start)
emit(start, end, positions, chord)
if cursor < length: emit(cursor, length)
result = parts.build_measure(source, 'canonical', 'rest', self.part, prior)
result['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
for note in notes: note['voice'] = 'voice_arranged'
self.cache[key] = copy.deepcopy(result)
return result
+24
View File
@@ -0,0 +1,24 @@
"""Explicit single-role build. Does not publish or consume an existing ensemble."""
import argparse
import json
import time
from pathlib import Path
from generator import musicxml, parts, production
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--song',type=Path,required=True)
parser.add_argument('--role',choices=production.ROLES,required=True)
parser.add_argument('--data-dir',type=Path,default=Path('data'))
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--cache-dir',type=Path)
parser.add_argument('--settings',type=Path)
args=parser.parse_args()
source=production.SourceAnalysis.read(parts.prepare_document(musicxml.load_musicxml(args.song)))
start=time.monotonic()
result=production.generate_role(source,args.role,args.data_dir,json.loads(args.settings.read_text()) if args.settings else {},args.cache_dir)
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(result,indent=2))
print(f'{args.role}: {time.monotonic()-start:.2f}s wall; cached={result["cache_hit"]}; original composition {result["seconds"]:.2f}s')
if __name__=='__main__':main()
+286
View File
@@ -0,0 +1,286 @@
"""Independent, reproducible role generation and non-composing assembly.
Only source documents and production resources enter generate_role. Completed
roles and published assets are downstream values, never composition inputs.
"""
from __future__ import annotations
import concurrent.futures
import copy
import dataclasses
import hashlib
import functools
import json
import multiprocessing
import os
from pathlib import Path
import tempfile
import time
from fractions import Fraction as F
from generator import accompaniment, comping, fretboard, model, musicxml, parts, rhythm, score, vocabulary
from generator.arrange import form, generate, pickup
from generator.solo import analysis, composer, notation, report, sequences
from generator.scales import library as scales
VERSION = 'independent-roles-1'
ROLES = ('acoustic_accompaniment', 'electric_accompaniment', 'electric_melody', 'vocal_melody', 'acoustic_solo', 'electric_solo')
@dataclasses.dataclass(frozen=True)
class SourceAnalysis:
document_json: str
musical_key: str
@classmethod
def read(cls, document):
prepared = copy.deepcopy(document)
staff = score.source_staff(prepared)
for i, m in enumerate(staff['measures']):
m['id'], m['number'] = f'source_{i}', str(i+1)
staff['tuplets'] = score.reidentify(staff['measures'], staff.get('tuplets', []))
prepared['metadata'] = {k:v for k,v in prepared['metadata'].items() if k not in ('title', 'composer', 'copyright', 'software')}
prepared['metadata'].update(title='Source', composer='')
staff.update(id='source', name='Source')
melodic = analysis.analyze(prepared)
content = {'melody': [(str(n.start), str(n.end), dataclasses.asdict(n.pitch)) for n in melodic.melody],
'measures': [{k: m.get(k) for k in ('duration', 'meter', 'key', 'harmonies', 'tempos', 'section_label', 'implicit')} for m in staff['measures']]}
# Fraction/string spelling is canonical and never includes filesystem IDs.
key = hashlib.sha256(json.dumps(content, sort_keys=True, default=str).encode()).hexdigest()
return cls(json.dumps(prepared, sort_keys=True), key)
def document(self):
return json.loads(self.document_json)
def windows(document, part):
source = score.source_staff(document)['measures']
sections = form.full_arrangement_sections(source, part)
result, cursor = [], F()
for section in sections:
duration = sum((model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index+section.measure_count]), F())
role = {'slash': 'solo', 'vocal': 'melody'}.get(section.role, section.role)
if section.role in ('vocal', 'melody') and pickup.enters(section, source) and result:
attacks = [model.fraction(n['onset']) for n in score.measure_notes(source[0]) if n['pitches']]
if attacks:
start = cursor-model.fraction(source[0]['duration'])+min(attacks)
a, _, old = result[-1]; result[-1] = (a, start, old)
result.append((start, cursor, part+'_'+role))
end = cursor+duration
result.append((cursor, end, part+'_'+role))
if part == 'electric' and section.role == 'slash' and score.body_start_measure_index(source):
attacks = [model.fraction(n['onset']) for n in score.measure_notes(source[0]) if n['pitches']]
if attacks:
cutoff = end-model.fraction(source[0]['duration'])+min(attacks)
result[-1] = (cursor, cutoff, part+'_solo')
result.append((cutoff, end, part+'_rest'))
cursor = end
return tuple(result)
def dependencies(role, resources):
names = ('grammar', 'harmony', 'lead_ins', 'rhythms') if role.endswith('_solo') else ('rhythms',) if role.endswith('_accompaniment') else ()
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
**{name: resources[name] for name in names}}
def _code_key(role):
root = Path(__file__).parent
paths = [root / n for n in ('production.py', 'model.py', 'harmony.py', 'pitch.py', 'fretboard.py', 'parts.py', 'score.py', 'vocabulary.py')]
paths += list((root/'arrange').glob('*.py'))
if role.endswith('_solo'):
paths += [p for p in (root/'solo').glob('*.py') if 'audition' not in p.name and p.name != 'references.py']
paths += list((root/'scales').glob('*.py')) + [root/'rhythm.py']
elif role.endswith('_accompaniment'):
paths += [root/n for n in ('comping.py', 'accompaniment.py', 'guitar.py')]
elif role == 'electric_melody':
paths += [root/'guitar.py', root/'melody.py']
return vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))])
def _resource_key(directory, role):
names = ('scales', 'sequences', 'rhythms') if role.endswith('_solo') else ()
return {name: vocabulary.fingerprint([(p.relative_to(directory/name).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted((directory/name).rglob('*.xml'))]) for name in names}
@functools.lru_cache(maxsize=4)
def _solo_libraries(directory, fingerprints):
return scales.load_library(directory/'scales'),sequences.load_library(directory/'sequences'),rhythm.load_library(directory/'rhythms')
def _mask(document, allowed):
"""Apply source-defined role exclusions, without observing another part."""
absolute = F()
for measure in document['staves'][0]['measures']:
length = model.fraction(measure['duration'])
for voice in measure['voices']:
selected = []
for n in voice['notes']:
start = absolute+model.fraction(n['onset'])
end = start+model.fraction(n['duration'])
intervals = [(max(start,a),min(end,b)) for a,b in allowed if a < end and b > start]
for a,b in intervals:
note = copy.deepcopy(n)
if a != start:
note['onset'] = model.rational(a-absolute)
for pitch in note['pitches']: pitch.pop('tie_from',None)
pickup.truncate(note,b-absolute)
if a < b: selected.append(note)
voice['notes'] = _fill(selected, length, measure['id']+'_'+voice['id'], voice['id'])
absolute += length
staff=document['staves'][0]
staff['tuplets']=pickup.retain_links(staff['measures'],staff.get('tuplets',[]))
def _fill(notes, length, prefix, voice='voice_arranged'):
notes=sorted(notes,key=lambda n:model.fraction(n['onset']))
result=[]; cursor=F()
def rests(end):
nonlocal cursor
for duration,written in accompaniment.written_durations(end-cursor):
result.append(model.make_event(prefix+'_rest_'+str(len(result)),cursor,duration,'rest',written=written)|{'voice':voice})
cursor+=duration
for n in notes:
start=model.fraction(n['onset'])
if start<cursor: raise ValueError(prefix+': overlapping independently generated roles')
rests(start); n['voice']=voice;result.append(n)
cursor=start+model.fraction(n['duration'])
rests(length)
return result
def generate_role(source, role, data_dir, settings=None, cache_dir=None):
if role not in ROLES: raise ValueError('Unknown generation role: '+role)
data_dir=Path(data_dir); resources=vocabulary.load(data_dir/'composition')
settings=settings or {}
inputs={'version':VERSION,'source':source.musical_key,'role':role,'settings':settings,
'vocabulary':dependencies(role,resources),'catalogs':_resource_key(data_dir,role),'code':_code_key(role)}
# Source notation/lyrics also invalidate cached score representation, never the musical seed.
key=vocabulary.fingerprint({'inputs':inputs,'notation':source.document_json})
path=Path(cache_dir)/role/(key+'.json') if cache_dir else None
if path and path.is_file() and not path.is_symlink():
try:
entry=json.loads(path.read_text()); value=entry['result']
if entry['key']==key and entry['digest']==vocabulary.fingerprint(value):
model.validate_document(value['document']); fretboard.validate_document(value['document'])
return {**value,'cache_hit':True}
except (ValueError,KeyError,TypeError): pass
started=time.monotonic(); document=source.document(); part=role.split('_')[0]
trace={'source':source.musical_key,'role':role,'production_resources':inputs['catalogs'],
'vocabulary':vocabulary.fingerprint(inputs['vocabulary']),'settings':settings,'algorithm':VERSION,'code':inputs['code'],'cache_key':key}
seed=vocabulary.fingerprint({'source':source.musical_key,'role':role,'settings':settings,'data':inputs['vocabulary'],'version':VERSION})
details={}; solo_report=''
with vocabulary.using(resources,role,settings|{'choice_seed':seed}):
if role.endswith('_solo'):
result=generate._arrange_document(document,part,{},owned={'slash'})
from generator.solo.arrangement import regions,annotate
si=3 if part=='acoustic' else 4
first,count,offset,length=regions(document)[si]
# The template/harmony comes directly from source form, not another generated part.
authored=score.source_staff(document)['measures']
section=form.full_arrangement_sections(authored,part)[si]
effective=generate.section_source(authored,section,form.full_arrangement_sections(authored,part)[si+1])
template=copy.deepcopy(effective[section.source_measure_index:section.source_measure_index+count])
initial=accompaniment.carried_chords(effective)[section.source_measure_index]
end=next(b for a,b,r in windows(document,part) if r==role)-offset
scale_library,sequence_library,rhythm_library=_solo_libraries(data_dir,tuple(sorted(inputs['catalogs'].items())))
solo=composer.compose(analysis.analyze(document),template,part,end,
initial=initial,scales=scale_library,sequences=sequence_library,rhythms=rhythm_library,
enclosures_enabled=vocabulary.role().get('expressions',True))
output=notation.measures(result['staves'][0]['measures'][first:first+count],solo.notes(),'solo_'+part)
notation.replace_region(result,first,output);annotate(result,solo,offset)
details=report.structure(solo)
solo_report=report.describe(solo,scale_library,first,offset)
elif role.endswith('_accompaniment'):
canonical=comping.Canonical(document,part,seed)
result=generate._arrange_document(document,part,{},owned={'accompaniment'},accompaniment=canonical)
details={'canonical_comping':canonical.report}
else:
result=generate._arrange_document(document,part,{},owned={'vocal' if part=='vocal' else 'melody'})
allowed=tuple((a,b) for a,b,r in windows(document,part) if r==role)
_mask(result,allowed)
staff=result['staves'][0]
for measure in staff['measures']:
measure['id']=role+'_'+measure['id']
staff['tuplets']=score.reidentify(staff['measures'],staff.get('tuplets',[]))
if part!='vocal':result['staves'][0]['tablature']=True
model.validate_document(result);fretboard.validate_document(result)
value={'role':role,'document':result,'trace':trace,'details':details,'report':solo_report,'seconds':time.monotonic()-started,'cache_hit':False}
# Sidecar diagnostics use the same rational-safe representation as reports.
# Identical insertion order after cold generation and cache loading also
# keeps serialized scores and the catalog byte-for-byte reproducible.
value=json.loads(json.dumps(value,default=str,sort_keys=True))
if path:
path.parent.mkdir(parents=True,exist_ok=True)
payload={'key':key,'digest':vocabulary.fingerprint(value),'result':value}
with tempfile.NamedTemporaryFile('w',dir=path.parent,delete=False) as out:
json.dump(payload,out,sort_keys=True);temporary=out.name
os.replace(temporary,path)
return value
def assemble(source, results):
"""Union prevalidated role windows; no selection or pitch/fingering rewrite."""
documents={}
for part in parts.PART_TYPES:
selected=[results[r]['document'] for r in ROLES if r.startswith(part+'_')]
document=copy.deepcopy(selected[0]); staff=document['staves'][0]
if part=='vocal':
documents[part]=document;continue
staff['tuplets']=[];staff['octave_shifts']=[];staff['scale_spans']=[]
for other in selected:
for field in ('tuplets','octave_shifts','scale_spans'):
staff[field].extend(copy.deepcopy(other['staves'][0].get(field,[])))
for i,m in enumerate(staff['measures']):
# Tuplet rests belong to the source/role rhythm and must survive union.
sounding=[copy.deepcopy(n) for d in selected for n in score.measure_notes(d['staves'][0]['measures'][i]) if n['pitches'] or n.get('tuplet_ids')]
m['voices']=[{'id':'voice_arranged','notes':_fill(sounding,model.fraction(m['duration']),m['id'])}]
# Attribute changes belong to source melody pickups and are assembly metadata.
for d in selected:
other=d['staves'][0]['measures'][i]
if other.get('attributes'):m['attributes']=copy.deepcopy(other['attributes'])
staff['tuplets']=pickup.retain_links(staff['measures'],staff['tuplets'])
for span in staff['tuplets']:span['voice']='voice_arranged'
model.validate_document(document);fretboard.validate_document(document)
documents[part]=document
return documents
def _job(request):
source,role,directory,settings,cache=request
return generate_role(source,role,directory,settings,cache)
def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=False):
if type(jobs) is not int or not 1<=jobs<=16:
raise ValueError('composition workers must be between 1 and 16')
source_dir,output_dir=Path(source_dir),Path(output_dir)
sources={}
for path in parts.source_paths(source_dir):
document=parts.prepare_document(musicxml.load_musicxml(path))
sources[path]=(document,SourceAnalysis.read(document))
requests=[(path,role,(s,role,source_dir.parent,{},cache_dir)) for path,(_,s) in sources.items() for role in ROLES]
results={p:{} for p in sources}
def accept(path,role,value):
results[path][role]=value
if progress:print(f'Composition: {path.stem}/{role}: '+('cached' if value['cache_hit'] else f'{value["seconds"]:.1f}s'),flush=True)
if jobs==1:
for path,role,args in requests:
try:accept(path,role,_job(args))
except Exception as error:raise ValueError(f'{path.name}/{role}: {error}') from error
else:
with concurrent.futures.ProcessPoolExecutor(max_workers=jobs,mp_context=multiprocessing.get_context('spawn')) as pool:
futures={pool.submit(_job,args):(path,role) for path,role,args in requests}
for future in concurrent.futures.as_completed(futures):
path,role=futures[future]
try:accept(path,role,future.result())
except Exception as error:raise ValueError(f'{path.name}/{role}: {error}') from error
for path,(original,source) in sources.items():
assembled=assemble(source,results[path])
for part,document in assembled.items():
document['metadata']=copy.deepcopy(original['metadata'])
score.write_json(output_dir/(score.slugify(path.stem)+'_'+part+'.json'),document)
analysis_dir=output_dir.parent/'analysis';analysis_dir.mkdir(parents=True,exist_ok=True)
(analysis_dir/(score.slugify(path.stem)+'.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES))
# Not a score file: diagnostics are deliberately outside the songs schema.
stable={role:{k:v for k,v in value.items() if k not in ('seconds','cache_hit')} for role,value in results[path].items()}
(analysis_dir/(score.slugify(path.stem)+'.roles')).write_text(json.dumps(stable,sort_keys=True,indent=2))
return len(sources)*3
+4 -5
View File
@@ -17,6 +17,7 @@ class Event:
dots: int = 0 dots: int = 0
beams: tuple[tuple[int, str], ...] = () beams: tuple[tuple[int, str], ...] = ()
continuation: bool = False continuation: bool = False
ratio: fractions.Fraction = fractions.Fraction(1)
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Bar: class Bar:
@@ -53,8 +54,8 @@ class Library:
def read_snippet(path: pathlib.Path, identifier: str) -> Snippet: def read_snippet(path: pathlib.Path, identifier: str) -> Snippet:
document = generator.musicxml.load_musicxml(path) document = generator.musicxml.load_musicxml(path)
staff = document['staves'][0] staff = document['staves'][0]
if len(staff['measures']) not in (1, 2) or staff.get('tuplets'): if len(staff['measures']) not in (1, 2):
raise ValueError(f'{path}: rhythm snippets require one or two bars without tuplets') raise ValueError(f'{path}: rhythm snippets require one or two bars')
meter = staff['measures'][0]['time_signature'] meter = staff['measures'][0]['time_signature']
bars = [] bars = []
for measure in staff['measures']: for measure in staff['measures']:
@@ -69,9 +70,7 @@ def read_snippet(path: pathlib.Path, identifier: str) -> Snippet:
onset, duration = generator.model.fraction(note['onset']), generator.model.fraction(note['duration']) onset, duration = generator.model.fraction(note['onset']), generator.model.fraction(note['duration'])
if onset != cursor or duration <= 0 or note.get('grace') or note['kind'] not in ('note', 'rest') or note.get('tremolo'): if onset != cursor or duration <= 0 or note.get('grace') or note['kind'] not in ('note', 'rest') or note.get('tremolo'):
raise ValueError(f'{path}, measure {measure["number"]}: rhythm events must fill the bar sequentially') raise ValueError(f'{path}, measure {measure["number"]}: rhythm events must fill the bar sequentially')
if 'ratio' in note['written']: events.append(Event(onset, duration, note['kind'] == 'note', note['written']['type'], note['written'].get('dots', 0), tuple((b['level'], b['type']) for b in note.get('beams', [])), any('tie_from' in p for p in note['pitches']), generator.model.fraction(note['written'].get('ratio',generator.model.rational(1)))))
raise ValueError(f'{path}, measure {measure["number"]}: rhythm snippets do not support time modifications')
events.append(Event(onset, duration, note['kind'] == 'note', note['written']['type'], note['written'].get('dots', 0), tuple((b['level'], b['type']) for b in note.get('beams', [])), any('tie_from' in p for p in note['pitches'])))
cursor += duration cursor += duration
if cursor != length: if cursor != length:
raise ValueError(f'{path}: rhythm events do not fill the bar') raise ValueError(f'{path}: rhythm events do not fill the bar')
+8 -2
View File
@@ -40,6 +40,9 @@ def annotate(document, solo, offset):
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated', enclosures_enabled=True): def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated', enclosures_enabled=True):
"""Compose once per song, replacing only already-placed solo section windows.""" """Compose once per song, replacing only already-placed solo section windows."""
if references:
raise ValueError('Audition references are not production composition inputs')
from generator.arrange import generate
analysis = generator.solo.analysis.analyze(source) analysis = generator.solo.analysis.analyze(source)
sections = regions(source) sections = regions(source)
source_measures = generator.score.source_staff(source)['measures'] source_measures = generator.score.source_staff(source)['measures']
@@ -47,8 +50,11 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
requests = {} requests = {}
for section_index, part in ((3, 'acoustic'), (4, 'electric')): for section_index, part in ((3, 'acoustic'), (4, 'electric')):
first, count, offset, length = sections[section_index] first, count, offset, length = sections[section_index]
initial = generator.accompaniment.carried_chords(parts[part]['staves'][0]['measures'])[first] form = generator.arrange.form.full_arrangement_sections(source_measures, part)
template = copy.deepcopy(parts[part]['staves'][0]['measures'][first:first + count]) section = form[section_index]
effective = generate.section_source(source_measures, section, form[section_index+1])
initial = generator.accompaniment.carried_chords(effective)[section.source_measure_index]
template = copy.deepcopy(effective[section.source_measure_index:section.source_measure_index+count])
cutoff = length cutoff = length
if part == 'electric' and generator.score.body_start_measure_index(source_measures): if part == 'electric' and generator.score.body_start_measure_index(source_measures):
pickup = source_measures[0] pickup = source_measures[0]
+2 -2
View File
@@ -83,7 +83,7 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
if destination.exists(): if destination.exists():
raise ValueError(f'{destination}: snapshot already exists; choose a new output root') raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
rhythms, scales, sequences = libraries or resources() rhythms, scales, sequences = libraries or resources()
reference_library = generator.solo.references.load_library(pathlib.Path('data/lead_phrases')) reference_library = generator.solo.references.load_library(pathlib.Path('audition/controls'))
result = {'enclosures_enabled':enclosures_enabled,'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}} result = {'enclosures_enabled':enclosures_enabled,'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}}
try: try:
result['policy'] = importlib.import_module('generator.solo.policy').describe() result['policy'] = importlib.import_module('generator.solo.policy').describe()
@@ -107,7 +107,7 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
if shared: if shared:
for context in shared.values(): for context in shared.values():
context.diagnostics = generator.solo.realization.SearchContext().diagnostics context.diagnostics = generator.solo.realization.SearchContext().diagnostics
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared, references=reference_library,enclosures_enabled=enclosures_enabled) generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared,enclosures_enabled=enclosures_enabled)
song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started} song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started}
regions = generator.solo.arrangement.regions(document) regions = generator.solo.arrangement.regions(document)
for solo, index in zip(solos, (3, 4)): for solo, index in zip(solos, (3, 4)):
+11 -33
View File
@@ -20,6 +20,8 @@ import generator.solo.realization
import generator.solo.types import generator.solo.types
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic', enclosures_enabled=True): def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic', enclosures_enabled=True):
if references or context is not None and getattr(context,'references',()):
raise ValueError('Audition references are not production composition inputs')
started = time.monotonic() started = time.monotonic()
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial) timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo') region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
@@ -32,43 +34,18 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode) options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
result = options[0][1] result = options[0][1]
result.diagnostics['strict_composition_seconds'] = time.monotonic()-started result.diagnostics['strict_composition_seconds'] = time.monotonic()-started
result = generator.solo.enclosures.refine(result,sequences,enclosures_enabled and stage=='elaborated' and mode=='thematic') from generator.solo import expressions
result = expressions.refine(result,enclosures_enabled and stage=='elaborated' and mode=='thematic')
validate(result) validate(result)
return result return result
def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic', enclosures_enabled=True): def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic', enclosures_enabled=True):
"""Select two recognizable interpretations together, before arranging backing.""" """Compatibility batch helper: each winner is selected independently."""
from generator.solo import leads return tuple(compose(analysis, measures, part, end, scales=scales, sequences=sequences,
started = time.monotonic() rhythms=rhythms, initial=initial, context=(contexts or {}).get(part), stage=stage,
pools = {} mode=mode, enclosures_enabled=enclosures_enabled)
for part, (measures,end,initial) in requests.items(): for part, (measures, end, initial) in requests.items())
timeline = generator.scales.timeline.read({'measures': measures},initial=initial)
choices = generator.scales.selection.select(timeline,generator.scales.timeline.Solo(0,end,part),scales)
for c in choices:
if c.span.chord and c.scale is None:
raise ValueError(f'{part} solo, source measure {c.span.source_measure}: No exact match within the permitted catalog')
context = (contexts or {}).get(part) or generator.solo.realization.SearchContext()
pools[part] = leads.solo_options(analysis,measures,part,end,choices,sequences,rhythms,context,stage,mode)
def theme_key(solo):
home=next((p.state.home for p in solo.phrases if p.state and p.state.home),None)
if not home or not home.notes:return ()
return tuple((n.start-home.notes[0].start,n.end-n.start,n.pitch.number()-home.notes[0].pitch.number()) for n in home.notes)
pairs = [(a_cost+e_cost+2*leads.similarity(a,e),a,e) for a_cost,a in pools['acoustic'] for e_cost,e in pools['electric']
if mode!='thematic' or not theme_key(a) or theme_key(a)!=theme_key(e)]
if not pairs:
raise generator.solo.realization.SearchExhausted('Distinct acoustic/electric themes not found within the bounded pair search')
_,acoustic,electric = min(pairs,key=lambda v:(v[0],generator.solo.policy.stable_rank('solo-pair',(v[1].notes(),v[2].notes()))))
overlap = leads.similarity(acoustic,electric)
strict_seconds=time.monotonic()-started
selected=[]
for solo in (acoustic,electric):
solo.diagnostics.update(pair_similarity=overlap,pairs_compared=len(pairs),solo_alternatives=len(pools[solo.part]))
solo.diagnostics['strict_pair_seconds']=strict_seconds
solo=generator.solo.enclosures.refine(solo,sequences,enclosures_enabled and stage=='elaborated' and mode=='thematic')
validate(solo)
selected.append(solo)
return tuple(selected)
def phrase_notes(phrase): def phrase_notes(phrase):
@@ -144,7 +121,8 @@ def validate(solo):
harmony = generator.solo.harmonic.Harmony(solo.choices) harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments)) contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments))
enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures) enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures) expressions = tuple(e for p in solo.phrases if p.draft for e in p.draft.expressions)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures,expressions=expressions)
if not checked.valid: if not checked.valid:
raise ValueError(f'{solo.part} solo: {checked.error}') raise ValueError(f'{solo.part} solo: {checked.error}')
for phrase in solo.phrases: for phrase in solo.phrases:
+9 -4
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import dataclasses import dataclasses
import fractions import fractions
import functools
import generator.model import generator.model
import generator.solo.harmonic import generator.solo.harmonic
@@ -14,7 +15,7 @@ import generator.solo.policy
import generator.solo.scoring import generator.solo.scoring
import generator.solo.sequences import generator.solo.sequences
import generator.solo.types import generator.solo.types
import generator.solo.references import generator.solo.features
F = fractions.Fraction F = fractions.Fraction
@@ -47,9 +48,13 @@ class RhythmDraft:
octave: int = 0 octave: int = 0
def catalog(library, meter): def catalog(library, meter):
return _catalog(library.for_meter(meter))
@functools.lru_cache(maxsize=32)
def _catalog(snippets):
"""Read complete snippets; ties across authored bars stay one attack.""" """Read complete snippets; ties across authored bars stay one attack."""
result = {} result = {}
for snippet in library.for_meter(meter): for snippet in snippets:
offset, spans = F(), [] offset, spans = F(), []
for bar in snippet.bars: for bar in snippet.bars:
for event in bar.events: for event in bar.events:
@@ -370,7 +375,7 @@ def contract_valid(draft, notes):
if 'opening_contour' in transform.preserves and directions != transform.directions[:len(directions)]: if 'opening_contour' in transform.preserves and directions != transform.directions[:len(directions)]:
return False, f'{transform.id}: identifying contour changed' return False, f'{transform.id}: identifying contour changed'
for feature in draft.features: for feature in draft.features:
if generator.solo.references.feature_error(feature, local_sounding(notes)): if generator.solo.features.feature_error(feature, local_sounding(notes)):
return False, f'{feature.id}: selected identifying relationship changed' return False, f'{feature.id}: selected identifying relationship changed'
for first, repeated in draft.correspondence: for first, repeated in draft.correspondence:
if first not in by_id or repeated not in by_id: if first not in by_id or repeated not in by_id:
@@ -389,7 +394,7 @@ def relationship_features(draft, notes):
direction.extend(a != b for a, b in zip(actual, transform.directions)) direction.extend(a != b for a, b in zip(actual, transform.directions))
reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home
if reference and not draft.lead_identity and not draft.source_phrase and transform.operation not in ('close', 'fragment', 'broaden_ending'): if reference and not draft.lead_identity and not draft.source_phrase and transform.operation not in ('close', 'fragment', 'broaden_ending'):
development.append(generator.solo.references.relationship(group, reference)) development.append(generator.solo.features.relationship(group, reference))
joins = [] joins = []
for time in draft.joins: for time in draft.joins:
left = next((n for n in reversed(notes) if n.start < time), None) left = next((n for n in reversed(notes) if n.start < time), None)
+223
View File
@@ -0,0 +1,223 @@
"""Typed, target-driven harmonic expression over a strictly mapped theme."""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
from generator import harmony as chord_model, model, pitch as spelling, vocabulary
from generator.solo import types, drafts, enclosures, placement, policy
def quality(chord):
degrees=chord_model.degrees(chord)
if degrees.get(3)==0 and degrees.get(7)==-1:return 'dominant'
if degrees.get(3)==-1:return 'half_diminished' if degrees.get(5)==-1 else 'minor'
if degrees.get(3)==0 and degrees.get(5)==0:return 'major'
return 'other'
def degree_pitch(chord,degree,alter,near):
root={**chord['root'],'octave':4}
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter))
value=types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
return dataclasses.replace(value,octave=value.octave+round((near-value.number())/12))
def chromatic(target,delta):
# Chromatic neighbors are spelled relative to their exact destination.
if delta in (-2,-1,1,2):
step=1 if delta>0 else -1
root={'letter':target.letter,'alter':model.rational(target.alter),'octave':target.octave}
p=spelling.shift_pitch(root,step,delta)
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
raise ValueError('unsupported approach interval')
def eligible_color(rule,span,harmony):
chord=chord_model.parse_symbol(span.chord)
degrees=chord_model.degrees(chord)
if quality(chord) not in rule['qualities']:return False
degree=rule['degree']; simple=(degree-1)%7+1
# Explicit alterations and omissions take precedence over optional colors.
if any((d['value']-1)%7+1==simple and (d['type']=='subtract' or model.alteration(d['alter'])!=rule['alter']) for d in chord.get('degrees',[])):return False
if degree in degrees and degrees[degree]!=rule['alter']:return False
following=next((s for s in harmony.spans if s.start>=span.end and s.chord!=span.chord and s.scale),None)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12
next_chord=chord_model.parse_symbol(following.chord) if following else None
next_root=int(model.NATURAL[next_chord['root']['letter']]+model.alteration(next_chord['root']['alter']))%12 if next_chord else None
function=rule.get('function')
if function=='resolving_dominant' and (next_root is None or (root-next_root)%12!=7):return False
if function=='tonic_or_predominant':
tonic=model.parse_pitch_name(span.scale.root)
tonic_pc=int(model.NATURAL[tonic['letter']]+model.alteration(tonic['alter']))%12
if root!=tonic_pc and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
return True
def validate(notes,contracts,harmony):
"""Return event permissions only after checking complete actual gestures."""
actual={n.event_id:n for n in notes};approved={}
def same(expected):
n=actual.get(expected.event_id)
return n and (n.start,n.end,n.pitch)==(expected.start,expected.end,expected.pitch)
for c in contracts:
if not c.events or not all(same(n) for n in (*c.events,c.target)):
return {},c.id+': missing or changed expression event/target'
if c.preparation and not same(c.preparation):return {},c.id+': changed preparation'
if any(n.event_id in approved for n in c.events):return {},c.id+': overlapping expression permissions'
target=c.target; span=harmony.at(c.boundary if c.kind=='anticipation' else target.start)
if not span or not span.scale or span.chord!=c.chord:return {},c.id+': wrong target harmony'
if c.kind in ('approach','enclosure','connection'):
rule=next((r for r in vocabulary.data('lead_ins')['patterns'] if r['id']==c.rule and r['kind']==c.kind),None)
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula'
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,target.start,target.end):return {},c.id+': invalid destination hold'
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())
if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long'
for n,step,duration in zip(c.events,rule['semitones'],rule['durations']):
if n.start!=cursor or n.end!=cursor+F(duration) or n.pitch.number()!=target.pitch.number()+step:return {},c.id+': wrong register, interval, or timing'
cursor=n.end
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
elif c.kind=='color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
if not rule:return {},c.id+': unknown color policy'
for n in c.events:
if n.end-n.start>F(rule['max_beats']):return {},c.id+': excessive color duration'
for region,a,b in harmony.segments(n):
if not region.scale or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
p=degree_pitch(chord_model.parse_symbol(region.chord),rule['degree'],rule['alter'],n.pitch.number())
if p!=n.pitch:return {},c.id+': color degree/spelling mismatch'
if not rule['outside'] and n.pitch.number()%12 not in region.scale.pitches:return {},c.id+': color requires mapped collection'
last=c.events[-1]
if rule.get('resolve'):
if target is last or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
elif c.kind=='anticipation':
if c.rule!='early_chord_tone' or len(c.events)!=1 or c.target!=c.events[0] or span.start!=c.boundary:
return {},c.id+': unknown anticipation or non-boundary target'
n,=c.events
if c.boundary is None or not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
if n.pitch.number()%12 not in harmony.members(c.boundary,n.end) or n.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,n.end):return {},c.id+': invalid future destination'
elif c.kind=='blues':
if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event'
rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None)
n,=c.events
chord=chord_model.parse_symbol(span.chord)
if not rule or n.end-n.start>F(1,2) or n.end!=target.start or target.pitch.number()%12 not in span.roles.basic:return {},c.id+': unresolved blues inflection'
expected=degree_pitch(chord,rule['degree'],rule['alter'],n.pitch.number())
resolved=degree_pitch(chord,rule['target_degree'],0,target.pitch.number())
if n.pitch!=expected or target.pitch!=resolved or target.pitch.number()-n.pitch.number()!=1:return {},c.id+': unsupported blues destination'
if not c.preparation or c.preparation.end!=n.start or abs(c.preparation.pitch.number()-n.pitch.number())>2:return {},c.id+': unprepared blues gesture'
else:return {},c.id+': unknown expression kind'
for n in c.events:
covered=n.start
for region,a,b in harmony.segments(n):
if a!=covered or not region.scale:return {},c.id+': missing harmony'
covered=b
if covered!=n.end:return {},c.id+': incomplete harmonic coverage'
approved[n.event_id]=c
return approved,''
def proposals(phrase,harmony):
draft=phrase.draft
if not draft or any(n.continuation for n in draft.notes):return
notes=types.sounding_notes(draft.notes)
for target,role in enclosures.declared_targets(phrase,harmony):
for rule in vocabulary.data('lead_ins')['patterns']:
start=target.start-F(rule['gap'])-sum((F(d) for d in rule['durations']),F())
if start<phrase.plan.start or not enclosures.editable(draft,start,target.start):continue
cursor=start;events=[]
for i,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
events.append(types.Note(cursor,cursor+F(duration),chromatic(target.pitch,step),target.sources,rule['kind'],event_id=target.event_id+':'+rule['id']+':'+str(i)))
cursor+=F(duration)
actual=enclosures.replace_window(notes,start,target.start,tuple(events))
if actual is None:continue
prior=next((n for n in reversed(actual) if n.end<=start),None)
contract=types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(events),target,harmony.at(target.start).chord,preparation=prior)
yield actual,contract
for i,n in enumerate(notes[:-1]):
target=notes[i+1]
if not enclosures.editable(draft,n.start,n.end):continue
span=harmony.at(n.start)
if not span or not span.scale:continue
for rule in vocabulary.data('harmony')['colors']:
if not eligible_color(rule,span,harmony) or n.end-n.start>F(rule['max_beats']):continue
p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number())
if abs(p.number()-n.pitch.number())>4 or p==n.pitch:continue
color=dataclasses.replace(n,pitch=p,role='color')
resolution=target if rule.get('resolve') else color
contract=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(color,),resolution,harmony.at(resolution.start).chord)
yield (*notes[:i],color,*notes[i+1:]),contract
for rule in vocabulary.data('lead_ins')['blues']:
if i==0 or n.end-n.start>F(1,2):continue
p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number())
changed=dataclasses.replace(n,pitch=p,role='blues')
yield (*notes[:i],changed,*notes[i+1:]),types.ExpressionContract(n.event_id+':'+rule['id'],'blues',rule['id'],(changed,),target,harmony.at(target.start).chord,preparation=notes[i-1])
for i,target in enumerate(notes):
if i==0 or target.start not in {s.start for s in harmony.spans}:continue
start=target.start-F(1,2)
if not enclosures.editable(draft,start,target.start):continue
n=dataclasses.replace(target,start=start,role='anticipation')
actual=enclosures.replace_window(notes,start,target.start,())
if actual:
actual=tuple(n if a.event_id==target.event_id else a for a in actual)
yield actual,types.ExpressionContract(target.event_id+':anticipate','anticipation','early_chord_tone',(n,),n,harmony.at(target.start).chord,boundary=target.start)
def refine(solo,enabled=True):
from generator.solo import harmonic
harmony=harmonic.Harmony(solo.choices,solo.profile);result=[];history=[];attempts=0
seed=vocabulary.role(solo.part+'_solo').get('choice_seed',solo.part)
for phrase in solo.phrases:
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes))
phrase=dataclasses.replace(phrase,draft=draft,before_enclosure=draft)
options=[(0.,phrase,None)]
if enabled:
original=draft.notes
for actual,c in proposals(phrase,harmony):
attempts+=1
if not drafts.contract_valid(draft,actual)[0]:continue
if not harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(c,)).valid:continue
if not harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)[0]:continue
placed=placement.place(actual,solo.tempo,profile=solo.profile)
if not placed:continue
# Context, repetition, and motion determine utility; no chromatic count target.
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0
duplicated=bool(prior and prior.pitch==c.events[0].pitch)
destination=c.target.pitch.number()
old_approach=next((n for n in reversed(original) if n.end<=c.target.start),None)
prepares=bool(old_approach and abs(old_approach.pitch.number()-destination)>2 and join<=3)
recent=sum(kind==c.kind for kind in history[-2:])
cost=.08*max(0,join-3)+.18*duplicated+.12*recent+.3*(enclosures.movement(actual)-enclosures.movement(original))-.12*prepares
if c.kind=='anticipation':cost-=.05
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
if tuple(n for d in decisions for n in d.notes)!=actual:continue
# Rebase editable development spans onto the completed gesture. The
# feature contract is unchanged, but stale event IDs/rhythm would make
# reports falsely describe notes that the expression replaced.
transforms=tuple(dataclasses.replace(t,
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
for t in draft.transformations)
changed=dataclasses.replace(draft,notes=actual,expressions=(c,),transformations=transforms)
routes=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
options.append((cost,dataclasses.replace(phrase,draft=changed,decisions=decisions,routes=routes),c))
_,chosen,c=min(options,key=lambda v:(round(v[0],8),policy.stable_rank(seed,(phrase.plan.id,v[2]))))
if c:history.append(c.kind)
result.append(chosen)
raw=tuple(n for p in result for d in p.decisions for n in d.notes)
placed=placement.place(raw,solo.tempo,profile=solo.profile)
if raw and placed is None:raise ValueError('Expression realization has no complete guitar path')
by_id={n.event_id:n for n in placed[1]} if placed else {}
final=[]
for p in result:
decisions=tuple(dataclasses.replace(d,notes=tuple(by_id[n.event_id] for n in d.notes)) for d in p.decisions)
notes=tuple(n for d in decisions for n in d.notes)
state=dataclasses.replace(p.state,notes=notes,last=notes[-1] if notes else p.state.last) if p.state else None
# A legal timing revision preserves the complete route with its new times.
routes=[]
for route in p.routes:
if all(n.event_id in by_id and by_id[n.event_id].pitch==n.pitch for n in route.notes):
route_notes=tuple(by_id[n.event_id] for n in route.notes)
routes.append(dataclasses.replace(route,notes=route_notes,destination=route_notes[-1]))
final.append(dataclasses.replace(p,decisions=decisions,draft=dataclasses.replace(p.draft,notes=notes),state=state,routes=tuple(routes)))
return dataclasses.replace(solo,phrases=tuple(final),diagnostics={**solo.diagnostics,'expression_candidates':attempts,'expressions_selected':history,'enclosures_enabled':enabled})
+56
View File
@@ -0,0 +1,56 @@
"""Pure musical contracts; no reference files or filesystem access."""
from __future__ import annotations
import dataclasses
import fractions
import generator.solo.types as types
F = fractions.Fraction
def identifying_features(notes, source):
"""Select a characteristic leap followed by return, wherever it occurs."""
notes = tuple(notes)
candidates = []
for i in range(len(notes) - 2):
a, b, c = notes[i:i + 3]
leap, reply = b.pitch.number() - a.pitch.number(), c.pitch.number() - b.pitch.number()
if 3 <= abs(leap) <= 9 and leap * reply < 0 and c.start - a.start <= 4:
group = notes[i:min(len(notes), i + 4)]
rhythm = tuple((n.start - a.start, n.end - n.start) for n in group)
intervals = tuple(y.pitch.number() - x.pitch.number() for x, y in zip(group, group[1:]))
candidates.append(types.IdentifyingFeature(a.event_id + '_identity', tuple(n.event_id for n in group), rhythm, intervals, source))
return tuple(candidates[:1])
def feature_error(feature, notes):
by_id = {n.event_id: n for n in notes}
actual = [by_id.get(identity) for identity in feature.events]
if not actual or not all(actual):
return 1.0
first = actual[0].start
rhythm = tuple((n.start - first, n.end - n.start) for n in actual)
movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:]))
errors = []
if 'rhythm' in feature.preserves:
errors.append(1.0 if len(rhythm) != len(feature.rhythm) else sum(a != b for a,b in zip(rhythm,feature.rhythm))/max(1,len(rhythm)))
if 'intervals' in feature.preserves or 'contour' in feature.preserves:
if len(movement) != len(feature.intervals) or not movement and feature.intervals:
errors.append(1.0)
else:
errors.append(sum(((a>0)-(a<0)) != ((b>0)-(b<0)) or 'intervals' in feature.preserves and abs(a-b)>2 for a,b in zip(movement,feature.intervals))/max(1,len(movement)))
if 'exact_intervals' in feature.preserves:
errors.append(float(movement != feature.intervals))
if 'pitches' in feature.preserves:
errors.append(float(tuple(n.pitch.number() for n in actual) != feature.pitches))
return max(errors,default=0.0)
def relationship(notes, reference):
"""One joint relationship cost: timed movement, not absolute-pitch copying."""
notes = tuple(notes)
if len(notes) < 2 or len(reference.notes) < 2:
return 0.0 if len(notes) == len(reference.notes) == 1 else 1.0
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
expected = reference.contour()
count = min(len(actual), len(expected))
contour = sum(0.75 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) + 0.25 * min(1, max(0, abs(a - b) - 2) / 7) for a, b in zip(actual, expected)) / max(1, count)
start, ref_start = notes[0].start, reference.notes[0].start
rhythm = sum(min(1, float(abs((n.start - start) - (r.start - ref_start))) / 2) for n, r in zip(notes, reference.notes)) / min(len(notes), len(reference.notes))
evidence = max(0, min(3, len(reference.notes)) - len(notes)) / 3
return max(evidence, (contour + rhythm) / 2)
+15 -5
View File
@@ -148,8 +148,12 @@ class Harmony:
intervals.append((upper.start,target.start)) intervals.append((upper.start,target.start))
return accepted, '' return accepted, ''
def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=()): def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=(), expressions=()):
notes = generator.solo.types.sounding_notes(notes) notes = generator.solo.types.sounding_notes(notes)
from generator.solo.expressions import validate
permissions, expression_error = validate(notes, expressions, self)
if expression_error:
return Result(False, 0, error=expression_error)
until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction()) until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '') ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '')
if error: if error:
@@ -190,13 +194,14 @@ class Harmony:
for span, start, end in self.segments(note): for span, start, end in self.segments(note):
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None) contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
enclosure = ornaments.get(note.event_id) enclosure = ornaments.get(note.event_id)
exception = enclosure is not None and note.event_id == enclosure.lower.event_id expression = permissions.get(note.event_id)
exception = enclosure is not None and note.event_id == enclosure.lower.event_id or expression is not None and (expression.kind != 'anticipation' or end <= expression.boundary)
if start > covered or not span.scale or number % 12 not in span.scale.pitches and not exception: if start > covered or not span.scale or number % 12 not in span.scale.pitches and not exception:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony') return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
covered = end covered = end
function = self.function(number, span) function = self.function(number, span)
brief_enclosure = enclosure and (exception or enclosure.approach_start is None or enclosure.approach_start == note.start) brief_enclosure = enclosure and (exception or enclosure.approach_start is None or enclosure.approach_start == note.start)
if function == 'basic' or note.role == 'quote' or brief_enclosure: if function == 'basic' or note.role == 'quote' or brief_enclosure or expression:
continue continue
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None: if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment') return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
@@ -206,8 +211,12 @@ class Harmony:
if covered < note.end: if covered < note.end:
return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage') return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage')
return Result(True, exposed / max(sounding, 0.001), tuple(pending)) return Result(True, exposed / max(sounding, 0.001), tuple(pending))
def describe(self, notes, treatments=(), enclosures=()): def describe(self, notes, treatments=(), enclosures=(), expressions=()):
notes = generator.solo.types.sounding_notes(notes) notes = generator.solo.types.sounding_notes(notes)
from generator.solo.expressions import validate
permissions, error = validate(notes, expressions, self)
if error:
raise ValueError(error)
ornaments, error = self.enclosure_events(notes,enclosures) if enclosures else ({}, '') ornaments, error = self.enclosure_events(notes,enclosures) if enclosures else ({}, '')
if error: if error:
raise ValueError(error) raise ValueError(error)
@@ -215,7 +224,8 @@ class Harmony:
for index, note in enumerate(notes): for index, note in enumerate(notes):
for span, start, end in self.segments(note): for span, start, end in self.segments(note):
contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end] contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'enclosure': ornaments[note.event_id].id if note.event_id in ornaments else '', 'scale_exception': note.event_id in ornaments and self.function(note.pitch.number(), span) == 'outside_collection', 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]}) expression = permissions.get(note.event_id)
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'enclosure': ornaments[note.event_id].id if note.event_id in ornaments else '', 'expression': expression.kind if expression else '', 'expression_rule': expression.rule if expression else '', 'scale_exception': bool(note.event_id in ornaments or expression) and self.function(note.pitch.number(), span) == 'outside_collection', 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
return result return result
def anchor_note(anchor, notes, *, until=None): def anchor_note(anchor, notes, *, until=None):
+1 -1
View File
@@ -45,7 +45,7 @@ def document_for(template, notes, tempo, identifier):
def controls(song, libraries): def controls(song, libraries):
"""Literal reference injection is restricted to this diagnostic, never production.""" """Literal reference injection is restricted to this diagnostic, never production."""
_, scales, sequences = libraries _, scales, sequences = libraries
reference, = references.load_library(pathlib.Path('data/lead_phrases')) reference, = references.load_library(pathlib.Path('audition/controls'))
solo = song['solos']['acoustic'] solo = song['solos']['acoustic']
measures = song['parts']['acoustic']['staves'][0]['measures'][solo['first']:solo['first'] + solo['count']] measures = song['parts']['acoustic']['staves'][0]['measures'][solo['first']:solo['first'] + solo['count']]
choices = generator.scales.selection.select(generator.scales.timeline.read({'measures': measures}), generator.scales.timeline.Solo(F(), F(solo['length']), 'control'), scales) choices = generator.scales.selection.select(generator.scales.timeline.read({'measures': measures}), generator.scales.timeline.Solo(F(), F(solo['length']), 'control'), scales)
+12 -9
View File
@@ -7,10 +7,12 @@ from __future__ import annotations
import dataclasses import dataclasses
import fractions import fractions
import functools
import generator.vocabulary
import generator.model import generator.model
import generator.fretboard import generator.fretboard
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references, profiles from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, features as references, profiles
F = fractions.Fraction F = fractions.Fraction
@@ -133,10 +135,10 @@ def rhythm_catalog(library, meter):
return drafts.catalog(library,meter) return drafts.catalog(library,meter)
def connection_patterns(start,end,entry,destination,rhythms,meter): @functools.lru_cache(maxsize=256)
length=end-start def connection_windows(catalog,length):
spans={( (F(),length), ):'held'} spans={( (F(),length), ):'held'}
for identifier,pattern,total in rhythm_catalog(rhythms,meter): for identifier,pattern,total in catalog:
if total < length: if total < length:
continue continue
# Retrieve every exact-duration window, including offbeat and cross-bar windows. # Retrieve every exact-duration window, including offbeat and cross-bar windows.
@@ -151,6 +153,10 @@ def connection_patterns(start,end,entry,destination,rhythms,meter):
ranked=sequences.diverse(tuple(spans.items()), ranked=sequences.diverse(tuple(spans.items()),
lambda item:(abs(len(item[0])-float(length)*1.5),item[1]), lambda item:(abs(len(item[0])-float(length)*1.5),item[1]),
lambda item:(len(item[0]),any(a[1]!=b[0] for a,b in zip(item[0],item[0][1:]))),8) lambda item:(len(item[0]),any(a[1]!=b[0] for a,b in zip(item[0],item[0][1:]))),8)
return ranked
def connection_patterns(start,end,entry,destination,rhythms,meter):
ranked=connection_windows(tuple(rhythm_catalog(rhythms,meter)),end-start)
result=[] result=[]
for pattern,identifier in ranked: for pattern,identifier in ranked:
events=tuple(dataclasses.replace(entry,start=start+a,end=start+b,role='connection', events=tuple(dataclasses.replace(entry,start=start+a,end=start+b,role='connection',
@@ -323,9 +329,10 @@ def transition(first,second):
def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage): def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage):
from generator.solo import realization,themes,phrasing from generator.solo import realization,themes,phrasing
context.resources[id(library)]=library context.resources[id(library)]=library
context.tempo=analysis.tempo
context.resources[id(rhythms)]=rhythms context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis)) harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE)) seed=policy.stable_rank('source_variations',(part,analysis.melody,choices,generator.vocabulary.role(part+'_solo'),policy.DEFAULT.composition_version))
states=[(0.,())] states=[(0.,())]
failures=[] failures=[]
for _ in range(2*len(measures)+1): for _ in range(2*len(measures)+1):
@@ -372,10 +379,6 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
def rank(value): def rank(value):
cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end
musical=cost/max(.001,float(elapsed/end)) musical=cost/max(.001,float(elapsed/end))
if elapsed==end:
target=policy.DEFAULT.settling_target*float(end)/(32*4)
musical+=.1*max(0,abs(state.closures-target)-1)
musical+=.15*abs(len(state.returns)-policy.DEFAULT.return_target*float(end)/(32*4))
return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history)) return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history))
states=sequences.diverse(following,rank, states=sequences.diverse(following,rank,
lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier) lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier)
+20 -12
View File
@@ -60,20 +60,28 @@ def measures(sources: list[dict], notes: tuple[generator.solo.types.Note, ...],
return result return result
def tuplets(measures): def tuplets(measures):
spans = [] # Group by sounding duration, including notation fragments across barlines.
for measure in measures: spans=[]
for ratio, values in itertools.groupby(generator.score.measure_notes(measure), key=lambda n: generator.model.fraction(n['written'].get('ratio', generator.model.rational(1)))): events=[n for m in measures for n in generator.score.measure_notes(m)]
notes = list(values) for ratio,values in itertools.groupby(events,key=lambda n:generator.model.fraction(n['written'].get('ratio',generator.model.rational(1)))):
if ratio == 1: if ratio==1:continue
continue members=[];elapsed=fractions.Fraction();length=None
for first in range(0, len(notes), ratio.denominator): for note in values:
members = notes[first:first + ratio.denominator] if not members:
identifier = members[0]['id'] + '_tuplet' length=generator.model.TYPE_LENGTHS[note['written']['type']]*ratio.numerator
spans.append({'id': identifier, 'voice': 'voice_arranged', 'start': members[0]['id'], 'end': members[-1]['id'], 'note_count': ratio.denominator, 'space_count': ratio.numerator, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'}) members.append(note);elapsed+=generator.model.fraction(note['duration'])
for note in members: if elapsed>=length:
note['tuplet_ids'] = [identifier] identifier=members[0]['id']+'_tuplet'
spans.append({'id':identifier,'voice':'voice_arranged','start':members[0]['id'],'end':members[-1]['id'],'note_count':ratio.denominator,'space_count':ratio.numerator,'depth':0,'bracket':'auto','show_number':'actual'})
for n in members:n['tuplet_ids']=[identifier]
members=[];elapsed=fractions.Fraction()
if members:
identifier=members[0]['id']+'_tuplet'
spans.append({'id':identifier,'voice':'voice_arranged','start':members[0]['id'],'end':members[-1]['id'],'note_count':ratio.denominator,'space_count':ratio.numerator,'depth':0,'bracket':'auto','show_number':'actual'})
for n in members:n['tuplet_ids']=[identifier]
return spans return spans
def replace_region(document: dict, first: int, replacement: list[dict]) -> None: def replace_region(document: dict, first: int, replacement: list[dict]) -> None:
staff = document['staves'][0] staff = document['staves'][0]
replacement = copy.deepcopy(replacement) replacement = copy.deepcopy(replacement)
+18 -17
View File
@@ -7,14 +7,15 @@ from fractions import Fraction as F
import generator.model import generator.model
import generator.harmony import generator.harmony
import generator.vocabulary
from generator.solo import types, policy, sequences, profiles from generator.solo import types, policy, sequences, profiles
def heard(notes, operation, anchor=None): def heard(notes, operation, anchor=None, bar=F(4)):
notes = types.sounding_notes(notes) notes = types.sounding_notes(notes)
first = notes[0].start first = notes[0].start
events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes) events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes)
ending = (anchor.pitch.number()%12, anchor.earliest%4, anchor.duration) if anchor and anchor.role == 'terminal' else () ending = (anchor.pitch.number()%12, anchor.earliest%bar, anchor.duration) if anchor and anchor.role == 'terminal' else ()
return types.HeardFigure(first, notes[-1].end, events, operation, ending) return types.HeardFigure(first, notes[-1].end, events, operation, ending)
@@ -56,7 +57,10 @@ def register_goal(incoming, part, fallback, profile=None):
if profile and profile.reference is not None: if profile and profile.reference is not None:
recent = [p for h in incoming.history[-2:] for _,_,p in h.events] recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
center = round(sum(recent)/len(recent)) if recent else profile.reference center = round(sum(recent)/len(recent)) if recent else profile.reference
return max(profile.reference-4,min(profile.reference+4,center)) trajectory=generator.vocabulary.role(part+'_solo').get('register_motion',[0,4,-3,0])
phase=min(len(trajectory)-1,incoming.closures%len(trajectory))
goal=profile.reference+trajectory[phase]
return max(profile.register[0]+3,min(profile.register[1]-3,round((center+goal)/2)))
recent = [p for h in incoming.history[-2:] for _,_,p in h.events] recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
if not recent: if not recent:
return fallback return fallback
@@ -72,9 +76,7 @@ def register_goal(incoming, part, fallback, profile=None):
def endpoint_cost(finish, start, end, count, harmony, bar): def endpoint_cost(finish, start, end, count, harmony, bar):
length = float((finish-start)/bar) length = float((finish-start)/bar)
remaining = float((end-finish)/bar) remaining = float((end-finish)/bar)
projected = count+1+remaining/(32/policy.DEFAULT.settling_target) pacing = 0.
low, high = policy.DEFAULT.settling_range
pacing = .2*max(0,low-projected,projected-high)+.025*abs(projected-policy.DEFAULT.settling_target)
span = harmony.at(finish-F(1,100)) span = harmony.at(finish-F(1,100))
# A dominant may close an open question, but a nearby resolution is preferred. # A dominant may close an open question, but a nearby resolution is preferred.
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {} degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
@@ -94,7 +96,8 @@ def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2
ends=(min(end,current.end if current else bounds[index+1]),) ends=(min(end,current.end if current else bounds[index+1]),)
else: else:
barrier=min((s.start for s in harmony.spans if not s.scale and s.start>start),default=end) barrier=min((s.start for s in harmony.spans if not s.scale and s.start>start),default=end)
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (4,5,6,8)})) authored=[n.end-analysis.body_offset for n in analysis.melody if n.end-n.start>=2]
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (3,4,5,6,8)} | {t for t in authored if start+3*bar<=t<=min(end,barrier,start+8*bar)}))
if barrier>=end: if barrier>=end:
# The returning pickup may shorten the last measure. Do not strand a # The returning pickup may shorten the last measure. Do not strand a
# one-beat "new phrase" after an otherwise completed sustained close. # one-beat "new phrase" after an otherwise completed sustained close.
@@ -116,9 +119,9 @@ def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
goal=register_goal(incoming,part,base,harmony.profile) goal=register_goal(incoming,part,base,harmony.profile)
values=[] values=[]
duration=source.end-source.start duration=source.end-source.start
holds=(F(2),F(5,2),F(3),F(7,2),F(4)) if duration>=4 else (min(F(2),duration/2),) holds=tuple(F(v) for v in generator.vocabulary.data('grammar')['ending_holds']) if duration>=4 else (min(F(2),duration/2),)
for hold in holds: for hold in holds:
for release in (F(),F(1,2),F(1),F(2)): for release in map(F,generator.vocabulary.data('grammar')['ending_releases']):
arrival=source.end-release-hold arrival=source.end-release-hold
if arrival-source.start<F(3,2):continue if arrival-source.start<F(3,2):continue
for pitch in available(harmony,arrival,arrival+hold,members=True): for pitch in available(harmony,arrival,arrival+hold,members=True):
@@ -126,8 +129,8 @@ def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch, anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal',harmony.at(arrival).chord, harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal',harmony.at(arrival).chord,
hold_until=arrival+hold,effect=ending_effect(arrival,harmony,final)) hold_until=arrival+hold,effect=ending_effect(arrival,harmony,final))
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history) cadence=sum(h.ending==(pitch.number()%12,arrival%bar,hold) for h in incoming.history)
rank=harmony.profile.cost(pitch.number())+destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release) rank=harmony.profile.cost(pitch.number())+destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)
values.append((rank,anchor)) values.append((rank,anchor))
selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])), selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit) lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
@@ -135,12 +138,10 @@ def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
def appearance_cost(operation, incoming, start, end): def appearance_cost(operation, incoming, start, end):
expected=policy.DEFAULT.return_target*float(start/max(end,F(1)))
actual=len(incoming.returns)
if operation=='return':
gap=start-(incoming.returns[-1] if incoming.returns else F()) gap=start-(incoming.returns[-1] if incoming.returns else F())
return .65*max(0,actual+1-expected)+.5*max(0,1-float(gap)/24) if operation=='return':
return .45*max(0,expected-actual) return .5*max(0,1-float(gap)/16)
return .1*min(1,max(0,float(gap)/32-1))
def activity(source, ending): def activity(source, ending):
@@ -162,7 +163,7 @@ def evaluate(notes, incoming, harmony, start, end, bar):
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0. entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1) leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
history_cost=repetition(heard(notes,'develop'),incoming.history,bar) history_cost=repetition(heard(notes,'develop'),incoming.history,bar)
return harmony.profile.cost(profiles.weighted_center(notes))+2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost return harmony.profile.cost(profiles.weighted_center(notes))+2*checked.exposure+entry+leaps+.8*history_cost
@functools.lru_cache(maxsize=512) @functools.lru_cache(maxsize=512)
+1 -3
View File
@@ -45,14 +45,12 @@ class Policy:
theme_continuations: int = 8 theme_continuations: int = 8
theme_route_jobs: int = 16 theme_route_jobs: int = 16
settling_range: tuple[int, int] = (5, 7) settling_range: tuple[int, int] = (5, 7)
settling_target: int = 6
return_target: int = 2
history_bars: int = 16 history_bars: int = 16
enclosure_targets: int = 4 enclosure_targets: int = 4
enclosure_routes: int = 4 enclosure_routes: int = 4
enclosure_frontier: int = 8 enclosure_frontier: int = 8
enclosure_max_per_32_bars: int = 4 enclosure_max_per_32_bars: int = 4
composition_version: str = 'six-parts-register-signatures-1' composition_version: str = 'independent-roles-1'
# Preserve the former default root seeds while decoupling them from policy edits. # Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1' SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+4 -3
View File
@@ -5,6 +5,7 @@ import dataclasses
from fractions import Fraction as F from fractions import Fraction as F
import generator.fretboard import generator.fretboard
import generator.vocabulary
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
@@ -23,7 +24,7 @@ class Instrument:
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number))) return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number)))
def cost(self, number): def cost(self, number):
return min(1., max(0, abs(number - self.reference) - 4) / 12) if self.reference is not None else 0. return .5 * min(1., max(0, abs(number - self.reference) - 7) / 12) if self.reference is not None else 0.
UNRESTRICTED = Instrument() UNRESTRICTED = Instrument()
@@ -43,13 +44,13 @@ def weighted_center(notes):
def for_part(part, analysis=None): def for_part(part, analysis=None):
if part not in ('acoustic', 'electric'): if part not in ('acoustic', 'electric'):
raise ValueError('unknown solo instrument: ' + part) raise ValueError('unknown solo instrument: ' + part)
strings = (2, 3, 4, 5, 6) if part == 'acoustic' else (1, 2, 3, 4) strings = (1, 2, 3, 4, 5, 6)
if analysis is None or not analysis.melody: if analysis is None or not analysis.melody:
return Instrument(part, strings) return Instrument(part, strings)
# Analysis contains the prepared concert source. Electric melody is written # Analysis contains the prepared concert source. Electric melody is written
# two octaves up and sounds one down (parts.build_measure), hence +12. # two octaves up and sounds one down (parts.build_measure), hence +12.
# Use the source voice, never the highest added harmonization voice. # Use the source voice, never the highest added harmonization voice.
shift = 12 if part == 'electric' else 0 shift = generator.vocabulary.role(part + '_solo').get('register_offset', 12 if part == 'electric' else 0)
notes = tuple(n for n in analysis.melody if n.end > analysis.body_offset) notes = tuple(n for n in analysis.melody if n.end > analysis.body_offset)
return Instrument(part, strings, weighted_center(notes) + shift, return Instrument(part, strings, weighted_center(notes) + shift,
(min(n.pitch.number() for n in notes) + shift, max(n.pitch.number() for n in notes) + shift)) (min(n.pitch.number() for n in notes) + shift, max(n.pitch.number() for n in notes) + shift))
+2 -2
View File
@@ -6,7 +6,7 @@ import functools
import time import time
import generator.solo.drafts import generator.solo.drafts
import generator.solo.references import generator.solo.features
import generator.solo.routes import generator.solo.routes
import generator.fretboard import generator.fretboard
@@ -700,7 +700,7 @@ def refine(phrase, pool, incoming, harmony, sequences, tempo, context, search=Tr
notes = emitted_notes(phrase) notes = emitted_notes(phrase)
draft = rebase_draft(phrase.draft, notes, harmony) draft = rebase_draft(phrase.draft, notes, harmony)
# Only now commit a selected, inspectable relationship for elaboration. # Only now commit a selected, inspectable relationship for elaboration.
features = generator.solo.references.identifying_features(notes, draft.reference) features = generator.solo.features.identifying_features(notes, draft.reference)
draft = dataclasses.replace(draft, features=features) draft = dataclasses.replace(draft, features=features)
# Rebuild lineage after group removal or replacement, including when no run # Rebuild lineage after group removal or replacement, including when no run
# is selected afterward. The introduced home is the actual refined tune. # is selected afterward. The introduced home is the actual refined tune.
+1 -49
View File
@@ -76,52 +76,4 @@ def read(path):
def load_library(root: pathlib.Path): def load_library(root: pathlib.Path):
return tuple(read(path) for path in sorted(root.glob('*.json'))) return tuple(read(path) for path in sorted(root.glob('*.json')))
def identifying_features(notes, source): from generator.solo.features import identifying_features, feature_error, relationship
"""Select a characteristic leap followed by return, wherever it occurs."""
notes = tuple(notes)
candidates = []
for i in range(len(notes) - 2):
a, b, c = notes[i:i + 3]
leap, reply = b.pitch.number() - a.pitch.number(), c.pitch.number() - b.pitch.number()
if 3 <= abs(leap) <= 9 and leap * reply < 0 and c.start - a.start <= 4:
group = notes[i:min(len(notes), i + 4)]
rhythm = tuple((n.start - a.start, n.end - n.start) for n in group)
intervals = tuple(y.pitch.number() - x.pitch.number() for x, y in zip(group, group[1:]))
candidates.append(types.IdentifyingFeature(a.event_id + '_identity', tuple(n.event_id for n in group), rhythm, intervals, source))
return tuple(candidates[:1])
def feature_error(feature, notes):
by_id = {n.event_id: n for n in notes}
actual = [by_id.get(identity) for identity in feature.events]
if not actual or not all(actual):
return 1.0
first = actual[0].start
rhythm = tuple((n.start - first, n.end - n.start) for n in actual)
movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:]))
errors = []
if 'rhythm' in feature.preserves:
errors.append(1.0 if len(rhythm) != len(feature.rhythm) else sum(a != b for a,b in zip(rhythm,feature.rhythm))/max(1,len(rhythm)))
if 'intervals' in feature.preserves or 'contour' in feature.preserves:
if len(movement) != len(feature.intervals) or not movement and feature.intervals:
errors.append(1.0)
else:
errors.append(sum(((a>0)-(a<0)) != ((b>0)-(b<0)) or 'intervals' in feature.preserves and abs(a-b)>2 for a,b in zip(movement,feature.intervals))/max(1,len(movement)))
if 'exact_intervals' in feature.preserves:
errors.append(float(movement != feature.intervals))
if 'pitches' in feature.preserves:
errors.append(float(tuple(n.pitch.number() for n in actual) != feature.pitches))
return max(errors,default=0.0)
def relationship(notes, reference):
"""One joint relationship cost: timed movement, not absolute-pitch copying."""
notes = tuple(notes)
if len(notes) < 2 or len(reference.notes) < 2:
return 0.0 if len(notes) == len(reference.notes) == 1 else 1.0
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
expected = reference.contour()
count = min(len(actual), len(expected))
contour = sum(0.75 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) + 0.25 * min(1, max(0, abs(a - b) - 2) / 7) for a, b in zip(actual, expected)) / max(1, count)
start, ref_start = notes[0].start, reference.notes[0].start
rhythm = sum(min(1, float(abs((n.start - start) - (r.start - ref_start))) / 2) for n, r in zip(notes, reference.notes)) / min(len(notes), len(reference.notes))
evidence = max(0, min(3, len(reference.notes)) - len(notes)) / 3
return max(evidence, (contour + rhythm) / 2)
+9 -6
View File
@@ -13,7 +13,7 @@ import generator.solo.types
import generator.solo.harmonic import generator.solo.harmonic
import generator.solo.drafts import generator.solo.drafts
import generator.solo.similarity import generator.solo.similarity
import generator.solo.references import generator.solo.features
import generator.solo.placement import generator.solo.placement
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str: def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
@@ -32,7 +32,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources))) lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}') lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
if plan.treatment in ('source_variation', 'thematic_recomposition'): if plan.treatment in ('source_variation', 'thematic_recomposition'):
lines.append(' Activity preference: 4–6 attacks per four beats; source phrasing takes precedence. Occupancy is diagnostic, not a target.') lines.append(' Activity follows the selected gesture and tempo-aware vocabulary; occupancy is diagnostic, not a target.')
if phrase.draft: if phrase.draft:
draft = phrase.draft draft = phrase.draft
lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}') lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
@@ -76,8 +76,10 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12] degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection' function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
if note.pitch.number()%12 not in generator.solo.types.permitted(solo.choices,note.start,note.end): if note.pitch.number()%12 not in generator.solo.types.permitted(solo.choices,note.start,note.end):
function='validated enclosure exception' function='validated expression exception'
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}') lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
for expression in detail['expressions']:
lines.append('Expression: ' + expression['kind'] + ' / ' + expression['rule'] + ' -> ' + str(expression['target']['pitch']))
lines.append('\nSounding harmonic spans (ties merged before classification):') lines.append('\nSounding harmonic spans (ties merged before classification):')
for event in detail['harmonic_roles']: for event in detail['harmonic_roles']:
if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected'] or event['enclosure']: if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected'] or event['enclosure']:
@@ -88,7 +90,8 @@ def structure(solo):
harmony = generator.solo.harmonic.Harmony(solo.choices) harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments)) contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments))
enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures) enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures) expressions = tuple(e for p in solo.phrases if p.draft for e in p.draft.expressions)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures,expressions=expressions)
def rational(value): def rational(value):
if isinstance(value, dict): if isinstance(value, dict):
@@ -122,10 +125,10 @@ def structure(solo):
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,local_source) entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,local_source)
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),local_source) entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),local_source)
if phrase.state and phrase.state.home: if phrase.state and phrase.state.home:
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home) entry['theme_relationship']=generator.solo.features.relationship(phrase.refined.notes,phrase.state.home)
entry['score'] = phrase.score entry['score'] = phrase.score
phrases.append(entry) phrases.append(entry)
return {'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])} return {'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'expressions': rational([dataclasses.asdict(e) for e in expressions]), 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures,expressions), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
def phrasing_report(solo): def phrasing_report(solo):
+33 -12
View File
@@ -10,7 +10,8 @@ from fractions import Fraction as F
import generator.model import generator.model
import generator.harmony import generator.harmony
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing, profiles import generator.vocabulary
from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles
def source_notes(analysis): def source_notes(analysis):
@@ -60,20 +61,23 @@ def seeds(analysis, bar, seed):
event_id=n.sources[0]) for n in analysis.melody) event_id=n.sources[0]) for n in analysis.melody)
groups=[] groups=[]
for i in range(len(melody)): for i in range(len(melody)):
for count in ((len(melody),) if len(melody)<3 else (3,4,5)): for count in ((len(melody),) if len(melody)<3 else (3,4,5,6,7,8)):
group=melody[i:i+count] group=melody[i:i+count]
if len(group)!=count or group[-1].end-group[0].start>max(bar,F(3)) or group[0].start>=8*bar: if len(group)!=count or group[-1].end-group[0].start>max(2*bar,F(3)):
continue continue
local=tuple(dataclasses.replace(n,start=n.start-group[0].start,end=n.end-group[0].start) for n in group) local=tuple(dataclasses.replace(n,start=n.start-group[0].start,end=n.end-group[0].start) for n in group)
parent=next((s.id for s in analysis.source_phrases if s.start<=max(0,group[0].start)<s.end),'pickup') parent=next((s.id for s in analysis.source_phrases if s.start<=max(0,group[0].start)<s.end),'pickup')
for treatment in ('rhythmic_seed','contour_seed','opening_fragment','characteristic_interval'): for treatment in generator.vocabulary.role().get('preferred_features',('rhythmic_seed','contour_seed','opening_fragment','characteristic_interval')):
identity=policy.stable_rank(seed,(group,treatment))[:12] identity=policy.stable_rank(seed,(group,treatment))[:12]
groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment)) groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment))
def rank(ref): def rank(ref):
rhythm={(n.start,n.end-n.start) for n in ref.notes} rhythm={(n.start,n.end-n.start) for n in ref.notes}
intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])} intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])}
return (-len(intervals),-len(rhythm),ref.start,ref.id) preferred=generator.vocabulary.role().get('preferred_features',[])
return sequences.diverse(groups,rank,lambda r:r.treatment,policy.DEFAULT.theme_seed_limit) preference=preferred.index(ref.treatment) if ref.treatment in preferred else 0
turn=any(a*b<0 for a,b in zip(ref.notes and tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])),tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:]))[1:]))
return (-min(4,len(intervals))-.3*turn+.15*preference,-min(3,len(rhythm)),ref.id)
return sequences.diverse(groups,rank,lambda r:(r.treatment,int(max(0,r.start)//(4*bar))),policy.DEFAULT.theme_seed_limit)
def feature(reference, actual, *, treatment=None): def feature(reference, actual, *, treatment=None):
@@ -85,6 +89,8 @@ def feature(reference, actual, *, treatment=None):
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',), 'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
'develop_contour':('intervals',), 'continue':('contour',)}[treatment] 'develop_contour':('intervals',), 'continue':('contour',)}[treatment]
original=reference.notes original=reference.notes
authored=next((r for r in generator.vocabulary.data('grammar')['transformations'] if r['id']==treatment),None)
if authored:required=tuple(authored['preserve'])
origin=original[0].start origin=original[0].start
octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12) octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12)
return types.IdentifyingFeature(reference.id+':'+treatment,tuple(n.event_id for n in actual), return types.IdentifyingFeature(reference.id+':'+treatment,tuple(n.event_id for n in actual),
@@ -95,7 +101,8 @@ def feature(reference, actual, *, treatment=None):
def rhythm_windows(library,meter,length,context): def rhythm_windows(library,meter,length,context):
from generator.solo import drafts from generator.solo import drafts
key=('theme-rhythm',id(library),meter,length) tempo=getattr(context,'tempo',120)
key=('theme-rhythm',id(library),meter,length,tempo)
if key not in context.pools: if key not in context.pools:
patterns={} patterns={}
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1]) bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
@@ -115,9 +122,18 @@ def rhythm_windows(library,meter,length,context):
if offset+length>total or any(a<offset<b or a<offset+length<b for a,b in pattern):continue if offset+length>total or any(a<offset<b or a<offset+length<b for a,b in pattern):continue
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length) selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
if selected and len(selected)<=16:patterns.setdefault(selected,identifier) if selected and len(selected)<=16:patterns.setdefault(selected,identifier)
# Authored cells can start late and continue over computational barlines.
for identifier,pattern,total in generator.vocabulary.rhythm_cells(tempo):
if total>length:continue
for offset in (F(),F(1,2),max(F(),length-total)):
if offset+total>length:continue
head=tuple((a+offset,b+offset) for a,b in pattern)
tails=tuple((a,b) for a,b in next(iter(patterns),()) if a>=offset+total)
combined=(*head,*tails)
if 2<=len(combined)<=16:patterns.setdefault(combined,'authored:'+identifier)
context.pools[key]=sequences.diverse(tuple(patterns.items()), context.pools[key]=sequences.diverse(tuple(patterns.items()),
lambda x:(not .7*float(length)<=len(x[0])<=1.8*float(length),policy.stable_rank('rhythm-window',x[0])), lambda x:(len(x[0])>16,policy.stable_rank('rhythm-window',x[0])),
lambda x:(len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8) lambda x:(x[1].startswith('authored:'),len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
return context.pools[key] return context.pools[key]
@@ -366,6 +382,11 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
cursor=source.start cursor=source.start
while cursor<source.end and states: while cursor<source.end and states:
finish=min(cursor+unit_length,source.end) finish=min(cursor+unit_length,source.end)
if cursor==source.start and source.end-source.start>=3*bar:
# Selected home rhythms finish as objects, not at the nearest barline.
signature=incoming.home.signature if incoming.home else ()
if signature and signature[-1].end>unit_length:
finish=min(source.end,cursor+signature[-1].end)
if source.end-finish<6 or any(ending.earliest<finish for *_,ending in states):finish=source.end if source.end-finish<6 or any(ending.earliest<finish for *_,ending in states):finish=source.end
next_states=[] next_states=[]
for cost,units,home,state,ending in states: for cost,units,home,state,ending in states:
@@ -409,7 +430,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']: if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
actual_home=home or make_home(unit,harmony,cursor,finish) actual_home=home or make_home(unit,harmony,cursor,finish)
figure=phrasing.heard(unit.notes,operation,unit.anchor) figure=phrasing.heard(unit.notes,operation,unit.anchor,bar)
repetition=phrasing.repetition(figure,state.history,bar) repetition=phrasing.repetition(figure,state.history,bar)
appearance=phrasing.appearance_cost(operation,state,cursor,solo_end) if home else 0. appearance=phrasing.appearance_cost(operation,state,cursor,solo_end) if home else 0.
history=phrasing.remember(state.history,figure,bar) history=phrasing.remember(state.history,figure,bar)
@@ -423,7 +444,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
remaining=max(F(),ending.earliest-finish) remaining=max(F(),ending.earliest-finish)
possible=attacks+2*float(remaining)+(0 if finish==source.end else 1) possible=attacks+2*float(remaining)+(0 if finish==source.end else 1)
expected=float(source.end-source.start) expected=float(source.end-source.start)
activity_bound=.35*max(0,expected-possible,attacks-1.5*expected)/max(1,float(source.end-source.start)/4) activity_bound=0.0 # Activity is a gesture/difficulty property, not a chorus quota.
return cost/max(1,len(units))+activity_bound,policy.stable_rank(seed,tuple(u.notes for u in units)) return cost/max(1,len(units))+activity_bound,policy.stable_rank(seed,tuple(u.notes for u in units))
states=sequences.diverse(next_states,rank, states=sequences.diverse(next_states,rank,
lambda v:(v[2].id,v[4].pitch.number(),v[4].duration,len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations) lambda v:(v[2].id,v[4].pitch.number(),v[4].duration,len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
@@ -474,7 +495,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
history=incoming.history history=incoming.history
for transform in transforms: for transform in transforms:
group=tuple(n for n in notes if n.event_id in transform.events) group=tuple(n for n in notes if n.event_id in transform.events)
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if group[-1].end==ending.hold_until else None),bar) if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if group[-1].end==ending.hold_until else None,bar),bar)
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony),home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units), current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony),home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
history=history,returns=state.returns,closures=incoming.closures+1,register_peak=max(state.register_peak,*(n.pitch.number() for n in notes))) history=history,returns=state.returns,closures=incoming.closures+1,register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
if not incoming.home: if not incoming.home:
+12
View File
@@ -97,6 +97,17 @@ class HarmonicTreatment:
event: str = '' event: str = ''
resolution_event: str = '' resolution_event: str = ''
@dataclasses.dataclass(frozen=True)
class ExpressionContract:
id: str
kind: str
rule: str
events: tuple[Note, ...]
target: Note
chord: str
boundary: fractions.Fraction | None = None
preparation: Note | None = None
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class LeadIdentity: class LeadIdentity:
id: str id: str
@@ -273,6 +284,7 @@ class MelodicDraft:
source_reference: SourceReference | None = None source_reference: SourceReference | None = None
sustained_tails: tuple[SustainedTail, ...] = () sustained_tails: tuple[SustainedTail, ...] = ()
enclosures: tuple[EnclosureContract, ...] = () enclosures: tuple[EnclosureContract, ...] = ()
expressions: tuple[ExpressionContract, ...] = ()
def rests(self): def rests(self):
cursor, result = self.start, [] cursor, result = self.start, []
for note in self.notes: for note in self.notes:
+76
View File
@@ -0,0 +1,76 @@
"""Versioned production vocabulary. This module never reads audition inputs."""
from __future__ import annotations
import contextlib
import contextvars
import functools
import hashlib
import json
from pathlib import Path
from fractions import Fraction as F
ROOT = Path(__file__).resolve().parents[1] / 'data' / 'composition'
ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None)
def load(root=ROOT):
# Content, not just the directory, keys memoization. Long-lived individual
# role builds must observe edited production data without restarting Python.
names = ('roles', 'grammar', 'harmony', 'lead_ins', 'rhythms')
return _load(tuple((name, (Path(root)/(name+'.json')).read_text()) for name in names))
@functools.lru_cache(maxsize=16)
def _load(contents):
result = {}
for name, text in contents:
value = json.loads(text)
if value.get('version') != 1 or not value.get('provenance') or not value.get('license'):
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
if len({v['id'] for v in entries}) != len(entries):
raise ValueError(f'{name}: duplicate vocabulary ID')
result[name] = value
return result
@functools.lru_cache(maxsize=1)
def _default():
return {'data': load(), 'role': '', 'settings': {}}
def current():
return ACTIVE.get() or _default()
def data(name):
return current()['data'][name]
def role(name=None):
active = current()
name = name or active['role']
return {**active['data']['roles']['roles'].get(name, {}), **active['settings']}
@contextlib.contextmanager
def using(resources, name, settings=None):
token = ACTIVE.set({'data': resources, 'role': name, 'settings': settings or {}})
try:
yield
finally:
ACTIVE.reset(token)
def fingerprint(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def rhythm_cells(tempo):
limits = data('roles')['difficulty']
for cell in data('rhythms')['cells']:
difficulty = cell.get('difficulty')
if difficulty and tempo > limits[difficulty + '_max_bpm']:
continue
cursor, notes = F(), []
for value in cell['values']:
rest = value.startswith('r:')
duration = F(value[2:] if rest else value)
if duration <= 0:
raise ValueError(cell['id'] + ': nonpositive rhythm duration')
if not rest:
notes.append((cursor, cursor + duration))
cursor += duration
if difficulty and len(notes) > limits['burst_attacks']:
continue
yield cell['id'], tuple(notes), cursor
+1 -1
View File
@@ -60,7 +60,7 @@ add_test(
) )
# Complete deterministic phrase searches make this suite CPU-sensitive; # Complete deterministic phrase searches make this suite CPU-sensitive;
# leave headroom for concurrent asset generation on development machines. # leave headroom for concurrent asset generation on development machines.
set_tests_properties(product_vamp_generator PROPERTIES TIMEOUT 300) set_tests_properties(product_vamp_generator PROPERTIES TIMEOUT 600)
add_custom_target(check add_custom_target(check
COMMAND ${CMAKE_CTEST_COMMAND} COMMAND ${CMAKE_CTEST_COMMAND}
--test-dir ${CMAKE_BINARY_DIR} --test-dir ${CMAKE_BINARY_DIR}
+19
View File
@@ -0,0 +1,19 @@
"""Small synthetic catalogs for infrastructure tests, never production fallback.
Dependency/process tests separately exercise the real data path without mocks.
"""
from unittest.mock import patch
from generator import production, vocabulary
from tests import rhythm_fixtures, scale_fixtures, solo_fixtures
def install(test):
resources=vocabulary.load()
libraries=(scale_fixtures.library(),solo_fixtures.library(),rhythm_fixtures.library())
tree=production.generate_tree
def serial(*args,**kwargs):
kwargs['jobs']=1
return tree(*args,**kwargs)
test.enterContext(patch('generator.production.generate_tree',side_effect=serial))
test.enterContext(patch('generator.vocabulary.load',return_value=resources))
test.enterContext(patch('generator.production._solo_libraries',return_value=libraries))
test.enterContext(patch('generator.production._resource_key',return_value={'fixture':'independently authored synthetic catalogs'}))
+3 -4
View File
@@ -53,11 +53,11 @@ def held_half_bar(sounding=True):
), fractions.Fraction(4)) ), fractions.Fraction(4))
class RhythmLibraryTests(unittest.TestCase): class RhythmLibraryTests(unittest.TestCase):
def test_unbracketed_time_modifications_are_rejected(self): def test_unbracketed_time_modifications_preserve_exact_ratios(self):
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>' extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
source = xml_bars([tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=extra) * 6], divisions=3) source = xml_bars([tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=extra) * 6], divisions=3)
with self.assertRaisesRegex(ValueError, 'time modifications'): library=self.load({'4_4/1/ratio.xml': source})
self.load({'4_4/1/ratio.xml': source}) self.assertTrue(all(e.ratio==fractions.Fraction(2,3) for e in library.for_meter('4/4')[0].bars[0].events))
def load(self, sources): def load(self, sources):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary) root = pathlib.Path(temporary)
@@ -169,7 +169,6 @@ class RhythmLibraryTests(unittest.TestCase):
'grace': xml_bars(['<note><grace/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>' + whole]), 'grace': xml_bars(['<note><grace/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>' + whole]),
'spacer': xml_bars(['<forward><duration>1</duration><voice>1</voice></forward>' + quarter * 3]), 'spacer': xml_bars(['<forward><duration>1</duration><voice>1</voice></forward>' + quarter * 3]),
'tremolo': xml_bars([tests.test_musicxml.xml_note(extra='<notations><ornaments><tremolo type="single">1</tremolo></ornaments></notations>')]), 'tremolo': xml_bars([tests.test_musicxml.xml_note(extra='<notations><ornaments><tremolo type="single">1</tremolo></ornaments></notations>')]),
'tuplet': xml_bars([triplet + tests.test_musicxml.xml_note(duration=9, kind='half', extra='<dot/>')], divisions=3),
'malformed XML': '<broken', 'malformed XML': '<broken',
'wrong root': '<score-timewise version="4.0"/>', 'wrong root': '<score-timewise version="4.0"/>',
} }
+3
View File
@@ -1,3 +1,4 @@
import tests.production_fixtures
import json import json
import pathlib import pathlib
import tempfile import tempfile
@@ -10,6 +11,8 @@ import tests.solo_fixtures
import tests.test_musicxml import tests.test_musicxml
class CatalogTests(unittest.TestCase): class CatalogTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def test_songs_generate_only_authored_key_parts(self): def test_songs_generate_only_authored_key_parts(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output' source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
+4 -26
View File
@@ -106,33 +106,11 @@ class ElectricVoicingTests(unittest.TestCase):
before = copy.deepcopy(target) before = copy.deepcopy(target)
generator.electric.reuse_voicings(links) generator.electric.reuse_voicings(links)
self.assertEqual(target, before) self.assertEqual(target, before)
def test_real_arrangement_reuses_requested_grips_without_changing_melody(self): def test_arrangement_never_reuses_generated_melody_grips(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml')) source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml'))
before = copy.deepcopy(source) before = copy.deepcopy(source)
rhythms = tests.rhythm_fixtures.library() with unittest.mock.patch('generator.electric.reuse_voicings', side_effect=AssertionError('cross-part grip dependency')) as reuse:
with unittest.mock.patch('generator.electric.reuse_voicings'): result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
independent = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
generator.model.validate_document(result) generator.model.validate_document(result)
reuse.assert_not_called()
self.assertEqual(source, before) self.assertEqual(source, before)
start, checked = 0, set()
for section in generator.arrange.form.full_arrangement_sections(source['staves'][0]['measures'], 'electric'):
measures = result['staves'][0]['measures'][start:start + section.measure_count]
original = independent['staves'][0]['measures'][start:start + section.measure_count]
start += section.measure_count
if section.role == 'melody':
self.assertEqual(measures, original)
if section.role != 'accompaniment':
continue
for offset, (m, old) in enumerate(zip(measures, original)):
source_number = source['staves'][0]['measures'][section.source_measure_index + offset]['number']
for voice, old_voice in zip(m['voices'], old['voices']):
self.assertEqual([{k: v for k, v in n.items() if k != 'pitches'} for n in voice['notes']], [{k: v for k, v in n.items() if k != 'pitches'} for n in old_voice['notes']])
for n in voice['notes']:
if not n['pitches'] or source_number not in ('4', '12', '22', '24'):
continue
expected = (3, 3, 3, 3) if source_number == '22' else (3, 5, 5, 5)
self.assertEqual(tuple(f for _, f in sorted(shape(n), reverse=True)), expected)
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in n['pitches']))
checked.add(source_number)
self.assertEqual(checked, {'4', '12', '22', '24'})
+2
View File
@@ -1,3 +1,4 @@
import tests.production_fixtures
import argparse import argparse
import contextlib import contextlib
import copy import copy
@@ -38,6 +39,7 @@ class EngravingCacheTests(unittest.TestCase):
self.assertIn("if: steps.engraving_cache.outputs.cache-hit != 'true'", workflow) self.assertIn("if: steps.engraving_cache.outputs.cache-hit != 'true'", workflow)
self.assertIn('key: ${{ steps.engraving_cache.outputs.cache-primary-key }}', workflow) self.assertIn('key: ${{ steps.engraving_cache.outputs.cache-primary-key }}', workflow)
def setUp(self): def setUp(self):
tests.production_fixtures.install(self)
self.temporary = tempfile.TemporaryDirectory() self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup) self.addCleanup(self.temporary.cleanup)
self.root = pathlib.Path(self.temporary.name) self.root = pathlib.Path(self.temporary.name)
+7 -11
View File
@@ -1,3 +1,4 @@
import tests.production_fixtures
import argparse import argparse
import copy import copy
import fractions import fractions
@@ -24,6 +25,8 @@ import tests.solo_fixtures
import tests.test_musicxml import tests.test_musicxml
class GuitarTests(unittest.TestCase): class GuitarTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def test_fret_boundaries_and_span(self): def test_fret_boundaries_and_span(self):
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(40))) self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(40)))
self.assertIn(generator.fretboard.TabPosition(6, 1, 41), generator.fretboard.positions_for_pitch(fractions.Fraction(41))) self.assertIn(generator.fretboard.TabPosition(6, 1, 41), generator.fretboard.positions_for_pitch(fractions.Fraction(41)))
@@ -265,19 +268,12 @@ class GuitarTests(unittest.TestCase):
output.mkdir() output.mkdir()
(output / 'last-good').write_text('preserved') (output / 'last-good').write_text('preserved')
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False) args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part electric:.*1-20'): with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml/electric_melody:.*1-20'):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good']) self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved') self.assertEqual((output / 'last-good').read_text(), 'preserved')
harmony = '<harmony><root><root-step>C</root-step></root><kind>dominant</kind><degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree><degree><degree-value>11</degree-value><degree-alter>1</degree-alter><degree-type>add</degree-type></degree></harmony>' with unittest.mock.patch('generator.comping.voicings', side_effect=ValueError('no legal sparse voicing')), unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()):
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note())) (root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>dominant</kind></harmony>'+tests.test_musicxml.xml_note()))
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*measure 1.*no acoustic grip.*1-20'): with self.assertRaisesRegex(ValueError, 'no legal sparse voicing'):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved')
harmony = '<harmony><root><root-step>C</root-step></root><kind>power</kind></harmony>'
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note()))
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*3 consecutive fret positions'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved') self.assertEqual((output / 'last-good').read_text(), 'preserved')
+196
View File
@@ -0,0 +1,196 @@
"""Dependency and music contracts use synthetic sources, never audition scores."""
import concurrent.futures
import copy
import contextlib
import dataclasses
import json
import multiprocessing
from pathlib import Path
import tempfile
import unittest
from fractions import Fraction as F
from unittest.mock import patch
from generator import production, model, vocabulary
from generator.solo import expressions, types, harmonic, placement, notation, composer, themes
from tests.test_solo import song
from tests.test_solo_passages import choices
from tests.test_solo_development import note
def music(result):
return result['document']
class IndependentRoles(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.source=production.SourceAnalysis.read(song(count=1,chords=('CM7',)))
cls.root=Path('data').resolve()
def test_alone_order_parallel_and_assembly(self):
serial={role:production.generate_role(self.source,role,self.root) for role in production.ROLES}
with concurrent.futures.ProcessPoolExecutor(max_workers=2,mp_context=multiprocessing.get_context('spawn')) as pool:
jobs={role:pool.submit(production._job,(self.source,role,self.root,{},None)) for role in reversed(production.ROLES)}
parallel={role:future.result() for role,future in jobs.items()}
for role in production.ROLES:self.assertEqual(music(serial[role]),music(parallel[role]),role)
for doc in production.assemble(self.source,serial).values():model.validate_document(doc)
def test_role_setting_and_cache_are_independent(self):
with tempfile.TemporaryDirectory() as directory:
a=production.generate_role(self.source,'acoustic_solo',self.root,cache_dir=directory)
b=production.generate_role(self.source,'electric_solo',self.root,{'seed':'changed'},directory)
again=production.generate_role(self.source,'acoustic_solo',self.root,cache_dir=directory)
clean=production.generate_role(self.source,'acoustic_solo',self.root)
self.assertTrue(again['cache_hit']);self.assertEqual(music(a),music(again));self.assertEqual(music(a),music(clean))
self.assertEqual(json.dumps(music(a)),json.dumps(music(again)))
self.assertNotEqual(a['trace']['cache_key'],b['trace']['cache_key'])
def test_optional_inputs_cannot_be_opened_by_composition(self):
original=Path.open
def guarded(path,*args,**kwargs):
self.assertNotIn('audition',str(path));self.assertNotIn('reference',str(path));self.assertNotIn('generated_assets',str(path))
return original(path,*args,**kwargs)
with patch.object(Path,'open',guarded):
result=production.generate_role(self.source,'acoustic_solo',self.root)
self.assertNotIn('reference',json.dumps(result['trace']))
def test_titles_and_export_ids_do_not_supply_musical_identity(self):
document=song(count=1,chords=('CM7',));other=copy.deepcopy(document)
other['metadata'].update(title='A completely different title',composer='Different')
for m in other['staves'][0]['measures']:m['id']='exporter_123';m['number']='99'
self.assertEqual(production.SourceAnalysis.read(document),production.SourceAnalysis.read(other))
def test_reference_presence_and_changes_do_not_change_clean_music(self):
# No repository reference is a fixture or prerequisite. These deliberately
# invalid optional files test the absence of a discovery/fallback branch.
with tempfile.TemporaryDirectory() as directory, contextlib.chdir(directory):
reference_dir=Path(directory)/'build';reference_dir.mkdir()
references=[reference_dir/(song+'-solo-'+str(index)+suffix+'.xml')
for song in ('all-of-me','autumn-leaves') for index in (1,2) for suffix in ('','(4)')]
before=production.generate_role(self.source,'electric_solo',self.root)
for reference in references:reference.write_text('<not-a-production-source/>')
present=production.generate_role(self.source,'electric_solo',self.root)
for reference in references:reference.write_text('changed optional target, still not MusicXML')
changed=production.generate_role(self.source,'electric_solo',self.root)
for reference in references:reference.unlink()
absent=production.generate_role(self.source,'electric_solo',self.root)
self.assertTrue(all(music(before)==music(v) for v in (present,changed,absent)))
def test_original_melody_changes_the_theme_with_unchanged_harmony(self):
source=song(count=4,chords=('CM7',))
changed=copy.deepcopy(source)
for m in changed['staves'][0]['measures']:
for v in m['voices']:
for n in v['notes']:
for p in n['pitches']:p['letter']={'c':'e','d':'f','e':'g','f':'a'}[p['letter']]
a=production.generate_role(production.SourceAnalysis.read(source),'acoustic_solo',self.root)
b=production.generate_role(production.SourceAnalysis.read(changed),'acoustic_solo',self.root)
self.assertNotEqual(a['details']['phrases'][0]['draft']['notes'],b['details']['phrases'][0]['draft']['notes'])
def test_source_tuplet_lanes_survive_assembly(self):
serial={r:production.generate_role(self.source,r,self.root) for r in production.ROLES}
# Exercise a different original lane name, as in the imported Autumn head.
doc=serial['electric_melody']['document'];staff=doc['staves'][0]
raw=tuple(note(F(i,3),F(i+1,3),'c',event_id=str(i)) for i in range(3))
placed=placement.place(raw,F(120))[1]
index=next(i for i,m in enumerate(staff['measures']) if m.get('section_label')=='Guitar Melody')
replacement=notation.measures(staff['measures'][index:index+1],placed,'source_triplet')
notation.replace_region(doc,index,replacement)
for m in staff['measures']:
for v in m['voices']:
v['id']='voice_source'
for n in v['notes']:n['voice']='voice_source'
for t in staff['tuplets']:t['voice']='voice_source'
model.validate_document(doc)
model.validate_document(production.assemble(self.source,serial)['electric'])
def test_sparse_parts_reuse_the_canonical_form(self):
result=production.generate_role(production.SourceAnalysis.read(song(count=4,chords=('Dm7','G7','CM7'))),'acoustic_accompaniment',self.root)
measures=result['document']['staves'][0]['measures']
def signature(m):
return [(n['onset'],n['duration'],[(model.pitch_number(p),p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']]
self.assertEqual([signature(m) for m in measures[4:8]],[signature(m) for m in measures[8:12]])
self.assertTrue(all(len(n['pitches'])<=3 for m in measures for v in m['voices'] for n in v['notes']))
class TypedExpressionContracts(unittest.TestCase):
def setUp(self):
self.h=harmonic.Harmony(choices(('C7','FM7')))
def make(self,rule='upper_lower'):
target=note(2,4,'e',event_id='target')
upper=note(1,F(3,2),'f',event_id='upper')
lower=dataclasses.replace(note(F(3,2),2,'d',event_id='lower'),pitch=types.Pitch('d',F(1),4))
c=types.ExpressionContract('gesture','enclosure',rule,(upper,lower),target,'C7')
return (upper,lower,target),c
def test_complete_enclosure_and_orphans(self):
notes,c=self.make()
self.assertTrue(self.h.evaluate(notes,expressions=(c,)).valid)
self.assertFalse(self.h.evaluate(notes).valid)
self.assertFalse(self.h.evaluate(notes[:-1],expressions=(c,)).valid)
changed=(*notes[:-1],dataclasses.replace(notes[-1],pitch=dataclasses.replace(notes[-1].pitch,octave=5)))
self.assertFalse(self.h.evaluate(changed,expressions=(c,)).valid)
def test_anticipation_validates_actual_future_harmony(self):
n=note(F(7,2),6,'a',event_id='early')
c=types.ExpressionContract('anticipation','anticipation','early_chord_tone',(n,),n,'FM7',boundary=F(4))
self.assertTrue(self.h.evaluate((n,),expressions=(c,)).valid)
self.assertFalse(self.h.evaluate((n,)).valid)
self.assertFalse(self.h.evaluate((n,),expressions=(dataclasses.replace(c,boundary=F(3)),)).valid)
def test_optional_color_is_not_a_written_chord_requirement(self):
n=note(0,2,'d',event_id='ninth')
c=types.ExpressionContract('color','color','dominant_ninth',(n,),n,'C7')
self.assertTrue(self.h.evaluate((n,),expressions=(c,)).valid)
self.assertFalse(self.h.evaluate((n,)).valid)
self.assertNotIn(n.pitch.number()%12,self.h.spans[0].roles.basic)
self.assertNotIn(n.pitch.number()%12,self.h.spans[0].roles.specified)
def test_labels_do_not_authorize_unresolved_notes(self):
n=dataclasses.replace(note(0,2,'c',event_id='bad'),pitch=types.Pitch('c',F(1),4),role='color')
self.assertFalse(self.h.evaluate((n,)).valid)
c=types.ExpressionContract('bad','color','anything',(n,),n,'C7')
self.assertFalse(self.h.evaluate((n,),expressions=(c,)).valid)
def test_delayed_approach_allows_only_the_declared_rest(self):
lower=dataclasses.replace(note(1,F(3,2),'d',event_id='lower'),pitch=types.Pitch('d',F(1),4))
target=note(2,4,'e',event_id='target')
c=types.ExpressionContract('delay','approach','delayed_below',(lower,),target,'C7')
self.assertTrue(self.h.evaluate((lower,target),expressions=(c,)).valid)
wrong=dataclasses.replace(c,rule='below')
self.assertFalse(self.h.evaluate((lower,target),expressions=(wrong,)).valid)
def test_altered_dominant_color_requires_function_and_resolution(self):
color=dataclasses.replace(note(0,1,'d',event_id='color'),pitch=types.Pitch('d',F(-1),4))
target=note(1,2,'c',event_id='target')
c=types.ExpressionContract('flat9','color','dominant_flat_ninth',(color,),target,'C7')
self.assertTrue(self.h.evaluate((color,target),expressions=(c,)).valid)
unresolved=harmonic.Harmony(choices(('C7','Dm7')))
self.assertFalse(unresolved.evaluate((color,target),expressions=(c,)).valid)
self.assertFalse(self.h.evaluate((color,),expressions=(c,)).valid)
def test_mapped_semitone_needs_no_expression_exception(self):
from tests import scale_fixtures
h=harmonic.Harmony(choices(('A7','Dm7'),scale=scale_fixtures.library().scale('harmonic_minor',1,'D')))
a=dataclasses.replace(note(0,F(1,2),'c',event_id='csharp'),pitch=types.Pitch('c',F(1),4))
b=note(F(1,2),1,'d',event_id='d')
self.assertTrue(h.evaluate((a,b)).valid)
def test_ties_and_barlines_do_not_restart_triplets(self):
raw=tuple(note(F(7,2)+F(i,3),F(7,2)+F(i+1,3),'c',event_id=str(i)) for i in range(3))
placed=placement.place(raw,F(120))[1]
doc=song(count=2,chords=('CM7',));measures=notation.measures(doc['staves'][0]['measures'],placed,'triplet')
spans=notation.tuplets(measures)
self.assertEqual(len(spans),1)
self.assertTrue(any('tie_from' in p for m in measures for v in m['voices'] for n in v['notes'] for p in n['pitches']))
def test_authored_fast_cells_respect_tempo(self):
fast={c[0] for c in vocabulary.rhythm_cells(180)}
slow={c[0] for c in vocabulary.rhythm_cells(100)}
self.assertNotIn('sixteenth_pickup',fast);self.assertNotIn('triplet_turn',fast)
self.assertIn('sixteenth_pickup',slow);self.assertIn('triplet_turn',slow)
if __name__=='__main__':unittest.main()
+2
View File
@@ -1,3 +1,4 @@
import tests.production_fixtures
import argparse import argparse
import fractions import fractions
import pathlib import pathlib
@@ -22,6 +23,7 @@ import tests.test_musicxml
class MusicModelTests(unittest.TestCase): class MusicModelTests(unittest.TestCase):
def setUp(self): def setUp(self):
tests.production_fixtures.install(self)
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library())) self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library())) self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
engraving = unittest.mock.patch('generator.engraving.pipeline.engrave_tree') engraving = unittest.mock.patch('generator.engraving.pipeline.engrave_tree')
+3
View File
@@ -1,3 +1,4 @@
import tests.production_fixtures
import argparse import argparse
import copy import copy
import fractions import fractions
@@ -26,6 +27,8 @@ def xml_harmony(root='C', kind='major', offset=0):
return f'<harmony><root><root-step>{root}</root-step></root><kind>{kind}</kind><offset>{offset}</offset></harmony>' return f'<harmony><root><root-step>{root}</root-step></root><kind>{kind}</kind><offset>{offset}</offset></harmony>'
class PartGenerationTests(unittest.TestCase): class PartGenerationTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def assert_complete_measure(self, measure): def assert_complete_measure(self, measure):
cursor = fractions.Fraction() cursor = fractions.Fraction()
for note in measure['voices'][0]['notes']: for note in measure['voices'][0]['notes']:
+3
View File
@@ -1,3 +1,4 @@
import tests.production_fixtures
import argparse import argparse
import copy import copy
import fractions import fractions
@@ -35,6 +36,8 @@ import tests.rhythm_fixtures
import tests.test_musicxml import tests.test_musicxml
class ScoreBundleTests(unittest.TestCase): class ScoreBundleTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self): def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self):
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library())) self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
+4 -4
View File
@@ -57,16 +57,16 @@ class ReusableAccompaniment(unittest.TestCase):
class SoloInstrumentAndIdentity(unittest.TestCase): class SoloInstrumentAndIdentity(unittest.TestCase):
def test_role_ranges_and_no_open_strings(self): def test_role_ranges_and_no_open_strings(self):
acoustic=profiles.for_part('acoustic');electric=profiles.for_part('electric') acoustic=profiles.for_part('acoustic');electric=profiles.for_part('electric')
self.assertEqual(acoustic.register,(41,71));self.assertEqual(electric.register,(51,76)) self.assertEqual(acoustic.register,(41,76));self.assertEqual(electric.register,(41,76))
self.assertTrue(acoustic.permits(41));self.assertFalse(electric.permits(50)) self.assertTrue(acoustic.permits(41));self.assertTrue(electric.permits(50))
self.assertFalse(acoustic.permits(72)) self.assertTrue(acoustic.permits(72))
for profile in (acoustic,electric): for profile in (acoustic,electric):
for midi in range(40,78): for midi in range(40,78):
for p in placement.positions(midi,profile): for p in placement.positions(midi,profile):
self.assertIn(p.string_number,profile.strings) self.assertIn(p.string_number,profile.strings)
self.assertTrue(1<=p.fret<=12) self.assertTrue(1<=p.fret<=12)
self.assertTrue(any(p.string_number==1 for p in placement.positions(65,electric))) self.assertTrue(any(p.string_number==1 for p in placement.positions(65,electric)))
self.assertFalse(any(p.string_number==1 for p in placement.positions(65,acoustic))) self.assertTrue(any(p.string_number==1 for p in placement.positions(65,acoustic)))
def test_sounding_melody_reference_and_octave_relation(self): def test_sounding_melody_reference_and_octave_relation(self):
source=analysis.analyze(generator.parts.prepare_document(song(count=4,chords=('CM7',)))) source=analysis.analyze(generator.parts.prepare_document(song(count=4,chords=('CM7',))))
+4 -1
View File
@@ -1,3 +1,4 @@
import tests.production_fixtures
import argparse import argparse
import copy import copy
import dataclasses import dataclasses
@@ -55,6 +56,8 @@ def compose(document, part='acoustic', cutoff=None):
return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library()) return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
class SoloTests(unittest.TestCase): class SoloTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def test_catalog_intervals_and_negative_scale_indices(self): def test_catalog_intervals_and_negative_scale_indices(self):
library = generator.solo.sequences.load_library(pathlib.Path('data/sequences')) library = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
self.assertEqual({k: len(v) for k, v in library.paths.items()}, {2: 14, 3: 196, 4: 2744}) self.assertEqual({k: len(v) for k, v in library.paths.items()}, {2: 14, 3: 196, 4: 2744})
@@ -227,7 +230,7 @@ class SoloTests(unittest.TestCase):
output.mkdir() output.mkdir()
(output / 'accepted').write_text('previous build') (output / 'accepted').write_text('previous build')
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False) args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()), unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()), unittest.mock.patch('generator.solo.arrangement.apply', side_effect=ValueError('No exact match within the permitted catalog')): with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()), unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()), unittest.mock.patch('generator.solo.composer.compose', side_effect=ValueError('No exact match within the permitted catalog')):
with self.assertRaisesRegex(ValueError, 'conflict.xml.*No exact match'): with self.assertRaisesRegex(ValueError, 'conflict.xml.*No exact match'):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['accepted']) self.assertEqual([p.name for p in output.iterdir()], ['accepted'])
+1 -1
View File
@@ -183,7 +183,7 @@ class DraftCompositionTests(unittest.TestCase):
self.assertIsNotNone(draft) self.assertIsNotNone(draft)
self.assertEqual((draft.start, draft.end), (phrase.plan.start, phrase.plan.end)) self.assertEqual((draft.start, draft.end), (phrase.plan.start, phrase.plan.end))
prefix = (dataclasses.replace(previous, position=None),) if previous else () prefix = (dataclasses.replace(previous, position=None),) if previous else ()
self.assertTrue(harmony.evaluate((*prefix, *draft.notes), phrase.state.treatments, until=draft.end, enclosures=draft.enclosures).valid) self.assertTrue(harmony.evaluate((*prefix, *draft.notes), phrase.state.treatments, until=draft.end, enclosures=draft.enclosures, expressions=draft.expressions).valid)
self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous)) self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous))
self.assertTrue(draft.transformations) self.assertTrue(draft.transformations)
previous = phrase.state.last previous = phrase.state.last
+2 -1
View File
@@ -249,6 +249,7 @@ class EnclosureCompositionAPI(unittest.TestCase):
self.assertEqual([p.anchors for p in decorated.phrases],[p.anchors for p in strict.phrases]) self.assertEqual([p.anchors for p in decorated.phrases],[p.anchors for p in strict.phrases])
self.assertTrue(harmonic.Harmony(strict.choices).evaluate(strict.notes()).valid) self.assertTrue(harmonic.Harmony(strict.choices).evaluate(strict.notes()).valid)
self.assertTrue(all(not p.draft.enclosures for p in strict.phrases)) self.assertTrue(all(not p.draft.enclosures for p in strict.phrases))
self.assertTrue(any(p.draft.enclosures for p in decorated.phrases)) self.assertGreater(decorated.diagnostics["expression_candidates"],0)
self.assertTrue(all(not p.draft.expressions for p in strict.phrases))
if __name__=='__main__':unittest.main() if __name__=='__main__':unittest.main()
+7 -5
View File
@@ -22,8 +22,8 @@ class SourceVariationTests(unittest.TestCase):
cls.context=realization.SearchContext() cls.context=realization.SearchContext()
cls.solo=cls.compose(cls.document,cls.context) cls.solo=cls.compose(cls.document,cls.context)
@classmethod @classmethod
def compose(cls,document,context=None,end=32): def compose(cls,document,context=None,end=32,part='acoustic'):
return composer.compose(analysis.analyze(document),document['staves'][0]['measures'],'acoustic',F(end), return composer.compose(analysis.analyze(document),document['staves'][0]['measures'],part,F(end),
scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=context,mode='paraphrase') scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=context,mode='paraphrase')
def test_production_is_traceable_to_complete_source_phrases(self): def test_production_is_traceable_to_complete_source_phrases(self):
self.assertTrue(self.solo.phrases) self.assertTrue(self.solo.phrases)
@@ -109,12 +109,14 @@ class SourceVariationTests(unittest.TestCase):
shortened=self.compose(self.document,end=F(29)) shortened=self.compose(self.document,end=F(29))
self.assertTrue(all(n.end<=29 for n in shortened.notes())) self.assertTrue(all(n.end<=29 for n in shortened.notes()))
composer.validate(shortened) composer.validate(shortened)
def test_pair_selection_produces_valid_distinct_source_variations(self): def test_batch_selection_preserves_independent_source_variations(self):
a=analysis.analyze(self.document) a=analysis.analyze(self.document)
pair=composer.compose_pair(a,{part:(self.document['staves'][0]['measures'],F(32),None) for part in ('acoustic','electric')}, pair=composer.compose_pair(a,{part:(self.document['staves'][0]['measures'],F(32),None) for part in ('acoustic','electric')},
scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),mode='paraphrase') scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),mode='paraphrase')
self.assertLess(leads.similarity(*pair),1) for solo in pair:
self.assertEqual(pair[0].diagnostics['pairs_compared'],64) alone=self.compose(self.document,part=solo.part)
self.assertEqual(solo.notes(),alone.notes())
self.assertNotIn('pairs_compared',solo.diagnostics)
for solo in pair: for solo in pair:
composer.validate(solo) composer.validate(solo)
for phrase in solo.phrases: for phrase in solo.phrases:
+5 -15
View File
@@ -24,21 +24,11 @@ def notes(names):
return tuple(dataclasses.replace(fixtures.note(F(i, 2), F(i + 1, 2), name), event_id=f'n{i}') for i, name in enumerate(names)) return tuple(dataclasses.replace(fixtures.note(F(i, 2), F(i + 1, 2), name), event_id=f'n{i}') for i, name in enumerate(names))
class LeadReferenceTests(unittest.TestCase): class LeadReferenceTests(unittest.TestCase):
def test_accepted_pair_preserves_sounding_pitch_exact_time_and_harmony(self): def test_authored_sounding_phrase_is_valid_without_audition_files(self):
reference, = references.load_library(pathlib.Path('data/lead_phrases')) original=tuple(dataclasses.replace(n,start=n.start*2,end=n.end*2) for n in notes(('c','e','g','e')))
self.assertEqual((len(reference.notes), len(reference.decorated), reference.duration), (31, 36, 32)) harmony=harmonic.Harmony(passages.choices(('CM7',)))
self.assertEqual(reference.notes[0].pitch.number(), 62) self.assertTrue(harmony.evaluate(original,complete=True).valid)
self.assertEqual(reference.notes[1].end - reference.notes[1].start, F(3, 2)) self.assertTrue(generator.solo.placement.place(original,F(120)))
choices = list(passages.choices(tuple(chord for _, chord in reference.harmony)))
minor = tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')
choices[5] = dataclasses.replace(choices[5], scale=minor)
harmony = harmonic.Harmony(tuple(choices))
for variant in (reference.notes, reference.decorated):
self.assertTrue(harmony.evaluate(variant, complete=True).valid)
placed = generator.solo.placement.place(variant, F(120))
self.assertTrue(placed)
self.assertTrue(all(1 <= n.position.fret <= 12 for n in placed[1]))
self.assertTrue(all(m.context and m.duration in (8, 16) for m in reference.windows()))
def test_feature_selection_finds_the_relationship_not_the_first_three(self): def test_feature_selection_finds_the_relationship_not_the_first_three(self):
original = notes(('c', 'd', 'e', 'c', 'g', 'f', 'd')) original = notes(('c', 'd', 'e', 'c', 'g', 'f', 'd'))
feature, = references.identifying_features(original, 'test') feature, = references.identifying_features(original, 'test')