From 8fdd736e13f28f2e3ac8e12188d61e8d8a687517 Mon Sep 17 00:00:00 2001 From: khodowany Date: Sun, 4 Oct 2026 09:05:25 -0500 Subject: [PATCH] Sepearate Generation --- README.md | 42 +-- TECHNICAL_GUIDE.md | 70 ++++- audition/__init__.py | 1 + .../controls}/original_lead_theme.json | 0 audition/production_review.py | 145 +++++++++ audition/reference_review.py | 127 ++++++++ cmake/zero.cmake | 5 +- data/composition/grammar.json | 78 +++++ data/composition/harmony.json | 20 ++ data/composition/lead_ins.json | 16 + data/composition/rhythms.json | 24 ++ data/composition/roles.json | 14 + generator/__main__.py | 10 +- generator/accompaniment.py | 4 +- generator/arrange/accompaniment.py | 20 -- generator/arrange/generate.py | 52 ++-- generator/comping.py | 123 ++++++++ generator/compose.py | 24 ++ generator/production.py | 286 ++++++++++++++++++ generator/rhythm.py | 9 +- generator/solo/arrangement.py | 10 +- generator/solo/audition.py | 4 +- generator/solo/composer.py | 44 +-- generator/solo/drafts.py | 13 +- generator/solo/expressions.py | 223 ++++++++++++++ generator/solo/features.py | 56 ++++ generator/solo/harmonic.py | 20 +- generator/solo/lead_audition.py | 2 +- generator/solo/leads.py | 21 +- generator/solo/notation.py | 32 +- generator/solo/phrasing.py | 35 +-- generator/solo/policy.py | 4 +- generator/solo/profiles.py | 7 +- generator/solo/realization.py | 4 +- generator/solo/references.py | 50 +-- generator/solo/report.py | 15 +- generator/solo/themes.py | 45 ++- generator/solo/types.py | 12 + generator/vocabulary.py | 76 +++++ tests/CMakeLists.txt | 2 +- tests/production_fixtures.py | 19 ++ tests/test_accompaniment.py | 7 +- tests/test_catalog.py | 3 + tests/test_electric_voicing.py | 30 +- tests/test_engraving_cache.py | 2 + tests/test_guitar.py | 20 +- tests/test_independent_generation.py | 196 ++++++++++++ tests/test_music_model.py | 2 + tests/test_parts.py | 3 + tests/test_score_bundle.py | 3 + tests/test_six_parts.py | 8 +- tests/test_solo.py | 5 +- tests/test_solo_drafts.py | 2 +- tests/test_solo_enclosures.py | 3 +- tests/test_solo_lead_tunes.py | 12 +- tests/test_solo_leads.py | 20 +- 56 files changed, 1739 insertions(+), 341 deletions(-) create mode 100644 audition/__init__.py rename {data/lead_phrases => audition/controls}/original_lead_theme.json (100%) create mode 100644 audition/production_review.py create mode 100644 audition/reference_review.py create mode 100644 data/composition/grammar.json create mode 100644 data/composition/harmony.json create mode 100644 data/composition/lead_ins.json create mode 100644 data/composition/rhythms.json create mode 100644 data/composition/roles.json create mode 100644 generator/comping.py create mode 100644 generator/compose.py create mode 100644 generator/production.py create mode 100644 generator/solo/expressions.py create mode 100644 generator/solo/features.py create mode 100644 generator/vocabulary.py create mode 100644 tests/production_fixtures.py create mode 100644 tests/test_independent_generation.py diff --git a/README.md b/README.md index 35f544c..9bc6e20 100644 --- a/README.md +++ b/README.md @@ -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///` 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. diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 7625cc8..eabe70c 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -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/.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/.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 diff --git a/audition/__init__.py b/audition/__init__.py new file mode 100644 index 0000000..f048df3 --- /dev/null +++ b/audition/__init__.py @@ -0,0 +1 @@ +"""Optional, downstream audition tools. Never imported by production composers.""" diff --git a/data/lead_phrases/original_lead_theme.json b/audition/controls/original_lead_theme.json similarity index 100% rename from data/lead_phrases/original_lead_theme.json rename to audition/controls/original_lead_theme.json diff --git a/audition/production_review.py b/audition/production_review.py new file mode 100644 index 0000000..18ce55a --- /dev/null +++ b/audition/production_review.py @@ -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 sstart: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'])<=xpitch 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=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=['Independent generation auditions', + '

