Sepearate Generation
This commit is contained in:
@@ -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.
|
||||
|
||||
`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.
|
||||
|
||||
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/`.
|
||||
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.
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Optional, downstream audition tools. Never imported by production composers."""
|
||||
@@ -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()
|
||||
@@ -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
@@ -137,7 +137,7 @@ configure_file(package/manifest.json.in ${VAMP_MANIFEST} @ONLY)
|
||||
file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/data/*.xml
|
||||
${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
|
||||
${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_STAMP ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.stamp)
|
||||
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_ARGS)
|
||||
if(VAMP_ENGRAVING_CACHE_DIR)
|
||||
@@ -169,6 +170,8 @@ add_custom_command(
|
||||
--static-assets-dir ${CMAKE_CURRENT_SOURCE_DIR}/package/assets
|
||||
--check
|
||||
--jobs ${VAMP_ENGRAVING_JOBS}
|
||||
--composition-jobs ${VAMP_COMPOSITION_JOBS}
|
||||
--composition-cache ${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/composition_cache
|
||||
--progress
|
||||
${VAMP_ENGRAVING_CACHE_ARGS}
|
||||
COMMAND ${CMAKE_COMMAND} -E touch ${VAMP_GENERATED_ASSETS_STAMP}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": []}
|
||||
]
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--check", action="store_true")
|
||||
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("--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")
|
||||
args = parser.parse_args()
|
||||
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
|
||||
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')
|
||||
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)
|
||||
started = time.monotonic()
|
||||
|
||||
@@ -81,7 +88,8 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
copy_runtime_fonts(static_assets_dir, staged)
|
||||
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)
|
||||
if args.check:
|
||||
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
||||
|
||||
@@ -139,7 +139,7 @@ def plan_section(measures: list[dict], library: generator.rhythm.Library, *, ini
|
||||
bass = cycle(snippets, contexts, 'bass')
|
||||
upper = cycle(snippets, contexts, 'upper')
|
||||
companions = [tuple(sorted(set(sounding_attacks(bass[i % 4], item)) | set(sounding_attacks(upper[i % 4], item)))) for i, item in enumerate(contexts)]
|
||||
response = cycle(snippets, contexts, 'electric', companions if 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)))
|
||||
uppers.extend(upper[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
|
||||
duration = sounding_end - start
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -35,26 +35,6 @@ def voice(source, document, measures, part, initial=None, source_offset=0):
|
||||
generator.acoustic.combine(result)
|
||||
else:
|
||||
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.fretboard.validate_document(result)
|
||||
return result['staves'][0]['measures']
|
||||
|
||||
@@ -57,7 +57,7 @@ def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[
|
||||
canonical.acoustic_upper[start:end], canonical.electric[start:end])
|
||||
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:
|
||||
raise ValueError(f'unknown arrangement part: {part_type}')
|
||||
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_time += generator.model.fraction(m['duration'])
|
||||
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)
|
||||
previous = generator.accompaniment.carried_chords(section_measures)
|
||||
segment = []
|
||||
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
|
||||
m = section_measures[si]
|
||||
rhythm = None
|
||||
if section.role == 'accompaniment':
|
||||
if role == 'accompaniment' and accompaniment is None:
|
||||
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)]
|
||||
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)
|
||||
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
|
||||
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())
|
||||
if section.role == 'slash':
|
||||
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]
|
||||
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']))
|
||||
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)})
|
||||
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)
|
||||
tuplets.extend(generator.score.reidentify(([pickup] if pickup else []) + segment, staff.get('tuplets', [])))
|
||||
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)
|
||||
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
|
||||
result['metadata'].setdefault('tempo', 124)
|
||||
if part_type == 'acoustic':
|
||||
if part_type == 'acoustic' and accompaniment is None and (owned is None or 'accompaniment' in owned):
|
||||
generator.acoustic.prepare(result)
|
||||
generator.acoustic_voicing.place(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)
|
||||
voicing_links = generator.electric.link_voicings(source, voicing_origins)
|
||||
generator.guitar.place_guitar(result)
|
||||
if generator.accompaniment.connect_continuations(result):
|
||||
generator.guitar.place_guitar(result)
|
||||
generator.electric.reuse_voicings(voicing_links)
|
||||
# Source labels identify the head while arranging; published labels identify
|
||||
# the complete performance, including its lead-in and repeated choruses.
|
||||
for number, measure in enumerate(output, 1):
|
||||
measure['number'] = str(number)
|
||||
if part_type != 'vocal':
|
||||
if part_type != 'vocal' and owned is None:
|
||||
generator.arrange.accompaniment.install(document, result, plans)
|
||||
generator.fretboard.validate_document(result)
|
||||
if scales and solos:
|
||||
@@ -139,28 +143,6 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
reports.append(report)
|
||||
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:
|
||||
count = 0
|
||||
for path in generator.parts.source_paths(source_dir):
|
||||
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
|
||||
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:
|
||||
from generator.production import generate_tree
|
||||
return generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -17,6 +17,7 @@ class Event:
|
||||
dots: int = 0
|
||||
beams: tuple[tuple[int, str], ...] = ()
|
||||
continuation: bool = False
|
||||
ratio: fractions.Fraction = fractions.Fraction(1)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Bar:
|
||||
@@ -53,8 +54,8 @@ class Library:
|
||||
def read_snippet(path: pathlib.Path, identifier: str) -> Snippet:
|
||||
document = generator.musicxml.load_musicxml(path)
|
||||
staff = document['staves'][0]
|
||||
if len(staff['measures']) not in (1, 2) or staff.get('tuplets'):
|
||||
raise ValueError(f'{path}: rhythm snippets require one or two bars without tuplets')
|
||||
if len(staff['measures']) not in (1, 2):
|
||||
raise ValueError(f'{path}: rhythm snippets require one or two bars')
|
||||
meter = staff['measures'][0]['time_signature']
|
||||
bars = []
|
||||
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'])
|
||||
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')
|
||||
if 'ratio' in note['written']:
|
||||
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'])))
|
||||
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)))))
|
||||
cursor += duration
|
||||
if cursor != length:
|
||||
raise ValueError(f'{path}: rhythm events do not fill the bar')
|
||||
|
||||
@@ -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):
|
||||
"""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)
|
||||
sections = regions(source)
|
||||
source_measures = generator.score.source_staff(source)['measures']
|
||||
@@ -47,8 +50,11 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
|
||||
requests = {}
|
||||
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
|
||||
first, count, offset, length = sections[section_index]
|
||||
initial = generator.accompaniment.carried_chords(parts[part]['staves'][0]['measures'])[first]
|
||||
template = copy.deepcopy(parts[part]['staves'][0]['measures'][first:first + count])
|
||||
form = generator.arrange.form.full_arrangement_sections(source_measures, part)
|
||||
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
|
||||
if part == 'electric' and generator.score.body_start_measure_index(source_measures):
|
||||
pickup = source_measures[0]
|
||||
|
||||
@@ -83,7 +83,7 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
|
||||
if destination.exists():
|
||||
raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
|
||||
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': {}}
|
||||
try:
|
||||
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:
|
||||
for context in shared.values():
|
||||
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}
|
||||
regions = generator.solo.arrangement.regions(document)
|
||||
for solo, index in zip(solos, (3, 4)):
|
||||
|
||||
+11
-33
@@ -20,6 +20,8 @@ import generator.solo.realization
|
||||
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):
|
||||
if references or context is not None and getattr(context,'references',()):
|
||||
raise ValueError('Audition references are not production composition inputs')
|
||||
started = time.monotonic()
|
||||
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
|
||||
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)
|
||||
result = options[0][1]
|
||||
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)
|
||||
return result
|
||||
|
||||
|
||||
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."""
|
||||
from generator.solo import leads
|
||||
started = time.monotonic()
|
||||
pools = {}
|
||||
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)
|
||||
"""Compatibility batch helper: each winner is selected independently."""
|
||||
return tuple(compose(analysis, measures, part, end, scales=scales, sequences=sequences,
|
||||
rhythms=rhythms, initial=initial, context=(contexts or {}).get(part), stage=stage,
|
||||
mode=mode, enclosures_enabled=enclosures_enabled)
|
||||
for part, (measures, end, initial) in requests.items())
|
||||
|
||||
|
||||
def phrase_notes(phrase):
|
||||
@@ -144,7 +121,8 @@ def validate(solo):
|
||||
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))
|
||||
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:
|
||||
raise ValueError(f'{solo.part} solo: {checked.error}')
|
||||
for phrase in solo.phrases:
|
||||
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
|
||||
import generator.model
|
||||
import generator.solo.harmonic
|
||||
@@ -14,7 +15,7 @@ import generator.solo.policy
|
||||
import generator.solo.scoring
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types
|
||||
import generator.solo.references
|
||||
import generator.solo.features
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
@@ -47,9 +48,13 @@ class RhythmDraft:
|
||||
octave: int = 0
|
||||
|
||||
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."""
|
||||
result = {}
|
||||
for snippet in library.for_meter(meter):
|
||||
for snippet in snippets:
|
||||
offset, spans = F(), []
|
||||
for bar in snippet.bars:
|
||||
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)]:
|
||||
return False, f'{transform.id}: identifying contour changed'
|
||||
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'
|
||||
for first, repeated in draft.correspondence:
|
||||
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))
|
||||
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'):
|
||||
development.append(generator.solo.references.relationship(group, reference))
|
||||
development.append(generator.solo.features.relationship(group, reference))
|
||||
joins = []
|
||||
for time in draft.joins:
|
||||
left = next((n for n in reversed(notes) if n.start < time), None)
|
||||
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -148,8 +148,12 @@ class Harmony:
|
||||
intervals.append((upper.start,target.start))
|
||||
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)
|
||||
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())
|
||||
ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '')
|
||||
if error:
|
||||
@@ -190,13 +194,14 @@ class Harmony:
|
||||
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)
|
||||
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:
|
||||
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
|
||||
covered = end
|
||||
function = self.function(number, span)
|
||||
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
|
||||
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')
|
||||
@@ -206,8 +211,12 @@ class Harmony:
|
||||
if covered < note.end:
|
||||
return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage')
|
||||
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)
|
||||
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 ({}, '')
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
@@ -215,7 +224,8 @@ class Harmony:
|
||||
for index, note in enumerate(notes):
|
||||
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]
|
||||
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
|
||||
|
||||
def anchor_note(anchor, notes, *, until=None):
|
||||
|
||||
@@ -45,7 +45,7 @@ def document_for(template, notes, tempo, identifier):
|
||||
def controls(song, libraries):
|
||||
"""Literal reference injection is restricted to this diagnostic, never production."""
|
||||
_, scales, sequences = libraries
|
||||
reference, = references.load_library(pathlib.Path('data/lead_phrases'))
|
||||
reference, = references.load_library(pathlib.Path('audition/controls'))
|
||||
solo = song['solos']['acoustic']
|
||||
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)
|
||||
|
||||
+12
-9
@@ -7,10 +7,12 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import generator.vocabulary
|
||||
|
||||
import generator.model
|
||||
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
|
||||
|
||||
@@ -133,10 +135,10 @@ def rhythm_catalog(library, meter):
|
||||
return drafts.catalog(library,meter)
|
||||
|
||||
|
||||
def connection_patterns(start,end,entry,destination,rhythms,meter):
|
||||
length=end-start
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def connection_windows(catalog,length):
|
||||
spans={( (F(),length), ):'held'}
|
||||
for identifier,pattern,total in rhythm_catalog(rhythms,meter):
|
||||
for identifier,pattern,total in catalog:
|
||||
if total < length:
|
||||
continue
|
||||
# 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()),
|
||||
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)
|
||||
return ranked
|
||||
|
||||
def connection_patterns(start,end,entry,destination,rhythms,meter):
|
||||
ranked=connection_windows(tuple(rhythm_catalog(rhythms,meter)),end-start)
|
||||
result=[]
|
||||
for pattern,identifier in ranked:
|
||||
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):
|
||||
from generator.solo import realization,themes,phrasing
|
||||
context.resources[id(library)]=library
|
||||
context.tempo=analysis.tempo
|
||||
context.resources[id(rhythms)]=rhythms
|
||||
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.,())]
|
||||
failures=[]
|
||||
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):
|
||||
cost,history=value;state=history[-1].state;elapsed=history[-1].plan.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))
|
||||
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)
|
||||
|
||||
+20
-12
@@ -60,20 +60,28 @@ def measures(sources: list[dict], notes: tuple[generator.solo.types.Note, ...],
|
||||
return result
|
||||
|
||||
def tuplets(measures):
|
||||
spans = []
|
||||
for measure in measures:
|
||||
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)))):
|
||||
notes = list(values)
|
||||
if ratio == 1:
|
||||
continue
|
||||
for first in range(0, len(notes), ratio.denominator):
|
||||
members = notes[first:first + ratio.denominator]
|
||||
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 note in members:
|
||||
note['tuplet_ids'] = [identifier]
|
||||
# Group by sounding duration, including notation fragments across barlines.
|
||||
spans=[]
|
||||
events=[n for m in measures for n in generator.score.measure_notes(m)]
|
||||
for ratio,values in itertools.groupby(events,key=lambda n:generator.model.fraction(n['written'].get('ratio',generator.model.rational(1)))):
|
||||
if ratio==1:continue
|
||||
members=[];elapsed=fractions.Fraction();length=None
|
||||
for note in values:
|
||||
if not members:
|
||||
length=generator.model.TYPE_LENGTHS[note['written']['type']]*ratio.numerator
|
||||
members.append(note);elapsed+=generator.model.fraction(note['duration'])
|
||||
if elapsed>=length:
|
||||
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
|
||||
|
||||
|
||||
def replace_region(document: dict, first: int, replacement: list[dict]) -> None:
|
||||
staff = document['staves'][0]
|
||||
replacement = copy.deepcopy(replacement)
|
||||
|
||||
+18
-17
@@ -7,14 +7,15 @@ from fractions import Fraction as F
|
||||
|
||||
import generator.model
|
||||
import generator.harmony
|
||||
import generator.vocabulary
|
||||
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)
|
||||
first = notes[0].start
|
||||
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)
|
||||
|
||||
|
||||
@@ -56,7 +57,10 @@ def register_goal(incoming, part, fallback, profile=None):
|
||||
if profile and profile.reference is not None:
|
||||
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
||||
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]
|
||||
if not recent:
|
||||
return fallback
|
||||
@@ -72,9 +76,7 @@ def register_goal(incoming, part, fallback, profile=None):
|
||||
def endpoint_cost(finish, start, end, count, harmony, bar):
|
||||
length = float((finish-start)/bar)
|
||||
remaining = float((end-finish)/bar)
|
||||
projected = count+1+remaining/(32/policy.DEFAULT.settling_target)
|
||||
low, high = policy.DEFAULT.settling_range
|
||||
pacing = .2*max(0,low-projected,projected-high)+.025*abs(projected-policy.DEFAULT.settling_target)
|
||||
pacing = 0.
|
||||
span = harmony.at(finish-F(1,100))
|
||||
# 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 {}
|
||||
@@ -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]),)
|
||||
else:
|
||||
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:
|
||||
# The returning pickup may shorten the last measure. Do not strand a
|
||||
# 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)
|
||||
values=[]
|
||||
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 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
|
||||
if arrival-source.start<F(3,2):continue
|
||||
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,
|
||||
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal',harmony.at(arrival).chord,
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
values.append((rank,anchor))
|
||||
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)
|
||||
@@ -135,12 +138,10 @@ def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
|
||||
|
||||
|
||||
def appearance_cost(operation, incoming, start, end):
|
||||
expected=policy.DEFAULT.return_target*float(start/max(end,F(1)))
|
||||
actual=len(incoming.returns)
|
||||
gap=start-(incoming.returns[-1] if incoming.returns else F())
|
||||
if operation=='return':
|
||||
gap=start-(incoming.returns[-1] if incoming.returns else F())
|
||||
return .65*max(0,actual+1-expected)+.5*max(0,1-float(gap)/24)
|
||||
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):
|
||||
@@ -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.
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -45,14 +45,12 @@ class Policy:
|
||||
theme_continuations: int = 8
|
||||
theme_route_jobs: int = 16
|
||||
settling_range: tuple[int, int] = (5, 7)
|
||||
settling_target: int = 6
|
||||
return_target: int = 2
|
||||
history_bars: int = 16
|
||||
enclosure_targets: int = 4
|
||||
enclosure_routes: int = 4
|
||||
enclosure_frontier: int = 8
|
||||
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.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
@@ -5,6 +5,7 @@ import dataclasses
|
||||
from fractions import Fraction as F
|
||||
|
||||
import generator.fretboard
|
||||
import generator.vocabulary
|
||||
|
||||
|
||||
@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)))
|
||||
|
||||
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()
|
||||
@@ -43,13 +44,13 @@ def weighted_center(notes):
|
||||
def for_part(part, analysis=None):
|
||||
if part not in ('acoustic', 'electric'):
|
||||
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:
|
||||
return Instrument(part, strings)
|
||||
# Analysis contains the prepared concert source. Electric melody is written
|
||||
# two octaves up and sounds one down (parts.build_measure), hence +12.
|
||||
# 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)
|
||||
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))
|
||||
|
||||
@@ -6,7 +6,7 @@ import functools
|
||||
import time
|
||||
|
||||
import generator.solo.drafts
|
||||
import generator.solo.references
|
||||
import generator.solo.features
|
||||
import generator.solo.routes
|
||||
|
||||
import generator.fretboard
|
||||
@@ -700,7 +700,7 @@ def refine(phrase, pool, incoming, harmony, sequences, tempo, context, search=Tr
|
||||
notes = emitted_notes(phrase)
|
||||
draft = rebase_draft(phrase.draft, notes, harmony)
|
||||
# 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)
|
||||
# Rebuild lineage after group removal or replacement, including when no run
|
||||
# is selected afterward. The introduced home is the actual refined tune.
|
||||
|
||||
@@ -76,52 +76,4 @@ def read(path):
|
||||
def load_library(root: pathlib.Path):
|
||||
return tuple(read(path) for path in sorted(root.glob('*.json')))
|
||||
|
||||
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)
|
||||
from generator.solo.features import identifying_features, feature_error, relationship
|
||||
|
||||
@@ -13,7 +13,7 @@ import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.drafts
|
||||
import generator.solo.similarity
|
||||
import generator.solo.references
|
||||
import generator.solo.features
|
||||
import generator.solo.placement
|
||||
|
||||
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.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'):
|
||||
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:
|
||||
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()}')
|
||||
@@ -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]
|
||||
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):
|
||||
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}')
|
||||
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):')
|
||||
for event in detail['harmonic_roles']:
|
||||
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)
|
||||
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)
|
||||
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):
|
||||
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_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:
|
||||
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
|
||||
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):
|
||||
|
||||
+33
-12
@@ -10,7 +10,8 @@ from fractions import Fraction as F
|
||||
|
||||
import generator.model
|
||||
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):
|
||||
@@ -60,20 +61,23 @@ def seeds(analysis, bar, seed):
|
||||
event_id=n.sources[0]) for n in analysis.melody)
|
||||
groups=[]
|
||||
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]
|
||||
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
|
||||
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')
|
||||
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]
|
||||
groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment))
|
||||
def rank(ref):
|
||||
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:])}
|
||||
return (-len(intervals),-len(rhythm),ref.start,ref.id)
|
||||
return sequences.diverse(groups,rank,lambda r:r.treatment,policy.DEFAULT.theme_seed_limit)
|
||||
preferred=generator.vocabulary.role().get('preferred_features',[])
|
||||
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):
|
||||
@@ -85,6 +89,8 @@ def feature(reference, actual, *, treatment=None):
|
||||
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
|
||||
'develop_contour':('intervals',), 'continue':('contour',)}[treatment]
|
||||
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
|
||||
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),
|
||||
@@ -95,7 +101,8 @@ def feature(reference, actual, *, treatment=None):
|
||||
|
||||
def rhythm_windows(library,meter,length,context):
|
||||
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:
|
||||
patterns={}
|
||||
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
|
||||
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)
|
||||
# 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()),
|
||||
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]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
|
||||
lambda x:(len(x[0])>16,policy.stable_rank('rhythm-window',x[0])),
|
||||
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]
|
||||
|
||||
|
||||
@@ -366,6 +382,11 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
cursor=source.start
|
||||
while cursor<source.end and states:
|
||||
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
|
||||
next_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']:
|
||||
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
||||
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)
|
||||
appearance=phrasing.appearance_cost(operation,state,cursor,solo_end) if home else 0.
|
||||
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)
|
||||
possible=attacks+2*float(remaining)+(0 if finish==source.end else 1)
|
||||
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))
|
||||
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)
|
||||
@@ -474,7 +495,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
history=incoming.history
|
||||
for transform in transforms:
|
||||
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),
|
||||
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:
|
||||
|
||||
@@ -97,6 +97,17 @@ class HarmonicTreatment:
|
||||
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)
|
||||
class LeadIdentity:
|
||||
id: str
|
||||
@@ -273,6 +284,7 @@ class MelodicDraft:
|
||||
source_reference: SourceReference | None = None
|
||||
sustained_tails: tuple[SustainedTail, ...] = ()
|
||||
enclosures: tuple[EnclosureContract, ...] = ()
|
||||
expressions: tuple[ExpressionContract, ...] = ()
|
||||
def rests(self):
|
||||
cursor, result = self.start, []
|
||||
for note in self.notes:
|
||||
|
||||
@@ -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
|
||||
@@ -60,7 +60,7 @@ add_test(
|
||||
)
|
||||
# Complete deterministic phrase searches make this suite CPU-sensitive;
|
||||
# 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
|
||||
COMMAND ${CMAKE_CTEST_COMMAND}
|
||||
--test-dir ${CMAKE_BINARY_DIR}
|
||||
|
||||
@@ -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'}))
|
||||
@@ -53,11 +53,11 @@ def held_half_bar(sounding=True):
|
||||
), fractions.Fraction(4))
|
||||
|
||||
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>'
|
||||
source = xml_bars([tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=extra) * 6], divisions=3)
|
||||
with self.assertRaisesRegex(ValueError, 'time modifications'):
|
||||
self.load({'4_4/1/ratio.xml': source})
|
||||
library=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):
|
||||
with tempfile.TemporaryDirectory() as 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]),
|
||||
'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>')]),
|
||||
'tuplet': xml_bars([triplet + tests.test_musicxml.xml_note(duration=9, kind='half', extra='<dot/>')], divisions=3),
|
||||
'malformed XML': '<broken',
|
||||
'wrong root': '<score-timewise version="4.0"/>',
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import tests.production_fixtures
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
@@ -10,6 +11,8 @@ import tests.solo_fixtures
|
||||
import tests.test_musicxml
|
||||
|
||||
class CatalogTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tests.production_fixtures.install(self)
|
||||
def test_songs_generate_only_authored_key_parts(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
|
||||
|
||||
@@ -106,33 +106,11 @@ class ElectricVoicingTests(unittest.TestCase):
|
||||
before = copy.deepcopy(target)
|
||||
generator.electric.reuse_voicings(links)
|
||||
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'))
|
||||
before = copy.deepcopy(source)
|
||||
rhythms = tests.rhythm_fixtures.library()
|
||||
with unittest.mock.patch('generator.electric.reuse_voicings'):
|
||||
independent = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
|
||||
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
|
||||
with unittest.mock.patch('generator.electric.reuse_voicings', side_effect=AssertionError('cross-part grip dependency')) as reuse:
|
||||
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
|
||||
generator.model.validate_document(result)
|
||||
reuse.assert_not_called()
|
||||
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'})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import tests.production_fixtures
|
||||
import argparse
|
||||
import contextlib
|
||||
import copy
|
||||
@@ -38,6 +39,7 @@ class EngravingCacheTests(unittest.TestCase):
|
||||
self.assertIn("if: steps.engraving_cache.outputs.cache-hit != 'true'", workflow)
|
||||
self.assertIn('key: ${{ steps.engraving_cache.outputs.cache-primary-key }}', workflow)
|
||||
def setUp(self):
|
||||
tests.production_fixtures.install(self)
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = pathlib.Path(self.temporary.name)
|
||||
|
||||
+8
-12
@@ -1,3 +1,4 @@
|
||||
import tests.production_fixtures
|
||||
import argparse
|
||||
import copy
|
||||
import fractions
|
||||
@@ -24,6 +25,8 @@ import tests.solo_fixtures
|
||||
import tests.test_musicxml
|
||||
|
||||
class GuitarTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tests.production_fixtures.install(self)
|
||||
def test_fret_boundaries_and_span(self):
|
||||
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)))
|
||||
@@ -265,19 +268,12 @@ class GuitarTests(unittest.TestCase):
|
||||
output.mkdir()
|
||||
(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)
|
||||
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)
|
||||
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>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>'
|
||||
(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:.*measure 1.*no acoustic grip.*1-20'):
|
||||
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'])
|
||||
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><root><root-step>C</root-step></root><kind>dominant</kind></harmony>'+tests.test_musicxml.xml_note()))
|
||||
with self.assertRaisesRegex(ValueError, 'no legal sparse voicing'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
|
||||
@@ -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()
|
||||
@@ -1,3 +1,4 @@
|
||||
import tests.production_fixtures
|
||||
import argparse
|
||||
import fractions
|
||||
import pathlib
|
||||
@@ -22,6 +23,7 @@ import tests.test_musicxml
|
||||
|
||||
class MusicModelTests(unittest.TestCase):
|
||||
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.scales.library.load_library', return_value=tests.scale_fixtures.library()))
|
||||
engraving = unittest.mock.patch('generator.engraving.pipeline.engrave_tree')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import tests.production_fixtures
|
||||
import argparse
|
||||
import copy
|
||||
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>'
|
||||
|
||||
class PartGenerationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tests.production_fixtures.install(self)
|
||||
def assert_complete_measure(self, measure):
|
||||
cursor = fractions.Fraction()
|
||||
for note in measure['voices'][0]['notes']:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import tests.production_fixtures
|
||||
import argparse
|
||||
import copy
|
||||
import fractions
|
||||
@@ -35,6 +36,8 @@ import tests.rhythm_fixtures
|
||||
import tests.test_musicxml
|
||||
|
||||
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):
|
||||
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
|
||||
@@ -57,16 +57,16 @@ class ReusableAccompaniment(unittest.TestCase):
|
||||
class SoloInstrumentAndIdentity(unittest.TestCase):
|
||||
def test_role_ranges_and_no_open_strings(self):
|
||||
acoustic=profiles.for_part('acoustic');electric=profiles.for_part('electric')
|
||||
self.assertEqual(acoustic.register,(41,71));self.assertEqual(electric.register,(51,76))
|
||||
self.assertTrue(acoustic.permits(41));self.assertFalse(electric.permits(50))
|
||||
self.assertFalse(acoustic.permits(72))
|
||||
self.assertEqual(acoustic.register,(41,76));self.assertEqual(electric.register,(41,76))
|
||||
self.assertTrue(acoustic.permits(41));self.assertTrue(electric.permits(50))
|
||||
self.assertTrue(acoustic.permits(72))
|
||||
for profile in (acoustic,electric):
|
||||
for midi in range(40,78):
|
||||
for p in placement.positions(midi,profile):
|
||||
self.assertIn(p.string_number,profile.strings)
|
||||
self.assertTrue(1<=p.fret<=12)
|
||||
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):
|
||||
source=analysis.analyze(generator.parts.prepare_document(song(count=4,chords=('CM7',))))
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
import tests.production_fixtures
|
||||
import argparse
|
||||
import copy
|
||||
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())
|
||||
|
||||
class SoloTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tests.production_fixtures.install(self)
|
||||
def test_catalog_intervals_and_negative_scale_indices(self):
|
||||
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})
|
||||
@@ -227,7 +230,7 @@ class SoloTests(unittest.TestCase):
|
||||
output.mkdir()
|
||||
(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)
|
||||
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'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual([p.name for p in output.iterdir()], ['accepted'])
|
||||
|
||||
@@ -183,7 +183,7 @@ class DraftCompositionTests(unittest.TestCase):
|
||||
self.assertIsNotNone(draft)
|
||||
self.assertEqual((draft.start, draft.end), (phrase.plan.start, phrase.plan.end))
|
||||
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(draft.transformations)
|
||||
previous = phrase.state.last
|
||||
|
||||
@@ -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.assertTrue(harmonic.Harmony(strict.choices).evaluate(strict.notes()).valid)
|
||||
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()
|
||||
|
||||
@@ -22,8 +22,8 @@ class SourceVariationTests(unittest.TestCase):
|
||||
cls.context=realization.SearchContext()
|
||||
cls.solo=cls.compose(cls.document,cls.context)
|
||||
@classmethod
|
||||
def compose(cls,document,context=None,end=32):
|
||||
return composer.compose(analysis.analyze(document),document['staves'][0]['measures'],'acoustic',F(end),
|
||||
def compose(cls,document,context=None,end=32,part='acoustic'):
|
||||
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')
|
||||
def test_production_is_traceable_to_complete_source_phrases(self):
|
||||
self.assertTrue(self.solo.phrases)
|
||||
@@ -109,12 +109,14 @@ class SourceVariationTests(unittest.TestCase):
|
||||
shortened=self.compose(self.document,end=F(29))
|
||||
self.assertTrue(all(n.end<=29 for n in shortened.notes()))
|
||||
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)
|
||||
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')
|
||||
self.assertLess(leads.similarity(*pair),1)
|
||||
self.assertEqual(pair[0].diagnostics['pairs_compared'],64)
|
||||
for solo in pair:
|
||||
alone=self.compose(self.document,part=solo.part)
|
||||
self.assertEqual(solo.notes(),alone.notes())
|
||||
self.assertNotIn('pairs_compared',solo.diagnostics)
|
||||
for solo in pair:
|
||||
composer.validate(solo)
|
||||
for phrase in solo.phrases:
|
||||
|
||||
@@ -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))
|
||||
|
||||
class LeadReferenceTests(unittest.TestCase):
|
||||
def test_accepted_pair_preserves_sounding_pitch_exact_time_and_harmony(self):
|
||||
reference, = references.load_library(pathlib.Path('data/lead_phrases'))
|
||||
self.assertEqual((len(reference.notes), len(reference.decorated), reference.duration), (31, 36, 32))
|
||||
self.assertEqual(reference.notes[0].pitch.number(), 62)
|
||||
self.assertEqual(reference.notes[1].end - reference.notes[1].start, F(3, 2))
|
||||
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_authored_sounding_phrase_is_valid_without_audition_files(self):
|
||||
original=tuple(dataclasses.replace(n,start=n.start*2,end=n.end*2) for n in notes(('c','e','g','e')))
|
||||
harmony=harmonic.Harmony(passages.choices(('CM7',)))
|
||||
self.assertTrue(harmony.evaluate(original,complete=True).valid)
|
||||
self.assertTrue(generator.solo.placement.place(original,F(120)))
|
||||
def test_feature_selection_finds_the_relationship_not_the_first_three(self):
|
||||
original = notes(('c', 'd', 'e', 'c', 'g', 'f', 'd'))
|
||||
feature, = references.identifying_features(original, 'test')
|
||||
|
||||
Reference in New Issue
Block a user