Playback
ci / checks (push) Failing after 23m37s

This commit is contained in:
khodowany
2026-10-05 23:41:11 -05:00
parent ffdbe48258
commit 82349cf897
104 changed files with 4095 additions and 4354 deletions
+8 -8
View File
@@ -9,7 +9,7 @@ add_executable(product_vamp_test
test_menu.cpp
test_pico.cpp
test_control_artwork.cpp
test_playback.cpp
test_performance.cpp
test_score_loader.cpp
test_touch_input.cpp
test_ui.cpp
@@ -29,7 +29,7 @@ target_compile_definitions(product_vamp_test PRIVATE
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
VAMP_TEST_PYTHON="${Python3_EXECUTABLE}"
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated_assets"
VAMP_TEST_PLAYBACK_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/playback_assets"
VAMP_TEST_PERFORMANCE_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/performance_assets"
VAMP_TEST_UI_DIR="${CMAKE_CURRENT_BINARY_DIR}/ui_previews"
)
add_custom_command(
@@ -41,16 +41,16 @@ add_custom_command(
)
add_custom_target(vamp_ui_test_assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/ui_previews/static.p4)
add_dependencies(product_vamp_test vamp_ui_test_assets)
file(GLOB_RECURSE VAMP_PLAYBACK_TEST_INPUTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/generator/*.py)
file(GLOB_RECURSE VAMP_PERFORMANCE_TEST_INPUTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/generator/*.py)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/playback_assets/score.json
COMMAND ${VAMP_PYTHON_COMMAND} -m tests.test_playback --fixture ${CMAKE_CURRENT_BINARY_DIR}/playback_assets
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/performance_assets/score.json
COMMAND ${VAMP_PYTHON_COMMAND} -m tests.test_performance --fixture ${CMAKE_CURRENT_BINARY_DIR}/performance_assets
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
DEPENDS test_playback.py test_musicxml.py ${VAMP_PLAYBACK_TEST_INPUTS}
DEPENDS test_performance.py test_musicxml.py ${VAMP_PERFORMANCE_TEST_INPUTS}
VERBATIM
)
add_custom_target(vamp_playback_test_assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/playback_assets/score.json)
add_dependencies(product_vamp_test vamp_playback_test_assets)
add_custom_target(vamp_performance_test_assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/performance_assets/score.json)
add_dependencies(product_vamp_test vamp_performance_test_assets)
add_test(NAME product_vamp COMMAND product_vamp_test)
set_tests_properties(product_vamp PROPERTIES TIMEOUT 180)
add_test(
+12 -9
View File
@@ -1,11 +1,14 @@
import unittest.mock
from generator import comping, vocabulary, parts
from generator.arrange import form, generate
import generator.comping
import generator.vocabulary
import generator.parts
import generator.arrange.form
import generator.arrange.generate
def section_document(document,part_type):
prepared=parts.prepare_document(document)
role={'acoustic':'accompaniment','electric':'melody','vocal':'vocal'}[part_type]
section=form.ArrangementSection('',0,len(prepared['staves'][0]['measures']),0,role)
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections',return_value=[section]), vocabulary.using(vocabulary.load(),part_type+'_accompaniment'):
backing=comping.Canonical(prepared,part_type,'fixture') if part_type=='acoustic' else None
return generate._arrange_document(prepared,part_type,accompaniment=backing)
def section_document(document, part_type):
prepared = generator.parts.prepare_document(document)
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
section = generator.arrange.form.ArrangementSection('', 0, len(prepared['staves'][0]['measures']), 0, role)
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]), generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'):
backing = generator.comping.Canonical(prepared, part_type, 'fixture') if part_type == 'acoustic' else None
return generator.arrange.generate._arrange_document(prepared, part_type, accompaniment=backing)
+30
View File
@@ -8,12 +8,42 @@
#include <unistd.h>
#include <boost/test/unit_test.hpp>
#include <boost/json.hpp>
#include <modulus/types.hpp>
namespace vamp_test {
inline auto add_timing(boost::json::value &value) -> void {
auto &music = value.at("music");
music.at("metadata").as_object()["song_id"] = "fixture";
auto &measures = music.at("staves").as_array().front().at("measures").as_array();
auto &pages = value.at("presentation").at("pages").as_array();
while (measures.size() < pages.size()) {
measures.push_back(boost::json::object{});
}
auto beat = [](std::size_t n) { return boost::json::object{{"numerator", n}, {"denominator", 1}}; };
auto clicks = boost::json::array{};
for (std::size_t i = 0; i < measures.size(); ++i) {
measures[i].as_object()["duration"] = beat(4);
for (std::size_t b = 0; b < 4; ++b) {
clicks.push_back(boost::json::object{{"beat", beat(i * 4 + b)}, {"downbeat", b == 0}});
}
}
std::size_t cursor{};
for (std::size_t i = 0; i < pages.size(); ++i) {
auto count = i + 1 == pages.size() ? measures.size() - cursor : 1U;
auto &page = pages[i].as_object();
page["first_measure"] = cursor + 1;
page["measure_count"] = count;
page["start_beat"] = beat(cursor * 4);
page["end_beat"] = beat((cursor + count) * 4);
cursor += count;
}
value.as_object()["performance"] = boost::json::object{{"song_id", "fixture"}, {"duration", beat(measures.size() * 4)}, {"count_in_beats", 4}, {"clicks", clicks}};
}
inline auto build_catalog(std::filesystem::path const &assets, std::filesystem::path const &database) -> std::bool_t {
auto source = assets.string();
auto destination = database.string();
+12 -725
View File
@@ -1,10 +1,10 @@
{
"all_of_me_acoustic.json": {
"title": "all-of-me",
"title": "All Of Me",
"composer": "",
"key_signature": "G",
"part_type": "acoustic",
"measure_count": 176,
"measure_count": 168,
"section_labels": [
{
"measure": 1,
@@ -29,10 +29,6 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -1824,10 +1820,6 @@
{
"onset": "0",
"symbol": "GM6"
},
{
"onset": "2",
"symbol": "(Bbdim7)"
}
],
"duration": "4",
@@ -1836,110 +1828,6 @@
{
"measure": 168,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "(Am7)"
},
{
"onset": "2",
"symbol": "(D7)"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "CM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM7"
},
{
"onset": "2",
"symbol": "Bm7b5/F"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "E7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Am7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "D7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 176,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
@@ -1947,11 +1835,11 @@
]
},
"all_of_me_electric.json": {
"title": "all-of-me",
"title": "All Of Me",
"composer": "",
"key_signature": "G",
"part_type": "electric",
"measure_count": 176,
"measure_count": 168,
"section_labels": [
{
"measure": 1,
@@ -1976,10 +1864,6 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -3771,10 +3655,6 @@
{
"onset": "0",
"symbol": "GM6"
},
{
"onset": "2",
"symbol": "(Bbdim7)"
}
],
"duration": "4",
@@ -3783,110 +3663,6 @@
{
"measure": 168,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "(Am7)"
},
{
"onset": "2",
"symbol": "(D7)"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "CM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM7"
},
{
"onset": "2",
"symbol": "Bm7b5/F"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "E7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Am7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "D7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 176,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
@@ -3894,11 +3670,11 @@
]
},
"all_of_me_vocal.json": {
"title": "all-of-me",
"title": "All Of Me",
"composer": "",
"key_signature": "G",
"part_type": "vocal",
"measure_count": 176,
"measure_count": 168,
"section_labels": [
{
"measure": 1,
@@ -3923,10 +3699,6 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -6322,10 +6094,6 @@
{
"onset": "0",
"symbol": "GM6"
},
{
"onset": "2",
"symbol": "(Bbdim7)"
}
],
"duration": "4",
@@ -6338,145 +6106,6 @@
{
"measure": 168,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "(Am7)"
},
{
"onset": "2",
"symbol": "(D7)"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note G4 ",
"2+2 half note G4 "
]
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "CM6"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note A4 You",
"2+1 quarter note G4 took",
"3+1 quarter note F#4 the"
]
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm6"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+3 half. note A4 part",
"3+1 quarter note G4 that"
]
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM7"
},
{
"onset": "2",
"symbol": "Bm7b5/F"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note F#4 once",
"2+1 quarter note B3 was",
"3+1 quarter note D4 my"
]
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "E7"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+3 half. note F#4 heart,",
"3+1 quarter note D4 so"
]
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Am7"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note G4 why",
"2+1 quarter note E4 not",
"3+1 quarter note G4 take"
]
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "D7"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note B4 all",
"2+2 half note B4 of"
]
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note G4 me?",
"2+2 half note G4 "
]
},
{
"measure": 176,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
@@ -6488,11 +6117,11 @@
]
},
"autumn_leaves_acoustic.json": {
"title": "autumn_leaves",
"title": "Autumn Leaves",
"composer": "",
"key_signature": "D minor",
"part_type": "acoustic",
"measure_count": 176,
"measure_count": 168,
"section_labels": [
{
"measure": 1,
@@ -6517,10 +6146,6 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -8457,114 +8082,15 @@
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm7"
},
{
"onset": "2",
"symbol": "G7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm7"
},
{
"onset": "2",
"symbol": "F7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 176,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
}
]
},
"autumn_leaves_electric.json": {
"title": "autumn_leaves",
"title": "Autumn Leaves",
"composer": "",
"key_signature": "D minor",
"part_type": "electric",
"measure_count": 176,
"measure_count": 168,
"section_labels": [
{
"measure": 1,
@@ -8589,10 +8115,6 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -10529,114 +10051,15 @@
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm7"
},
{
"onset": "2",
"symbol": "G7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm7"
},
{
"onset": "2",
"symbol": "F7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 176,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
}
]
},
"autumn_leaves_vocal.json": {
"title": "autumn_leaves",
"title": "Autumn Leaves",
"composer": "",
"key_signature": "D minor",
"part_type": "vocal",
"measure_count": 176,
"measure_count": 168,
"section_labels": [
{
"measure": 1,
@@ -10661,10 +10084,6 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -13209,138 +12628,6 @@
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+1 quarter note Bb4 miss",
"1+1 quarter note Bb4 you",
"2+1 quarter note G4 most",
"3+1 quarter note G4 of"
]
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+3 half. note E4 all,",
"3+1 quarter note Bb4 my"
]
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm7"
},
{
"onset": "2",
"symbol": "G7"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note A4 dar-",
"2+2 half note A4 ling,"
]
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm7"
},
{
"onset": "2",
"symbol": "F7"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note A4 ",
"2+2 half note D4 when"
]
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+3 half. note G4 au-",
"3+1 quarter note F4 tumn"
]
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note E4 leaves",
"2+1 quarter note F4 start",
"3+1 quarter note A3 to"
]
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole note D4 fall."
]
},
{
"measure": 176,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
}
]
}
+42 -32
View File
@@ -2,39 +2,49 @@
Learned decoding and authored-repertoire tests separately use the real path.
"""
from fractions import Fraction as F
from pathlib import Path
from unittest.mock import patch
from generator import production,vocabulary,harmonic_time
from generator.solo import types,placement,profiles,decoder,learned,harmonic
import fractions
import pathlib
import unittest.mock
import generator.production
import generator.vocabulary
import generator.harmonic_time
import generator.solo.types
import generator.solo.placement
import generator.solo.profiles
import generator.solo.decoder
import generator.solo.learned
import generator.solo.harmonic
import dataclasses
import generator.solo.expressions
import generator.harmony
def compose(analysis,measures,part,end,*,initial=None,start=F(),**kwargs):
import dataclasses
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
h=harmonic.Harmony(spans);notes=[]
def compose(analysis, measures, part, end, *, initial=None, start=fractions.Fraction(), **kwargs):
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
h = generator.solo.harmonic.Harmony(spans)
notes = []
for s in h.spans:
a,b=max(start,s.start),min(end,s.end)
if a>=b or s.chord=='N.C.':continue
number=next(n for n in range(55,72) if n%12 in s.roles.basic)
from generator.solo import expressions
chord=expressions.parsed_chord(s.chord)
from generator import harmony
pitch=next(expressions.degree_pitch(chord,d,x,number) for d,x in harmony.degrees(chord).items() if expressions.degree_pitch(chord,d,x,number).number()==number)
notes.append(types.Note(a,b,pitch,event_id=str(a)))
placed=placement.place(tuple(notes),analysis.tempo)[1] if notes else ()
return types.Solo(part,spans,(types.Phrase('fixture',start,end,placed,'test','test','handoff'),),analysis.tempo,end,profile=profiles.for_part(part),start=start)
a, b = max(start, s.start), min(end, s.end)
if a >= b or s.chord == 'N.C.':
continue
number = next(n for n in range(55, 72) if n % 12 in s.roles.basic)
chord = generator.solo.expressions.parsed_chord(s.chord)
pitch = next(generator.solo.expressions.degree_pitch(chord, d, x, number) for d, x in generator.harmony.degrees(chord).items() if generator.solo.expressions.degree_pitch(chord, d, x, number).number() == number)
notes.append(generator.solo.types.Note(a, b, pitch, event_id=str(a)))
placed = generator.solo.placement.place(tuple(notes), analysis.tempo)[1] if notes else ()
return generator.solo.types.Solo(part, spans, (generator.solo.types.Phrase('fixture', start, end, placed, 'test', 'test', 'handoff'),), analysis.tempo, end, profile=generator.solo.profiles.for_part(part), start=start)
def install(test):
resources=vocabulary.load();predictor=learned.Model.load(Path(__file__).resolve().parents[1]/'data/models/solo.json')
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.repertoire.lookup',return_value=None))
test.enterContext(patch('generator.production._solo_model',return_value=predictor))
test.enterContext(patch('generator.production._resource_key',return_value={'model':'synthetic infrastructure fixture'}))
test.enterContext(patch('generator.solo.composer.compose',side_effect=compose))
resources = generator.vocabulary.load()
predictor = generator.solo.learned.Model.load(pathlib.Path(__file__).resolve().parents[1] / 'data/models/solo.json')
tree = generator.production.generate_tree
def serial(*args, **kwargs):
kwargs['jobs'] = 1
return tree(*args, **kwargs)
test.enterContext(unittest.mock.patch('generator.production.generate_tree', side_effect=serial))
test.enterContext(unittest.mock.patch('generator.vocabulary.load', return_value=resources))
test.enterContext(unittest.mock.patch('generator.solo.authored.lookup', return_value=None))
test.enterContext(unittest.mock.patch('generator.production._solo_model', return_value=predictor))
test.enterContext(unittest.mock.patch('generator.production._resource_key', return_value={'model': 'synthetic infrastructure fixture'}))
test.enterContext(unittest.mock.patch('generator.solo.composer.compose', side_effect=compose))
+38
View File
@@ -0,0 +1,38 @@
"""Exact notated attacks used to compare arrangements; no audio rendering."""
import fractions
import generator.model
import generator.pitch
def timeline(document):
events, linked = [], {}
absolute = fractions.Fraction()
for measure in document['staves'][0]['measures']:
for voice in measure['voices']:
state = {key: measure[key] for key in ('transpose', 'clef', 'staff_details')}
changes = iter(sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset'])))
change = next(changes, None)
for note in voice['notes']:
onset = generator.model.fraction(note['onset'])
while change is not None and generator.model.fraction(change['onset']) <= onset:
state.update({key: change[key] for key in state if key in change})
change = next(changes, None)
if note.get('cue') or note.get('grace'):
continue
for pitch in note['pitches']:
if pitch.get('cue'):
continue
length = generator.model.fraction(pitch['duration'])
value = generator.pitch.sounding_pitch(pitch, state)
if pitch.get('tie_from'):
previous = linked[pitch['tie_from']]
assert generator.model.fraction(previous['onset']) + generator.model.fraction(previous['duration']) == absolute + onset
assert generator.model.fraction(previous['pitch']) == value
previous['duration'] = generator.model.rational(generator.model.fraction(previous['duration']) + length)
linked[pitch['id']] = previous
else:
event = dict(id=pitch['id'], pitch=generator.model.rational(value), onset=generator.model.rational(absolute + onset), duration=generator.model.rational(length))
events.append(event)
linked[pitch['id']] = event
absolute += generator.model.fraction(measure['duration'])
events.sort(key=lambda e: (generator.model.fraction(e['onset']), e['id']))
return dict(duration=generator.model.rational(absolute), events=events)
+172 -255
View File
@@ -1,308 +1,225 @@
"""Read optional reference MusicXML literally, without repairing it or composing."""
from __future__ import annotations
import argparse,array,collections,html,os,sys,wave
import argparse, collections, html, os
import hashlib
import json
from pathlib import Path
from fractions import Fraction as F
import xml.etree.ElementTree as ET
from generator.training_io import read
from generator import fretboard
from generator.solo import placement,types
import pathlib
import fractions
import xml.etree.ElementTree
import generator.training_io
import generator.fretboard
import generator.solo.placement
import generator.solo.types
def events(review):
offset = F(review['alignment']['encoded_offset'] or 0)
return [dict(start=str(F(n['start'])-offset), end=str(F(n['end'])-offset),
offset = fractions.Fraction(review['alignment']['encoded_offset'] or 0)
return [dict(start=str(fractions.Fraction(n['start']) - offset), end=str(fractions.Fraction(n['end']) - offset),
pitch=n['sounding_midi'], spelling=n['pitch'], measure=n['measure']) for n in review['sounding_events']]
def rests(review):
offset = F(review['alignment']['encoded_offset'] or 0)
offset = fractions.Fraction(review['alignment']['encoded_offset'] or 0)
cursor = -offset
result = []
for n in sorted(events(review), key=lambda n:F(n['start'])):
start,end = F(n['start']),F(n['end'])
if start > cursor: result.append((str(cursor),str(start)))
cursor = max(cursor,end)
finish = max((F(m['start'])+F(m['duration']) for m in review['measures']),default=F())-offset
if cursor < finish: result.append((str(cursor),str(finish)))
for n in sorted(events(review), key=lambda n: fractions.Fraction(n['start'])):
start, end = fractions.Fraction(n['start']), fractions.Fraction(n['end'])
if start > cursor:
result.append((str(cursor), str(start)))
cursor = max(cursor, end)
finish = max((fractions.Fraction(m['start']) + fractions.Fraction(m['duration']) for m in review['measures']), default=fractions.Fraction()) - offset
if cursor < finish:
result.append((str(cursor), str(finish)))
return result
def playability(review, tempo=F(120)):
notes=[];conflicts=[]
for i,n in enumerate(review['sounding_events']):
p=n['pitch'];shift=F(n['sounding_midi'])-F(n['encoded_midi'])
if shift%12:
raise ValueError('Guitar audition expects octave-only transposition')
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+int(shift/12))
note=types.Note(F(n['start']),F(n['end']),pitch,event_id=str(i))
def playability(review, tempo=fractions.Fraction(120)):
notes = []
conflicts = []
for i, n in enumerate(review['sounding_events']):
p = n['pitch']
shift = fractions.Fraction(n['sounding_midi']) - fractions.Fraction(n['encoded_midi'])
if shift % 12:
raise ValueError('Guitar comparison expects octave-only transposition')
pitch = generator.solo.types.Pitch(p['step'].lower(), fractions.Fraction(p['alter']), p['octave'] + int(shift / 12))
note = generator.solo.types.Note(fractions.Fraction(n['start']), fractions.Fraction(n['end']), pitch, event_id=str(i))
notes.append(note)
if not fretboard.single_positions_for_pitch(F(pitch.number())):
conflicts.append({'measure':n['measure'],'written':p,'sounding_midi':pitch.number(),
'constraint':'No standard-tuning position on strings 1–6, frets 1–12, without open strings.'})
overlaps=[{'first':a.event_id,'second':b.event_id} for a,b in zip(notes,notes[1:]) if a.end>b.start]
placed=placement.place(tuple(notes),tempo) if notes and not conflicts and not overlaps else None
return {'conflicts':conflicts,'overlaps':overlaps,'complete_path_found':placed is not None,
'range':[min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
'positions':[list(n.position[:2]) for n in placed[1]] if placed else [],
'limitation':'Legal placement only. Shift cost is a preference, not a verified human speed limit.'}
if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())):
conflicts.append({'measure': n['measure'], 'written': p, 'sounding_midi': pitch.number(),
'constraint': 'No standard-tuning position on strings 1–6, frets 1–12, without open strings.'})
overlaps = [{'first': a.event_id, 'second': b.event_id} for a, b in zip(notes, notes[1:]) if a.end > b.start]
placed = generator.solo.placement.place(tuple(notes), tempo) if notes and not conflicts and not overlaps else None
return {'conflicts': conflicts, 'overlaps': overlaps, 'complete_path_found': placed is not None,
'range': [min(n.pitch.number() for n in notes), max(n.pitch.number() for n in notes)] if notes else [],
'positions': [list(n.position[:2]) for n in placed[1]] if placed else [],
'limitation': 'Legal placement only. Shift cost is a preference, not a verified human speed limit.'}
def musical_summary(review):
ns=events(review)
durations=collections.Counter(str(F(n['end'])-F(n['start'])) for n in ns)
intervals=collections.Counter(str(F(b['pitch'])-F(a['pitch'])) for a,b in zip(ns,ns[1:]))
holds=[]
for event,n in zip(review['sounding_events'],ns):
if F(n['end'])-F(n['start'])<2:continue
spans=[s for i in event['notation_events'] for s in review['notes'][i]['harmonic_spans']]
holds.append({**n,'harmonic_spans':spans})
return {'attacks':len(ns),'duration_counts':dict(durations),'interval_counts':dict(intervals),
'rests':rests(review),'sustained_events':holds,
'annotations':review['annotations'],'interpretation':'Post-generation diagnostics; does not train or reselect music.'}
ns = events(review)
durations = collections.Counter(str(fractions.Fraction(n['end']) - fractions.Fraction(n['start'])) for n in ns)
intervals = collections.Counter(str(fractions.Fraction(b['pitch']) - fractions.Fraction(a['pitch'])) for a, b in zip(ns, ns[1:]))
holds = []
for event, n in zip(review['sounding_events'], ns):
if fractions.Fraction(n['end']) - fractions.Fraction(n['start']) < 2:
continue
spans = [s for i in event['notation_events'] for s in review['notes'][i]['harmonic_spans']]
holds.append({**n, 'harmonic_spans': spans})
return {'attacks': len(ns), 'duration_counts': dict(durations), 'interval_counts': dict(intervals),
'rests': rests(review), 'sustained_events': holds,
'annotations': review['annotations'], 'interpretation': 'Post-generation diagnostics; does not train or reselect music.'}
def compare(generated, reference):
a, b = events(generated), events(reference)
def compare(generated,reference):
a,b=events(generated),events(reference)
def grouped(ns):
result=collections.defaultdict(list)
for n in ns:result[n['start']].append(n)
result = collections.defaultdict(list)
for n in ns:
result[n['start']].append(n)
return result
left,right=grouped(a),grouped(b)
differences=[];spelling=[];equal=0
for start in sorted(left.keys()|right.keys(),key=F):
x,y=left[start],right[start]
sounded=lambda ns:sorted((n['pitch'],n['end']) for n in ns)
if sounded(x)!=sounded(y):
differences.append({'time':start,'generated':x,'reference':y,
'context':'reference lead-in before form' if F(start)<0 else 'aligned form'})
left, right = grouped(a), grouped(b)
differences = []
spelling = []
equal = 0
for start in sorted(left.keys() | right.keys(), key=fractions.Fraction):
x, y = left[start], right[start]
def sounded(ns):
return sorted((n['pitch'], n['end']) for n in ns)
if sounded(x) != sounded(y):
differences.append({'time': start, 'generated': x, 'reference': y,
'context': 'reference lead-in before form' if fractions.Fraction(start) < 0 else 'aligned form'})
else:
equal+=len(x)
if [n['spelling'] for n in x]!=[n['spelling'] for n in y]:
spelling.append({'time':start,'generated':x,'reference':y})
rest_difference=rests(generated)!=rests(reference)
return {'exact_musical_match':not differences and not rest_difference,'exact_matching_attacks':equal,
'relationships':relationships(a,b),
'differences':differences,'spelling_differences':spelling,'rest_difference':rest_difference,
'generated':musical_summary(generated),'reference':musical_summary(reference),
'reference_playability':playability(reference),'reference_original':reference}
equal += len(x)
if [n['spelling'] for n in x] != [n['spelling'] for n in y]:
spelling.append({'time': start, 'generated': x, 'reference': y})
rest_difference = rests(generated) != rests(reference)
return {'exact_musical_match': not differences and not rest_difference, 'exact_matching_attacks': equal,
'relationships': relationships(a, b),
'differences': differences, 'spelling_differences': spelling, 'rest_difference': rest_difference,
'generated': musical_summary(generated), 'reference': musical_summary(reference),
'reference_playability': playability(reference), 'reference_original': reference}
def relationships(a,b):
def relationships(a, b):
"""Read-only comparisons on the aligned clock; no fitted aggregate loss."""
def distance(left,right):
row=list(range(len(right)+1))
for i,x in enumerate(left,1):
next_row=[i]
for j,y in enumerate(right,1):
next_row.append(min(next_row[-1]+1,row[j]+1,row[j-1]+(x!=y)))
row=next_row
def distance(left, right):
row = list(range(len(right) + 1))
for i, x in enumerate(left, 1):
next_row = [i]
for j, y in enumerate(right, 1):
next_row.append(min(next_row[-1] + 1, row[j] + 1, row[j - 1] + (x != y)))
row = next_row
return row[-1]
ns=[[dict(start=F(n['start']),end=F(n['end']),pitch=F(n['pitch'])) for n in notes] for notes in (a,b)]
points=sorted({n[k] for notes in ns for n in notes for k in ('start','end')})
occupied=equal=classes=F()
for start,end in zip(points,points[1:]):
pitches=[{n['pitch'] for n in notes if n['start']<=start<n['end']} for notes in ns]
ns = [[dict(start=fractions.Fraction(n['start']), end=fractions.Fraction(n['end']), pitch=fractions.Fraction(n['pitch'])) for n in notes] for notes in (a, b)]
points = sorted({n[k] for notes in ns for n in notes for k in ('start', 'end')})
occupied = equal = classes = fractions.Fraction()
for start, end in zip(points, points[1:]):
pitches = [{n['pitch'] for n in notes if n['start'] <= start < n['end']} for notes in ns]
if any(pitches):
occupied+=end-start
if pitches[0]==pitches[1]:equal+=end-start
if {p%12 for p in pitches[0]}=={p%12 for p in pitches[1]}:classes+=end-start
pitches=[[n['pitch'] for n in notes] for notes in ns]
rhythms=[[(n['start'],n['end']-n['start']) for n in notes] for notes in ns]
return {'pitch_sequence_edit_distance':distance(*pitches),
'timed_attack_edit_distance':distance(*rhythms),
'normalization_attacks':max(len(a),len(b)),
'same_sounding_pitch_beats':str(equal),'same_pitch_class_beats':str(classes),
'either_sounding_beats':str(occupied),
'interpretation':'Separate descriptive comparisons, not a quality score. Pitch distance ignores timing; timing distance ignores pitch. Held overlap uses the original aligned register and includes lead-ins.'}
occupied += end - start
if pitches[0] == pitches[1]:
equal += end - start
if {p % 12 for p in pitches[0]} == {p % 12 for p in pitches[1]}:
classes += end - start
pitches = [[n['pitch'] for n in notes] for notes in ns]
rhythms = [[(n['start'], n['end'] - n['start']) for n in notes] for notes in ns]
return {'pitch_sequence_edit_distance': distance(*pitches),
'timed_attack_edit_distance': distance(*rhythms),
'normalization_attacks': max(len(a), len(b)),
'same_sounding_pitch_beats': str(equal), 'same_pitch_class_beats': str(classes),
'either_sounding_beats': str(occupied),
'interpretation': 'Separate descriptive comparisons, not a quality score. Pitch distance ignores timing; timing distance ignores pitch. Held overlap uses the original aligned register and includes lead-ins.'}
def render_events(notes, duration, tempo, root, cache):
"""Use the production neutral note envelopes; no expression edits."""
from generator import playback
factor=F(60*playback.SAMPLE_RATE)/tempo
output=array.array('f',[0])* (round(duration*factor)+2400)
for start,end,pitch in notes:
if pitch not in cache:
sample=playback.generate_tone(root,pitch)
with wave.open(str(root/sample['path'])) as audio:
values=array.array('h',audio.readframes(audio.getnframes()))
if sys.byteorder!='little':values.byteswap()
cache[pitch]=(sample,values)
sample,values=cache[pitch]
first,last=round(start*factor),round(end*factor)
for frame in range(max(0,first),min(len(output),last+2400)):
age=frame-first
index=age if age<sample['loop_end'] else sample['loop_start']+(age-sample['loop_start'])%(sample['loop_end']-sample['loop_start'])
release=1 if frame<last else (1-(frame-last)/2399)**2
output[frame]+=release*values[index]/32767
return output
def audition_bounds(*reviews):
points=[(F(m['start'])-F(r['alignment']['encoded_offset'] or 0),F(m['duration']))
for r in reviews for m in r['measures']]
return min(a for a,b in points),max(a+b for a,b in points)
def audition_pair(actual,expected,role,assets,output,cache,baseline=None):
from generator import model, playback, score
from generator.solo.export import excerpt,region
song,part=role.split('/')
other='electric' if part=='acoustic' else 'acoustic'
completed=score.load_json(Path(assets)/'songs'/f'{song}_{part}.json')
first,count=region(completed,part)
leadin=actual['alignment']['form_start_measure']-1
backing=excerpt(score.load_json(Path(assets)/'songs'/f'{song}_{other}.json'),first-leadin,count+leadin)
backing_offset=F(actual['alignment']['encoded_offset'])
tempo=next((model.fraction(t['bpm'])*model.written_length(t['written']) for m in completed['staves'][0]['measures'] for t in m.get('tempos',[]) if 'bpm' in t),F(120))
# Include the entire notated lead-in, even when it is a full bar of rest.
# Using the first attack here would silently erase a silent initial measure.
origin,end=audition_bounds(actual,expected)
duration=end-origin
timeline=playback.compile_timeline(backing)
back=render_events([(model.fraction(e['onset'])-backing_offset-origin,model.fraction(e['onset'])+model.fraction(e['duration'])-backing_offset-origin,model.fraction(e['pitch'])) for e in timeline['events']],duration,tempo,output,cache)
(output/'audio').mkdir(parents=True,exist_ok=True)
clips=[]
for label,r in (('generated',actual),('reference',expected),*((('baseline',baseline),) if baseline else ())):
audio=render_events([(F(n['start'])-origin,F(n['end'])-origin,F(n['pitch'])) for n in events(r)],duration,tempo,output,cache)
combined=array.array('f',(a+.55*b for a,b in zip(audio,back)))
for context,values in (('solo',audio),('fixed_backing',combined)):
peak=.65*max(map(abs,values),default=0)
if peak>=1:raise ValueError('Comparison would clip at the shared rendering gain')
name='audio/'+role.replace('/','_')+'_'+label+'_'+context+'.wav'
playback.write_pcm(output/name,(.65*v for v in values))
clips.append({'file':name,'version':label,'context':context,'peak':peak})
if context=='fixed_backing':
# An excerpt uses the completed source meter, never reference-derived
# rhythm statistics. Full references and their lead-ins remain above.
beats=sum((model.fraction(m['duration']) for m in backing['staves'][0]['measures'][leadin:leadin+8]),F())
stop=round((min(end,beats)-origin)*60*playback.SAMPLE_RATE/tempo)
short=name.replace('.wav','_opening.wav')
playback.write_pcm(output/short,(.65*v for v in values[:stop]))
clips.append({'file':short,'version':label,'context':'opening with lead-in context','peak':peak})
return {'clips':clips,'tempo':str(tempo),'gain':.65,'backing_gain':.55,'form_begins_at_beat':str(-origin),
'backing':'Exact independently generated other-guitar part, including the production lead-in. Fixed unchanged for all three comparison versions.',
'alignment':'Shared audition clock includes reference-only lead-in; no harmony or production notes were added.',
'listening_status':'Rendered and signal-checked, not subjectively auditioned.'}
def candidate_clips(candidates,role,tempo,output,cache):
"""Render the already completed source-only alternatives, never reselect."""
from generator import playback
clips=[]
for index,candidate in enumerate(candidates,1):
origin,end=F(candidate['start']),F(candidate['end'])
audio=render_events([(F(a)-origin,F(b)-origin,F(p)) for a,b,p in candidate['events']],end-origin,tempo,output,cache)
peak=.65*max(map(abs,audio),default=0)
if peak>=1:raise ValueError('Candidate excerpt would clip at comparison gain')
name='audio/'+role.replace('/','_')+f'_candidate_{index}.wav'
playback.write_pcm(output/name,(.65*v for v in audio))
selected=candidate['selected_in_final_solo']
clips.append({'file':name,'version':f'Completed opening {index}'+(' — selected in final solo' if selected else ' — alternative'),
'context':'source-only candidate, solo','peak':peak})
return clips
def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None,baseline_dir=None):
generated_dir,output=Path(generated_dir),Path(output)
manifest=json.loads((generated_dir/'manifest.solo').read_text())
output.mkdir(parents=True,exist_ok=True)
tone_cache={}
baseline_manifest=json.loads((Path(baseline_dir)/'manifest.solo').read_text()) if baseline_dir else None
result={'comparison_only':True,'feeds_generation':False,'solos':{}}
for role,path in sorted(reference_paths.items()):
item=manifest['solos'][role]
actual=read(generated_dir/item['file'],role,item['form_start_measure'])
expected=read(path,role,starts.get(role),pitch_convention)
review=compare(actual,expected)
baseline=None
def run(generated_dir, reference_paths, starts, pitch_convention, output, baseline_dir=None):
generated_dir, output = pathlib.Path(generated_dir), pathlib.Path(output)
manifest = json.loads((generated_dir / 'manifest.solo').read_text())
output.mkdir(parents=True, exist_ok=True)
baseline_manifest = json.loads((pathlib.Path(baseline_dir) / 'manifest.solo').read_text()) if baseline_dir else None
result = {'comparison_only': True, 'feeds_generation': False, 'solos': {}}
for role, path in sorted(reference_paths.items()):
item = manifest['solos'][role]
actual = generator.training_io.read(generated_dir / item['file'], role, item['form_start_measure'])
expected = generator.training_io.read(path, role, starts.get(role), pitch_convention)
review = compare(actual, expected)
baseline = None
if baseline_manifest:
old=baseline_manifest['solos'][role]
baseline=read(Path(baseline_dir)/old['file'],role,old['form_start_measure'])
review['baseline']=musical_summary(baseline)
review['baseline_relationships']=relationships(events(baseline),events(expected))
if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache,baseline)
review['production_trace']=item['trace']
review['production_phrases']=item.get('phrase_report',{})
candidates=review['production_phrases'].get('selection_trace',{}).get('opening_candidates',[])
if assets and candidates:
review['audition']['clips']+=candidate_clips(candidates,role,F(review['audition']['tempo']),output,tone_cache)
old = baseline_manifest['solos'][role]
baseline = generator.training_io.read(pathlib.Path(baseline_dir) / old['file'], role, old['form_start_measure'])
review['baseline'] = musical_summary(baseline)
review['baseline_relationships'] = relationships(events(baseline), events(expected))
review['production_trace'] = item['trace']
review['production_phrases'] = item.get('phrase_report', {})
candidates = review['production_phrases'].get('selection_trace', {}).get('opening_candidates', [])
# Preserve the real role cutoff; reference material beyond it is not trimmed.
phrases=review['production_phrases'].get('phrases',[])
phrases = review['production_phrases'].get('phrases', [])
if phrases:
cutoff=max(F(p['end']) for p in phrases)
review['production_end']=str(cutoff)
review['reference_events_after_production_end']=[n for n in events(expected) if F(n['end'])>cutoff]
result['solos'][role]=review
output.mkdir(parents=True,exist_ok=True)
(output/'comparison.json').write_text(json.dumps(result,indent=2)+'\n')
rows=['<!doctype html><meta charset="utf-8"><title>Solo comparison</title>',
cutoff = max(fractions.Fraction(p['end']) for p in phrases)
review['production_end'] = str(cutoff)
review['reference_events_after_production_end'] = [n for n in events(expected) if fractions.Fraction(n['end']) > cutoff]
result['solos'][role] = review
output.mkdir(parents=True, exist_ok=True)
(output / 'comparison.json').write_text(json.dumps(result, indent=2) + '\n')
rows = ['<!doctype html><meta charset="utf-8"><title>Solo comparison</title>',
'<style>body{font:16px system-ui;max-width:1100px;margin:2rem auto}td,th{padding:.5rem;text-align:left}pre{white-space:pre-wrap}</style>',
'<h1>Completed solos and reference scores</h1><p>Differences are diagnostic. This comparison is read-only; registered authored scores are explicit repertoire inputs, and pooled examples supply training evidence. No listening verdict is claimed.</p>',
'<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a></p>']
for role,r in result['solos'].items():
actual_path=generated_dir/manifest['solos'][role]['file']
reference_path=Path(reference_paths[role])
rows += ['<h2>'+html.escape(role)+'</h2>',f'<p>Exact event match: <strong>{"yes" if r["exact_musical_match"] else "no"}</strong>. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
'<p><a href="'+html.escape(os.path.relpath(actual_path,output))+'">Generated MusicXML</a> · <a href="'+html.escape(os.path.relpath(reference_path,output))+'">Untouched reference MusicXML</a></p>']
for role, r in result['solos'].items():
actual_path = generated_dir / manifest['solos'][role]['file']
reference_path = pathlib.Path(reference_paths[role])
rows += ['<h2>' + html.escape(role) + '</h2>', f'<p>Exact event match: <strong>{"yes" if r["exact_musical_match"] else "no"}</strong>. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
'<p><a href="' + html.escape(os.path.relpath(actual_path, output)) + '">Generated MusicXML</a> · <a href="' + html.escape(os.path.relpath(reference_path, output)) + '">Untouched reference MusicXML</a></p>']
if baseline_manifest:
baseline_path=Path(baseline_dir)/baseline_manifest['solos'][role]['file']
rows.append('<p><a href="'+html.escape(os.path.relpath(baseline_path,output))+'">Previous generated MusicXML</a></p>')
baseline_path = pathlib.Path(baseline_dir) / baseline_manifest['solos'][role]['file']
rows.append('<p><a href="' + html.escape(os.path.relpath(baseline_path, output)) + '">Previous generated MusicXML</a></p>')
rows.append('<table><tr><th>Diagnostic against reference</th><th>Current</th><th>Baseline</th></tr>')
for key in ('pitch_sequence_edit_distance','timed_attack_edit_distance','same_sounding_pitch_beats','same_pitch_class_beats','either_sounding_beats'):
rows.append('<tr><td>'+key.replace('_',' ')+'</td><td>'+str(r['relationships'][key])+'</td><td>'+str(r.get('baseline_relationships',{}).get(key,'—'))+'</td></tr>')
for key in ('pitch_sequence_edit_distance', 'timed_attack_edit_distance', 'same_sounding_pitch_beats', 'same_pitch_class_beats', 'either_sounding_beats'):
rows.append('<tr><td>' + key.replace('_', ' ') + '</td><td>' + str(r['relationships'][key]) + '</td><td>' + str(r.get('baseline_relationships', {}).get(key, '—')) + '</td></tr>')
rows.append('</table><p>Lower edit distance means fewer insertions, deletions or substitutions on that axis. Sounding agreement is measured in quarter-note beats, without octave shifting or fitted alignment. These do not measure musical quality.</p>')
selected=collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions',[]))
rows.append('<p>Validated production treatments: '+html.escape(str(dict(selected)))+'. Registered authored music is preserved; harmonic relationships are recognized from notes and chords without requiring explanatory annotations.</p>')
selected = collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions', []))
rows.append('<p>Validated production treatments: ' + html.escape(str(dict(selected))) + '. Registered authored music is preserved; harmonic relationships are recognized from notes and chords without requiring explanatory annotations.</p>')
if r.get('reference_events_after_production_end'):
rows.append('<p>Reference material extends beyond this exported production allocation: '+str(len(r['reference_events_after_production_end']))+' events. The reference and source timing remain unchanged.</p>')
funnel=r['production_phrases'].get('selection_trace',{}).get('operation_coverage',{})
rows.append('<p>Reference material extends beyond this exported production allocation: ' + str(len(r['reference_events_after_production_end'])) + ' events. The reference and source timing remain unchanged.</p>')
funnel = r['production_phrases'].get('selection_trace', {}).get('operation_coverage', {})
if funnel:
stages=tuple(dict.fromkeys(k for counts in funnel.values() for k in counts))
rows.append('<details><summary>Construction coverage (not similarity scoring)</summary><table><tr><th>Purpose</th>'+''.join('<th>'+k.replace('_',' ')+'</th>' for k in stages)+'</tr>')
for purpose,counts in sorted(funnel.items()):
rows.append('<tr><td>'+html.escape(purpose)+'</td>'+''.join('<td>'+str(counts.get(k,0))+'</td>' for k in stages)+'</tr>')
stages = tuple(dict.fromkeys(k for counts in funnel.values() for k in counts))
rows.append('<details><summary>Construction coverage (not similarity scoring)</summary><table><tr><th>Purpose</th>' + ''.join('<th>' + k.replace('_', ' ') + '</th>' for k in stages) + '</tr>')
for purpose, counts in sorted(funnel.items()):
rows.append('<tr><td>' + html.escape(purpose) + '</td>' + ''.join('<td>' + str(counts.get(k, 0)) + '</td>' for k in stages) + '</tr>')
rows.append('</table></details>')
candidates=r['production_phrases'].get('selection_trace',{}).get('opening_candidates',[])
candidates = r['production_phrases'].get('selection_trace', {}).get('opening_candidates', [])
if candidates:
rows.append('<details open><summary>Completed source-only opening candidates</summary><p>All candidates were composed without the references. Selection accounts for later phrases; the final choice need not have the cheapest opening alone.</p><table><tr><th>Candidate</th><th>Final choice</th><th>Purpose</th><th>Score contributions</th></tr>')
for index,c in enumerate(candidates,1):
rows.append('<tr>'+''.join('<td>'+html.escape(str(v))+'</td>' for v in (index,c['selected_in_final_solo'],c['purpose'],c['evaluation']['contributions']))+'</tr>')
for index, c in enumerate(candidates, 1):
rows.append('<tr>' + ''.join('<td>' + html.escape(str(v)) + '</td>' for v in (index, c['selected_in_final_solo'], c['purpose'], c['evaluation']['contributions'])) + '</tr>')
rows.append('</table></details>')
for clip in r.get('audition',{}).get('clips',[]):
rows.append('<p>'+html.escape(clip['version']+' / '+clip['context'])+'<br><audio controls preload="none" src="'+html.escape(clip['file'])+'"></audio></p>')
rows.append('<details><summary>Exact note differences</summary><table><tr><th>Beat relative to form</th><th>Generated</th><th>Reference</th></tr>')
for d in r['differences']:
def describe(ns):return '; '.join(f'MIDI {n["pitch"]}, until {n["end"]}' for n in ns) or 'No attack'
rows.append('<tr>'+''.join('<td>'+html.escape(v)+'</td>' for v in (d['time'],describe(d['generated']),describe(d['reference'])))+'</tr>')
def describe(ns): return '; '.join(f'MIDI {n["pitch"]}, until {n["end"]}' for n in ns) or 'No attack'
rows.append('<tr>' + ''.join('<td>' + html.escape(v) + '</td>' for v in (d['time'], describe(d['generated']), describe(d['reference']))) + '</tr>')
rows.append('</table></details>')
(output/'index.html').write_text('\n'.join(rows))
(output / 'index.html').write_text('\n'.join(rows))
return result
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--generated-dir',type=Path,required=True)
parser.add_argument('--reference',action='append',default=[],metavar='SONG/ROLE=PATH')
parser.add_argument('--form-start',action='append',default=[],metavar='SONG/ROLE=MEASURE')
parser.add_argument('--reference-pitch',choices=('encoded','guitar-written'),default='encoded')
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--baseline-dir',type=Path,help='Optional previous completed solo exports; never generation inputs.')
parser.add_argument('--assets',type=Path,help='Optional completed assets for neutral reference/generated audio with identical backing.')
parser.add_argument('--require-exact',action='store_true',help='Fail unless every requested reference has identical complete sounding events and rests.')
args=parser.parse_args()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--generated-dir', type=pathlib.Path, required=True)
parser.add_argument('--reference', action='append', default=[], metavar='SONG/ROLE=PATH')
parser.add_argument('--form-start', action='append', default=[], metavar='SONG/ROLE=MEASURE')
parser.add_argument('--reference-pitch', choices=('encoded', 'guitar-written'), default='encoded')
parser.add_argument('--output', type=pathlib.Path, required=True)
parser.add_argument('--baseline-dir', type=pathlib.Path, help='Optional previous completed solo exports; never generation inputs.')
parser.add_argument('--require-exact', action='store_true', help='Fail unless every requested reference has identical complete sounding events and rests.')
args = parser.parse_args()
if args.require_exact and not args.reference:
parser.error('--require-exact requires at least one explicit --reference')
result=run(args.generated_dir,dict(x.split('=',1) for x in args.reference),
{k:int(v) for k,v in (x.split('=',1) for x in args.form_start)},args.reference_pitch,args.output,args.assets,args.baseline_dir)
for role,review in result['solos'].items():
result = run(args.generated_dir, dict(x.split('=', 1) for x in args.reference),
{k: int(v) for k, v in (x.split('=', 1) for x in args.form_start)}, args.reference_pitch, args.output, args.baseline_dir)
for role, review in result['solos'].items():
print(f'{role}: exact={review["exact_musical_match"]}; matching attacks={review["exact_matching_attacks"]}; differing onsets={len(review["differences"])}; rests differ={review["rest_difference"]}')
return 1 if args.require_exact and any(not r['exact_musical_match'] for r in result['solos'].values()) else 0
if __name__=='__main__':raise SystemExit(main())
if __name__ == '__main__':
raise SystemExit(main())
+5 -5
View File
@@ -2,6 +2,7 @@ import copy, fractions
import generator.model, generator.parts, generator.harmony
import generator.solo.analysis, generator.solo.composer, generator.solo.types
import tests.test_musicxml
import generator.harmonic_time
def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
@@ -24,9 +25,8 @@ def compose(document, part='acoustic', cutoff=None):
end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff
return generator.solo.composer.compose(analysis, measures, part, end)
def note(start,end,letter='g',alter=0,octave=4,**kwargs):
return generator.solo.types.Note(fractions.Fraction(start),fractions.Fraction(end),generator.solo.types.Pitch(letter,fractions.Fraction(alter),octave),**kwargs)
def note(start, end, letter='g', alter=0, octave=4, **kwargs):
return generator.solo.types.Note(fractions.Fraction(start), fractions.Fraction(end), generator.solo.types.Pitch(letter, fractions.Fraction(alter), octave), **kwargs)
def choices(symbols,duration=4):
from generator import harmonic_time
return tuple(harmonic_time.ChordSpan(fractions.Fraction(i*duration),fractions.Fraction((i+1)*duration),i+1,str(i+1),fractions.Fraction(1),generator.model.key_from_fifths(-1),generator.harmony.parse_symbol(symbol)) for i,symbol in enumerate(symbols))
def choices(symbols, duration=4):
return tuple(generator.harmonic_time.ChordSpan(fractions.Fraction(i * duration), fractions.Fraction((i + 1) * duration), i + 1, str(i + 1), fractions.Fraction(1), generator.model.key_from_fifths(-1), generator.harmony.parse_symbol(symbol)) for i, symbol in enumerate(symbols))
+19 -30
View File
@@ -1,4 +1,5 @@
#include <test.hpp>
#include "catalog_fixture.hpp"
#include <algorithm>
#include <chrono>
@@ -59,6 +60,7 @@ auto score_asset_json(std::string title, std::string key = "C", std::string part
metadata["title"] = std::move(title);
metadata["key_signature"] = std::move(key);
value.at("music").at("staves").as_array().front().as_object()["name"] = std::move(part);
vamp_test::add_timing(value);
return boost::json::serialize(value);
}
@@ -108,35 +110,24 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json"));
BOOST_REQUIRE(defaults);
BOOST_TEST(defaults->audio.enabled);
BOOST_TEST(!defaults->audio.playback_enabled);
BOOST_TEST(!defaults->audio.backing_enabled);
BOOST_TEST(!defaults->audio.metronome_enabled);
BOOST_TEST(defaults->audio.metronome_bpm == 120U);
BOOST_TEST(defaults->audio.metronome_min_bpm == 40U);
BOOST_TEST(defaults->audio.metronome_max_bpm == 240U);
BOOST_TEST(defaults->audio.metronome_step == 5U);
BOOST_TEST(defaults->audio.tempo_bpm == 120U);
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
"audio": {
"enabled": false,
"playback_enabled": true,
"backing_enabled": true,
"metronome_enabled": true,
"metronome_bpm": 90,
"metronome_min_bpm": 50,
"metronome_max_bpm": 180,
"metronome_step": 10,
"beats_per_bar": 3,
"tempo_bpm": 90,
"master_volume": 0.5,
"metronome_volume": 0.25
}
})json"));
BOOST_REQUIRE(parsed);
BOOST_TEST(!parsed->audio.enabled);
BOOST_TEST(parsed->audio.playback_enabled);
BOOST_TEST(parsed->audio.backing_enabled);
BOOST_TEST(parsed->audio.metronome_enabled);
BOOST_TEST(parsed->audio.metronome_bpm == 90U);
BOOST_TEST(parsed->audio.metronome_min_bpm == 50U);
BOOST_TEST(parsed->audio.metronome_max_bpm == 180U);
BOOST_TEST(parsed->audio.metronome_step == 10U);
BOOST_TEST(parsed->audio.beats_per_bar == 3U);
BOOST_TEST(parsed->audio.tempo_bpm == 90U);
BOOST_TEST(parsed->audio.master_volume == 0.5F);
BOOST_TEST(parsed->audio.metronome_volume == 0.25F);
}
@@ -144,9 +135,7 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) {
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
"audio": {
"metronome_bpm": 120,
"metronome_min_bpm": 160,
"metronome_max_bpm": 80
"tempo_bpm": 125
}
})json"));
BOOST_TEST(!parsed);
@@ -160,7 +149,7 @@ BOOST_AUTO_TEST_CASE(settings_menu_toggles_audio_settings_and_edits_tempo) {
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(state.selected_settings_index == 1U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_TEST(state.audio.playback_enabled);
BOOST_TEST(!state.audio.backing_enabled);
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::SETTINGS_MENU));
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.selected_settings_index == 2U);
@@ -172,17 +161,17 @@ BOOST_AUTO_TEST_CASE(settings_menu_toggles_audio_settings_and_edits_tempo) {
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_TEST(state.audio.tempo_editing);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.audio.metronome_bpm == 125U);
BOOST_TEST(state.audio.tempo_bpm == 120U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
BOOST_TEST(state.audio.metronome_bpm == 120U);
BOOST_TEST(state.audio.tempo_bpm == 110U);
for (auto index = std::int32_t{}; index < 40; ++index) {
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
}
BOOST_TEST(state.audio.metronome_bpm == 40U);
BOOST_TEST(state.audio.tempo_bpm == 60U);
for (auto index = std::int32_t{}; index < 80; ++index) {
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
}
BOOST_TEST(state.audio.metronome_bpm == 240U);
BOOST_TEST(state.audio.tempo_bpm == 120U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_TEST(!state.audio.tempo_editing);
}
@@ -191,17 +180,17 @@ BOOST_AUTO_TEST_CASE(remote_display_reports_settings_without_a_catalog_dump) {
auto state = navigation_state();
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
state.selected_settings_index = 3U;
state.audio.playback_enabled = true;
state.audio.backing_enabled = true;
state.audio.metronome_enabled = true;
state.audio.metronome_bpm = 135;
state.audio.tempo_bpm = 110;
state.audio.tempo_editing = true;
auto remote = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
BOOST_TEST(std::string{remote.focus} == "Tempo");
BOOST_TEST(std::string{remote.mode} == "settings_menu");
BOOST_TEST(remote.playback);
BOOST_TEST(remote.backing);
BOOST_TEST(remote.metronome);
BOOST_TEST(remote.bpm == 135U);
BOOST_TEST(remote.bpm == 110U);
BOOST_TEST(remote.editing);
}
@@ -279,7 +268,7 @@ BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display
for (auto index = std::uint32_t{}; index < 4U; ++index) {
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
}
BOOST_TEST(state.page_index == 2U);
BOOST_TEST(state.page_index == 0U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
BOOST_TEST(state.selected_part_index == selection);
+2 -2
View File
@@ -11,14 +11,14 @@ import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.types
import generator.model
import generator.playback
import tests.score_events
import tests.arrangement_fixtures
import tests.test_accompaniment
import tests.test_musicxml
import tests.test_parts
def sounding(document):
return sorted(tuple(generator.model.fraction(event[key]) for key in ('onset', 'duration', 'pitch')) for event in generator.playback.compile_timeline(document)['events'])
return sorted(tuple(generator.model.fraction(event[key]) for key in ('onset', 'duration', 'pitch')) for event in tests.score_events.timeline(document)['events'])
def acoustic_document(bars):
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * len(bars)))
+8 -8
View File
@@ -9,7 +9,7 @@ import generator.arrange.pickup
import generator.model
import generator.musicxml
import generator.parts
import generator.playback
import tests.score_events
import generator.score
import tests.test_musicxml
import tests.test_parts
@@ -31,10 +31,10 @@ class ArrangementPickupTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 176)
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 177)])
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137', '169'])
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 704)
self.assertEqual(len(measures), 168)
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 169)])
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137'])
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 672)
timelines.append([m['duration'] for m in measures])
if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'):
for boundary in ([8, 136] if part == 'vocal' else [40]):
@@ -49,7 +49,7 @@ class ArrangementPickupTests(unittest.TestCase):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
document = source(opening, implicit=False)
result = generator.arrange.generate.arrange_document(document, 'vocal')
self.assertEqual(len(result['staves'][0]['measures']), 14)
self.assertEqual(len(result['staves'][0]['measures']), 12)
self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0)
def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self):
document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>'))
@@ -59,7 +59,7 @@ class ArrangementPickupTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 7)
self.assertEqual(len(measures), 6)
for boundary in boundaries:
pickup = generator.score.measure_notes(measures[boundary - 1])[-1]
downbeat = generator.score.measure_notes(measures[boundary])[0]
@@ -67,7 +67,7 @@ class ArrangementPickupTests(unittest.TestCase):
self.assertEqual(pickup['pitches'][0]['tie_to'], downbeat['pitches'][0]['id'])
self.assertEqual(downbeat['pitches'][0]['tie_from'], pickup['pitches'][0]['id'])
self.assertTrue(any(generator.model.fraction(s['start']) == boundary * 4 - 1 and generator.model.fraction(s['end']) == boundary * 4 for s in result['staves'][0]['octave_shifts']))
self.assertTrue(generator.playback.compile_timeline(result)['events'])
self.assertTrue(tests.score_events.timeline(result)['events'])
def test_pickup_truncates_outgoing_music_without_discarding_earlier_notes(self):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note('D', 3, 'half', extra='<dot/><lyric><text>doo</text></lyric>')
document = source(opening)
+1 -1
View File
@@ -45,7 +45,7 @@ class CatalogTests(unittest.TestCase):
with contextlib.closing(sqlite3.connect(database)) as connection:
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], generator.catalog.APPLICATION_ID)
self.assertEqual(connection.execute('PRAGMA integrity_check').fetchall(), [('ok',)])
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('songs',)])
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('resources',), ('songs',)])
columns = {row[1]: row for row in connection.execute('PRAGMA table_info(songs)')}
self.assertEqual((columns['title_image'][2], columns['title_image'][3]), ('TEXT', 1))
self.assertFalse(pathlib.Path(str(database) + '-wal').exists())
+1
View File
@@ -90,6 +90,7 @@ auto catalog_bundle(std::string_view title, std::string_view key = "C", std::str
metadata["title"] = title;
metadata["key_signature"] = key;
value.at("music").at("staves").as_array().front().as_object()["name"] = part;
vamp_test::add_timing(value);
return boost::json::serialize(value);
}
+3 -3
View File
@@ -21,7 +21,7 @@ import generator.engraving.types
import generator.guitar
import generator.model
import generator.musicxml
import generator.playback
import tests.score_events
import tests.arrangement_fixtures
import tests.test_accompaniment
import tests.test_musicxml
@@ -77,7 +77,7 @@ class EngravingTests(unittest.TestCase):
for measure in without_tempos['staves'][0]['measures']:
measure.pop('tempos', None)
self.assertEqual(layout, self.layout(without_tempos))
self.assertEqual(generator.playback.compile_timeline(source), generator.playback.compile_timeline(without_tempos))
self.assertEqual(tests.score_events.timeline(source), tests.score_events.timeline(without_tempos))
def test_long_credits_wrap_without_overflow_or_page_specific_sizing(self):
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 32))
source['metadata']['composer'] = 'A moderately long composer credit ' * 6
@@ -398,7 +398,7 @@ class EngravingTests(unittest.TestCase):
self.assertLess(max(p.box.bottom for p in system.primitives if not p.role.startswith('tab')), min(p.box.top for p in system.primitives if p.role.startswith('tab')))
self.assertEqual({p.sources[-1] for p in heads}, {p.sources[-1] for p in tabs})
self.assertTrue(all(any(abs(head.points[0][0] - tab.points[0][0]) < 0.01 for head in heads if head.sources[-1] == tab.sources[-1]) for tab in tabs))
self.assertEqual(len(generator.playback.compile_timeline(source)['events']), 64)
self.assertEqual(len(tests.score_events.timeline(source)['events']), 64)
def test_generated_tab_ties_and_barline_styles(self):
content = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>') + '<barline location="right"><bar-style>dashed</bar-style></barline>'
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric')
+10 -8
View File
@@ -198,15 +198,17 @@ class EngravingCacheTests(unittest.TestCase):
with unittest.mock.patch('generator.engraving.raster.rasterize_page', side_effect=AssertionError('cache miss')):
generator.engraving.pipeline.engrave_tree(warm, self.fonts, cache_dir=self.cache)
self.assertEqual(self.files(cold), self.files(warm))
def test_playback_is_not_restored_from_page_cache(self):
self.seed_cache()
changed = copy.deepcopy(self.document)
changed['playback'] = {'new-playback': 'independent of page rendering'}
def test_invalid_cached_page_timing_is_rebuilt(self):
entry = self.seed_cache()
manifest = entry / 'pages.json'
pages = json.loads(manifest.read_text())
pages[0]['measure_count'] += 1
manifest.write_text(json.dumps(pages))
root = self.root / 'warm'
self.write_scores(root, [changed])
with unittest.mock.patch('generator.engraving.layout.layout_systems', side_effect=AssertionError('cache miss')):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
self.assertEqual(json.loads((root / '000.json').read_text())['playback'], changed['playback'])
self.write_scores(root, [self.document])
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
bundle = json.loads((root / '000.json').read_text())
self.assertEqual(bundle['presentation']['pages'][0]['measure_count'], 1)
def test_changed_music_and_renderer_invalidate_pages(self):
self.seed_cache()
changed = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')).replace('Fixture', 'Changed'))
+10 -10
View File
@@ -17,7 +17,7 @@ import generator.harmony
import generator.model
import generator.parts
import generator.pitch
import generator.playback
import tests.score_events
import tests.arrangement_fixtures
import tests.solo_fixtures
import tests.test_musicxml
@@ -137,8 +137,8 @@ class GuitarTests(unittest.TestCase):
vocal = tests.arrangement_fixtures.section_document(source, 'vocal')
generator.model.validate_document(electric)
self.assertEqual(source, before)
self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in generator.playback.compile_timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in generator.playback.compile_timeline(vocal)['events']])
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(electric)['events']], [(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(source)['events']])
self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in tests.score_events.timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in tests.score_events.timeline(vocal)['events']])
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in tests.score_events.timeline(electric)['events']], [(e['pitch'], e['onset'], e['duration']) for e in tests.score_events.timeline(source)['events']])
for original, generated in zip(tests.test_musicxml.notes(source), tests.test_musicxml.notes(electric)):
self.assertEqual(generator.model.pitch_number(generated['pitches'][0]), generator.model.pitch_number(original['pitches'][0]) + 12)
self.assertIn('tablature', generated['pitches'][0])
@@ -147,7 +147,7 @@ class GuitarTests(unittest.TestCase):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes))
for part in ('electric', 'vocal'):
document = tests.arrangement_fixtures.section_document(source, part)
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0))
self.assertEqual(generator.model.fraction(tests.score_events.timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0))
def test_written_middle_c_and_c_sharp_six_have_matching_tab_and_audio(self):
for octave, alter, expected, tab in ((4, 0, 48, {'string': 5, 'fret': 3}), (6, 1, 73, {'string': 1, 'fret': 9})):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave - 1, alter=alter)))
@@ -155,7 +155,7 @@ class GuitarTests(unittest.TestCase):
tone = tests.test_musicxml.notes(document)[0]['pitches'][0]
self.assertEqual(generator.model.pitch_number(tone), 12 * (octave + 1) + alter)
self.assertEqual(tone['tablature'], tab)
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected)
self.assertEqual(generator.model.fraction(tests.score_events.timeline(document)['events'][0]['pitch']), expected)
generator.model.validate_document(document)
def test_guitar_normalization_is_idempotent_and_respects_timed_transposition(self):
change = '<attributes><transpose><chromatic>0</chromatic></transpose></attributes>'
@@ -165,7 +165,7 @@ class GuitarTests(unittest.TestCase):
self.assertEqual(generator.pitch.concert_document(concert, octave_transposing=True), concert)
self.assertEqual(source, before)
document = tests.arrangement_fixtures.section_document(source, 'electric')
self.assertEqual([generator.model.fraction(e['pitch']) for e in generator.playback.compile_timeline(document)['events']], [60, 72])
self.assertEqual([generator.model.fraction(e['pitch']) for e in tests.score_events.timeline(document)['events']], [60, 72])
def test_all_parts_share_written_octave_convention(self):
chord = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord + tests.test_musicxml.xml_note()))
@@ -175,7 +175,7 @@ class GuitarTests(unittest.TestCase):
document = generate(source, part)
generator.model.validate_document(document)
written = {p['id']: generator.model.pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
events = generator.playback.compile_timeline(document)['events']
events = tests.score_events.timeline(document)['events']
self.assertTrue(events)
self.assertTrue(all(generator.model.fraction(e['pitch']) == written[e['id']] - 12 for e in events))
if part == 'vocal':
@@ -189,7 +189,7 @@ class GuitarTests(unittest.TestCase):
first, continuation, other = [p['tablature'] for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']]
self.assertEqual(first, continuation)
self.assertNotEqual(continuation['string'], other['string'])
self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 2)
self.assertEqual(len(tests.score_events.timeline(document)['events']), 2)
def test_overlapping_tones_cannot_expand_the_span_or_relocate_a_tie(self):
content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
@@ -229,7 +229,7 @@ class GuitarTests(unittest.TestCase):
generator.model.validate_document(document)
self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 12})
self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']])
self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 3)
self.assertEqual(len(tests.score_events.timeline(document)['events']), 3)
def test_more_than_six_simultaneous_tones_fail_without_omission(self):
content = '<backup><duration>4</duration></backup>'.join(tests.test_musicxml.xml_note(extra=f'<voice>{i}</voice>') for i in range(7))
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
@@ -269,7 +269,7 @@ class GuitarTests(unittest.TestCase):
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved')
with unittest.mock.patch('generator.comping.voicings', side_effect=ValueError('no legal sparse voicing')):
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>dominant</kind></harmony>'+tests.test_musicxml.xml_note()))
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>dominant</kind></harmony>' + tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, 'no legal sparse voicing'):
generator.__main__.generate_assets(args)
self.assertEqual((output / 'last-good').read_text(), 'preserved')
+8 -8
View File
@@ -10,7 +10,7 @@ import generator.guitar
import generator.harmony
import generator.model
import generator.parts
import generator.playback
import tests.score_events
import tests.arrangement_fixtures
import tests.test_accompaniment
import tests.test_musicxml
@@ -47,12 +47,12 @@ class GuitarPartTests(unittest.TestCase):
self.assertFalse(generator.acoustic_voicing.candidates(requirements))
self.assertFalse(generator.guitar.chord_positions(requirements, profile='electric'))
def test_sparse_acoustic_uses_compact_playable_chords(self):
result=self.section(tests.test_parts.xml_harmony()+tests.test_musicxml.xml_note(),'acoustic')
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
generator.model.validate_document(result)
generator.fretboard.validate_document(result)
sounding=[n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']]
sounding = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']]
self.assertTrue(sounding)
self.assertTrue(all(2<=len(n['pitches'])<=3 for n in sounding))
self.assertTrue(all(2 <= len(n['pitches']) <= 3 for n in sounding))
def test_explicit_bass_is_preserved_for_triads_and_extended_chords(self):
for symbol, expected in (('CM/E', 4), ('C7b9/E', 4)):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
@@ -77,7 +77,7 @@ class GuitarPartTests(unittest.TestCase):
for before, after in zip(first['pitches'], second['pitches']):
self.assertEqual(before['tie_to'], after['id'])
self.assertEqual(after['tablature'], before['tablature'])
self.assertEqual({generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(result)['events']}, {2})
self.assertEqual({generator.model.fraction(e['duration']) for e in tests.score_events.timeline(result)['events']}, {2})
def test_chord_change_reattacks_even_for_final_eighth(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=7) + tests.test_musicxml.xml_note('G', 8, 'whole', octave=4)
result = self.section(content, divisions=2)
@@ -87,7 +87,7 @@ class GuitarPartTests(unittest.TestCase):
self.assertIn(len(note['pitches']), (3, 4))
self.assertFalse(any('tie_from' in p or 'tie_to' in p for p in note['pitches']))
self.assertEqual(generator.model.pitch_number(note['pitches'][0]), 79)
self.assertEqual({generator.model.fraction(e['onset']) for e in generator.playback.compile_timeline(result)['events']}, {0, fractions.Fraction(7, 2)})
self.assertEqual({generator.model.fraction(e['onset']) for e in tests.score_events.timeline(result)['events']}, {0, fractions.Fraction(7, 2)})
def test_cross_measure_ties_continue_only_without_a_chord_change(self):
for root in ('C', 'F'):
with self.subTest(root=root):
@@ -99,7 +99,7 @@ class GuitarPartTests(unittest.TestCase):
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']]
self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes))
self.assertEqual(bool(notes[0]['pitches'][0].get('tie_to')), root == 'C')
self.assertEqual({generator.model.fraction(e['onset']) for e in generator.playback.compile_timeline(result)['events']}, {0} if root == 'C' else {0, 4})
self.assertEqual({generator.model.fraction(e['onset']) for e in tests.score_events.timeline(result)['events']}, {0} if root == 'C' else {0, 4})
def test_split_tuplet_keeps_exact_timing_and_span_end(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=10)
for index in range(3):
@@ -120,7 +120,7 @@ class GuitarPartTests(unittest.TestCase):
self.assertIn(len(first['pitches']), (3, 4))
self.assertEqual(len(second['pitches']), 1)
self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id'])
timeline = generator.playback.compile_timeline(result)['events']
timeline = tests.score_events.timeline(result)['events']
self.assertEqual(sum(generator.model.fraction(e['duration']) == 4 for e in timeline), 1)
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 for e in timeline))
def test_unplayable_harmonization_preserves_low_melody(self):
+132 -127
View File
@@ -1,163 +1,168 @@
"""Contextual musical validity needs notes and chords, not author annotations."""
import dataclasses
import json
from fractions import Fraction as F
from pathlib import Path
import fractions
import pathlib
import unittest
from generator import train,training_io,vocabulary,harmonic_time,harmony as chords
from generator.solo import harmonic,types,decoder,learned,analysis,expressions
from tests.solo_fixtures import choices,note,song
ROOT=Path(__file__).resolve().parents[1]
import generator.train
import generator.training_io
import generator.vocabulary
import generator.harmonic_time
import generator.harmony
import generator.solo.harmonic
import generator.solo.types
import generator.solo.decoder
import generator.solo.learned
import generator.solo.analysis
import generator.solo.expressions
import tests.solo_fixtures
import generator.musicxml
import generator.parts
import generator.solo.authored
ROOT = pathlib.Path(__file__).resolve().parents[1]
class ContextualHarmony(unittest.TestCase):
def test_approved_corpus_passes_without_annotations_or_authored_privileges(self):
examples,_=train.load_examples(ROOT/'data/training/manifest.json')
manifest=json.loads((ROOT/'data/training/manifest.json').read_text())
from generator import musicxml,parts
from generator.solo.authored import sounding
examples, _ = generator.train.load_examples(ROOT / 'data/training/manifest.json')
manifest = json.loads((ROOT / 'data/training/manifest.json').read_text())
for entry in manifest['examples']:
raw=training_io.read(ROOT/'data/training'/entry['solo'],'',pitch_convention=entry['pitch_convention'])
self.assertEqual(raw['annotations'],[])
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source']))
h=harmonic.Harmony(harmonic_time.solo_source(doc['staves'][0]))
ns=tuple(dataclasses.replace(n,role='ordinary',sources=(),event_id='') for n in sounding(raw))
checked=h.evaluate(ns,complete=True)
self.assertTrue(checked.valid,(entry['id'],checked.error))
raw = generator.training_io.read(ROOT / 'data/training' / entry['solo'], '', pitch_convention=entry['pitch_convention'])
self.assertEqual(raw['annotations'], [])
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(doc['staves'][0]))
ns = tuple(dataclasses.replace(n, role='ordinary', sources=(), event_id='') for n in generator.solo.authored.sounding(raw))
checked = h.evaluate(ns, complete=True)
self.assertTrue(checked.valid, (entry['id'], checked.error))
self.assertTrue(checked.contracts)
self.assertEqual(len(examples),4)
self.assertEqual(len(examples), 4)
def test_passing_to_nonchord_scale_note(self):
h=harmonic.Harmony(choices(('G6',)))
notes=(note(0,F(1,2),'g'),note(F(1,2),1,'g',alter=1),note(1,2,'a'))
result=h.evaluate(notes)
self.assertTrue(result.valid,result.error)
self.assertEqual(result.contracts[0].kind,'passing')
self.assertFalse(h.evaluate(notes,infer=False).valid)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('G6',)))
notes = (tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'g', alter=1), tests.solo_fixtures.note(1, 2, 'a'))
result = h.evaluate(notes)
self.assertTrue(result.valid, result.error)
self.assertEqual(result.contracts[0].kind, 'passing')
self.assertFalse(h.evaluate(notes, infer=False).valid)
self.assertFalse(h.evaluate(notes[:-1]).valid)
leap=(*notes[:2],dataclasses.replace(notes[2],pitch=types.Pitch('b',F(),4)))
leap = (*notes[:2], dataclasses.replace(notes[2], pitch=generator.solo.types.Pitch('b', fractions.Fraction(), 4)))
self.assertFalse(h.evaluate(leap).valid)
long=(notes[0],dataclasses.replace(notes[1],end=F(3,2)),dataclasses.replace(notes[2],start=F(3,2)))
long = (notes[0], dataclasses.replace(notes[1], end=fractions.Fraction(3, 2)), dataclasses.replace(notes[2], start=fractions.Fraction(3, 2)))
self.assertFalse(h.evaluate(long).valid)
def test_passing_contract_requires_its_actual_preparation(self):
h=harmonic.Harmony(choices(('G6',)))
a=note(0,F(1,2),'g',event_id='a');b=note(F(1,2),1,'g',alter=1,event_id='b');c=note(1,2,'a',event_id='c')
contract=types.ExpressionContract('p','passing','chromatic_passing',(b,),c,'GM6',preparation=a)
self.assertTrue(h.evaluate((a,b,c),expressions=(contract,)).valid)
changed=dataclasses.replace(a,end=F(1,4))
wrong=dataclasses.replace(contract,preparation=changed)
self.assertFalse(h.evaluate((changed,b,c),expressions=(wrong,)).valid)
high=dataclasses.replace(c,pitch=dataclasses.replace(c.pitch,octave=5))
self.assertFalse(h.evaluate((a,b,high),expressions=(dataclasses.replace(contract,target=high),)).valid)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('G6',)))
a = tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g', event_id='a')
b = tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'g', alter=1, event_id='b')
c = tests.solo_fixtures.note(1, 2, 'a', event_id='c')
contract = generator.solo.types.ExpressionContract('p', 'passing', 'chromatic_passing', (b,), c, 'GM6', preparation=a)
self.assertTrue(h.evaluate((a, b, c), expressions=(contract,)).valid)
changed = dataclasses.replace(a, end=fractions.Fraction(1, 4))
wrong = dataclasses.replace(contract, preparation=changed)
self.assertFalse(h.evaluate((changed, b, c), expressions=(wrong,)).valid)
high = dataclasses.replace(c, pitch=dataclasses.replace(c.pitch, octave=5))
self.assertFalse(h.evaluate((a, b, high), expressions=(dataclasses.replace(contract, target=high),)).valid)
def test_color_resolution_can_have_intervening_valid_motion(self):
h=harmonic.Harmony(choices(('C7',)))
notes=(note(0,F(3,2),'f',alter=1),note(F(3,2),2,'a'),note(2,4,'g'))
checked=h.evaluate(notes)
self.assertTrue(checked.valid,checked.error)
self.assertEqual(checked.contracts[0].rule,'lydian_color')
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
notes = (tests.solo_fixtures.note(0, fractions.Fraction(3, 2), 'f', alter=1), tests.solo_fixtures.note(fractions.Fraction(3, 2), 2, 'a'), tests.solo_fixtures.note(2, 4, 'g'))
checked = h.evaluate(notes)
self.assertTrue(checked.valid, checked.error)
self.assertEqual(checked.contracts[0].rule, 'lydian_color')
self.assertFalse(h.evaluate(notes[:2]).valid)
bad_mid=dataclasses.replace(notes[1],pitch=types.Pitch('g',F(1),5))
self.assertFalse(h.evaluate((notes[0],bad_mid,notes[2])).valid)
bad_mid = dataclasses.replace(notes[1], pitch=generator.solo.types.Pitch('g', fractions.Fraction(1), 5))
self.assertFalse(h.evaluate((notes[0], bad_mid, notes[2])).valid)
def test_anticipated_stable_and_resolving_colors(self):
h=harmonic.Harmony(choices(('Bbdim7','Am7')))
n=note(F(7,2),6,'d')
result=h.evaluate((n,))
self.assertTrue(result.valid,result.error)
self.assertEqual(result.contracts[0].kind,'anticipated_color')
self.assertFalse(h.evaluate((dataclasses.replace(n,start=F(2)),)).valid)
h=harmonic.Harmony(choices(('Gm7','C7')))
color=note(F(7,2),F(11,2),'f',alter=1)
target=note(F(11,2),7,'g')
self.assertTrue(h.evaluate((color,target)).valid)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Bbdim7', 'Am7')))
n = tests.solo_fixtures.note(fractions.Fraction(7, 2), 6, 'd')
result = h.evaluate((n,))
self.assertTrue(result.valid, result.error)
self.assertEqual(result.contracts[0].kind, 'anticipated_color')
self.assertFalse(h.evaluate((dataclasses.replace(n, start=fractions.Fraction(2)),)).valid)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Gm7', 'C7')))
color = tests.solo_fixtures.note(fractions.Fraction(7, 2), fractions.Fraction(11, 2), 'f', alter=1)
target = tests.solo_fixtures.note(fractions.Fraction(11, 2), 7, 'g')
self.assertTrue(h.evaluate((color, target)).valid)
self.assertFalse(h.evaluate((color,)).valid)
flat=dataclasses.replace(color,pitch=types.Pitch('g',F(-1),4))
self.assertTrue(h.evaluate((flat,target)).valid)
flat = dataclasses.replace(color, pitch=generator.solo.types.Pitch('g', fractions.Fraction(-1), 4))
self.assertTrue(h.evaluate((flat, target)).valid)
def test_repeated_harmony_cannot_reset_color_duration(self):
h=harmonic.Harmony(choices(('C7','C7')))
color=note(3,6,'f',alter=1);target=note(6,7,'g')
self.assertFalse(h.evaluate((color,target)).valid)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7', 'C7')))
color = tests.solo_fixtures.note(3, 6, 'f', alter=1)
target = tests.solo_fixtures.note(6, 7, 'g')
self.assertFalse(h.evaluate((color, target)).valid)
def test_held_color_is_independent_of_notation_partition(self):
h=harmonic.Harmony(choices(('Bbdim7','Am7')))
whole=note(F(7,2),6,'d')
split=(dataclasses.replace(whole,end=F(4)),dataclasses.replace(whole,start=F(4),continuation=True))
self.assertEqual(h.evaluate((whole,)),h.evaluate(split))
self.assertEqual(h.describe((whole,)),h.describe(split))
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Bbdim7', 'Am7')))
whole = tests.solo_fixtures.note(fractions.Fraction(7, 2), 6, 'd')
split = (dataclasses.replace(whole, end=fractions.Fraction(4)), dataclasses.replace(whole, start=fractions.Fraction(4), continuation=True))
self.assertEqual(h.evaluate((whole,)), h.evaluate(split))
self.assertEqual(h.describe((whole,)), h.describe(split))
def test_unsupported_notes_are_not_authorized_by_labels(self):
h=harmonic.Harmony(choices(('C7',)))
for role in ('ordinary','authored','quote','color','Lead in'):
self.assertFalse(h.evaluate((note(0,3,'c',alter=1,role=role),)).valid)
missing=harmonic.Harmony(())
self.assertFalse(missing.evaluate((note(0,1,'c'),)).valid)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
for role in ('ordinary', 'authored', 'quote', 'color', 'Lead in'):
self.assertFalse(h.evaluate((tests.solo_fixtures.note(0, 3, 'c', alter=1, role=role),)).valid)
missing = generator.solo.harmonic.Harmony(())
self.assertFalse(missing.evaluate((tests.solo_fixtures.note(0, 1, 'c'),)).valid)
def test_fully_expired_partial_treatments_remain_invalid(self):
h=harmonic.Harmony(choices(('C7',)))
notes=(note(0,F(1,2),'d',alter=1,event_id='a'),note(F(1,2),2,'e',event_id='b'))
contract=types.ExpressionContract('a','approach','below',(notes[0],),notes[1],'C7')
partial=h.evaluate(notes[:1],until=F(1,2),expressions=(contract,))
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
notes = (tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'd', alter=1, event_id='a'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 2, 'e', event_id='b'))
contract = generator.solo.types.ExpressionContract('a', 'approach', 'below', (notes[0],), notes[1], 'C7')
partial = h.evaluate(notes[:1], until=fractions.Fraction(1, 2), expressions=(contract,))
self.assertTrue(partial.valid)
self.assertEqual(len(partial.pending),1)
self.assertFalse(h.evaluate(notes[:1],until=F(2),expressions=(contract,)).valid)
self.assertEqual(len(partial.pending), 1)
self.assertFalse(h.evaluate(notes[:1], until=fractions.Fraction(2), expressions=(contract,)).valid)
def test_pickup_uses_source_form_context_without_overriding_written_harmony(self):
doc=song(count=3,chords=('C','Dm','G7'));staff=doc['staves'][0]
pickup=staff['measures'][0];pickup['implicit']=True;pickup['harmonies']=[]
self.assertEqual(chords.symbol(harmonic_time.solo_source(staff)[0].chord),'G7')
self.assertIsNone(harmonic_time.read(staff)[0].chord)
pickup['harmonies']=[dict(chords.parse_symbol('F'),onset={'numerator':0,'denominator':1})]
self.assertEqual(chords.symbol(harmonic_time.solo_source(staff)[0].chord),'FM')
pickup['harmonies']=[{'kind':'none','onset':{'numerator':0,'denominator':1}}]
self.assertIsNone(harmonic_time.solo_source(staff)[0].chord)
pickup['harmonies']=[];pickup['implicit']=False
self.assertIsNone(harmonic_time.solo_source(staff)[0].chord)
doc = tests.solo_fixtures.song(count=3, chords=('C', 'Dm', 'G7'))
staff = doc['staves'][0]
pickup = staff['measures'][0]
pickup['implicit'] = True
pickup['harmonies'] = []
self.assertEqual(generator.harmony.symbol(generator.harmonic_time.solo_source(staff)[0].chord), 'G7')
self.assertIsNone(generator.harmonic_time.read(staff)[0].chord)
pickup['harmonies'] = [dict(generator.harmony.parse_symbol('F'), onset={'numerator': 0, 'denominator': 1})]
self.assertEqual(generator.harmony.symbol(generator.harmonic_time.solo_source(staff)[0].chord), 'FM')
pickup['harmonies'] = [{'kind': 'none', 'onset': {'numerator': 0, 'denominator': 1}}]
self.assertIsNone(generator.harmonic_time.solo_source(staff)[0].chord)
pickup['harmonies'] = []
pickup['implicit'] = False
self.assertIsNone(generator.harmonic_time.solo_source(staff)[0].chord)
class PredictionReachability(unittest.TestCase):
def engine(self,chords):
doc=song(count=2,chords=chords)
return decoder.Decoder(analysis.analyze(doc),harmonic.Harmony(choices(chords)),
learned.Model.load(ROOT/'data/models/solo.json'),'acoustic',F(),F(8))
def engine(self, chords):
doc = tests.solo_fixtures.song(count=2, chords=chords)
return generator.solo.decoder.Decoder(generator.solo.analysis.analyze(doc), generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(chords)), generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8))
def test_joint_resolved_color_reaches_the_real_action_builder(self):
d=self.engine(('C7','FM7'))
n=d.note(F(),F(3,2),66)
actions=list(d.resolutions(n))
self.assertTrue(any([p for _,_,p in events]==[66,69,67] for events,_,_ in actions))
for _,notes,contracts in actions:
self.assertTrue(d.harmony.evaluate(notes,expressions=contracts,infer=False).valid)
d = self.engine(('C7', 'FM7'))
n = d.note(fractions.Fraction(), fractions.Fraction(3, 2), 66)
actions = list(d.resolutions(n))
self.assertTrue(any([p for _, _, p in events] == [66, 69, 67] for events, _, _ in actions))
for _, notes, contracts in actions:
self.assertTrue(d.harmony.evaluate(notes, expressions=contracts, infer=False).valid)
def test_anticipated_color_is_a_normal_single_event_option(self):
d=self.engine(('Bbdim7','Am7'));n=d.note(F(7,2),F(6),62)
support=d.permission(n)
d = self.engine(('Bbdim7', 'Am7'))
n = d.note(fractions.Fraction(7, 2), fractions.Fraction(6), 62)
support = d.permission(n)
self.assertTrue(support)
self.assertEqual(support[0].kind,'anticipated_color')
d.expressions_enabled=False;d._permissions.clear()
self.assertEqual(support[0].kind, 'anticipated_color')
d.expressions_enabled = False
d._permissions.clear()
self.assertIsNone(d.permission(n))
def test_delayed_action_retains_space_and_intervening_motion(self):
d=self.engine(('C7','FM7'));n=d.note(F(),F(1,2),63)
actions=list(d.resolutions(n))
self.assertTrue(any(c[0].kind=='delayed_approach' and any(p is None for _,_,p in events) for events,_,c in actions))
self.assertTrue(any(c[0].kind=='delayed_approach' and len(notes)==3 for _,notes,c in actions))
d = self.engine(('C7', 'FM7'))
n = d.note(fractions.Fraction(), fractions.Fraction(1, 2), 63)
actions = list(d.resolutions(n))
self.assertTrue(any(c[0].kind == 'delayed_approach' and any(p is None for _, _, p in events) for events, _, c in actions))
self.assertTrue(any(c[0].kind == 'delayed_approach' and len(notes) == 3 for _, notes, c in actions))
def test_passing_action_uses_incoming_state(self):
d=self.engine(('G6','G6'));first=d.note(F(),F(1,2),67)
state=d.extend(decoder.State(),(((first.start,first.end,67),),(first,),()))
actions=[a for a in d.actions(F(1,2)) if a[2] and a[2][0].kind=='passing'
and a[0]==((F(1,2),F(1),68),(F(1),F(2),69))]
d = self.engine(('G6', 'G6'))
first = d.note(fractions.Fraction(), fractions.Fraction(1, 2), 67)
state = d.extend(generator.solo.decoder.State(), (((first.start, first.end, 67),), (first,), ()))
actions = [a for a in d.actions(fractions.Fraction(1, 2)) if a[2] and a[2][0].kind == 'passing'
and a[0] == ((fractions.Fraction(1, 2), fractions.Fraction(1), 68), (fractions.Fraction(1), fractions.Fraction(2), 69))]
self.assertTrue(actions)
self.assertIsNotNone(d.extend(state,actions[0]))
self.assertIsNone(d.extend(decoder.State(),actions[0]))
wrong=d.note(F(),F(1,2),64)
prior=d.extend(decoder.State(),(((wrong.start,wrong.end,64),),(wrong,),()))
self.assertIsNone(d.extend(prior,actions[0]))
self.assertIsNotNone(d.extend(state, actions[0]))
self.assertIsNone(d.extend(generator.solo.decoder.State(), actions[0]))
wrong = d.note(fractions.Fraction(), fractions.Fraction(1, 2), 64)
prior = d.extend(generator.solo.decoder.State(), (((wrong.start, wrong.end, 64),), (wrong,), ()))
self.assertIsNone(d.extend(prior, actions[0]))
if __name__=='__main__':unittest.main()
if __name__ == '__main__':
unittest.main()
+10 -18
View File
@@ -135,7 +135,7 @@ auto config_file(std::filesystem::path const &root, vamp_zero::config const &con
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
{"database", {{"path", config.database.path}}},
{"control", {{"endpoint", ligo::to_json(config.control.endpoint)}, {"zero_ip", config.control.zero_ip}}},
{"audio", {{"enabled", true}, {"playback_enabled", true}, {"metronome_enabled", true}}},
{"audio", {{"enabled", true}, {"backing_enabled", false}, {"metronome_enabled", true}}},
};
auto path = root / "config.json";
write_file(path, boost::json::serialize(json));
@@ -182,6 +182,7 @@ auto write_score(std::filesystem::path const &path, std::filesystem::path const
write_file(path.parent_path() / relative, bytes);
pages.push_back(boost::json::object{{"path", relative}, {"sha256", checksum(bytes)}});
}
vamp_test::add_timing(score);
write_file(path, boost::json::serialize(score));
}
@@ -405,32 +406,23 @@ BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_corrupted_pico_titles) {
}
}
BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_root) {
BOOST_AUTO_TEST_CASE(readiness_validates_supplied_audio_using_configured_asset_root) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto assets = std::filesystem::path{config.runtime.assets_path};
auto fixture = std::filesystem::path{VAMP_TEST_PLAYBACK_ASSETS_DIR};
std::filesystem::copy(fixture / "audio", assets / "audio", std::filesystem::copy_options::recursive);
auto path = assets / "songs/acoustic.json";
write_score(path, assets, "Acoustic");
auto score = boost::json::parse(read_file(path));
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
write_file(path, boost::json::serialize(score));
auto fixture = std::filesystem::path{VAMP_TEST_PERFORMANCE_ASSETS_DIR};
std::filesystem::copy(fixture / "sounds", assets / "sounds", std::filesystem::copy_options::recursive);
BOOST_REQUIRE(vamp_test::build_catalog(assets, config.database.path));
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
auto &sample = score.at("playback").at("samples").as_array().front().as_object();
auto audio = assets / std::string{sample.at("path").as_string()};
auto corrupt = read_file(audio);
corrupt[0] = 'X';
sample["sha256"] = checksum(corrupt);
write_file(audio, corrupt);
write_file(path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalog(assets, config.database.path));
auto path = assets / "sounds/beat1.wav";
auto bytes = read_file(path);
bytes.back() ^= 1;
write_file(path, bytes);
ready = run_once(root.path, config);
BOOST_REQUIRE(!ready);
BOOST_TEST(std::string{ready.error().message()}.find("PCM16 WAV") != std::string::npos);
BOOST_TEST(std::string{ready.error().message()}.find("checksum mismatch") != std::string::npos);
}
BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) {
+144 -140
View File
@@ -5,189 +5,193 @@ import contextlib
import dataclasses
import json
import multiprocessing
from pathlib import Path
import pathlib
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
from tests.solo_fixtures import song
from tests.solo_fixtures import choices
from tests.solo_fixtures import note
import fractions
import unittest.mock
import generator.production
import generator.model
import generator.vocabulary
import generator.solo.expressions
import generator.solo.types
import generator.solo.harmonic
import generator.solo.placement
import generator.solo.notation
import generator.solo.composer
import tests.solo_fixtures
import tests.solo_fixtures
import tests.solo_fixtures
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()
cls.source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=1, chords=('CM7',)))
cls.root = pathlib.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)
serial = {role: generator.production.generate_role(self.source, role, self.root) for role in generator.production.ROLES}
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
jobs = {role: pool.submit(generator.production._job, (self.source, role, self.root, {}, None)) for role in reversed(generator.production.ROLES)}
parallel = {role: future.result() for role, future in jobs.items()}
for role in generator.production.ROLES:
self.assertEqual(music(serial[role]), music(parallel[role]), role)
for doc in generator.production.assemble(self.source, serial).values():
generator.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'])
a = generator.production.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory)
b = generator.production.generate_role(self.source, 'electric_solo', self.root, {'seed': 'changed'}, directory)
again = generator.production.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory)
clean = generator.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']))
original = pathlib.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 unittest.mock.patch.object(pathlib.Path, 'open', guarded):
result = generator.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))
document = tests.solo_fixtures.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(generator.production.SourceAnalysis.read(document), generator.production.SourceAnalysis.read(other))
def test_reference_presence_and_changes_do_not_change_clean_music(self):
# No repository reference is a fixture or prerequisite. These deliberately
# invalid optional files test the absence of a discovery/fallback branch.
with tempfile.TemporaryDirectory() as directory, contextlib.chdir(directory):
reference_dir=Path(directory)/'build';reference_dir.mkdir()
references=[reference_dir/(song+'-solo-'+str(index)+suffix+'.xml')
for song in ('all-of-me','autumn-leaves') for index in (1,2) for suffix in ('','(4)')]
before=production.generate_role(self.source,'electric_solo',self.root)
for reference in references:reference.write_text('<not-a-production-source/>')
present=production.generate_role(self.source,'electric_solo',self.root)
for reference in references:reference.write_text('changed optional target, still not MusicXML')
changed=production.generate_role(self.source,'electric_solo',self.root)
for reference in references:reference.unlink()
absent=production.generate_role(self.source,'electric_solo',self.root)
self.assertTrue(all(music(before)==music(v) for v in (present,changed,absent)))
reference_dir = pathlib.Path(directory) / 'build'
reference_dir.mkdir()
references = [reference_dir / (source_name + '-solo-' + str(index) + suffix + '.xml')
for source_name in ('all-of-me', 'autumn-leaves') for index in (1, 2) for suffix in ('', '(4)')]
before = generator.production.generate_role(self.source, 'electric_solo', self.root)
for reference in references:
reference.write_text('<not-a-production-source/>')
present = generator.production.generate_role(self.source, 'electric_solo', self.root)
for reference in references:
reference.write_text('changed optional target, still not MusicXML')
changed = generator.production.generate_role(self.source, 'electric_solo', self.root)
for reference in references:
reference.unlink()
absent = generator.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)
source = tests.solo_fixtures.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]['notes'],b['details']['phrases'][0]['notes'])
for p in n['pitches']:
p['letter'] = {'c': 'e', 'd': 'f', 'e': 'g', 'f': 'a'}[p['letter']]
a = generator.production.generate_role(generator.production.SourceAnalysis.read(source), 'acoustic_solo', self.root)
b = generator.production.generate_role(generator.production.SourceAnalysis.read(changed), 'acoustic_solo', self.root)
self.assertNotEqual(a['details']['phrases'][0]['notes'], b['details']['phrases'][0]['notes'])
def test_source_tuplet_lanes_survive_assembly(self):
serial={r:production.generate_role(self.source,r,self.root) for r in production.ROLES}
serial = {r: generator.production.generate_role(self.source, r, self.root) for r in generator.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)
doc = serial['electric_melody']['document']
staff = doc['staves'][0]
raw = tuple(tests.solo_fixtures.note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), 'c', event_id=str(i)) for i in range(3))
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
index = next(i for i, m in enumerate(staff['measures']) if m.get('section_label') == 'Guitar Melody')
replacement = generator.solo.notation.measures(staff['measures'][index:index + 1], placed, 'source_triplet')
generator.solo.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'])
v['id'] = 'voice_source'
for n in v['notes']:
n['voice'] = 'voice_source'
for t in staff['tuplets']:
t['voice'] = 'voice_source'
generator.model.validate_document(doc)
generator.model.validate_document(generator.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']
result = generator.production.generate_role(generator.production.SourceAnalysis.read(tests.solo_fixtures.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']))
return [(n['onset'], n['duration'], [(generator.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
self.h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7', 'FM7')))
def make(self, rule='upper_lower'):
target = tests.solo_fixtures.note(2, 4, 'e', event_id='target')
upper = tests.solo_fixtures.note(1, fractions.Fraction(3, 2), 'f', event_id='upper')
lower = dataclasses.replace(tests.solo_fixtures.note(fractions.Fraction(3, 2), 2, 'd', event_id='lower'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(1), 4))
c = generator.solo.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)
notes, c = self.make()
self.assertTrue(self.h.evaluate(notes, expressions=(c,)).valid)
self.assertTrue(self.h.evaluate(notes).valid)
self.assertFalse(self.h.evaluate(notes,infer=False).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)
self.assertFalse(self.h.evaluate(notes, infer=False).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)
n = tests.solo_fixtures.note(fractions.Fraction(7, 2), 6, 'a', event_id='early')
c = generator.solo.types.ExpressionContract('anticipation', 'anticipation', 'early_chord_tone', (n,), n, 'FM7', boundary=fractions.Fraction(4))
self.assertTrue(self.h.evaluate((n,), expressions=(c,)).valid)
self.assertTrue(self.h.evaluate((n,)).valid)
self.assertFalse(self.h.evaluate((n,),infer=False).valid)
self.assertFalse(self.h.evaluate((n,),expressions=(dataclasses.replace(c,boundary=F(3)),)).valid)
self.assertFalse(self.h.evaluate((n,), infer=False).valid)
self.assertFalse(self.h.evaluate((n,), expressions=(dataclasses.replace(c, boundary=fractions.Fraction(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)
n = tests.solo_fixtures.note(0, 2, 'd', event_id='ninth')
c = generator.solo.types.ExpressionContract('color', 'color', 'dominant_ninth', (n,), n, 'C7')
self.assertTrue(self.h.evaluate((n,), expressions=(c,)).valid)
self.assertTrue(self.h.evaluate((n,)).valid)
self.assertFalse(self.h.evaluate((n,),infer=False).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)
self.assertFalse(self.h.evaluate((n,), infer=False).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,'f',event_id='bad'),pitch=types.Pitch('f',F(1),4),role='color')
n = dataclasses.replace(tests.solo_fixtures.note(0, 2, 'f', event_id='bad'), pitch=generator.solo.types.Pitch('f', fractions.Fraction(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)
c = generator.solo.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)
lower = dataclasses.replace(tests.solo_fixtures.note(1, fractions.Fraction(3, 2), 'd', event_id='lower'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(1), 4))
target = tests.solo_fixtures.note(2, 4, 'e', event_id='target')
c = generator.solo.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)
color = dataclasses.replace(tests.solo_fixtures.note(0, 1, 'd', event_id='color'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(-1), 4))
target = tests.solo_fixtures.note(1, 2, 'c', event_id='target')
c = generator.solo.types.ExpressionContract('flat9', 'color', 'dominant_flat_ninth', (color,), target, 'C7')
self.assertTrue(self.h.evaluate((color, target), expressions=(c,)).valid)
unresolved = generator.solo.harmonic.Harmony(tests.solo_fixtures.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):
h=harmonic.Harmony(choices(('A7','Dm7')))
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)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('A7', 'Dm7')))
a = dataclasses.replace(tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'c', event_id='csharp'), pitch=generator.solo.types.Pitch('c', fractions.Fraction(1), 4))
b = tests.solo_fixtures.note(fractions.Fraction(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)
raw = tuple(tests.solo_fixtures.note(fractions.Fraction(7, 2) + fractions.Fraction(i, 3), fractions.Fraction(7, 2) + fractions.Fraction(i + 1, 3), 'c', event_id=str(i)) for i in range(3))
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
doc = tests.solo_fixtures.song(count=2, chords=('CM7',))
measures = generator.solo.notation.measures(doc['staves'][0]['measures'], placed, 'triplet')
spans = generator.solo.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']))
if __name__=='__main__':unittest.main()
if __name__ == '__main__':
unittest.main()
+127 -111
View File
@@ -2,143 +2,159 @@
import copy
import dataclasses
import json
from pathlib import Path
from fractions import Fraction as F
import pathlib
import fractions
import tempfile
import unittest
from unittest.mock import patch
import unittest.mock
from generator import production,parts,musicxml,train,training_io,vocabulary
from generator.solo import authored,learned,composer,decoder,harmonic,analysis,export
from tests.solo_fixtures import song
from tests.test_musicxml import xml_score,xml_note
import generator.production
import generator.parts
import generator.musicxml
import generator.train
import generator.training_io
import generator.vocabulary
import generator.solo.authored
import generator.solo.learned
import generator.solo.composer
import generator.solo.decoder
import generator.solo.harmonic
import generator.solo.analysis
import generator.solo.export
import tests.solo_fixtures
import tests.test_musicxml
import generator.harmonic_time
ROOT=Path(__file__).resolve().parents[1]
def music(events):return tuple((F(n['start']),F(n['end']),F(n['sounding_midi'])) for n in events)
ROOT = pathlib.Path(__file__).resolve().parents[1]
def music(events): return tuple((fractions.Fraction(n['start']), fractions.Fraction(n['end']), fractions.Fraction(n['sounding_midi'])) for n in events)
class TrainingAndRepertoire(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.examples,cls.provenance=train.load_examples(ROOT/'data/training/manifest.json')
cls.examples, cls.provenance = generator.train.load_examples(ROOT / 'data/training/manifest.json')
def test_all_four_examples_and_intentional_pickup_forms(self):
self.assertEqual(len(self.examples),4)
self.assertEqual(sorted(p['measures'] for p in self.provenance['examples']),[32,32,33,33])
self.assertEqual(sorted(p['pickup_beats'] for p in self.provenance['examples']),['0','0','4','4'])
self.assertEqual(len(self.examples), 4)
self.assertEqual(sorted(p['measures'] for p in self.provenance['examples']), [32, 32, 33, 33])
self.assertEqual(sorted(p['pickup_beats'] for p in self.provenance['examples']), ['0', '0', '4', '4'])
def test_training_is_pooled_and_deterministic(self):
baseline=learned.fit(self.examples,{})
swapped=[dict(e,role='electric' if e['role']=='acoustic' else 'acoustic') for e in reversed(self.examples)]
self.assertEqual(baseline,learned.fit(swapped,{}))
self.assertNotIn('acoustic',json.dumps(baseline))
self.assertNotIn('electric',json.dumps(baseline))
frozen=json.loads((ROOT/'data/models/solo.json').read_text())
self.assertEqual(frozen,learned.fit(self.examples,self.provenance))
baseline = generator.solo.learned.fit(self.examples, {})
swapped = [dict(e, role='electric' if e['role'] == 'acoustic' else 'acoustic') for e in reversed(self.examples)]
self.assertEqual(baseline, generator.solo.learned.fit(swapped, {}))
self.assertNotIn('acoustic', json.dumps(baseline))
self.assertNotIn('electric', json.dumps(baseline))
frozen = json.loads((ROOT / 'data/models/solo.json').read_text())
self.assertEqual(frozen, generator.solo.learned.fit(self.examples, self.provenance))
def test_repertoire_exports_exact_sounding_music_for_every_registered_pair(self):
manifest=json.loads((ROOT/'data/training/manifest.json').read_text())
manifest = json.loads((ROOT / 'data/training/manifest.json').read_text())
with tempfile.TemporaryDirectory() as directory:
for entry in manifest['examples']:
part='acoustic' if entry['slot']==1 else 'electric'
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source']))
source=production.SourceAnalysis.read(doc)
with patch('generator.solo.composer.compose',side_effect=AssertionError('authored pair must not be predicted')):
result=production.generate_role(source,part+'_solo',ROOT/'data')
path=Path(directory)/(part+'.xml');export.write_role(result,path)
actual=training_io.read(path,part);expected=training_io.read(ROOT/'data/training'/entry['solo'],part,pitch_convention=entry['pitch_convention'])
self.assertEqual(music(actual['sounding_events']),music(expected['sounding_events']),entry['id'])
self.assertEqual([(m['start'],m['duration']) for m in actual['measures']],[(m['start'],m['duration']) for m in expected['measures']])
self.assertEqual(actual['discrepancies'],[])
part = 'acoustic' if entry['slot'] == 1 else 'electric'
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
source = generator.production.SourceAnalysis.read(doc)
with unittest.mock.patch('generator.solo.composer.compose', side_effect=AssertionError('authored pair must not be predicted')):
result = generator.production.generate_role(source, part + '_solo', ROOT / 'data')
path = pathlib.Path(directory) / (part + '.xml')
generator.solo.export.write_role(result, path)
actual = generator.training_io.read(path, part)
expected = generator.training_io.read(ROOT / 'data/training' / entry['solo'], part, pitch_convention=entry['pitch_convention'])
self.assertEqual(music(actual['sounding_events']), music(expected['sounding_events']), entry['id'])
self.assertEqual([(m['start'], m['duration']) for m in actual['measures']], [(m['start'], m['duration']) for m in expected['measures']])
self.assertEqual(actual['discrepancies'], [])
self.assertTrue(all(n['legal_positions_at_sounding_pitch'] for n in actual['notes'] if n['pitch']))
def test_lookup_uses_source_music_and_slot_not_title_or_path(self):
entry=json.loads((ROOT/'data/training/manifest.json').read_text())['examples'][0]
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source']))
source=production.SourceAnalysis.read(doc)
part='acoustic' if entry['slot']==1 else 'electric'
self.assertIsNotNone(authored.lookup(source,part,ROOT/'data'))
doc['metadata'].update(title='Renamed standard',composer='Display only')
self.assertEqual(source,production.SourceAnalysis.read(doc))
doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave']+=1
self.assertIsNone(authored.lookup(production.SourceAnalysis.read(doc),part,ROOT/'data'))
entry = json.loads((ROOT / 'data/training/manifest.json').read_text())['examples'][0]
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
source = generator.production.SourceAnalysis.read(doc)
part = 'acoustic' if entry['slot'] == 1 else 'electric'
self.assertIsNotNone(generator.solo.authored.lookup(source, part, ROOT / 'data'))
doc['metadata'].update(title='Renamed standard', composer='Display only')
self.assertEqual(source, generator.production.SourceAnalysis.read(doc))
doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave'] += 1
self.assertIsNone(generator.solo.authored.lookup(generator.production.SourceAnalysis.read(doc), part, ROOT / 'data'))
def test_adding_a_new_pair_requires_no_code_or_existing_song_names(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);source=root/'new-standard.xml';solo=root/'interpretation.xml'
content=xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'+xml_note('E',octave=5))
source.write_text(content);solo.write_text(content)
manifest=root/'data/training/manifest.json'
train.register_example(manifest,source,solo,1,'guitar-written')
examples,_=train.load_examples(manifest)
self.assertEqual(len(examples),1)
doc=parts.prepare_document(musicxml.load_musicxml(root/'data/songs'/source.name))
context=production.SourceAnalysis.read(doc)
self.assertIsNotNone(authored.lookup(context,'acoustic',root/'data'))
self.assertIsNone(authored.lookup(context,'electric',root/'data'))
self.assertEqual(solo.read_bytes(),(manifest.parent/solo.name).read_bytes())
before=manifest.read_bytes()
with self.assertRaisesRegex(ValueError,'already registered'):train.register_example(manifest,source,solo,1,'guitar-written')
self.assertEqual(manifest.read_bytes(),before)
root = pathlib.Path(directory)
source = root / 'new-standard.xml'
solo = root / 'interpretation.xml'
content = tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', octave=5))
source.write_text(content)
solo.write_text(content)
manifest = root / 'data/training/manifest.json'
generator.train.register_example(manifest, source, solo, 1, 'guitar-written')
examples, _ = generator.train.load_examples(manifest)
self.assertEqual(len(examples), 1)
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(root / 'data/songs' / source.name))
context = generator.production.SourceAnalysis.read(doc)
self.assertIsNotNone(generator.solo.authored.lookup(context, 'acoustic', root / 'data'))
self.assertIsNone(generator.solo.authored.lookup(context, 'electric', root / 'data'))
self.assertEqual(solo.read_bytes(), (manifest.parent / solo.name).read_bytes())
before = manifest.read_bytes()
with self.assertRaisesRegex(ValueError, 'already registered'):
generator.train.register_example(manifest, source, solo, 1, 'guitar-written')
self.assertEqual(manifest.read_bytes(), before)
def test_incorrect_form_registration_rolls_back(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);source=root/'song.xml';solo=root/'solo.xml'
source.write_text(xml_score(xml_note()));solo.write_text(xml_score(xml_note(duration=2,kind='half')))
manifest=root/'data/training/manifest.json'
with self.assertRaisesRegex(ValueError,'timing differs'):train.register_example(manifest,source,solo,2,'guitar-written')
self.assertFalse(manifest.exists());self.assertFalse((root/'data/songs/song.xml').exists());self.assertFalse((root/'data/training/solo.xml').exists())
root = pathlib.Path(directory)
source = root / 'song.xml'
solo = root / 'solo.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
solo.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half')))
manifest = root / 'data/training/manifest.json'
with self.assertRaisesRegex(ValueError, 'timing differs'):
generator.train.register_example(manifest, source, solo, 2, 'guitar-written')
self.assertFalse(manifest.exists())
self.assertFalse((root / 'data/songs/song.xml').exists())
self.assertFalse((root / 'data/training/solo.xml').exists())
class LearnedInference(unittest.TestCase):
def test_model_is_required_without_repertoire(self):
with self.assertRaisesRegex(ValueError,'run python -m generator.train'):learned.Model.load('/missing-model-file')
with self.assertRaisesRegex(ValueError, 'run python -m generator.train'):
generator.solo.learned.Model.load('/missing-model-file')
def test_decoder_runs_without_training_files_and_is_deterministic(self):
source=song(count=1,chords=('C7',));a=analysis.analyze(source)
predictor=learned.Model.load(ROOT/'data/models/solo.json')
with vocabulary.using(vocabulary.load(),'acoustic_solo',{'attempts':2}):
original=Path.open
def guarded(path,*args,**kwargs):
self.assertNotIn('training',str(path));return original(path,*args,**kwargs)
with patch.object(Path,'open',guarded):
first=composer.compose(a,source['staves'][0]['measures'],'acoustic',F(4),learned_model=predictor)
second=composer.compose(a,source['staves'][0]['measures'],'acoustic',F(4),learned_model=predictor)
self.assertEqual(first.notes(),second.notes());composer.validate(first)
self.assertTrue(first.notes());self.assertEqual(first.diagnostics['construction'],'learned_joint_events')
source = tests.solo_fixtures.song(count=1, chords=('C7',))
a = generator.solo.analysis.analyze(source)
predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json')
with generator.vocabulary.using(generator.vocabulary.load(), 'acoustic_solo', {'attempts': 2}):
original = pathlib.Path.open
def guarded(path, *args, **kwargs):
self.assertNotIn('training', str(path))
return original(path, *args, **kwargs)
with unittest.mock.patch.object(pathlib.Path, 'open', guarded):
first = generator.solo.composer.compose(a, source['staves'][0]['measures'], 'acoustic', fractions.Fraction(4), learned_model=predictor)
second = generator.solo.composer.compose(a, source['staves'][0]['measures'], 'acoustic', fractions.Fraction(4), learned_model=predictor)
self.assertEqual(first.notes(), second.notes())
generator.solo.composer.validate(first)
self.assertTrue(first.notes())
self.assertEqual(first.diagnostics['construction'], 'learned_joint_events')
def test_event_score_does_not_depend_on_action_grouping(self):
source=song(count=1,chords=('C7',));a=analysis.analyze(source)
from generator.harmonic_time import read
h=harmonic.Harmony(read(source['staves'][0]));predictor=learned.Model.load(ROOT/'data/models/solo.json')
engine=decoder.Decoder(a,h,predictor,'acoustic',F(),F(4))
notes=(engine.note(F(),F(1),60),engine.note(F(1),F(2),64))
events=tuple((n.start,n.end,n.pitch.number()) for n in notes)
combined=engine.extend(decoder.State(),(events,notes,()))
split=engine.extend(engine.extend(decoder.State(),(events[:1],notes[:1],())),(events[1:],notes[1:],()))
self.assertEqual(combined,split)
source = tests.solo_fixtures.song(count=1, chords=('C7',))
a = generator.solo.analysis.analyze(source)
h = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0]))
predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json')
engine = generator.solo.decoder.Decoder(a, h, predictor, 'acoustic', fractions.Fraction(), fractions.Fraction(4))
notes = (engine.note(fractions.Fraction(), fractions.Fraction(1), 60), engine.note(fractions.Fraction(1), fractions.Fraction(2), 64))
events = tuple((n.start, n.end, n.pitch.number()) for n in notes)
combined = engine.extend(generator.solo.decoder.State(), (events, notes, ()))
split = engine.extend(engine.extend(generator.solo.decoder.State(), (events[:1], notes[:1], ())), (events[1:], notes[1:], ()))
self.assertEqual(combined, split)
def test_source_features_include_actual_melodic_intervals(self):
source=song(count=1,chords=('C7',));a=analysis.analyze(source)
changed=dataclasses.replace(a,melody=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+(i==1))) for i,n in enumerate(a.melody)))
self.assertNotEqual(learned.source_feature(a,F(1)),learned.source_feature(changed,F(1)))
source = tests.solo_fixtures.song(count=1, chords=('C7',))
a = generator.solo.analysis.analyze(source)
changed = dataclasses.replace(a, melody=tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave + (i == 1))) for i, n in enumerate(a.melody)))
self.assertNotEqual(generator.solo.learned.source_feature(a, fractions.Fraction(1)), generator.solo.learned.source_feature(changed, fractions.Fraction(1)))
def test_generated_long_nonchord_notes_still_need_support(self):
from tests.solo_fixtures import choices,note
h=harmonic.Harmony(choices(('C7',)))
self.assertTrue(h.evaluate((note(0,2,'e'),)).valid)
self.assertFalse(h.evaluate((note(0,2,'f'),)).valid)
self.assertTrue(h.evaluate((note(0,F(1,2),'g'),note(F(1,2),1,'f'),note(1,2,'e'))).valid)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
self.assertTrue(h.evaluate((tests.solo_fixtures.note(0, 2, 'e'),)).valid)
self.assertFalse(h.evaluate((tests.solo_fixtures.note(0, 2, 'f'),)).valid)
self.assertTrue(h.evaluate((tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'f'), tests.solo_fixtures.note(1, 2, 'e'))).valid)
def test_held_chord_member_can_become_a_supported_color(self):
from tests.solo_fixtures import choices
source=song(count=2,chords=('C','Dm'));a=analysis.analyze(source)
h=harmonic.Harmony(choices(('C','Dm')))
engine=decoder.Decoder(a,h,learned.Model.load(ROOT/'data/models/solo.json'),'acoustic',F(),F(8))
n=engine.note(F(3),F(6),64) # E: C's third becomes D minor's ninth.
support=engine.permission(n)
source = tests.solo_fixtures.song(count=2, chords=('C', 'Dm'))
a = generator.solo.analysis.analyze(source)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C', 'Dm')))
engine = generator.solo.decoder.Decoder(a, h, generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8))
n = engine.note(fractions.Fraction(3), fractions.Fraction(6), 64) # E: C's third becomes D minor's ninth.
support = engine.permission(n)
self.assertIsNotNone(support)
self.assertEqual(support[0].rule,'minor_ninth')
self.assertTrue(h.evaluate((n,),complete=True,expressions=support).valid)
self.assertEqual(support[0].rule, 'minor_ninth')
self.assertTrue(h.evaluate((n,), complete=True, expressions=support).valid)
+10 -10
View File
@@ -41,7 +41,7 @@ BOOST_AUTO_TEST_CASE(menu_actions_share_navigation_and_preserve_settings_focus)
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(state.selected_settings_index == 1U);
state = vamp_zero::apply_menu_action(state, action(state, 1U));
BOOST_TEST(state.audio.playback_enabled);
BOOST_TEST(!state.audio.backing_enabled);
state = vamp_zero::apply_menu_action(state, action(state, 2U));
BOOST_TEST(state.audio.metronome_enabled);
state = vamp_zero::apply_menu_action(state, action(state, 3U));
@@ -53,7 +53,7 @@ BOOST_AUTO_TEST_CASE(menu_actions_share_navigation_and_preserve_settings_focus)
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(state.selected_settings_index == 3U);
BOOST_TEST(!state.audio.tempo_editing);
BOOST_TEST(state.audio.playback_enabled);
BOOST_TEST(!state.audio.backing_enabled);
BOOST_TEST(state.audio.metronome_enabled);
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
}
@@ -68,7 +68,7 @@ BOOST_AUTO_TEST_CASE(menu_touch_activates_once_on_release_and_stepper_has_priori
BOOST_REQUIRE(tapped);
state = vamp_zero::apply_menu_action(state, *tapped);
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(!state.audio.playback_enabled);
BOOST_TEST(!state.audio.backing_enabled);
BOOST_CHECK(!touch.handle(point_event(luma::touch_phase::UP, point, now), menu_bounds, state));
point = row_center(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP);
touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state);
@@ -76,16 +76,16 @@ BOOST_AUTO_TEST_CASE(menu_touch_activates_once_on_release_and_stepper_has_priori
BOOST_REQUIRE(tapped);
BOOST_CHECK(tapped->kind == vamp_zero::menu_action_kind::TEMPO_UP);
state = vamp_zero::apply_menu_action(state, *tapped);
BOOST_TEST(state.audio.metronome_bpm == 125U);
BOOST_TEST(state.audio.tempo_bpm == 120U);
BOOST_TEST(!state.audio.tempo_editing);
for (auto i = 0U; i < 100U; ++i) {
state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_DOWN));
}
BOOST_TEST(state.audio.metronome_bpm == state.audio.metronome_min_bpm);
BOOST_TEST(state.audio.tempo_bpm == 60U);
for (auto i = 0U; i < 100U; ++i) {
state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP));
}
BOOST_TEST(state.audio.metronome_bpm == state.audio.metronome_max_bpm);
BOOST_TEST(state.audio.tempo_bpm == 120U);
}
BOOST_AUTO_TEST_CASE(menu_touch_cancels_drag_hold_multitouch_and_stale_press) {
@@ -121,14 +121,14 @@ BOOST_AUTO_TEST_CASE(control_rejects_delayed_actions_after_remote_navigation_or_
control.apply(tapped);
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::SETTINGS_MENU);
control.apply(tapped);
BOOST_TEST(!control.snapshot().audio.playback_enabled);
BOOST_TEST(!control.snapshot().audio.backing_enabled);
auto settings = control.snapshot();
tapped = action(settings, 1U);
control.apply(vamp_protocol::page_command::NEXT);
auto before = control.snapshot();
control.apply(tapped);
BOOST_TEST(control.snapshot().input_revision == before.input_revision);
BOOST_TEST(!control.snapshot().audio.playback_enabled);
BOOST_TEST(!control.snapshot().audio.backing_enabled);
tapped = action(before, 1U);
tapped.catalog = std::make_shared<vamp_zero::catalog const>();
control.apply(tapped);
@@ -165,9 +165,9 @@ BOOST_AUTO_TEST_CASE(menu_scroll_reaches_every_song_and_remote_shows_menu_titles
BOOST_AUTO_TEST_CASE(pico_settings_parser_rejects_invalid_values_without_mutating_state) {
auto state = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("mode=settings_menu\nfocus=Playback\nplayback=1\nbpm=135\n", state));
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("mode=settings_menu\nfocus=Backing\nbacking=1\nbpm=135\n", state));
auto before = state;
for (auto text : {"bpm=0\n", "bpm=401\n", "bpm=-1\n", "editing=yes\n", "playback=2\n", "metronome=true\n", "focus=abcdefghijklmnopqrstuvwxyz0123456789\n"}) {
for (auto text : {"bpm=0\n", "bpm=401\n", "bpm=-1\n", "editing=yes\n", "backing=2\n", "metronome=true\n", "focus=abcdefghijklmnopqrstuvwxyz0123456789\n"}) {
BOOST_CHECK(!vamp_pico::parse_remote_display_body(text, state));
BOOST_CHECK(state == before);
}
+1 -1
View File
@@ -84,7 +84,7 @@ class MusicModelTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(source, part)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending'])
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody'])
self.assertFalse(measures[-1]['harmonies'])
self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies']))
self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies']))
+7 -7
View File
@@ -16,7 +16,7 @@ import generator.model
import generator.musicxml
import generator.parts
import generator.pitch
import generator.playback
import tests.score_events
import tests.arrangement_fixtures
import tests.solo_fixtures
import tests.test_musicxml
@@ -51,7 +51,7 @@ class PartGenerationTests(unittest.TestCase):
if onset < fractions.Fraction(3, 2):
self.assertLessEqual(onset + generator.model.fraction(note['duration']), fractions.Fraction(3, 2))
self.assertLessEqual({generator.model.pitch_number(p) % 12 for p in note['pitches']}, {0, 4, 7} if onset < fractions.Fraction(3, 2) else {0, 5, 9})
self.assertTrue(generator.playback.compile_timeline(result)['events'])
self.assertTrue(tests.score_events.timeline(result)['events'])
def test_harmony_carry_and_explicit_silence(self):
content = xml_harmony(offset=1) + xml_harmony(kind='none', offset=2) + xml_harmony('F', offset=3) + tests.test_musicxml.xml_note()
source = tests.test_musicxml.xml_score(content).replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note() + '</measure><measure number="3">' + xml_harmony(kind='none') + tests.test_musicxml.xml_note() + '</measure><measure number="4">' + tests.test_musicxml.xml_note() + '</measure></part>')
@@ -75,10 +75,10 @@ class PartGenerationTests(unittest.TestCase):
bar = xml_harmony() + ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(4))
xml = tests.test_musicxml.xml_score(bar).replace('</part>', ''.join(f'<measure number="{i}">{bar}</measure>' for i in range(2, 13)) + '</part>')
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(xml), 'acoustic')
self.assertEqual(len(result['staves'][0]['measures']),12)
self.assertEqual(len(result['staves'][0]['measures']), 12)
generator.model.validate_document(result)
self.assertTrue(generator.playback.compile_timeline(result)['events'])
self.assertTrue(any(n['kind']=='rest' for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m)))
self.assertTrue(tests.score_events.timeline(result)['events'])
self.assertTrue(any(n['kind'] == 'rest' for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m)))
def test_melody_written_pitch_rhythm_rests_and_cross_bar_ties_are_preserved(self):
content = ''
content += tests.test_musicxml.xml_note(duration=2, kind='quarter', alter=1, extra='<tie type="start"/>')
@@ -115,7 +115,7 @@ class PartGenerationTests(unittest.TestCase):
self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id'])
self.assertEqual(notes[1]['pitches'][0]['tie_to'], notes[2]['pitches'][0]['id'])
if builder is tests.arrangement_fixtures.section_document:
events = generator.playback.compile_timeline(result)['events']
events = tests.score_events.timeline(result)['events']
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in events], [(0, 6, 61 if part == 'electric' else 49), (fractions.Fraction(13, 2), fractions.Fraction(3, 2), 63 if part == 'electric' else 51)])
self.assertEqual(source, before)
def test_song_slug_collisions_fail_before_writes(self):
@@ -146,7 +146,7 @@ class PartGenerationTests(unittest.TestCase):
unittest.mock.patch('generator.engraving.pipeline.engrave_tree'),
unittest.mock.patch('generator.pico_artwork.generate_titles'),
unittest.mock.patch('generator.catalog.build_catalog'),
unittest.mock.patch('generator.playback.compile_tree'),
unittest.mock.patch('generator.performance.copy_audio', return_value=[]),
):
generator.__main__.generate_assets(args)
# Role catalog loading is replaced by this infrastructure fixture. The
+193
View File
@@ -0,0 +1,193 @@
#include <boost/test/unit_test.hpp>
#include <boost/json.hpp>
#include <array>
#include <fstream>
#include <iterator>
#include <thread>
#include <vamp_zero/audio.hpp>
#include <vamp_zero/control.hpp>
#include <vamp_zero/notation.hpp>
#include <vamp_zero/page_control.hpp>
#include <vamp_zero/performance.hpp>
namespace {
auto read(std::string const &name) -> std::string {
auto input = std::ifstream{std::string{VAMP_TEST_PERFORMANCE_ASSETS_DIR} + "/" + name};
return {std::istreambuf_iterator<char>{input}, {}};
}
auto score() -> vamp_zero::score_document {
auto parsed = vamp_zero::parse_notation_document(read("score.json"), "score.json");
BOOST_REQUIRE_MESSAGE(parsed, (parsed ? "" : parsed.error().describe()));
return *parsed;
}
auto state(bool metronome = false, bool backing = false) -> vamp_zero::app_state {
auto catalog = std::make_shared<vamp_zero::catalog>();
auto audio = vamp_zero::parse_audio_assets(read("manifest.audio"));
BOOST_REQUIRE(audio);
catalog->audio_assets = *audio;
catalog->parts.push_back({.id = "score", .title = "Fixture", .part = "Vocal", .total_pages = 2, .song_id = "fixture"});
catalog->groups.push_back({.title = "Fixture", .parts = {0}});
auto result = vamp_zero::app_state{.catalog = catalog, .mode = vamp_zero::app_mode::PERFORMANCE_SCORE};
result.selected_song_index = 1;
result.selected_part_index = 1;
result.active_part_id = "score";
result.score_session = 1;
result.audio.metronome_enabled = metronome;
result.audio.backing_enabled = backing;
return result;
}
}
BOOST_AUTO_TEST_CASE(recordings_and_clicks_share_clock_and_tail_is_unclicked) {
for (auto metronome : {false, true}) {
for (auto backing : {false, true}) {
auto app = state(metronome, backing);
auto document = score();
auto prepared = vamp_zero::prepare_performance(document, app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
if (!metronome && !backing) {
BOOST_CHECK(!prepared);
continue;
}
BOOST_REQUIRE_MESSAGE(prepared, (prepared ? "" : prepared.error().describe()));
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .master_volume = 1.F, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session);
auto frames = backing ? 576000U : 288000U;
auto buffer = std::array<float, 960>{};
for (unsigned cursor = 0; cursor < frames; cursor += 480) {
BOOST_REQUIRE(engine->render(buffer.data(), 480));
BOOST_TEST(session->frames() == cursor + 480);
if (cursor % 24000 == 0) {
auto expected = backing ? 0.03125F : 0.F;
if (metronome && cursor < 288000 && (!backing || cursor >= 96000)) {
expected += cursor % 96000 == 0 ? .25F : .125F;
}
BOOST_TEST(buffer.front() == expected, boost::test_tools::tolerance(0.0001F));
}
// Let the background stream worker refill between accelerated renders.
if (backing && cursor % 48000 == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds{2});
}
}
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
BOOST_TEST(vamp_zero::performance_at(document, 95999, 120).phase == "Count-in");
BOOST_TEST(vamp_zero::performance_at(document, 96000, 120).page == 0U);
BOOST_TEST(vamp_zero::performance_at(document, 191999, 120).page == 0U);
BOOST_TEST(vamp_zero::performance_at(document, 192000, 120).page == 1U);
BOOST_TEST(vamp_zero::performance_at(document, 288000, 120).phase == "Sustain");
}
}
}
BOOST_AUTO_TEST_CASE(manual_navigation_requires_both_toggles_off) {
for (auto metro : {false, true}) {
for (auto backing : {false, true}) {
auto app = state(metro, backing);
auto next = vamp_zero::apply_app_command(app, vamp_protocol::page_command::NEXT);
BOOST_TEST(next.page_index == (metro || backing ? 0U : 1U));
next = vamp_zero::apply_app_command(next, vamp_protocol::page_command::SELECT);
BOOST_CHECK(next.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
}
}
}
BOOST_AUTO_TEST_CASE(tempo_steps_use_global_recording_intersection_and_nearest_slower_tie) {
auto app = state();
BOOST_TEST(vamp_zero::available_tempos(app).size() == 7U);
app.mode = vamp_zero::app_mode::SETTINGS_MENU;
app.selected_settings_index = 1;
app.audio.tempo_bpm = 80;
app = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
BOOST_TEST(app.audio.backing_enabled);
BOOST_TEST(app.audio.tempo_bpm == 120U);
auto catalog = std::make_shared<vamp_zero::catalog>(*app.catalog);
auto entry = catalog->audio_assets.back();
entry.bpm = 100;
entry.path = "backing/fixture_100.wav";
catalog->audio_assets.push_back(entry);
app.catalog = catalog;
app.audio.backing_enabled = false;
app.audio.tempo_bpm = 110;
app = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
BOOST_TEST(app.audio.tempo_bpm == 100U);
catalog->parts.push_back({.song_id = "other"});
catalog->groups.push_back({.parts = {1}});
BOOST_TEST(vamp_zero::available_tempos(app).empty());
}
BOOST_AUTO_TEST_CASE(stale_performance_updates_do_not_navigate_or_restart) {
auto app = state(true, false);
auto control = vamp_zero::control_state{{}, app};
control.update_performance(99, 1, "Playing", app.catalog);
BOOST_TEST(control.snapshot().page_index == 0U);
control.update_performance(1, 1, "Playing", app.catalog);
BOOST_TEST(control.snapshot().page_index == 1U);
control.finish_playback(99, {}, app.catalog);
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
control.finish_playback(1, "Recording failed", app.catalog);
control.update_performance(1, 0, "Playing", app.catalog);
BOOST_TEST(control.snapshot().page_index == 1U);
BOOST_TEST(control.snapshot().audio.phase == "Error");
control.apply(vamp_protocol::page_command::SELECT);
control.finish_playback(1, {}, app.catalog);
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
}
BOOST_AUTO_TEST_CASE(recording_transport_cancellation_does_not_advance_clock) {
auto prepared = vamp_zero::prepare_performance(score(), state(true), VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
BOOST_REQUIRE(prepared);
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session);
session->stop();
std::array<float, 960> buffer{};
BOOST_REQUIRE(engine->render(buffer.data(), 480));
BOOST_TEST(session->frames() == 0U);
BOOST_TEST(buffer.front() == 0.F);
}
BOOST_AUTO_TEST_CASE(page_timing_and_recording_manifests_reject_corruption) {
auto document = boost::json::parse(read("score.json"));
document.at("presentation").at("pages").as_array()[1].at("first_measure") = 1;
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(document), "score.json"));
auto app = state(true);
auto catalog = std::make_shared<vamp_zero::catalog>(*app.catalog);
catalog->audio_assets.front().sha256 = std::string(64, '0');
app.catalog = catalog;
BOOST_CHECK(!vamp_zero::prepare_performance(score(), app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F));
}
BOOST_AUTO_TEST_CASE(preparation_waits_for_pages_and_cancellation_discards_stale_work) {
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
BOOST_REQUIRE(engine);
auto controller = vamp_zero::audio_controller{{}, std::move(*engine)};
auto app = state(true);
auto document = std::make_shared<vamp_zero::score_document const>(score());
auto loaded = vamp_zero::score_load_state{.document = document};
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
BOOST_CHECK(!controller.position(*document, 120));
loaded.pages_ready = true;
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
auto exited = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
controller.sync(exited);
for (unsigned i = 0; i < 20; ++i) {
BOOST_CHECK(!controller.tick(exited, {}, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
std::this_thread::sleep_for(std::chrono::milliseconds{1});
}
BOOST_CHECK(!controller.position(*document, 120));
++app.score_session;
for (unsigned i = 0; i < 200 && !controller.position(*document, 120); ++i) {
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
std::this_thread::sleep_for(std::chrono::milliseconds{1});
}
BOOST_REQUIRE(controller.position(*document, 120));
controller.sync(exited);
BOOST_CHECK(!controller.position(*document, 120));
}
+86
View File
@@ -0,0 +1,86 @@
import argparse
import copy
import hashlib
import json
import pathlib
import shutil
import tempfile
import unittest
import wave
import generator.performance
import generator.model
import tests.test_musicxml
def fixture(root):
root.mkdir(parents=True, exist_ok=True)
music = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
first = music['staves'][0]['measures'][0]
music['staves'][0]['measures'].append(copy.deepcopy(first))
music['metadata']['song_id'] = 'fixture'
pages = [dict(path=f'{i + 1}.png', sha256='0' * 64, first_measure=i + 1, measure_count=1,
start_beat=generator.model.rational(i * 4), end_beat=generator.model.rational((i + 1) * 4)) for i in range(2)]
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages), performance=generator.performance.timing(music))
(root / 'score.json').write_text(json.dumps(bundle))
entries = []
# Constant PCM makes accents, recorded count-in and the silent click tail observable.
for name, frames, value, song_id, bpm in [('sounds/beat1.wav', 2400, 8192, '', 0), ('sounds/beat2.wav', 2400, 4096, '', 0), ('backing/fixture_120.wav', 576000, 1024, 'fixture', 120)]:
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), 'wb') as output:
output.setparams((2, 2, 48000, 0, 'NONE', ''))
output.writeframes(value.to_bytes(2, 'little', signed=True) * 2 * frames)
entries.append(generator.performance.describe_wav(path, root, song_id=song_id, bpm=bpm))
(root / 'manifest.audio').write_text(json.dumps(entries))
class PerformanceTests(unittest.TestCase):
def test_supplied_audio_is_copied_without_transformation(self):
source = pathlib.Path('package/assets')
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
entries = generator.performance.copy_audio(source, root, {'all_of_me', 'autumn_leaves'})
self.assertEqual(len(entries), 4)
for entry in entries:
self.assertEqual((source / entry['path']).read_bytes(), (root / entry['path']).read_bytes())
self.assertEqual(entry['sha256'], hashlib.sha256((root / entry['path']).read_bytes()).hexdigest())
def test_recording_names_and_truncation_are_rejected(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
fixture(root)
path = root / 'backing/fixture_120.wav'
path.write_bytes(path.read_bytes()[:-2])
with self.assertRaisesRegex(ValueError, 'truncated'):
generator.performance.describe_wav(path, root)
path.rename(root / 'backing/fixture_115.wav')
with self.assertRaisesRegex(ValueError, 'unsupported'):
generator.performance.copy_audio(root, root / 'out', {'fixture'})
def test_short_backing_cannot_publish(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
fixture(root)
(root / 'songs').mkdir()
bundle = json.loads((root / 'score.json').read_text())
(root / 'songs/fixture.json').write_text(json.dumps(bundle['music']))
entries = json.loads((root / 'manifest.audio').read_text())
generator.performance.validate_recording_lengths(root, entries)
entries[-1]['frames'] = 1
with self.assertRaisesRegex(ValueError, 'shorter'):
generator.performance.validate_recording_lengths(root, entries)
def test_page_coverage_and_measure_accents(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
fixture(root)
bundle = json.loads((root / 'score.json').read_text())
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
self.assertEqual([c['downbeat'] for c in bundle['performance']['clicks']], [True, False, False, False] * 2)
self.assertEqual(bundle['performance']['duration'], generator.model.rational(8))
bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3)
with self.assertRaisesRegex(ValueError, 'timing'):
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--fixture', type=pathlib.Path)
args = parser.parse_args()
if args.fixture:
fixture(args.fixture)
-133
View File
@@ -1,133 +0,0 @@
#include <boost/test/unit_test.hpp>
#include <boost/json.hpp>
#include <array>
#include <fstream>
#include <iterator>
#include <thread>
#include <vamp_zero/audio.hpp>
#include <vamp_zero/config.hpp>
#include <vamp_zero/playback.hpp>
#include <vamp_zero/control.hpp>
namespace {
auto playback_score() -> vamp_zero::score_document {
auto input = std::ifstream{std::string{VAMP_TEST_PLAYBACK_ASSETS_DIR} + "/score.json"};
auto score = vamp_zero::score_document{};
score.source_json = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
return score;
}
auto performance_state() -> vamp_zero::app_state {
auto catalog = std::make_shared<vamp_zero::catalog>();
catalog->parts.push_back({.id = "part", .title = "Song", .part = "Vocal", .total_pages = 2U});
catalog->groups.push_back({.title = "Song", .parts = {0U}});
auto state = vamp_zero::app_state{};
state.catalog = std::move(catalog);
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
state.selected_song_index = 1U;
state.selected_part_index = 1U;
state.active_part_id = "part";
state.score_session = 42U;
state.audio.playback_enabled = true;
return state;
}
}
BOOST_AUTO_TEST_CASE(generated_audio_plays_at_menu_bpm) {
auto score = playback_score();
auto normal = vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 120U);
auto slow = vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 60U);
BOOST_REQUIRE_MESSAGE(normal, (normal ? "" : normal.error().describe()));
BOOST_REQUIRE(slow);
BOOST_TEST(normal->duration_frames == 96000U);
BOOST_TEST(slow->duration_frames == normal->duration_frames * 2U);
BOOST_TEST(normal->samples[0].frames == slow->samples[0].frames, boost::test_tools::per_element());
auto engine = sonus::engine::create({.sample_rate = 48000U, .channels = 1U, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_sequence(std::move(*normal));
BOOST_REQUIRE(session);
auto output = std::array<std::float32_t, 480>{};
auto energy = std::float32_t{};
for (auto block = std::uint32_t{}; block < 200U; ++block) {
BOOST_REQUIRE(engine->render(output.data(), output.size()));
for (auto value : output) {
energy += value * value;
}
if (block == 150U) {
BOOST_CHECK(session->status() == sonus::playback_status::PLAYING);
}
}
BOOST_TEST(energy > 0.1F);
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
BOOST_REQUIRE(vamp_zero::validate_playback_assets(score, VAMP_TEST_PLAYBACK_ASSETS_DIR));
}
BOOST_AUTO_TEST_CASE(playback_completion_is_session_guarded) {
auto state = vamp_zero::control_state{{}, performance_state()};
state.finish_playback(41U);
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
state.finish_playback(42U, {}, std::make_shared<vamp_zero::catalog const>());
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
state.finish_playback(42U, "sample missing");
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
BOOST_TEST(state.snapshot().audio.playback_error == "sample missing");
state.finish_playback(42U);
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
BOOST_TEST(state.snapshot().selected_part_index == 1U);
BOOST_TEST(state.snapshot().score_session == 42U);
state.apply(vamp_protocol::page_command::SELECT);
BOOST_TEST(state.snapshot().score_session == 43U);
state.finish_playback(42U);
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
auto disabled = performance_state();
disabled.audio.playback_enabled = false;
auto quiet = vamp_zero::control_state{{}, disabled};
quiet.finish_playback(42U);
BOOST_CHECK(quiet.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
}
BOOST_AUTO_TEST_CASE(playback_worker_discards_canceled_preparation_and_reports_errors_once) {
auto engine = sonus::engine::create({.no_device = true});
BOOST_REQUIRE(engine);
auto audio = vamp_zero::audio_controller{{}, std::move(*engine)};
auto state = performance_state();
auto invalid = std::make_shared<vamp_zero::score_document>();
invalid->id = "part";
invalid->source_json = "{}";
auto loaded = vamp_zero::score_load_state{.document = invalid};
BOOST_CHECK(!audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR));
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
for (auto attempt = std::uint32_t{}; attempt < 100U; ++attempt) {
BOOST_CHECK(!audio.tick(state, {}, VAMP_TEST_PLAYBACK_ASSETS_DIR));
std::this_thread::sleep_for(std::chrono::milliseconds{1});
}
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
++state.score_session;
auto reported = std::bool_t{};
for (auto attempt = std::uint32_t{}; attempt < 1000U && !reported; ++attempt) {
if (auto result = audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR)) {
BOOST_CHECK(!*result);
reported = true;
}
std::this_thread::sleep_for(std::chrono::milliseconds{1});
}
BOOST_TEST(reported);
BOOST_CHECK(!audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR));
}
BOOST_AUTO_TEST_CASE(playback_rejects_legacy_config_and_invalid_assets) {
auto legacy = vamp_zero::parse_config(boost::json::object{{"audio", {{"track_enabled", true}}}});
BOOST_REQUIRE(!legacy);
BOOST_TEST(std::string{legacy.error().message()}.find("use audio.playback_enabled") != std::string::npos);
BOOST_CHECK(!vamp_zero::parse_config(boost::json::object{{"audio", {{"track_enabled", true}, {"playback_enabled", false}}}}));
auto score = playback_score();
BOOST_CHECK(!vamp_zero::prepare_playback(score, "/missing-vamp-assets", 120U));
BOOST_CHECK(!vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 0U));
score.source_json = "{}";
BOOST_CHECK(!vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 120U));
}
-112
View File
@@ -1,112 +0,0 @@
import fractions
import json
import pathlib
import tempfile
import unittest
import wave
import generator.model
import generator.playback
import generator.score
import tests.test_musicxml
class PlaybackTests(unittest.TestCase):
def test_grace_steals_accumulate_at_both_ends_of_a_donor(self):
grace = '<note><grace {}/><pitch><step>{}</step><octave>4</octave></pitch><type>eighth</type></note>'
content = grace.format('steal-time-following="25"', 'D') + tests.test_musicxml.xml_note(duration=2, kind='half') + grace.format('steal-time-previous="25"', 'E') + tests.test_musicxml.xml_note('F', 2, 'half')
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)))
self.assertEqual([(generator.model.fraction(e['pitch']), generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in result['events']], [(62, 0, fractions.Fraction(1, 2)), (60, fractions.Fraction(1, 2), 1), (64, fractions.Fraction(3, 2), fractions.Fraction(1, 2)), (65, 2, 2)])
for percent in (50, 60):
with self.subTest(percent=percent), self.assertRaisesRegex(generator.playback.PlaybackUnavailable, 'grace timing consumes'):
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content.replace('"25"', f'"{percent}"'))))
chord = content.replace(tests.test_musicxml.xml_note(duration=2, kind='half'), tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('G', 1, 'quarter', prefix='<chord/>'))
with self.assertRaisesRegex(generator.playback.PlaybackUnavailable, 'chord tone'):
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord)))
def test_grace_previous_and_make_time_preserve_their_timing(self):
for mode, expected, total in [('steal-time-previous="25"', [(0, fractions.Fraction(3, 2)), (fractions.Fraction(3, 2), fractions.Fraction(1, 2)), (2, 2)], 4), ('make-time="1"', [(0, 2), (2, 1), (3, 2)], 5)]:
grace = f'<note><grace {mode}/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + grace + tests.test_musicxml.xml_note('E', 2, 'half'))))
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in result['events']], expected)
self.assertEqual(generator.model.fraction(result['duration']), total)
def test_chords_and_trailing_rest(self):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('E', 1, 'quarter', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'))
result = generator.playback.compile_timeline(document)
self.assertEqual(generator.model.fraction(result['duration']), 4)
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [2, 1])
def test_tie_is_one_attack(self):
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note(duration=2, kind='half', extra='<tie type="stop"/>'))))
self.assertEqual(len(result['events']), 1)
self.assertEqual(generator.model.fraction(result['events'][0]['duration']), 4)
def test_octave_and_microtones(self):
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.5'), '<transpose><diatonic>0</diatonic><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>')))
self.assertEqual(generator.model.fraction(result['events'][0]['pitch']), fractions.Fraction(97, 2))
def test_grace_default_and_cues(self):
grace = '<note><grace/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(grace + tests.test_musicxml.xml_note())))
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [1, 3])
self.assertEqual([generator.model.fraction(e['onset']) for e in result['events']], [0, 1])
self.assertEqual(generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(prefix='<cue/>'))))['events'], [])
def test_tremolo(self):
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<notations><ornaments><tremolo>3</tremolo></ornaments></notations>'))))
self.assertEqual(len(result['events']), 32)
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification><notations><ornaments><tremolo>1</tremolo></ornaments></notations>'
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='eighth', extra=extra), divisions=3, implicit=True)))
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [fractions.Fraction(1, 6)] * 2)
def test_hidden_pitches_sound_and_unpitched_is_unavailable(self):
hidden = tests.test_musicxml.xml_note().replace('<note>', '<note print-object="no">')
self.assertEqual(len(generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(hidden)))['events']), 1)
unpitched = '<note><unpitched><display-step>C</display-step><display-octave>4</display-octave></unpitched><duration>4</duration><type>whole</type></note>'
with self.assertRaises(generator.playback.PlaybackUnavailable):
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(unpitched)))
def test_sounding_pitch_depends_on_instrument_transposition_not_part_or_path(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
for part in ('acoustic', 'electric', 'vocal'):
source['staves'][0]['arrangement_part_type'] = part
source['staves'][0]['measures'][0]['transpose']['octave_change'] = -1 if part != 'vocal' else 0
generator.score.write_json(root / f'{part}.json', source)
generator.playback.compile_tree(root)
for part, pitch in [('acoustic', 48), ('electric', 48), ('vocal', 60)]:
playback = json.loads((root / f'{part}.json').read_text())['playback']
self.assertEqual(playback['samples'][0]['id'], generator.playback.generate_tone(root, fractions.Fraction(pitch))['id'])
def test_audio_assets_validate_and_reject_corruption(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
generate_fixture(root)
manifest = json.loads((root / 'score.json').read_text())['playback']
generator.playback.validate_assets(manifest, root)
sample = manifest['samples'][0]
path = root / sample['path']
path.write_bytes(path.read_bytes()[:-1])
with self.assertRaises(ValueError):
generator.playback.validate_assets(manifest, root)
generate_fixture(root)
manifest['release']['sha256'] = generator.playback.write_pcm(root / 'audio/release.wav', [0.5] * 2400)
with self.assertRaisesRegex(ValueError, 'envelope'):
generator.playback.validate_assets(manifest, root)
sample['path'] = '../outside.wav'
with self.assertRaises(ValueError):
generator.playback.validate_assets(manifest, root)
def test_generated_tone_is_deterministic_and_loopable(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
a = generator.playback.generate_tone(root, fractions.Fraction(60))
b = generator.playback.generate_tone(root, fractions.Fraction(60))
self.assertEqual(a, b)
with wave.open(str(root / a['path'])) as audio:
self.assertEqual(audio.getframerate(), 48000)
self.assertEqual(audio.getnframes(), a['loop_end'])
self.assertEqual(audio.getnchannels(), 1)
self.assertGreater(a['loop_end'], a['loop_start'])
def generate_fixture(directory):
root = pathlib.Path(directory)
generator.score.write_json(root / 'score.json', tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('E', 2, 'half', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>')))
generator.playback.compile_tree(root)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--fixture', required=True)
generate_fixture(parser.parse_args().fixture)
+60 -64
View File
@@ -1,73 +1,69 @@
import dataclasses, unittest
from fractions import Fraction as F
from generator.solo import types, harmonic, groups
from tests.solo_fixtures import note,choices
import fractions
import generator.solo.types
import generator.solo.harmonic
import generator.solo.groups
import tests.solo_fixtures
import tests.test_independent_generation
import tempfile
import pathlib
import generator.input_audit
def melody(rows): return tuple(tests.solo_fixtures.note(a, b, p, event_id=str(i)) for i, (a, b, p) in enumerate(rows))
def melody(rows):return tuple(note(a,b,p,event_id=str(i)) for i,(a,b,p) in enumerate(rows))
class RhythmGroups(unittest.TestCase):
def test_complete_triplet_can_be_displaced_without_offsetting_binary_tail(self):
pattern = ((F(1, 2), F(5, 6)), (F(5, 6), F(7, 6)), (F(7, 6), F(3, 2)), (F(3, 2), F(5, 2)))
result = groups.describe(pattern)
self.assertEqual(result[0].family, 'triplet')
self.assertEqual(result[-1].family, 'binary')
self.assertEqual(result[0].displacement, F(1, 2))
def test_partial_triplet_does_not_drift_into_binary(self):
self.assertFalse(groups.valid(((F(), F(1, 3)), (F(1, 3), F(5, 6)))))
self.assertFalse(groups.valid(((F(1, 3), F(4, 3)),)))
self.assertEqual(groups.describe(((F(1, 3), F(4, 3)),), imported=True)[0].family, 'source')
def test_relative_rhythm_identity_does_not_count_absolute_onsets(self):
original = melody(((0, 1, 'c'), (1, 2, 'e'), (2, 3, 'd')))
self.assertEqual(len(set(groups.signature(original))), 1)
moved = tuple((dataclasses.replace(n, start=n.start + 13, end=n.end + 13) for n in original))
self.assertEqual(groups.signature(original), groups.signature(moved))
def test_broadening_never_cuts_inside_tuplet(self):
pattern = ((F(), F(1, 3)), (F(1, 3), F(2, 3)), (F(2, 3), F(1)))
self.assertEqual(groups.broaden(pattern, F(1, 2), F(4)), ())
def test_complete_triplet_can_be_displaced_without_offsetting_binary_tail(self):
pattern = ((fractions.Fraction(1, 2), fractions.Fraction(5, 6)), (fractions.Fraction(5, 6), fractions.Fraction(7, 6)), (fractions.Fraction(7, 6), fractions.Fraction(3, 2)), (fractions.Fraction(3, 2), fractions.Fraction(5, 2)))
result = generator.solo.groups.describe(pattern)
self.assertEqual(result[0].family, 'triplet')
self.assertEqual(result[-1].family, 'binary')
self.assertEqual(result[0].displacement, fractions.Fraction(1, 2))
def test_partial_triplet_does_not_drift_into_binary(self):
self.assertFalse(generator.solo.groups.valid(((fractions.Fraction(), fractions.Fraction(1, 3)), (fractions.Fraction(1, 3), fractions.Fraction(5, 6)))))
self.assertFalse(generator.solo.groups.valid(((fractions.Fraction(1, 3), fractions.Fraction(4, 3)),)))
self.assertEqual(generator.solo.groups.describe(((fractions.Fraction(1, 3), fractions.Fraction(4, 3)),), imported=True)[0].family, 'source')
def test_relative_rhythm_identity_does_not_count_absolute_onsets(self):
original = melody(((0, 1, 'c'), (1, 2, 'e'), (2, 3, 'd')))
self.assertEqual(len(set(generator.solo.groups.signature(original))), 1)
moved = tuple((dataclasses.replace(n, start=n.start + 13, end=n.end + 13) for n in original))
self.assertEqual(generator.solo.groups.signature(original), generator.solo.groups.signature(moved))
def test_broadening_never_cuts_inside_tuplet(self):
pattern = ((fractions.Fraction(), fractions.Fraction(1, 3)), (fractions.Fraction(1, 3), fractions.Fraction(2, 3)), (fractions.Fraction(2, 3), fractions.Fraction(1)))
self.assertEqual(generator.solo.groups.broaden(pattern, fractions.Fraction(1, 2), fractions.Fraction(4)), ())
class PendingTreatments(unittest.TestCase):
def test_complete_route_can_cross_a_search_frontier(self):
from tests.test_independent_generation import TypedExpressionContracts
fixture = TypedExpressionContracts()
fixture.setUp()
notes, contract = fixture.make()
partial = fixture.h.evaluate(notes[:2], until=F(2), complete=False, expressions=(contract,))
self.assertTrue(partial.valid, partial.error)
self.assertEqual(partial.pending, (contract,))
self.assertFalse(fixture.h.evaluate(notes[:2], until=F(3), expressions=(contract,)).valid)
complete = fixture.h.evaluate(notes, until=F(4), complete=True, expressions=(contract,))
self.assertTrue(complete.valid)
self.assertFalse(complete.pending)
def test_complete_route_can_cross_a_search_frontier(self):
fixture = tests.test_independent_generation.TypedExpressionContracts()
fixture.setUp()
notes, contract = fixture.make()
partial = fixture.h.evaluate(notes[:2], until=fractions.Fraction(2), complete=False, expressions=(contract,))
self.assertTrue(partial.valid, partial.error)
self.assertEqual(partial.pending, (contract,))
self.assertFalse(fixture.h.evaluate(notes[:2], until=fractions.Fraction(3), expressions=(contract,)).valid)
complete = fixture.h.evaluate(notes, until=fractions.Fraction(4), complete=True, expressions=(contract,))
self.assertTrue(complete.valid)
self.assertFalse(complete.pending)
class InputFirewall(unittest.TestCase):
def test_read_write_handles_cannot_bypass_boundary(self):
import tempfile
from pathlib import Path
from generator.input_audit import composition
with tempfile.TemporaryDirectory() as folder:
forbidden = Path(folder) / 'target.xml'
forbidden.write_text('not musical input')
for mode in ('r', 'r+'):
with self.assertRaisesRegex(ValueError, 'Forbidden composition input'):
with composition(Path('data'), 'electric_solo'):
with forbidden.open(mode) as stream:
stream.read()
def test_read_write_handles_cannot_bypass_boundary(self):
with tempfile.TemporaryDirectory() as folder:
forbidden = pathlib.Path(folder) / 'target.xml'
forbidden.write_text('not musical input')
for mode in ('r', 'r+'):
with self.assertRaisesRegex(ValueError, 'Forbidden composition input'):
with generator.input_audit.composition(pathlib.Path('data'), 'electric_solo'):
with forbidden.open(mode) as stream:
stream.read()
class PreparedSuspensions(unittest.TestCase):
def test_preparation_and_delayed_resolution_are_required(self):
h = harmonic.Harmony(choices(('Gm7', 'C7')))
held = note(F(5, 2), F(9, 2), 'f', event_id='held')
target = note(F(9, 2), 6, 'e', event_id='resolution')
contract = types.ExpressionContract('prepared', 'suspension', 'prepared_step_down', (held,), target, 'C7', boundary=F(4))
self.assertTrue(h.evaluate((held, target), expressions=(contract,)).valid)
self.assertTrue(h.evaluate((held, target)).valid)
self.assertFalse(h.evaluate((held, target),infer=False).valid)
changed = dataclasses.replace(held, start=F(15, 4))
self.assertFalse(h.evaluate((changed, target), expressions=(dataclasses.replace(contract, events=(changed,)),)).valid)
self.assertFalse(h.evaluate((held,), expressions=(contract,)).valid)
def test_preparation_and_delayed_resolution_are_required(self):
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Gm7', 'C7')))
held = tests.solo_fixtures.note(fractions.Fraction(5, 2), fractions.Fraction(9, 2), 'f', event_id='held')
target = tests.solo_fixtures.note(fractions.Fraction(9, 2), 6, 'e', event_id='resolution')
contract = generator.solo.types.ExpressionContract('prepared', 'suspension', 'prepared_step_down', (held,), target, 'C7', boundary=fractions.Fraction(4))
self.assertTrue(h.evaluate((held, target), expressions=(contract,)).valid)
self.assertTrue(h.evaluate((held, target)).valid)
self.assertFalse(h.evaluate((held, target), infer=False).valid)
changed = dataclasses.replace(held, start=fractions.Fraction(15, 4))
self.assertFalse(h.evaluate((changed, target), expressions=(dataclasses.replace(contract, events=(changed,)),)).valid)
self.assertFalse(h.evaluate((held,), expressions=(contract,)).valid)
+6 -2
View File
@@ -26,8 +26,9 @@ import generator.model
import generator.musicxml
import generator.musicxml_writer
import generator.pico_artwork
import generator.performance
import generator.pitch
import generator.playback
import tests.score_events
import tests.arrangement_fixtures
import generator.score
import generator.theme
@@ -71,7 +72,7 @@ class ScoreBundleTests(unittest.TestCase):
for start, end in ((0, 4), (4, 8)):
system = generator.engraving.layout.make_system(index, start, end, fonts, generator.engraving.types.Style())
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
self.assertEqual([generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(source)['events']], [8])
self.assertEqual([generator.model.fraction(e['duration']) for e in tests.score_events.timeline(source)['events']], [8])
restored = self.round_trip(source)
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
self.assertEqual(restored['staves'][0]['measures'][1]['barlines'], {'right': 'light-heavy'})
@@ -212,6 +213,9 @@ class ScoreBundleTests(unittest.TestCase):
page.write_bytes(data)
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
times = generator.performance.boundaries(document)
bundle['presentation']['pages'][0].update(first_measure=1, measure_count=len(times) - 1, start_beat=generator.model.rational(0), end_beat=generator.model.rational(times[-1]))
bundle['performance'] = generator.performance.timing(document)
path = assets / 'score.json'
path.write_text(json.dumps(bundle))
self.assertEqual(generator.score.load_json(path), document)
+29 -27
View File
@@ -1,6 +1,6 @@
import copy
import dataclasses
from fractions import Fraction as F
import fractions
import unittest
import generator.arrange.generate
@@ -8,38 +8,40 @@ import generator.arrange.form
import generator.model
import generator.parts
import generator.solo.arrangement
from generator.solo import analysis, profiles, placement, types, harmonic
from tests.solo_fixtures import song
from tests.solo_fixtures import note
from tests.solo_fixtures import choices
import generator.solo.analysis
import generator.solo.profiles
import generator.solo.placement
import generator.solo.types
import generator.solo.harmonic
import tests.solo_fixtures
import tests.solo_fixtures
import tests.solo_fixtures
def events(measure):
return tuple((generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
tuple((p['letter'],p['alter']['numerator'],p['octave'],p['tablature']['string'],p['tablature']['fret']) for p in n['pitches']))
tuple((p['letter'], p['alter']['numerator'], p['octave'], p['tablature']['string'], p['tablature']['fret']) for p in n['pitches']))
for v in measure['voices'] for n in v['notes'])
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,76));self.assertEqual(electric.register,(41,76))
self.assertTrue(acoustic.permits(41));self.assertTrue(electric.permits(50))
acoustic = generator.solo.profiles.for_part('acoustic')
electric = generator.solo.profiles.for_part('electric')
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.assertTrue(any(p.string_number==1 for p in placement.positions(65,acoustic)))
for profile in (acoustic, electric):
for midi in range(40, 78):
for p in generator.solo.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 generator.solo.placement.positions(65, electric)))
self.assertTrue(any(p.string_number == 1 for p in generator.solo.placement.positions(65, acoustic)))
def test_soft_overlapping_registers_do_not_inherit_a_mandatory_octave(self):
source=analysis.analyze(generator.parts.prepare_document(song(count=4,chords=('CM7',))))
acoustic=profiles.for_part('acoustic',source);electric=profiles.for_part('electric',source)
self.assertIsNone(electric.reference);self.assertIsNone(acoustic.reference)
self.assertEqual(acoustic.melody_range,electric.melody_range)
source = generator.solo.analysis.analyze(generator.parts.prepare_document(tests.solo_fixtures.song(count=4, chords=('CM7',))))
acoustic = generator.solo.profiles.for_part('acoustic', source)
electric = generator.solo.profiles.for_part('electric', source)
self.assertIsNone(electric.reference)
self.assertIsNone(acoustic.reference)
self.assertEqual(acoustic.melody_range, electric.melody_range)
+104 -130
View File
@@ -2,163 +2,137 @@
import copy
import dataclasses
import json
from fractions import Fraction as F
from pathlib import Path
import fractions
import pathlib
import tempfile
from types import SimpleNamespace
import types
import unittest
from unittest.mock import patch
import unittest.mock
import contextlib
import io
from tests import solo_compare
reference_review = solo_compare
from generator import vocabulary, production
from generator.solo import export, harmonic, notation, placement, types
from tests.solo_fixtures import song
from tests.solo_fixtures import note
from tests.solo_fixtures import choices
from tests import solo_fixtures
import tests.solo_compare
import generator.score
import generator.training_io
import generator.vocabulary
import generator.production
import generator.solo.export
import generator.solo.harmonic
import generator.solo.notation
import generator.solo.placement
import generator.solo.types
import tests.solo_fixtures
import tests.solo_fixtures
import tests.solo_fixtures
import tests.solo_fixtures
def xml(notes, *, transpose=False, leadin=''):
return '<score-partwise version="4.0"><part-list><score-part id="P"><part-name>Vocal</part-name></score-part></part-list><part id="P">'+leadin+'<measure number="1"><attributes><divisions>6</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef>'+('<transpose><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>' if transpose else '')+'</attributes><harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>'+notes+'</measure></part></score-partwise>'
return '<score-partwise version="4.0"><part-list><score-part id="P"><part-name>Vocal</part-name></score-part></part-list><part id="P">' + leadin + '<measure number="1"><attributes><divisions>6</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef>' + ('<transpose><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>' if transpose else '') + '</attributes><harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + notes + '</measure></part></score-partwise>'
def pitched(duration, tie='', octave=5):
return f'<note><pitch><step>C</step><octave>{octave}</octave></pitch><duration>{duration}</duration>{tie}<type>quarter</type></note>'
class ComparisonTests(unittest.TestCase):
def test_comparison_separates_pitch_timing_and_sounding_duration(self):
events=lambda pitch,end: [dict(start='0',end=end,pitch=pitch)]
a=events(60,'2')
octave=solo_compare.relationships(a,events(72,'2'))
self.assertEqual(octave['pitch_sequence_edit_distance'],1)
self.assertEqual(octave['timed_attack_edit_distance'],0)
self.assertEqual(octave['same_sounding_pitch_beats'],'0')
self.assertEqual(octave['same_pitch_class_beats'],'2')
shorter=solo_compare.relationships(a,events(60,'1'))
self.assertEqual(shorter['pitch_sequence_edit_distance'],0)
self.assertEqual(shorter['timed_attack_edit_distance'],1)
self.assertEqual(shorter['same_sounding_pitch_beats'],'1')
self.assertEqual(shorter['either_sounding_beats'],'2')
def events(pitch, end):
return [dict(start='0', end=end, pitch=pitch)]
a = events(60, '2')
octave = tests.solo_compare.relationships(a, events(72, '2'))
self.assertEqual(octave['pitch_sequence_edit_distance'], 1)
self.assertEqual(octave['timed_attack_edit_distance'], 0)
self.assertEqual(octave['same_sounding_pitch_beats'], '0')
self.assertEqual(octave['same_pitch_class_beats'], '2')
shorter = tests.solo_compare.relationships(a, events(60, '1'))
self.assertEqual(shorter['pitch_sequence_edit_distance'], 0)
self.assertEqual(shorter['timed_attack_edit_distance'], 1)
self.assertEqual(shorter['same_sounding_pitch_beats'], '1')
self.assertEqual(shorter['either_sounding_beats'], '2')
def test_exact_cli_is_a_real_exit_gate(self):
args=['solo_compare','--generated-dir','unused','--output','unused','--reference','fixture/acoustic=unused','--require-exact']
review={'exact_musical_match':False,'exact_matching_attacks':0,'differences':[{}],'rest_difference':True}
with patch('sys.argv',args),patch.object(solo_compare,'run',return_value={'solos':{'fixture/acoustic':review}}),contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(solo_compare.main(),1)
review.update(exact_musical_match=True,differences=[],rest_difference=False)
self.assertEqual(solo_compare.main(),0)
with patch('sys.argv',args[:-3]+['--require-exact']),contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as error:solo_compare.main()
self.assertEqual(error.exception.code,2)
args = ['solo_compare', '--generated-dir', 'unused', '--output', 'unused', '--reference', 'fixture/acoustic=unused', '--require-exact']
review = {'exact_musical_match': False, 'exact_matching_attacks': 0, 'differences': [{}], 'rest_difference': True}
with unittest.mock.patch('sys.argv', args), unittest.mock.patch.object(tests.solo_compare, 'run', return_value={'solos': {'fixture/acoustic': review}}), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(tests.solo_compare.main(), 1)
review.update(exact_musical_match=True, differences=[], rest_difference=False)
self.assertEqual(tests.solo_compare.main(), 0)
with unittest.mock.patch('sys.argv', args[:-3] + ['--require-exact']), contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as error:
tests.solo_compare.main()
self.assertEqual(error.exception.code, 2)
def test_note_container_does_not_turn_color_label_into_fingering(self):
with tempfile.TemporaryDirectory() as root:
note_xml=pitched(24).replace('</note>','<notations><technical><fingering>6</fingering></technical></notations></note>')
r=self.review(root,'annotation.xml',xml(note_xml),form_start=1)
self.assertEqual(r['annotations'][0]['text'],'6')
self.assertEqual(r['annotations'][0]['container'],'notations/technical/fingering')
note_xml = pitched(24).replace('</note>', '<notations><technical><fingering>6</fingering></technical></notations></note>')
r = self.review(root, 'annotation.xml', xml(note_xml), form_start=1)
self.assertEqual(r['annotations'][0]['text'], '6')
self.assertEqual(r['annotations'][0]['container'], 'notations/technical/fingering')
self.assertIsNone(r['notes'][0]['tablature'])
def review(self,directory,name,content,**kwargs):
path=Path(directory)/name;path.write_text(content)
return reference_review.read(path,'fixture/acoustic',**kwargs)
def review(self, directory, name, content, **kwargs):
path = pathlib.Path(directory) / name
path.write_text(content)
return generator.training_io.read(path, 'fixture/acoustic', **kwargs)
def test_ties_not_reattacks_and_one_guitar_transposition(self):
with tempfile.TemporaryDirectory() as root:
a=self.review(root,'actual.xml',xml(pitched(24),transpose=True),form_start=1)
b=self.review(root,'reference.xml',xml(pitched(12,'<tie type="start"/>')+pitched(12,'<tie type="stop"/>')),form_start=1,pitch_convention='guitar-written')
self.assertTrue(solo_compare.compare(a,b)['exact_musical_match'])
self.assertEqual(a['sounding_events'][0]['sounding_midi'],'60')
repeated=self.review(root,'repeat.xml',xml(pitched(12)+pitched(12)),form_start=1,pitch_convention='guitar-written')
self.assertFalse(solo_compare.compare(a,repeated)['exact_musical_match'])
a = self.review(root, 'actual.xml', xml(pitched(24), transpose=True), form_start=1)
b = self.review(root, 'reference.xml', xml(pitched(12, '<tie type="start"/>') + pitched(12, '<tie type="stop"/>')), form_start=1, pitch_convention='guitar-written')
self.assertTrue(tests.solo_compare.compare(a, b)['exact_musical_match'])
self.assertEqual(a['sounding_events'][0]['sounding_midi'], '60')
repeated = self.review(root, 'repeat.xml', xml(pitched(12) + pitched(12)), form_start=1, pitch_convention='guitar-written')
self.assertFalse(tests.solo_compare.compare(a, repeated)['exact_musical_match'])
def test_leadin_is_reported_without_harmony_or_alignment_padding(self):
with tempfile.TemporaryDirectory() as root:
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes>'+pitched(6)+'<note><rest/><duration>18</duration></note></measure>'
a=self.review(root,'a.xml',xml(pitched(24),transpose=True),form_start=1)
b=self.review(root,'b.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
result=solo_compare.compare(a,b)
self.assertEqual(result['differences'][0]['time'],'-4')
self.assertEqual(result['exact_matching_attacks'],1)
self.assertIn('uncertainty',b['notes'][0]['harmonic_spans'][0])
prelude = '<measure number="0"><attributes><divisions>6</divisions></attributes>' + pitched(6) + '<note><rest/><duration>18</duration></note></measure>'
a = self.review(root, 'a.xml', xml(pitched(24), transpose=True), form_start=1)
b = self.review(root, 'b.xml', xml(pitched(24), leadin=prelude), form_start=2, pitch_convention='guitar-written')
result = tests.solo_compare.compare(a, b)
self.assertEqual(result['differences'][0]['time'], '-4')
self.assertEqual(result['exact_matching_attacks'], 1)
self.assertIn('uncertainty', b['notes'][0]['harmonic_spans'][0])
def test_unplayable_pitch_is_reported_not_folded(self):
with tempfile.TemporaryDirectory() as root:
r=self.review(root,'high.xml',xml(pitched(24,octave=7)),form_start=1,pitch_convention='guitar-written')
checked=solo_compare.playability(r)
self.assertEqual(checked['conflicts'][0]['sounding_midi'],84)
self.assertEqual(r['notes'][0]['pitch']['octave'],7)
def test_silent_reference_leadin_remains_in_audition_clock(self):
with tempfile.TemporaryDirectory() as root:
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes><note><rest/><duration>24</duration></note></measure>'
a=self.review(root,'actual.xml',xml(pitched(24),transpose=True),form_start=1)
b=self.review(root,'reference.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
self.assertEqual(solo_compare.audition_bounds(a,b),(F(-4),F(4)))
def test_fixed_backing_includes_the_production_leadin(self):
import array
from generator import playback, score
document=song(count=2,chords=('CM7',))
document['staves'][0]['measures'][1]['section_label']='Acoustic Solo'
with tempfile.TemporaryDirectory() as root:
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes>'+pitched(24)+'</measure>'
actual=self.review(root,'actual.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
with patch.object(score,'load_json',return_value=document),patch.object(solo_compare,'render_events',return_value=array.array('f',[0]*10)) as render,patch.object(playback,'write_pcm'):
solo_compare.audition_pair(actual,actual,'fixture/acoustic',root,Path(root),{})
# Backing's preceding bar starts at the same audition clock as the lead.
self.assertEqual(render.call_args_list[0].args[0][0][0],0)
self.assertTrue(any(start>=4 for start,end,pitch in render.call_args_list[0].args[0]))
r = self.review(root, 'high.xml', xml(pitched(24, octave=7)), form_start=1, pitch_convention='guitar-written')
checked = tests.solo_compare.playability(r)
self.assertEqual(checked['conflicts'][0]['sounding_midi'], 84)
self.assertEqual(r['notes'][0]['pitch']['octave'], 7)
def test_triplet_time_and_annotations_are_literal(self):
with tempfile.TemporaryDirectory() as root:
triplet=pitched(2).replace('<type>quarter</type>','<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
r=self.review(root,'thirds.xml',xml('<direction><direction-type><words>Lead in</words></direction-type></direction>'+triplet*3+'<note><rest/><duration>18</duration></note>'),form_start=1)
self.assertEqual([n['start'] for n in r['sounding_events']],['0','1/3','2/3'])
self.assertEqual(r['annotations'][0]['text'],'Lead in')
triplet = pitched(2).replace('<type>quarter</type>', '<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
r = self.review(root, 'thirds.xml', xml('<direction><direction-type><words>Lead in</words></direction-type></direction>' + triplet * 3 + '<note><rest/><duration>18</duration></note>'), form_start=1)
self.assertEqual([n['start'] for n in r['sounding_events']], ['0', '1/3', '2/3'])
self.assertEqual(r['annotations'][0]['text'], 'Lead in')
def test_export_roundtrip_preserves_actual_sounding_events_and_tab(self):
document=song(count=2,chords=('CM7',))
raw=(note(0,F(1,3),'c'),note(F(1,3),F(2,3),'d'),note(F(2,3),1,'e'),note(F(7,2),5,'g'),note(5,6,'g'))
placed=placement.place(raw,F(120))[1]
notation.replace_region(document,0,notation.measures(document['staves'][0]['measures'],placed,'export'))
document['staves'][0]['measures'][0]['section_label']='Acoustic Solo'
before=copy.deepcopy(document)
document = tests.solo_fixtures.song(count=2, chords=('CM7',))
raw = (tests.solo_fixtures.note(0, fractions.Fraction(1, 3), 'c'), tests.solo_fixtures.note(fractions.Fraction(1, 3), fractions.Fraction(2, 3), 'd'), tests.solo_fixtures.note(fractions.Fraction(2, 3), 1, 'e'), tests.solo_fixtures.note(fractions.Fraction(7, 2), 5, 'g'), tests.solo_fixtures.note(5, 6, 'g'))
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
generator.solo.notation.replace_region(document, 0, generator.solo.notation.measures(document['staves'][0]['measures'], placed, 'export'))
document['staves'][0]['measures'][0]['section_label'] = 'Acoustic Solo'
before = copy.deepcopy(document)
with tempfile.TemporaryDirectory() as root:
path=Path(root)/'fixture-solo1.xml'
info=export.write_role({'role':'acoustic_solo','document':document,'trace':{'source':'fixture'}},path)
loaded=reference_review.read(path,'fixture/acoustic',1)
sounded=[(F(n['start']),F(n['end']),F(n['sounding_midi'])) for n in loaded['sounding_events']]
self.assertEqual([(n.start,n.end,n.pitch.number()) for n in raw],sounded)
from generator import score
tab=lambda d:[p['tablature'] for m in d['staves'][0]['measures'] for n in score.measure_notes(m) for p in n['pitches']]
self.assertEqual(tab(document),[n['tablature'] for n in loaded['notes'] if n['pitch']])
self.assertEqual(info['role'],'acoustic_solo')
self.assertEqual(before,document)
path = pathlib.Path(root) / 'fixture-solo1.xml'
info = generator.solo.export.write_role({'role': 'acoustic_solo', 'document': document, 'trace': {'source': 'fixture'}}, path)
loaded = generator.training_io.read(path, 'fixture/acoustic', 1)
sounded = [(fractions.Fraction(n['start']), fractions.Fraction(n['end']), fractions.Fraction(n['sounding_midi'])) for n in loaded['sounding_events']]
self.assertEqual([(n.start, n.end, n.pitch.number()) for n in raw], sounded)
def tab(d):
return [p['tablature'] for m in d['staves'][0]['measures'] for n in generator.score.measure_notes(m) for p in n['pitches']]
self.assertEqual(tab(document), [n['tablature'] for n in loaded['notes'] if n['pitch']])
self.assertEqual(info['role'], 'acoustic_solo')
self.assertEqual(before, document)
def test_export_manifests_are_not_discovered_as_runtime_scores(self):
from generator import score
document=song(count=1,chords=('CM7',))
document = tests.solo_fixtures.song(count=1, chords=('CM7',))
with tempfile.TemporaryDirectory() as root:
root=Path(root);(root/'analysis').mkdir()
roles={}
for part in ('acoustic','electric'):
doc=copy.deepcopy(document)
doc['staves'][0]['measures'][0]['section_label']=part.capitalize()+' Solo'
roles[part+'_solo']={'role':part+'_solo','document':doc,'trace':{'source':'synthetic'}}
(root/'analysis'/'fixture.roles').write_text(json.dumps(roles))
score.write_json(root/'score.json',document)
export.export_tree(root)
self.assertEqual(score.count_json_files(root),1)
score.validate_generated_tree(root)
self.assertTrue((root/'solo_xml'/'fixture-solo1.xml').is_file())
self.assertTrue((root/'solo_xml'/'fixture-solo2.xml').is_file())
root = pathlib.Path(root)
(root / 'analysis').mkdir()
roles = {}
for part in ('acoustic', 'electric'):
doc = copy.deepcopy(document)
doc['staves'][0]['measures'][0]['section_label'] = part.capitalize() + ' Solo'
roles[part + '_solo'] = {'role': part + '_solo', 'document': doc, 'trace': {'source': 'synthetic'}}
(root / 'analysis' / 'fixture.roles').write_text(json.dumps(roles))
generator.score.write_json(root / 'score.json', document)
generator.solo.export.export_tree(root)
self.assertEqual(generator.score.count_json_files(root), 1)
generator.score.validate_generated_tree(root)
self.assertTrue((root / 'solo_xml' / 'fixture-solo1.xml').is_file())
self.assertTrue((root / 'solo_xml' / 'fixture-solo2.xml').is_file())
if __name__=='__main__':unittest.main()
if __name__ == '__main__':
unittest.main()
+32 -9
View File
@@ -132,7 +132,16 @@ auto preview(vamp_zero::app_state const &state, std::string const &name, vamp_pi
std::filesystem::create_directories(directory);
auto context = image_context{};
vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state);
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
auto source = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
auto path = std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs" / std::string{source.at("presentation").at("pages").as_array().front().at("path").as_string()};
auto sheet = LoadImage(path.c_str());
BOOST_REQUIRE(sheet.data);
ImageDraw(&context.image, sheet, {0, 0, 976, 484}, {24, 98, 976, 484}, WHITE);
UnloadImage(sheet);
} else {
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
}
BOOST_REQUIRE(ExportImage(context.image, (directory / ("zero_" + name + ".png")).c_str()));
auto remote = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
@@ -169,7 +178,18 @@ BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays)
preview(state, "busy", vamp_pico::link_status::CONNECTED, true);
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
state.pico_connected = true;
state.audio.playback_enabled = true;
preview(state, "backing_unavailable");
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
preview(state, "empty");
auto catalog = std::make_shared<vamp_zero::catalog>();
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
auto title = std::string{score.at("presentation").at("title_image").as_string()};
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = score.at("presentation").at("pages").as_array().size(), .title_image = title, .song_id = "autumn_leaves"});
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
catalog->audio_assets.push_back({.path = "backing/autumn_leaves_120.wav", .song_id = "autumn_leaves", .bpm = 120});
state.catalog = catalog;
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
state.audio.backing_enabled = true;
state.audio.metronome_enabled = true;
preview(state, "settings");
state.selected_settings_index = 3U;
@@ -177,18 +197,21 @@ BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays)
preview(state, "tempo");
state.audio.tempo_editing = false;
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
preview(state, "empty");
auto catalog = std::make_shared<vamp_zero::catalog>();
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
auto title = std::string{score.at("presentation").at("title_image").as_string()};
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = 32U, .title_image = title});
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
state.catalog = catalog;
state.selected_song_index = 1U;
preview(state, "songs");
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
state.selected_part_index = 1U;
preview(state, "parts");
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
state.active_part_id = "electric";
for (auto phase : {"Manual", "Preparing", "Count-in", "Playing", "Sustain"}) {
state.audio.phase = phase;
preview(state, phase);
}
state.audio.phase = "Error";
state.audio.playback_error = "Recording unavailable";
preview(state, "audio_error");
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
catalog->groups[0].title = std::string(200U, 'M');
catalog->groups[0].composer = std::string(200U, 'W');
preview(state, "long_title");