Independent generation auditions

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.

Full roles, contracts and diagnostics · Sounding note report

'] + for key,detail in manifest['solos'].items(): + rows.append('

'+html.escape(key)+'

') + for c in clips: + if c['song']+'_'+c['part']!=key:continue + label=c['version']+' / '+c['context'] + rows.append('') + rows.append('
'+html.escape(label)+'WAV
') + (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() diff --git a/audition/reference_review.py b/audition/reference_review.py new file mode 100644 index 0000000..7db99bb --- /dev/null +++ b/audition/reference_review.py @@ -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 startt),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 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') diff --git a/generator/accompaniment.py b/generator/accompaniment.py index c1aee86..3917ca1 100644 --- a/generator/accompaniment.py +++ b/generator/accompaniment.py @@ -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) diff --git a/generator/arrange/accompaniment.py b/generator/arrange/accompaniment.py index cc71b5b..868dab3 100644 --- a/generator/arrange/accompaniment.py +++ b/generator/arrange/accompaniment.py @@ -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'] diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 1b96c48..6eaf5f3 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -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) diff --git a/generator/comping.py b/generator/comping.py new file mode 100644 index 0000000..830f2e3 --- /dev/null +++ b/generator/comping.py @@ -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 diff --git a/generator/compose.py b/generator/compose.py new file mode 100644 index 0000000..5f69a29 --- /dev/null +++ b/generator/compose.py @@ -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() diff --git a/generator/production.py b/generator/production.py new file mode 100644 index 0000000..2b25482 --- /dev/null +++ b/generator/production.py @@ -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 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') diff --git a/generator/solo/arrangement.py b/generator/solo/arrangement.py index a6c87a6..e8428e7 100644 --- a/generator/solo/arrangement.py +++ b/generator/solo/arrangement.py @@ -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] diff --git a/generator/solo/audition.py b/generator/solo/audition.py index 8b6b478..1c365ff 100644 --- a/generator/solo/audition.py +++ b/generator/solo/audition.py @@ -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)): diff --git a/generator/solo/composer.py b/generator/solo/composer.py index 2152584..8d648e9 100644 --- a/generator/solo/composer.py +++ b/generator/solo/composer.py @@ -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: diff --git a/generator/solo/drafts.py b/generator/solo/drafts.py index 06a670e..e1df1e0 100644 --- a/generator/solo/drafts.py +++ b/generator/solo/drafts.py @@ -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) diff --git a/generator/solo/expressions.py b/generator/solo/expressions.py new file mode 100644 index 0000000..7315bc2 --- /dev/null +++ b/generator/solo/expressions.py @@ -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.startc.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 0F(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 startF(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.start0)-(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) diff --git a/generator/solo/harmonic.py b/generator/solo/harmonic.py index 3e7128c..db9b30b 100644 --- a/generator/solo/harmonic.py +++ b/generator/solo/harmonic.py @@ -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): diff --git a/generator/solo/lead_audition.py b/generator/solo/lead_audition.py index e230c04..8d62b45 100644 --- a/generator/solo/lead_audition.py +++ b/generator/solo/lead_audition.py @@ -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) diff --git a/generator/solo/leads.py b/generator/solo/leads.py index 53f2532..03ea20c 100644 --- a/generator/solo/leads.py +++ b/generator/solo/leads.py @@ -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) diff --git a/generator/solo/notation.py b/generator/solo/notation.py index d02583b..43d8374 100644 --- a/generator/solo/notation.py +++ b/generator/solo/notation.py @@ -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) diff --git a/generator/solo/phrasing.py b/generator/solo/phrasing.py index 009074a..994de1a 100644 --- a/generator/solo/phrasing.py +++ b/generator/solo/phrasing.py @@ -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 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)) diff --git a/generator/solo/realization.py b/generator/solo/realization.py index 478873b..09a0329 100644 --- a/generator/solo/realization.py +++ b/generator/solo/realization.py @@ -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. diff --git a/generator/solo/references.py b/generator/solo/references.py index 16ec1a1..efcde92 100644 --- a/generator/solo/references.py +++ b/generator/solo/references.py @@ -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 diff --git a/generator/solo/report.py b/generator/solo/report.py index 0967c08..581e794 100644 --- a/generator/solo/report.py +++ b/generator/solo/report.py @@ -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): diff --git a/generator/solo/themes.py b/generator/solo/themes.py index 7f40a15..d6b2b47 100644 --- a/generator/solo/themes.py +++ b/generator/solo/themes.py @@ -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)total or any(alength: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=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 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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1045dd0..cbd1ac0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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} diff --git a/tests/production_fixtures.py b/tests/production_fixtures.py new file mode 100644 index 0000000..abc5c1f --- /dev/null +++ b/tests/production_fixtures.py @@ -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'})) diff --git a/tests/test_accompaniment.py b/tests/test_accompaniment.py index 228bb11..caadee3 100644 --- a/tests/test_accompaniment.py +++ b/tests/test_accompaniment.py @@ -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 = '32' 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(['D4eighth' + whole]), 'spacer': xml_bars(['11' + quarter * 3]), 'tremolo': xml_bars([tests.test_musicxml.xml_note(extra='1')]), - 'tuplet': xml_bars([triplet + tests.test_musicxml.xml_note(duration=9, kind='half', extra='')], divisions=3), 'malformed XML': '', } diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 6e0a440..dce65ad 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -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' diff --git a/tests/test_electric_voicing.py b/tests/test_electric_voicing.py index ca0b19d..02ce56e 100644 --- a/tests/test_electric_voicing.py +++ b/tests/test_electric_voicing.py @@ -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'}) diff --git a/tests/test_engraving_cache.py b/tests/test_engraving_cache.py index 95eda39..4e8e4e5 100644 --- a/tests/test_engraving_cache.py +++ b/tests/test_engraving_cache.py @@ -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) diff --git a/tests/test_guitar.py b/tests/test_guitar.py index 6bf7f37..5c91560 100644 --- a/tests/test_guitar.py +++ b/tests/test_guitar.py @@ -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 = 'Cdominant9-1add111add' - (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 = 'Cpower' - (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('Cdominant'+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') diff --git a/tests/test_independent_generation.py b/tests/test_independent_generation.py new file mode 100644 index 0000000..f7caaa8 --- /dev/null +++ b/tests/test_independent_generation.py @@ -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('') + 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() diff --git a/tests/test_music_model.py b/tests/test_music_model.py index ebc63f1..c3794be 100644 --- a/tests/test_music_model.py +++ b/tests/test_music_model.py @@ -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') diff --git a/tests/test_parts.py b/tests/test_parts.py index 1cc235a..c407414 100644 --- a/tests/test_parts.py +++ b/tests/test_parts.py @@ -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'{root}{kind}{offset}' 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']: diff --git a/tests/test_score_bundle.py b/tests/test_score_bundle.py index 0426ca1..4e6ae6a 100644 --- a/tests/test_score_bundle.py +++ b/tests/test_score_bundle.py @@ -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: diff --git a/tests/test_six_parts.py b/tests/test_six_parts.py index 337387b..3ccff81 100644 --- a/tests/test_six_parts.py +++ b/tests/test_six_parts.py @@ -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',)))) diff --git a/tests/test_solo.py b/tests/test_solo.py index fda8053..060b64f 100644 --- a/tests/test_solo.py +++ b/tests/test_solo.py @@ -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']) diff --git a/tests/test_solo_drafts.py b/tests/test_solo_drafts.py index 2a93bc8..b1e7397 100644 --- a/tests/test_solo_drafts.py +++ b/tests/test_solo_drafts.py @@ -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 diff --git a/tests/test_solo_enclosures.py b/tests/test_solo_enclosures.py index b7bac12..44a5607 100644 --- a/tests/test_solo_enclosures.py +++ b/tests/test_solo_enclosures.py @@ -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() diff --git a/tests/test_solo_lead_tunes.py b/tests/test_solo_lead_tunes.py index 370ed79..b0912e9 100644 --- a/tests/test_solo_lead_tunes.py +++ b/tests/test_solo_lead_tunes.py @@ -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: diff --git a/tests/test_solo_leads.py b/tests/test_solo_leads.py index c929a8d..c1c20ef 100644 --- a/tests/test_solo_leads.py +++ b/tests/test_solo_leads.py @@ -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')