From cf7c0c010c5ca51430952e12501a18e9a9fab468 Mon Sep 17 00:00:00 2001 From: khodowany Date: Sun, 20 Sep 2026 16:20:43 -0500 Subject: [PATCH] Tab Generation --- README.md | 8 +- SETUP.md | 4 +- TECHNICAL_GUIDE.md | 48 ++++-- cmake/zero.cmake | 3 +- conanfile.py | 7 +- data/{ => practice}/001.xml | 0 data/{ => practice}/002.xml | 0 data/{ => practice}/003.xml | 0 data/{ => practice}/004.xml | 0 data/{ => practice}/005.xml | 0 data/{ => practice}/006.xml | 0 data/{ => practice}/007.xml | 0 data/{ => practice}/008.xml | 0 data/{ => practice}/009.xml | 0 data/{ => practice}/010.xml | 0 data/{ => practice}/011.xml | 0 data/{ => practice}/012.xml | 0 data/{ => practice}/013.xml | 0 data/{ => practice}/014.xml | 0 data/{ => practice}/015.xml | 0 data/{ => practice}/016.xml | 0 data/{ => practice}/017.xml | 0 data/{ => practice}/018.xml | 0 data/{ => practice}/019.xml | 0 data/{ => practice}/020.xml | 0 data/{ => practice}/021.xml | 0 data/{ => practice}/022.xml | 0 data/{ => practice}/023.xml | 0 data/{ => practice}/024.xml | 0 data/{ => practice}/025.xml | 0 data/{ => practice}/026.xml | 0 data/{ => practice}/027.xml | 0 data/{ => practice}/028.xml | 0 data/{ => practice}/029.xml | 0 data/{ => practice}/030.xml | 0 data/{ => practice}/031.xml | 0 data/{ => practice}/032.xml | 0 data/{ => practice}/033.xml | 0 data/{ => practice}/034.xml | 0 data/{ => practice}/035.xml | 0 data/{ => practice}/036.xml | 0 data/{ => practice}/037.xml | 0 data/{ => practice}/038.xml | 0 data/{ => practice}/039.xml | 0 data/{ => practice}/040.xml | 0 data/{ => practice}/041.xml | 0 data/{ => practice}/042.xml | 0 data/{ => practice}/043.xml | 0 data/{ => practice}/044.xml | 0 data/{ => practice}/045.xml | 0 data/{ => practice}/046.xml | 0 data/{ => practice}/047.xml | 0 data/{ => practice}/048.xml | 0 data/{ => practice}/049.xml | 0 data/{ => practice}/050.xml | 0 data/{ => practice}/051.xml | 0 data/{ => practice}/052.xml | 0 data/{ => practice}/053.xml | 0 data/{ => practice}/054.xml | 0 data/{ => practice}/055.xml | 0 data/{ => practice}/056.xml | 0 data/{ => practice}/057.xml | 0 data/{ => practice}/058.xml | 0 data/{ => practice}/059.xml | 0 data/{ => practice}/060.xml | 0 data/{ => practice}/061.xml | 0 data/{ => practice}/062.xml | 0 data/{ => practice}/063.xml | 0 data/{ => practice}/064.xml | 0 data/{ => practice}/065.xml | 0 data/{ => practice}/066.xml | 0 data/{ => practice}/067.xml | 0 data/{ => practice}/068.xml | 0 data/{ => practice}/069.xml | 0 data/{ => practice}/070.xml | 0 data/{ => practice}/071.xml | 0 data/{ => practice}/072.xml | 0 data/{ => practice}/073.xml | 0 data/{ => practice}/074.xml | 0 data/{ => practice}/075.xml | 0 data/{ => practice}/076.xml | 0 data/{ => practice}/077.xml | 0 data/{ => practice}/078.xml | 0 data/{ => practice}/079.xml | 0 data/{ => practice}/080.xml | 0 data/{ => practice}/081.xml | 0 data/{ => practice}/082.xml | 0 data/{ => practice}/083.xml | 0 data/{ => practice}/084.xml | 0 data/{ => practice}/085.xml | 0 data/{ => practice}/086.xml | 0 data/{ => practice}/087.xml | 0 data/{ => practice}/088.xml | 0 data/{ => practice}/089.xml | 0 data/{ => practice}/090.xml | 0 data/{ => practice}/091.xml | 0 data/{ => practice}/092.xml | 0 data/{ => practice}/093.xml | 0 data/{ => practice}/094.xml | 0 data/{ => practice}/095.xml | 0 data/{ => practice}/096.xml | 0 data/{ => practice}/097.xml | 0 data/{ => practice}/098.xml | 0 data/{ => practice}/099.xml | 0 data/{ => practice}/100.xml | 0 data/{ => practice}/101.xml | 0 data/{ => practice}/102.xml | 0 data/{ => practice}/103.xml | 0 data/{ => practice}/104.xml | 0 data/{ => practice}/105.xml | 0 data/{ => practice}/106.xml | 0 data/{ => practice}/107.xml | 0 data/{ => practice}/108.xml | 0 data/{ => practice}/109.xml | 0 data/{ => practice}/110.xml | 0 data/{ => practice}/111.xml | 0 data/{ => practice}/112.xml | 0 data/{ => practice}/113.xml | 0 data/{ => practice}/114.xml | 0 data/{ => practice}/115.xml | 0 data/{ => practice}/116.xml | 0 data/{ => practice}/117.xml | 0 data/{ => practice}/118.xml | 0 data/{ => practice}/119.xml | 0 data/{ => practice}/120.xml | 0 data/{ => practice}/121.xml | 0 data/{ => practice}/122.xml | 0 data/{ => practice}/123.xml | 0 data/{ => practice}/124.xml | 0 data/{ => practice}/125.xml | 0 data/{ => practice}/126.xml | 0 data/{ => practice}/127.xml | 0 data/{ => practice}/128.xml | 0 data/{ => practice}/129.xml | 0 data/{ => practice}/130.xml | 0 data/{ => practice}/131.xml | 0 data/{ => practice}/132.xml | 0 data/{ => practice}/133.xml | 0 data/{ => practice}/134.xml | 0 data/{ => practice}/135.xml | 0 data/{ => practice}/136.xml | 0 data/{ => practice}/137.xml | 0 data/{ => practice}/138.xml | 0 data/{ => practice}/139.xml | 0 data/{ => practice}/140.xml | 0 data/{ => practice}/141.xml | 0 data/{ => practice}/142.xml | 0 data/{ => practice}/143.xml | 0 data/{ => practice}/144.xml | 0 data/{ => practice}/145.xml | 0 data/{ => practice}/146.xml | 0 data/{ => practice}/147.xml | 0 data/{ => practice}/148.xml | 0 data/{ => practice}/149.xml | 0 data/{ => practice}/150.xml | 0 data/{ => practice}/151.xml | 0 data/{ => practice}/152.xml | 0 data/{ => practice}/153.xml | 0 data/{ => practice}/154.xml | 0 data/{ => practice}/155.xml | 0 data/{ => practice}/156.xml | 0 data/{ => practice}/157.xml | 0 data/{ => practice}/158.xml | 0 data/{ => practice}/159.xml | 0 data/{ => practice}/160.xml | 0 data/{ => practice}/161.xml | 0 data/{ => practice}/162.xml | 0 data/{ => practice}/163.xml | 0 data/{ => practice}/164.xml | 0 data/{ => practice}/165.xml | 0 data/{ => practice}/166.xml | 0 data/{ => practice}/167.xml | 0 data/{ => practice}/168.xml | 0 data/{ => practice}/169.xml | 0 data/{ => practice}/170.xml | 0 data/{ => practice}/171.xml | 0 data/{ => practice}/172.xml | 0 data/{ => practice}/173.xml | 0 data/{ => practice}/174.xml | 0 data/{ => practice}/175.xml | 0 data/{ => practice}/176.xml | 0 data/{ => practice}/177.xml | 0 data/{ => practice}/178.xml | 0 data/{ => practice}/179.xml | 0 data/{ => practice}/180.xml | 0 data/{ => practice}/181.xml | 0 data/{ => practice}/182.xml | 0 data/{ => practice}/183.xml | 0 data/{ => practice}/184.xml | 0 data/{ => practice}/185.xml | 0 data/{ => practice}/186.xml | 0 data/{ => practice}/187.xml | 0 data/{ => practice}/188.xml | 0 data/{ => practice}/189.xml | 0 data/{ => practice}/190.xml | 0 data/{ => practice}/191.xml | 0 data/{ => practice}/192.xml | 0 data/{ => practice}/193.xml | 0 data/{ => practice}/194.xml | 0 data/{ => practice}/195.xml | 0 data/{ => practice}/196.xml | 0 data/{ => practice}/197.xml | 0 data/{ => practice}/198.xml | 0 data/{ => practice}/199.xml | 0 data/{ => practice}/200.xml | 0 data/{ => practice}/201.xml | 0 data/{ => practice}/202.xml | 0 data/{ => practice}/203.xml | 0 data/{ => practice}/204.xml | 0 data/{ => practice}/205.xml | 0 data/{ => practice}/206.xml | 0 data/{ => practice}/207.xml | 0 data/{ => practice}/208.xml | 0 data/{ => practice}/209.xml | 0 data/{ => practice}/210.xml | 0 data/{ => practice}/211.xml | 0 data/{ => practice}/212.xml | 0 data/{ => practice}/213.xml | 0 data/{ => practice}/214.xml | 0 data/{ => practice}/215.xml | 0 data/{ => practice}/216.xml | 0 data/{ => practice}/217.xml | 0 data/{ => practice}/218.xml | 0 data/{ => practice}/219.xml | 0 data/{ => practice}/220.xml | 0 data/{ => practice}/221.xml | 0 data/{ => practice}/222.xml | 0 data/{ => practice}/223.xml | 0 data/{ => practice}/224.xml | 0 data/{ => practice}/225.xml | 0 data/{ => practice}/226.xml | 0 data/{ => practice}/227.xml | 0 data/{ => practice}/228.xml | 0 data/{ => practice}/229.xml | 0 data/{ => practice}/230.xml | 0 data/{ => practice}/231.xml | 0 data/{ => practice}/232.xml | 0 data/{ => practice}/233.xml | 0 data/{ => practice}/234.xml | 0 data/{ => practice}/235.xml | 0 data/{ => practice}/236.xml | 0 data/{ => practice}/237.xml | 0 data/{ => practice}/238.xml | 0 data/{ => practice}/239.xml | 0 data/{ => practice}/240.xml | 0 data/{ => practice}/241.xml | 0 data/{ => practice}/242.xml | 0 data/{ => practice}/243.xml | 0 data/{ => practice}/244.xml | 0 data/{ => practice}/245.xml | 0 data/{ => practice}/246.xml | 0 data/{ => practice}/247.xml | 0 data/{ => practice}/248.xml | 0 data/{ => practice}/249.xml | 0 data/{ => practice}/250.xml | 0 data/{ => practice}/251.xml | 0 data/{ => practice}/252.xml | 0 data/{ => practice}/253.xml | 0 data/{ => practice}/254.xml | 0 data/{ => practice}/255.xml | 0 data/{ => practice}/256.xml | 0 data/{ => practice}/257.xml | 0 data/{ => practice}/258.xml | 0 data/{ => practice}/259.xml | 0 data/{ => practice}/260.xml | 0 data/{ => practice}/261.xml | 0 data/{ => practice}/262.xml | 0 data/{ => practice}/263.xml | 0 data/{ => practice}/264.xml | 0 data/{ => practice}/265.xml | 0 data/{ => practice}/266.xml | 0 data/{ => practice}/267.xml | 0 data/{ => practice}/268.xml | 0 data/{ => practice}/269.xml | 0 data/{ => practice}/270.xml | 0 data/{ => practice}/271.xml | 0 data/{ => practice}/272.xml | 0 data/{ => practice}/273.xml | 0 data/{ => practice}/274.xml | 0 data/{ => practice}/275.xml | 0 data/{ => practice}/276.xml | 0 data/{ => practice}/277.xml | 0 data/{ => practice}/278.xml | 0 data/{ => practice}/279.xml | 0 data/{ => practice}/280.xml | 0 data/{ => practice}/281.xml | 0 data/{ => practice}/282.xml | 0 data/{ => practice}/283.xml | 0 data/{ => practice}/284.xml | 0 data/{ => practice}/285.xml | 0 data/{ => practice}/286.xml | 0 data/{ => practice}/287.xml | 0 data/{ => practice}/288.xml | 0 data/{ => practice}/289.xml | 0 data/{ => practice}/290.xml | 0 data/{ => practice}/291.xml | 0 data/{ => practice}/292.xml | 0 data/{ => practice}/293.xml | 0 data/{ => practice}/294.xml | 0 data/{ => practice}/295.xml | 0 data/{ => practice}/296.xml | 0 data/{ => practice}/297.xml | 0 data/{ => practice}/298.xml | 0 data/{ => practice}/299.xml | 0 data/{ => practice}/300.xml | 0 data/{ => practice}/301.xml | 0 data/{ => practice}/302.xml | 0 data/{ => practice}/303.xml | 0 data/{ => practice}/304.xml | 0 data/{ => practice}/305.xml | 0 data/{ => practice}/306.xml | 0 data/{ => practice}/307.xml | 0 data/{ => practice}/308.xml | 0 data/{ => practice}/309.xml | 0 data/{ => practice}/310.xml | 0 data/{ => practice}/311.xml | 0 data/{ => practice}/312.xml | 0 data/{ => practice}/313.xml | 0 data/{ => practice}/314.xml | 0 data/{ => practice}/315.xml | 0 data/{ => practice}/316.xml | 0 data/{ => practice}/317.xml | 0 data/{ => practice}/318.xml | 0 data/{ => practice}/319.xml | 0 data/{ => practice}/320.xml | 0 data/{ => practice}/321.xml | 0 data/{ => practice}/322.xml | 0 data/{ => practice}/323.xml | 0 data/{ => practice}/324.xml | 0 data/{ => practice}/325.xml | 0 data/{ => practice}/326.xml | 0 data/{ => practice}/327.xml | 0 data/{ => practice}/328.xml | 0 data/{ => practice}/329.xml | 0 data/{ => practice}/330.xml | 0 data/{ => practice}/331.xml | 0 data/{ => practice}/332.xml | 0 data/{ => practice}/333.xml | 0 data/{ => practice}/334.xml | 0 data/{ => practice}/335.xml | 0 data/{ => practice}/336.xml | 0 data/{ => practice}/337.xml | 0 data/{ => practice}/338.xml | 0 data/{ => practice}/339.xml | 0 data/{ => practice}/340.xml | 0 data/{ => practice}/341.xml | 0 data/{ => practice}/342.xml | 0 data/{ => practice}/343.xml | 0 data/{ => practice}/344.xml | 0 data/{ => practice}/345.xml | 0 data/{ => practice}/346.xml | 0 data/{ => practice}/347.xml | 0 data/{ => practice}/348.xml | 0 data/{ => practice}/349.xml | 0 data/{ => practice}/350.xml | 0 data/{ => practice}/351.xml | 0 data/{ => practice}/352.xml | 0 data/{ => practice}/353.xml | 0 data/{ => practice}/354.xml | 0 data/{ => practice}/355.xml | 0 data/{ => practice}/356.xml | 0 data/{ => practice}/357.xml | 0 data/{ => practice}/358.xml | 0 data/{ => practice}/359.xml | 0 data/{ => practice}/360.xml | 0 data/{ => practice}/361.xml | 0 data/{ => practice}/362.xml | 0 data/{ => practice}/363.xml | 0 data/{ => practice}/364.xml | 0 data/{ => practice}/365.xml | 0 data/{ => practice}/366.xml | 0 generator/__main__.py | 12 +- generator/arrange/fretboard.py | 91 ----------- generator/arrange/generate.py | 110 ++----------- generator/catalogue.py | 29 ++-- generator/engraving/attributes.py | 4 +- generator/engraving/audit.py | 13 +- generator/engraving/layout.py | 13 +- generator/engraving/notation.py | 9 +- generator/engraving/notes.py | 21 +-- generator/engraving/spans.py | 9 +- generator/engraving/tablature.py | 59 +++++++ generator/guitar.py | 263 ++++++++++++++++++++++++++++++ generator/model.py | 21 ++- generator/musicxml.py | 12 +- generator/musicxml_writer.py | 7 - generator/parts.py | 116 +++++++++++++ generator/pitch.py | 95 +++++++++++ generator/playback.py | 23 +-- generator/practice/generate.py | 85 ++++------ generator/practice/guitar.py | 137 ---------------- generator/scat.py | 76 +++++++++ generator/summary.py | 6 +- generator/transposition.py | 142 ---------------- include/vamp_zero/navigation.hpp | 7 +- include/vamp_zero/types.hpp | 17 +- src/vamp_zero/app.cpp | 4 +- src/vamp_zero/audio.cpp | 2 +- src/vamp_zero/database.cpp | 12 +- src/vamp_zero/library.cpp | 30 ++-- src/vamp_zero/navigation.cpp | 36 ++-- src/vamp_zero/notation.cpp | 14 -- src/vamp_zero/page_control.cpp | 123 ++++++-------- src/vamp_zero/score_loader.cpp | 4 +- src/vamp_zero/types.cpp | 23 +-- src/vamp_zero/ui.cpp | 31 ++-- tests/catalogue_fixture.hpp | 8 + tests/test.cpp | 110 ++++++------- tests/test_catalogue.py | 37 +++-- tests/test_catalogue_database.py | 41 +++-- tests/test_catalogue_runtime.cpp | 129 +++++++-------- tests/test_engraving.py | 47 +++++- tests/test_engraving_cache.py | 3 +- tests/test_guitar.py | 185 +++++++++++++++++++++ tests/test_guitar_practice.py | 229 -------------------------- tests/test_harmony.py | 9 +- tests/test_health.cpp | 20 +-- tests/test_key_metadata.py | 74 ++------- tests/test_music_model.py | 16 +- tests/test_playback.py | 7 +- tests/test_scat.py | 77 +++++++++ tests/test_score_bundle.py | 28 ++-- tests/test_score_loader.cpp | 8 +- 423 files changed, 1422 insertions(+), 1332 deletions(-) rename data/{ => practice}/001.xml (100%) rename data/{ => practice}/002.xml (100%) rename data/{ => practice}/003.xml (100%) rename data/{ => practice}/004.xml (100%) rename data/{ => practice}/005.xml (100%) rename data/{ => practice}/006.xml (100%) rename data/{ => practice}/007.xml (100%) rename data/{ => practice}/008.xml (100%) rename data/{ => practice}/009.xml (100%) rename data/{ => practice}/010.xml (100%) rename data/{ => practice}/011.xml (100%) rename data/{ => practice}/012.xml (100%) rename data/{ => practice}/013.xml (100%) rename data/{ => practice}/014.xml (100%) rename data/{ => practice}/015.xml (100%) rename data/{ => practice}/016.xml (100%) rename data/{ => practice}/017.xml (100%) rename data/{ => practice}/018.xml (100%) rename data/{ => practice}/019.xml (100%) rename data/{ => practice}/020.xml (100%) rename data/{ => practice}/021.xml (100%) rename data/{ => practice}/022.xml (100%) rename data/{ => practice}/023.xml (100%) rename data/{ => practice}/024.xml (100%) rename data/{ => practice}/025.xml (100%) rename data/{ => practice}/026.xml (100%) rename data/{ => practice}/027.xml (100%) rename data/{ => practice}/028.xml (100%) rename data/{ => practice}/029.xml (100%) rename data/{ => practice}/030.xml (100%) rename data/{ => practice}/031.xml (100%) rename data/{ => practice}/032.xml (100%) rename data/{ => practice}/033.xml (100%) rename data/{ => practice}/034.xml (100%) rename data/{ => practice}/035.xml (100%) rename data/{ => practice}/036.xml (100%) rename data/{ => practice}/037.xml (100%) rename data/{ => practice}/038.xml (100%) rename data/{ => practice}/039.xml (100%) rename data/{ => practice}/040.xml (100%) rename data/{ => practice}/041.xml (100%) rename data/{ => practice}/042.xml (100%) rename data/{ => practice}/043.xml (100%) rename data/{ => practice}/044.xml (100%) rename data/{ => practice}/045.xml (100%) rename data/{ => practice}/046.xml (100%) rename data/{ => practice}/047.xml (100%) rename data/{ => practice}/048.xml (100%) rename data/{ => practice}/049.xml (100%) rename data/{ => practice}/050.xml (100%) rename data/{ => practice}/051.xml (100%) rename data/{ => practice}/052.xml (100%) rename data/{ => practice}/053.xml (100%) rename data/{ => practice}/054.xml (100%) rename data/{ => practice}/055.xml (100%) rename data/{ => practice}/056.xml (100%) rename data/{ => practice}/057.xml (100%) rename data/{ => practice}/058.xml (100%) rename data/{ => practice}/059.xml (100%) rename data/{ => practice}/060.xml (100%) rename data/{ => practice}/061.xml (100%) rename data/{ => practice}/062.xml (100%) rename data/{ => practice}/063.xml (100%) rename data/{ => practice}/064.xml (100%) rename data/{ => practice}/065.xml (100%) rename data/{ => practice}/066.xml (100%) rename data/{ => practice}/067.xml (100%) rename data/{ => practice}/068.xml (100%) rename data/{ => practice}/069.xml (100%) rename data/{ => practice}/070.xml (100%) rename data/{ => practice}/071.xml (100%) rename data/{ => practice}/072.xml (100%) rename data/{ => practice}/073.xml (100%) rename data/{ => practice}/074.xml (100%) rename data/{ => practice}/075.xml (100%) rename data/{ => practice}/076.xml (100%) rename data/{ => practice}/077.xml (100%) rename data/{ => practice}/078.xml (100%) rename data/{ => practice}/079.xml (100%) rename data/{ => practice}/080.xml (100%) rename data/{ => practice}/081.xml (100%) rename data/{ => practice}/082.xml (100%) rename data/{ => practice}/083.xml (100%) rename data/{ => practice}/084.xml (100%) rename data/{ => practice}/085.xml (100%) rename data/{ => practice}/086.xml (100%) rename data/{ => practice}/087.xml (100%) rename data/{ => practice}/088.xml (100%) rename data/{ => practice}/089.xml (100%) rename data/{ => practice}/090.xml (100%) rename data/{ => practice}/091.xml (100%) rename data/{ => practice}/092.xml (100%) rename data/{ => practice}/093.xml (100%) rename data/{ => practice}/094.xml (100%) rename data/{ => practice}/095.xml (100%) rename data/{ => practice}/096.xml (100%) rename data/{ => practice}/097.xml (100%) rename data/{ => practice}/098.xml (100%) rename data/{ => practice}/099.xml (100%) rename data/{ => practice}/100.xml (100%) rename data/{ => practice}/101.xml (100%) rename data/{ => practice}/102.xml (100%) rename data/{ => practice}/103.xml (100%) rename data/{ => practice}/104.xml (100%) rename data/{ => practice}/105.xml (100%) rename data/{ => practice}/106.xml (100%) rename data/{ => practice}/107.xml (100%) rename data/{ => practice}/108.xml (100%) rename data/{ => practice}/109.xml (100%) rename data/{ => practice}/110.xml (100%) rename data/{ => practice}/111.xml (100%) rename data/{ => practice}/112.xml (100%) rename data/{ => practice}/113.xml (100%) rename data/{ => practice}/114.xml (100%) rename data/{ => practice}/115.xml (100%) rename data/{ => practice}/116.xml (100%) rename data/{ => practice}/117.xml (100%) rename data/{ => practice}/118.xml (100%) rename data/{ => practice}/119.xml (100%) rename data/{ => practice}/120.xml (100%) rename data/{ => practice}/121.xml (100%) rename data/{ => practice}/122.xml (100%) rename data/{ => practice}/123.xml (100%) rename data/{ => practice}/124.xml (100%) rename data/{ => practice}/125.xml (100%) rename data/{ => practice}/126.xml (100%) rename data/{ => practice}/127.xml (100%) rename data/{ => practice}/128.xml (100%) rename data/{ => practice}/129.xml (100%) rename data/{ => practice}/130.xml (100%) rename data/{ => practice}/131.xml (100%) rename data/{ => practice}/132.xml (100%) rename data/{ => practice}/133.xml (100%) rename data/{ => practice}/134.xml (100%) rename data/{ => practice}/135.xml (100%) rename data/{ => practice}/136.xml (100%) rename data/{ => practice}/137.xml (100%) rename data/{ => practice}/138.xml (100%) rename data/{ => practice}/139.xml (100%) rename data/{ => practice}/140.xml (100%) rename data/{ => practice}/141.xml (100%) rename data/{ => practice}/142.xml (100%) rename data/{ => practice}/143.xml (100%) rename data/{ => practice}/144.xml (100%) rename data/{ => practice}/145.xml (100%) rename data/{ => practice}/146.xml (100%) rename data/{ => practice}/147.xml (100%) rename data/{ => practice}/148.xml (100%) rename data/{ => practice}/149.xml (100%) rename data/{ => practice}/150.xml (100%) rename data/{ => practice}/151.xml (100%) rename data/{ => practice}/152.xml (100%) rename data/{ => practice}/153.xml (100%) rename data/{ => practice}/154.xml (100%) rename data/{ => practice}/155.xml (100%) rename data/{ => practice}/156.xml (100%) rename data/{ => practice}/157.xml (100%) rename data/{ => practice}/158.xml (100%) rename data/{ => practice}/159.xml (100%) rename data/{ => practice}/160.xml (100%) rename data/{ => practice}/161.xml (100%) rename data/{ => practice}/162.xml (100%) rename data/{ => practice}/163.xml (100%) rename data/{ => practice}/164.xml (100%) rename data/{ => practice}/165.xml (100%) rename data/{ => practice}/166.xml (100%) rename data/{ => practice}/167.xml (100%) rename data/{ => practice}/168.xml (100%) rename data/{ => practice}/169.xml (100%) rename data/{ => practice}/170.xml (100%) rename data/{ => practice}/171.xml (100%) rename data/{ => practice}/172.xml (100%) rename data/{ => practice}/173.xml (100%) rename data/{ => practice}/174.xml (100%) rename data/{ => practice}/175.xml (100%) rename data/{ => practice}/176.xml (100%) rename data/{ => practice}/177.xml (100%) rename data/{ => practice}/178.xml (100%) rename data/{ => practice}/179.xml (100%) rename data/{ => practice}/180.xml (100%) rename data/{ => practice}/181.xml (100%) rename data/{ => practice}/182.xml (100%) rename data/{ => practice}/183.xml (100%) rename data/{ => practice}/184.xml (100%) rename data/{ => practice}/185.xml (100%) rename data/{ => practice}/186.xml (100%) rename data/{ => practice}/187.xml (100%) rename data/{ => practice}/188.xml (100%) rename data/{ => practice}/189.xml (100%) rename data/{ => practice}/190.xml (100%) rename data/{ => practice}/191.xml (100%) rename data/{ => practice}/192.xml (100%) rename data/{ => practice}/193.xml (100%) rename data/{ => practice}/194.xml (100%) rename data/{ => practice}/195.xml (100%) rename data/{ => practice}/196.xml (100%) rename data/{ => practice}/197.xml (100%) rename data/{ => practice}/198.xml (100%) rename data/{ => practice}/199.xml (100%) rename data/{ => practice}/200.xml (100%) rename data/{ => practice}/201.xml (100%) rename data/{ => practice}/202.xml (100%) rename data/{ => practice}/203.xml (100%) rename data/{ => practice}/204.xml (100%) rename data/{ => practice}/205.xml (100%) rename data/{ => practice}/206.xml (100%) rename data/{ => practice}/207.xml (100%) rename data/{ => practice}/208.xml (100%) rename data/{ => practice}/209.xml (100%) rename data/{ => practice}/210.xml (100%) rename data/{ => practice}/211.xml (100%) rename data/{ => practice}/212.xml (100%) rename data/{ => practice}/213.xml (100%) rename data/{ => practice}/214.xml (100%) rename data/{ => practice}/215.xml (100%) rename data/{ => practice}/216.xml (100%) rename data/{ => practice}/217.xml (100%) rename data/{ => practice}/218.xml (100%) rename data/{ => practice}/219.xml (100%) rename data/{ => practice}/220.xml (100%) rename data/{ => practice}/221.xml (100%) rename data/{ => practice}/222.xml (100%) rename data/{ => practice}/223.xml (100%) rename data/{ => practice}/224.xml (100%) rename data/{ => practice}/225.xml (100%) rename data/{ => practice}/226.xml (100%) rename data/{ => practice}/227.xml (100%) rename data/{ => practice}/228.xml (100%) rename data/{ => practice}/229.xml (100%) rename data/{ => practice}/230.xml (100%) rename data/{ => practice}/231.xml (100%) rename data/{ => practice}/232.xml (100%) rename data/{ => practice}/233.xml (100%) rename data/{ => practice}/234.xml (100%) rename data/{ => practice}/235.xml (100%) rename data/{ => practice}/236.xml (100%) rename data/{ => practice}/237.xml (100%) rename data/{ => practice}/238.xml (100%) rename data/{ => practice}/239.xml (100%) rename data/{ => practice}/240.xml (100%) rename data/{ => practice}/241.xml (100%) rename data/{ => practice}/242.xml (100%) rename data/{ => practice}/243.xml (100%) rename data/{ => practice}/244.xml (100%) rename data/{ => practice}/245.xml (100%) rename data/{ => practice}/246.xml (100%) rename data/{ => practice}/247.xml (100%) rename data/{ => practice}/248.xml (100%) rename data/{ => practice}/249.xml (100%) rename data/{ => practice}/250.xml (100%) rename data/{ => practice}/251.xml (100%) rename data/{ => practice}/252.xml (100%) rename data/{ => practice}/253.xml (100%) rename data/{ => practice}/254.xml (100%) rename data/{ => practice}/255.xml (100%) rename data/{ => practice}/256.xml (100%) rename data/{ => practice}/257.xml (100%) rename data/{ => practice}/258.xml (100%) rename data/{ => practice}/259.xml (100%) rename data/{ => practice}/260.xml (100%) rename data/{ => practice}/261.xml (100%) rename data/{ => practice}/262.xml (100%) rename data/{ => practice}/263.xml (100%) rename data/{ => practice}/264.xml (100%) rename data/{ => practice}/265.xml (100%) rename data/{ => practice}/266.xml (100%) rename data/{ => practice}/267.xml (100%) rename data/{ => practice}/268.xml (100%) rename data/{ => practice}/269.xml (100%) rename data/{ => practice}/270.xml (100%) rename data/{ => practice}/271.xml (100%) rename data/{ => practice}/272.xml (100%) rename data/{ => practice}/273.xml (100%) rename data/{ => practice}/274.xml (100%) rename data/{ => practice}/275.xml (100%) rename data/{ => practice}/276.xml (100%) rename data/{ => practice}/277.xml (100%) rename data/{ => practice}/278.xml (100%) rename data/{ => practice}/279.xml (100%) rename data/{ => practice}/280.xml (100%) rename data/{ => practice}/281.xml (100%) rename data/{ => practice}/282.xml (100%) rename data/{ => practice}/283.xml (100%) rename data/{ => practice}/284.xml (100%) rename data/{ => practice}/285.xml (100%) rename data/{ => practice}/286.xml (100%) rename data/{ => practice}/287.xml (100%) rename data/{ => practice}/288.xml (100%) rename data/{ => practice}/289.xml (100%) rename data/{ => practice}/290.xml (100%) rename data/{ => practice}/291.xml (100%) rename data/{ => practice}/292.xml (100%) rename data/{ => practice}/293.xml (100%) rename data/{ => practice}/294.xml (100%) rename data/{ => practice}/295.xml (100%) rename data/{ => practice}/296.xml (100%) rename data/{ => practice}/297.xml (100%) rename data/{ => practice}/298.xml (100%) rename data/{ => practice}/299.xml (100%) rename data/{ => practice}/300.xml (100%) rename data/{ => practice}/301.xml (100%) rename data/{ => practice}/302.xml (100%) rename data/{ => practice}/303.xml (100%) rename data/{ => practice}/304.xml (100%) rename data/{ => practice}/305.xml (100%) rename data/{ => practice}/306.xml (100%) rename data/{ => practice}/307.xml (100%) rename data/{ => practice}/308.xml (100%) rename data/{ => practice}/309.xml (100%) rename data/{ => practice}/310.xml (100%) rename data/{ => practice}/311.xml (100%) rename data/{ => practice}/312.xml (100%) rename data/{ => practice}/313.xml (100%) rename data/{ => practice}/314.xml (100%) rename data/{ => practice}/315.xml (100%) rename data/{ => practice}/316.xml (100%) rename data/{ => practice}/317.xml (100%) rename data/{ => practice}/318.xml (100%) rename data/{ => practice}/319.xml (100%) rename data/{ => practice}/320.xml (100%) rename data/{ => practice}/321.xml (100%) rename data/{ => practice}/322.xml (100%) rename data/{ => practice}/323.xml (100%) rename data/{ => practice}/324.xml (100%) rename data/{ => practice}/325.xml (100%) rename data/{ => practice}/326.xml (100%) rename data/{ => practice}/327.xml (100%) rename data/{ => practice}/328.xml (100%) rename data/{ => practice}/329.xml (100%) rename data/{ => practice}/330.xml (100%) rename data/{ => practice}/331.xml (100%) rename data/{ => practice}/332.xml (100%) rename data/{ => practice}/333.xml (100%) rename data/{ => practice}/334.xml (100%) rename data/{ => practice}/335.xml (100%) rename data/{ => practice}/336.xml (100%) rename data/{ => practice}/337.xml (100%) rename data/{ => practice}/338.xml (100%) rename data/{ => practice}/339.xml (100%) rename data/{ => practice}/340.xml (100%) rename data/{ => practice}/341.xml (100%) rename data/{ => practice}/342.xml (100%) rename data/{ => practice}/343.xml (100%) rename data/{ => practice}/344.xml (100%) rename data/{ => practice}/345.xml (100%) rename data/{ => practice}/346.xml (100%) rename data/{ => practice}/347.xml (100%) rename data/{ => practice}/348.xml (100%) rename data/{ => practice}/349.xml (100%) rename data/{ => practice}/350.xml (100%) rename data/{ => practice}/351.xml (100%) rename data/{ => practice}/352.xml (100%) rename data/{ => practice}/353.xml (100%) rename data/{ => practice}/354.xml (100%) rename data/{ => practice}/355.xml (100%) rename data/{ => practice}/356.xml (100%) rename data/{ => practice}/357.xml (100%) rename data/{ => practice}/358.xml (100%) rename data/{ => practice}/359.xml (100%) rename data/{ => practice}/360.xml (100%) rename data/{ => practice}/361.xml (100%) rename data/{ => practice}/362.xml (100%) rename data/{ => practice}/363.xml (100%) rename data/{ => practice}/364.xml (100%) rename data/{ => practice}/365.xml (100%) rename data/{ => practice}/366.xml (100%) delete mode 100644 generator/arrange/fretboard.py create mode 100644 generator/engraving/tablature.py create mode 100644 generator/parts.py create mode 100644 generator/pitch.py delete mode 100644 generator/practice/guitar.py create mode 100644 generator/scat.py delete mode 100644 generator/transposition.py create mode 100644 tests/test_guitar.py delete mode 100644 tests/test_guitar_practice.py create mode 100644 tests/test_scat.py diff --git a/README.md b/README.md index ae4d589..031ae29 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Pico controls: - Tap: next menu item or next score page. - Double tap: previous menu item or previous score page. -- Tap and hold: select the highlighted mode/song/part/practice row/key, or return from score view to the previous menu. +- Tap and hold: select the highlighted mode, song, exercise, or part, or return from score view to the previous menu. ## Build @@ -93,11 +93,13 @@ The pinned build-only dependencies are in `generator/requirements.txt`. Configur CMake builds engrave with two Python workers and share a disposable page cache under `build/build_common/engraving-cache`. Native and ARM packaging reuse unchanged pages; CI also retains this cache between runs. Music, playback, and catalogue validation still run for every generation. Set `VAMP_ENGRAVING_JOBS` (1-16) to adjust concurrency, or set `VAMP_ENGRAVING_CACHE_DIR` to an empty CMake value to disable caching. Direct generator runs can opt in with `--jobs 2 --engraving-cache build/build_common/engraving-cache --progress`. -The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model -> automatic arranger/transposer -> schema-v3 JSON, engraved PNG pages, and generated-tone audio assets. The Pi displays pages without interpreting notation. Sources in `data/songs/` produce acoustic, electric, and vocal parts in the authored musical key, with no song-key selection. Numbered practice sources `data/001.xml` through `data/366.xml` produce twelve tonic variants each relative to the MusicXML key. Their current C-major placeholders are included; identical titles or contents never merge exercises. Independent voices and exact pitch/rhythm remain in the semantic JSON. Practice preserves the authored voicings, shifting the entire exercise by an octave only when needed for guitar. Practice and generated guitar voicings use standard tuning, frets 0-20 inclusive, and a maximum four-fret span excluding open strings. +The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model -> part generation -> schema-v3 JSON, engraved PNG pages, generated-tone audio, and a read-only catalogue database. Sources in `data/songs/` retain the seven-section performance form. Numbered sources in `data/practice/` (`000.xml` through `366.xml`, as authored) produce one pass each of **acoustic accompaniment**, **electric melody**, and **vocal melody**. Both categories use the authored musical key, with no key variants or key-selection menu. Duplicate titles never merge exercises. + +Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 0-20, and a maximum four-fret span excluding open strings. They render standard notation plus generated TAB. Practice sources provide a monophonic melody and optional chord symbols; the vocal part adds deterministic scat syllables without changing rhythm or written register. Acoustic, electric, and vocal parts all sound one octave below their notation, for both songs and practice. Explicit MusicXML instrument transposition is honored once; an unplayable passage fails rather than being shifted to fit. Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending. -The main menu's **Playback** toggle plays the selected key variant or performance part once at the menu BPM, suppresses the metronome, and returns to its selection menu after natural completion. Manual page turns do not seek or restart audio. Playback is off by default. Python generates shared PCM tones and exact beat timelines; C++/Sonus only loads, schedules, and mixes them. Audio sample sustain loops hold individual notes for their written duration; they never repeat musical passages. No SoundFonts or runtime synthesizer are required. Practice opens the numbered exercise list directly, followed by key selection and score view. There are no chord/melody categories or lesson menus. Each exercise retains its filename number alongside an authored title when present. +The main menu's **Playback** toggle plays the selected part once at the menu BPM, suppresses the metronome, and returns to the part menu after natural completion. Manual page turns do not seek or restart audio. Playback is off by default. Python generates shared PCM tones and exact beat timelines; C++/Sonus only loads, schedules, and mixes them. Audio sample sustain loops hold individual notes for their written duration; they never repeat musical passages. No SoundFonts or runtime synthesizer are required. Practice navigation is Exercise -> Part -> Score, with the filename number retained alongside an authored title. Code style is managed by Modulus. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files. diff --git a/SETUP.md b/SETUP.md index 6c7d25a..5eb8b57 100644 --- a/SETUP.md +++ b/SETUP.md @@ -31,7 +31,7 @@ Defaults: For API-only quick checks, set `runtime.headless` to `true` or run `vamp-zero --once` from a development build. -Score inputs are tracked under `data/`. The build/package step runs the Python generator and packages the generated songs and practice variants into the Zero image automatically; generated JSON under the build directory does not need to be copied by hand. +Score inputs are tracked under `data/songs/` and `data/practice/`. The build/package step generates acoustic, electric, and vocal parts, engraved pages, audio, and the read-only catalogue database. Installation replaces these assets together; no database migration or runtime score import is needed. ## Raspberry Pi Pico 2 W @@ -45,7 +45,7 @@ to the mounted `RPI-RP2` drive. The default firmware connects to SSID `Vamp Control` with passphrase `vamp-control`, then talks to `192.168.42.1:8080`. -The Pico screen is landscape. Its top bar is blue while connecting, yellow while retrying or when the Zero API is unavailable, and green when the Zero responds. The area below the bar stays blank until a performance or practice score is active, then it shows the title, selected part or active practice key, and page count. +The Pico screen is landscape. Its top bar is blue while connecting, yellow while retrying or when the Zero API is unavailable, and green when the Zero responds. The area below the bar stays blank until a performance or practice score is active, then it shows the title, selected part, and page count. Controls: diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 903a166..844023d 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -10,7 +10,7 @@ Product Vamp follows the Modulus product layout used by the khodo repos: - `cmake/pico.cmake` and `cmake/zero.cmake`: device-specific build definitions selected by the root `CMakeLists.txt`. - `include/vamp_style.hpp`, `include/vamp_style/font.hpp`, and `src/vamp_style.cpp`: shared color palette and Petaluma font interfaces. Bitmap font data is generated in the build directory. - `data`: authored MusicXML for performance songs and practice exercises, plus the generator regression summary. -- `generator`: build-time Python preprocessing for performance arrangements and practice variants. +- `generator`: build-time Python part generation, guitar placement, scat lyrics, engraving, audio compilation, and catalogue construction. - `package`: generic bundle inputs, scripts, config, static font assets, and device install files. ## Zero Runtime @@ -31,11 +31,11 @@ The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP cip On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product-vamp-start-zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp-zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console. -At startup the Zero validates the packaged `catalogue.db` application identifier and schema version, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice exercise/key/score menus. Every practice exercise offers twelve pre-generated key variants; performance songs have no key-selection menu. The score view displays build-time engraved PNG pages. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host, not on the Pi. The view retains at most two page textures. +At startup the Zero validates the packaged `catalogue.db` application identifier and schema version, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. Performance navigation is Song -> Part -> Score; practice is Exercise -> Part -> Score. Both offer Acoustic, Electric, and Vocal, without key selection. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host. The Zero displays engraved PNGs and retains at most two page textures. Runtime catalogues contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups reference canonical records by index; practice is a flat, numerically ordered exercise list. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Python constructs the complete database before deployment; metadata and embedded JSON are published together. There are no custom rows, runtime imports, metadata-repair triggers, or persisted page-state tables. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures. -Practice exercise IDs are the three-digit positive source stems, such as `001`. Variant IDs append the canonical key stem, such as `001-db`. Duplicate titles and identical placeholder music remain independent exercises. Catalogue schema 2 has no practice-type or lesson-group columns, and control responses expose one flat exercise list without nested type/lesson copies. Older catalogues must be rebuilt or reinstalled, not migrated. +Practice exercise IDs are the three-digit source stems, including `000` when present. Part IDs append the part name, such as `001-electric`. Duplicate titles and identical placeholder music remain independent exercises. Catalogue schema 3 stores exercise identity and part metadata without practice categories, lesson groups, or key variants. Every exercise must have all three parts. Older catalogues must be rebuilt or reinstalled, not migrated. `--once` (also `runtime.exit_after_startup`) is a headless readiness check: it checks database integrity and validates the catalogue and referenced page assets, checks runtime-directory writes and the configured server bind, and exits without opening audio/display devices or publishing a readiness heartbeat. Page checks include containment, checksums, CPU-only PNG decoding, and exact 976x484 dimensions. The configured heartbeat destination must be absent or a regular file, never a directory, symlink, or special file. Runtime writes and readiness probes use exclusive sibling temporary files; readiness leaves an existing heartbeat unchanged and removes its probes. `--health-check` instead probes the running service's read-only `/api/health` endpoint using the configured timeout. A stopped/unresponsive service or an invalid response fails the check; it does not create or repair files. `--check-config` remains configuration-only. @@ -45,11 +45,11 @@ Zero and Pico share one gesture state machine: a second release within 220 ms of ## Score Generation -The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Numbered practice sources are emitted in all 12 keys at build time. Generated JSON keeps notation metadata while removing generator-only optional chord markers. The catalogue produces three bundles per song and twelve per practice exercise; source counts are discovered, not fixed in code. +The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Every song and exercise produces acoustic, electric, and vocal parts in its authored musical key. Source counts are discovered, not fixed in code. Generator-only optional chord markers are consumed before publishing. -Practice sources live directly under `data/` with positive three-digit filenames, currently `001.xml` through `366.xml`. For example, `data/001.xml` generates `practice/001/{c,db,d,eb,e,f,gb,g,ab,a,bb,b}.json` and corresponding page directories. Performance sources stay in `data/songs/`. The filename number determines identity and ordering; MusicXML work/movement titles determine labels, falling back to the number when absent. Other root XML names and nested practice sources are rejected. Adding, renaming, or removing a numbered source requires no registry edit; the next build replaces obsolete generated assets and rebuilds the database. The current catalogue has 366 exercises, 4,392 practice variants, and six performance parts. +Practice sources live directly in `data/practice/` with three-digit filenames, currently `001.xml` through `366.xml`; `000.xml` is also valid. For example, `data/practice/001.xml` generates `practice/001/{acoustic,electric,vocal}.json` and corresponding page directories. Songs stay in `data/songs/`. The filename number determines exercise identity and ordering; MusicXML titles determine labels, falling back to the number. Root-level XML, other practice filenames, and nested practice directories are rejected. Adding, renaming, or removing a source requires no registry edit. The current 366 exercises produce 1,098 practice parts, plus six performance parts. -The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model -> automatic arranger/transposer -> semantic JSON plus engraved pages. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. Independent rhythmic voices remain separate; simultaneous chord tones retain their own spelling, written duration, and tie links. The packaged music describes each transformed part or practice variant, not an archival copy of the source XML. +The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model -> part generation -> semantic JSON, audio, and engraved pages -> packaged SQLite catalogue. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. The musical model retains independent voices and per-tone durations/ties; automatic practice generation additionally requires a single monophonic melody and optional structured harmonies. Packaged music describes each generated part, not an archival copy of the XML. ### MusicXML Contract @@ -60,7 +60,7 @@ The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model -> | Chords and rests | Independent voice lanes, simultaneous chord tones, shorter additional chord tones, rests, full-measure rests, hidden events, forward spacers, and rhythm slashes. | | Ties and tuplets | Per-pitch ties, including partial chord ties; nested and cross-bar tuplet spans. Nested tuplets require explicit `tuplet-actual` and `tuplet-normal` numbers. | | Small notes | Grace notes with zero metric duration, cue notes, and measured single/paired tremolos. | -| Staff state | G/F/C, percussion, TAB, and no-clef staves; tablature string/fret/tunings; octave clefs and octave-shift spans; standard major/minor/modal keys or custom seven-letter alterations; key, clef, and meter changes. | +| Staff state | G/F/C, percussion, and no-clef staves; octave clefs and octave-shift spans; major/minor/modal keys or custom seven-letter alterations; timed key, clef, and meter changes. Authored TAB and technical fingering instructions are rejected; TAB is generated from pitched guitar parts. | | Meter | Simple, compound, additive, mixed-denominator, and unmetered timelines. A partial measure must be marked `implicit="yes"`; an initial implicit measure is the arrangement pickup. Leading rests are retained. | | Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Their positions never split or shorten notes. | | Text | Title, composer, part name, multiple lyric verses with syllabification, elisions, and extenders, rehearsal labels at measure starts, and Vamp directives. | @@ -85,13 +85,13 @@ In MuseScore's score properties, set `vamp:style` to a supported player style. I ``` -The default is Medium Swing; `Med.`, `Med. Swing`, and `Medium` remain aliases. A keyless exercise needs `vamp:tonic` (for example, `C`) as its transposition reference. Modes and local key changes are preserved when generating practice variants. Keyless variants remain unkeyed in the music and show their selected transposition reference in menus, for example `D (no key)`. +The default is Medium Swing; `Med.`, `Med. Swing`, and `Medium` remain style aliases. Keys, modes, keyless notation, and local changes are preserved. There is no transposition reference or `vamp:tonic` property. Parenthesized chord symbols mark optional turnarounds, including Soundslice's text-only `(Fm7)` and `(Bb7)` exports. Alternatively, place staff text `vamp:optional-chord` at the same onset as exactly one harmony; this text may be hidden. Earlier arrangement sections retain those chords and their parentheses; the final ending omits them. The normalized harmony stores `parenthesized` display metadata separately from the generator-only `chord_optional` flag. Orphaned/duplicate markers and unknown `vamp:` properties are errors; consumed directives never enter runtime JSON. ### Linear Reading Order -There is no repeat analyzer, expansion pass, or repeat/ending engraver. Source measures feed practice transposition, guitar validation, and playback in authored order. Performance songs still use the existing seven-section arranger, which writes every section explicitly onto successive pages; neither display nor playback jumps backward through those pages. +There is no repeat analyzer, expansion pass, or repeat/ending engraver. Practice reads the source once in authored order. Performance retains the seven-section arranger, writing every section explicitly onto successive pages; neither display nor playback jumps backward. Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to filenames and missing composer credits remain empty, as with the current Soundslice exports. @@ -106,29 +106,39 @@ A newly generated bundle has four top-level fields: - `presentation`: engraver/version, style checksum, fixed width/height, and ordered relative PNG paths with SHA-256 checksums. - `playback`: versioned availability, exact performed beat duration/events, shared PCM sample descriptors, and a generated release envelope. Unavailable interpretations carry a reason instead of an approximate timeline. -Practice metadata includes optional `authored_key_signature`, captured before transposition and shared by all twelve variants. The menu's `SOURCE` label uses this value, not the first variant. Existing bundles without it remain supported and omit the source label. Explicit key-cancellation signatures transpose with the same interval as the music; the engraver cancels only altered pitches that actually change. +There are no authored-key/selected-key metadata pairs. The score's key metadata describes its actual music, and menus display that key alongside its part name. Instrument normalization transforms explicit key cancellations with the same interval as the notes. Measure durations, event onsets/durations, harmony offsets, span times, and pitch alterations use `{ "numerator": N, "denominator": D }`. Times are in quarter-note units and alterations in semitones. There is no tick grid, display-duration surrogate, or runtime flattening into one voice. Written note types, dots, and time modifications remain distinct from metric duration. Every event owns a voice; every chord tone has its own ID, written duration, duration, and reciprocal tie links. Tuplets reference events and belong to one voice. -The arranger normalizes instrument transposition to concert pitch and preserves all source voices in source-copy sections. Generated guitar rhythms occupy one lane. The seven-section form, optional turnaround handling, and twelve practice keys remain automatic. Generated guitar voicings and practice validation share standard six-string tuning (E2 A2 D3 G3 B3 E4), frets 0-20 inclusive, and a maximum four-fret difference between fretted positions; open strings do not count toward that span. The arranger searches all candidates in that range and retains its existing deterministic scoring and voicing policy. Unplayable performance harmonies still produce a warned rhythmic-slash fallback without changing the structured chord meaning or quantizing microtones. +### Shared Part Generation -Practice transposes from the declared source key, not from its first chord. Sources currently declare C major, but the importer does not force C or discard modes and local key changes. For example, a C-key exercise beginning with G7 produces A7 when D is selected. It preserves every authored tone, interval, inversion, rhythmic voice, tie, and tuplet. The closest transposition is tried first, followed by whole-exercise octave alternatives ordered by absolute displacement, with upward movement preferred on ties. Register adjustment does not change harmony symbols or key signatures. Sustained and tied tones keep their strings as other voices enter. This is a fretboard feasibility check, not a guarantee of comfortable fingering, and does not apply the performance arranger's chord-tone omissions or revoicing. +`parts.py` owns shared melody-copying and harmonic-rhythm construction. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. The former generic twelve-key transposer and independent practice/arrangement fretboard implementations are removed. -For the fretboard check, ordinary unmarked treble notation uses the project's written-guitar convention (one octave above sounding pitch). An explicit MusicXML instrument transposition is applied instead, once, including an explicit zero interval; the importer records this distinction with `transpose.explicit`. Octave-clef and TAB pitches do not receive the implicit octave subtraction. Nonstandard tunings, capo configurations, microtonal frets, or exercises that cannot retain their voicings in every key fail explicitly. Errors identify the source, target key, and failing passage; existing generated assets are preserved. No notes are omitted or revoiced to make a key fit. +Practice acoustic accompaniment is derived from structured harmony roots, kinds, degrees, inversions, and slash basses using the shared accompaniment rhythm. A missing harmony or N.C. produces rests, not guessed chords. Electric retains the exact melody, spelling, rhythm, ties, and tuplets; vocal retains those same authored notes and receives scat lyrics. Song vocal lyrics are unchanged. Practice optional-turnaround harmonies are played once, without creating extra sections. -Automatic performance arrangements require a pitched, non-TAB lead. Percussion and tablature remain supported in the musical model and engraver, but performance conversion rejects them rather than reinterpreting their pitches. All practice exercises now require pitched, standard-tuning guitar material. Transposition preserves authored TAB strings and validates the resulting exact integer frets against 0-20; it never invents new TAB. The old unchecked melody-practice path is removed. +All three parts, for both songs and practice, use octave-transposing notation: written notes sound one octave lower. This includes the vocal part for the singer's range. Unmarked source notation uses that convention by default; copied melodies retain their written pitches and octave numbers. For example, written C4 sounds C3 (A-string fret 3), and written C#6 sounds C#5, within the 0-20 fret range. Generated accompaniment is notated using the same convention. + +`pitch.py` resolves explicit MusicXML instrument transposition once before part generation. Explicit zero transposition is also authoritative. Octave-clef exports already encode sounding pitch in their note values, so they do not receive another implicit octave subtraction. Generated parts carry an explicit `transpose.octave_change: -1`; guitar placement and audio read that shared state. Internal concert-pitch normalization and written-pitch conversion cancel for ordinary source notation. There is no octave fitting, vocal-specific range shift, or key transposition. + +`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 0-20 inclusive, with at most four frets between fretted positions; open strings do not count toward that span. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Harmonic candidates retain root and defining/altered/added degrees and requested bass; an unaltered fifth in extended chords and unaltered intermediate ninth/eleventh extensions may be omitted. Pitch classes are not doubled. + +Placement minimizes total movement of fret/string position centers, then fretted span and omitted optional degrees, with deterministic ties. Independent attacks use an exact small-grid distance transform; when notes sustain across attacks the search retains actual occupied strings. There is no finger assignment, barre logic, or predefined chord-shape dictionary. These are proximity and fretboard-feasibility constraints, not a guarantee of comfortable fingering. Nonstandard tuning, capos, microtonal frets, and unplayable passages fail with source/part/event context; there is no slash fallback for a failed chord. Existing assets remain untouched after failure. + +### Practice Scat + +`scat.py` generates one lyric verse from the authored melody without changing notes or rhythm. Every untied attack receives one syllable: `doo` for a duration at least the local beat, `dat` for a shorter phrase ending, `dit` for a short offbeat, and `ba` for another short attack. Consecutive matching short syllables alternate with `da`. Phrase boundaries come from gaps/rests, section labels, and the score end, not every barline. Compound/additive meters and pickups determine beat placement; unmetered music uses duration and phrase endings without inventing accents. Grace attacks receive short syllables; cue notes do not. Tied chains receive one syllable with an extender ending at the chain's actual end, including across pages. There is no randomness or vocal range/octave adaptation. ### Playback Boundary All musical compilation and tone synthesis run in Python (`generator/playback.py`). Each transformed score receives a sorted timeline of independent tone events with rational quarter-note `onset` and `end` positions. Ties become one continuous attack. Rests and spacers remain silence, including trailing silence. Harmony labels are not auto-accompaniment: only pitches actually present in the selected arrangement or exercise sound. Cue notes are silent, hidden pitched notes sound, and pitchless rhythm slashes are silent. -The `soft-struck-1` profile generates shared mono 48 kHz PCM16 WAVs with a short attack/decay and a seamless sustain loop, plus a shared 50 ms release envelope. Exact sounding pitches, including supported microtones, determine samples. Explicit instrument transposition is applied once; all practice and generated guitar parts use the existing written-guitar convention. Samples are shared across all scores and voices by content identity, not duplicated per complete performance. There are no SoundFonts, instrument sample bundles, oscillators, or MusicXML parsing at runtime. +The `soft-struck-1` profile generates shared mono 48 kHz PCM16 WAVs with a short attack/decay and a seamless sustain loop, plus a shared 50 ms release envelope. Exact sounding pitches, including supported microtones, determine samples. Instrument transposition is applied once from the generated musical model, without role- or filename-dependent octave heuristics. Samples are shared across scores and voices by content identity. There are no SoundFonts, bundled instrument samples, oscillators, or MusicXML parsing at runtime. Grace groups honor explicit steal-time-previous/following or make-time; unspecified groups share 25 percent of the following event. Conflicting grace modes, trailing grace groups, unsupported unpitched sound, tied measured tremolo, and incompatible paired tremolo make playback explicitly unavailable while retaining the notation. Measured tremolo uses written subdivision and tuplet ratios. The tone range is 20-18,000 Hz. Asset validation checks containment, SHA-256, PCM format/length, loop endpoints, envelope shape, event ordering, references, and rational timing. Generation remains transactional if validation fails. At runtime the shared on-demand score loader serves both display and audio. A background worker validates and loads only the selected score's samples, converts absolute beat positions to rounded sample frames at the menu BPM, and prepares a Sonus sequence. Pitch does not change with BPM, and rounding absolute positions avoids cumulative timing drift. The I/O loop publishes the prepared sequence and polls completion without doing disk I/O or musical interpretation. Sonus mixes preallocated voices from immutable PCM and retires them on control threads; the callback never synthesizes tones or loads files. Limits are one million events, 512 distinct samples, 64 MiB decoded samples per selection, 64 simultaneous voices including releases, and 24 hours at the selected BPM. Exceeding them reports an error rather than dropping notes. -`audio.playback_enabled` defaults to false and replaces the old inert `track_enabled` setting. The old config name is accepted for migration, but conflicting values fail. Playback suppresses the metronome without changing its stored toggle. Authored tempo marks remain notation; the menu BPM is constant throughout a session. Selecting a practice variant or performance part starts a new session. Manual page changes never restart or seek. Natural completion, including trailing silence/release, returns to the corresponding key/part menu with its selection preserved. Leaving the score cancels playback; stale load/completion results cannot close a newer score. Errors stay in the score view and `/api/status` rather than being mistaken for completion. Existing bundles without `playback` still display but require regeneration to play. +`audio.playback_enabled` defaults to false and replaces the old inert `track_enabled` setting. The old config name is accepted for migration, but conflicting values fail. Playback suppresses the metronome without changing its stored toggle. Authored tempo marks remain notation; menu BPM is constant throughout a session. Selecting a practice or performance part starts a new session. Manual page changes never restart or seek. Natural completion, including trailing silence/release, returns to the corresponding part menu with its selection preserved. Leaving the score cancels playback; stale results cannot close a newer score. Errors remain visible rather than being mistaken for completion. `--once` additionally validates referenced audio without opening display/audio hardware. Runtime packages contain WAVs alongside pages and the SQLite catalogue; the source XML and Python dependencies remain build-only. @@ -140,7 +150,9 @@ Engraving supports a bounded process pool (`VAMP_ENGRAVING_JOBS`, default 2) and `notation.py` interprets staff state, exact pitch positions and shared accidental state; `notes.py` and `attributes.py` construct symbols; `spans.py` handles beams, ties, tuplets, octave shifts and lyric continuations; `layout.py` handles spacing and pagination. `fonts.py` reads bundled OTF outlines and SMuFL metadata, caching metrics and outlines for one generation run. `raster.py` uses FontTools, aggdraw and Pillow only as low-level font and drawing libraries. All dependencies are pinned in `generator/requirements.txt` and remain build-only. Standard glyph names and their upstream notice are vendored alongside the fonts; no system music fonts are consulted. -The former backend-specific four-voice, duration, unequal-tone and tuplet restrictions are removed. Voices retain separate stems and collision offsets; chord tones retain their own written durations and per-tone ties. Pitched, percussion and explicitly fingered TAB material use their own staff positions. Unknown exact microtonal alterations use a numeric rational semitone annotation relative to the natural pitch, not a rounded accidental. An explicit unavailable SMuFL glyph is an error. The single-part/single-staff importer contract and exclusions for dynamics, accents, expressive ornaments and navigation remain unchanged. +Voices retain separate stems and collision offsets; chord tones retain their own written durations and per-tone ties. Pitched and percussion material use their own staff positions. Unknown exact microtonal alterations use a rational semitone annotation rather than a rounded accidental; an unavailable explicit SMuFL glyph is an error. The broader model/engraver supports this notation, while automatic guitar and practice generation enforce the source contracts above. + +Generated guitar tones carry `tablature: {string, fret}`; their staff is marked `tablature: true`. `engraving/tablature.py` draws a six-line TAB view below standard notation, sharing onset columns, barlines, and page breaks. Both views belong to one system and never duplicate the semantic timeline or playback events. Standard notation carries rhythm and tuplets; TAB carries positions, rests/slashes, and tied continuations. Strings and frets are validated against sounding pitch, and the layout audit checks each visible TAB numeral's position and alignment. `audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies, tempos and spans. It rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite. @@ -170,7 +182,7 @@ The display is rotated 90 degrees clockwise into a 320x240 landscape UI. The top - Yellow: retrying or remote request failed. - Green: connected to the Zero API. -The body stays blank until a performance or practice score is active, then it shows the active title, selected part or active practice key, and page count. Touch controls are tap for next, double tap for previous, and tap-and-hold for select. +The body stays blank until a performance or practice score is active, then it shows the active title, selected part, and page count. Touch controls are tap for next, double tap for previous, and tap-and-hold for select. The Pico text renderer uses an antialiased bitmap atlas generated from `package/assets/fonts/petaluma/PetalumaScript.otf`. The shared font CMake rule generates `generated/vamp_style/font.cpp` separately in each native/Pico build directory, using `generator/generate_pico_font.py --output `. Font and generator changes trigger regeneration. The generated atlas includes ASCII kerning pairs so display text such as `VAMP` uses the same spacing as the source font. The pinned Pillow build dependency is declared in `generator/requirements.txt`. The root `CMakeLists.txt` is also the Pico firmware entrypoint when configured with `-DVAMP_PICO_FIRMWARE_PROJECT=ON`. That mode returns before the Zero/Linux targets are declared, so the Pico build can use the embedded ARM toolchain cleanly. diff --git a/cmake/zero.cmake b/cmake/zero.cmake index a25e861..92da2db 100644 --- a/cmake/zero.cmake +++ b/cmake/zero.cmake @@ -112,8 +112,7 @@ configure_file(package/manifest.json.in ${VAMP_MANIFEST} @ONLY) file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/data/*.xml - ${CMAKE_CURRENT_SOURCE_DIR}/data/*.json - ${CMAKE_CURRENT_SOURCE_DIR}/data/**/*.json + ${CMAKE_CURRENT_SOURCE_DIR}/data/expected/*.json ${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py ${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py ${CMAKE_CURRENT_SOURCE_DIR}/generator/requirements.txt diff --git a/conanfile.py b/conanfile.py index 32b6b66..1838717 100644 --- a/conanfile.py +++ b/conanfile.py @@ -25,10 +25,9 @@ class product_vamp_conan(conan.ConanFile): "modulus.py", "modulus.json", "cmake/*.cmake", - "data/*.json", - "data/**/*.json", - "data/*.xml", - "data/**/*.xml", + "data/expected/*.json", + "data/practice/*.xml", + "data/songs/*.xml", "tests/fixtures/*", "tests/fixtures/**/*", "generator/*.py", diff --git a/data/001.xml b/data/practice/001.xml similarity index 100% rename from data/001.xml rename to data/practice/001.xml diff --git a/data/002.xml b/data/practice/002.xml similarity index 100% rename from data/002.xml rename to data/practice/002.xml diff --git a/data/003.xml b/data/practice/003.xml similarity index 100% rename from data/003.xml rename to data/practice/003.xml diff --git a/data/004.xml b/data/practice/004.xml similarity index 100% rename from data/004.xml rename to data/practice/004.xml diff --git a/data/005.xml b/data/practice/005.xml similarity index 100% rename from data/005.xml rename to data/practice/005.xml diff --git a/data/006.xml b/data/practice/006.xml similarity index 100% rename from data/006.xml rename to data/practice/006.xml diff --git a/data/007.xml b/data/practice/007.xml similarity index 100% rename from data/007.xml rename to data/practice/007.xml diff --git a/data/008.xml b/data/practice/008.xml similarity index 100% rename from data/008.xml rename to data/practice/008.xml diff --git a/data/009.xml b/data/practice/009.xml similarity index 100% rename from data/009.xml rename to data/practice/009.xml diff --git a/data/010.xml b/data/practice/010.xml similarity index 100% rename from data/010.xml rename to data/practice/010.xml diff --git a/data/011.xml b/data/practice/011.xml similarity index 100% rename from data/011.xml rename to data/practice/011.xml diff --git a/data/012.xml b/data/practice/012.xml similarity index 100% rename from data/012.xml rename to data/practice/012.xml diff --git a/data/013.xml b/data/practice/013.xml similarity index 100% rename from data/013.xml rename to data/practice/013.xml diff --git a/data/014.xml b/data/practice/014.xml similarity index 100% rename from data/014.xml rename to data/practice/014.xml diff --git a/data/015.xml b/data/practice/015.xml similarity index 100% rename from data/015.xml rename to data/practice/015.xml diff --git a/data/016.xml b/data/practice/016.xml similarity index 100% rename from data/016.xml rename to data/practice/016.xml diff --git a/data/017.xml b/data/practice/017.xml similarity index 100% rename from data/017.xml rename to data/practice/017.xml diff --git a/data/018.xml b/data/practice/018.xml similarity index 100% rename from data/018.xml rename to data/practice/018.xml diff --git a/data/019.xml b/data/practice/019.xml similarity index 100% rename from data/019.xml rename to data/practice/019.xml diff --git a/data/020.xml b/data/practice/020.xml similarity index 100% rename from data/020.xml rename to data/practice/020.xml diff --git a/data/021.xml b/data/practice/021.xml similarity index 100% rename from data/021.xml rename to data/practice/021.xml diff --git a/data/022.xml b/data/practice/022.xml similarity index 100% rename from data/022.xml rename to data/practice/022.xml diff --git a/data/023.xml b/data/practice/023.xml similarity index 100% rename from data/023.xml rename to data/practice/023.xml diff --git a/data/024.xml b/data/practice/024.xml similarity index 100% rename from data/024.xml rename to data/practice/024.xml diff --git a/data/025.xml b/data/practice/025.xml similarity index 100% rename from data/025.xml rename to data/practice/025.xml diff --git a/data/026.xml b/data/practice/026.xml similarity index 100% rename from data/026.xml rename to data/practice/026.xml diff --git a/data/027.xml b/data/practice/027.xml similarity index 100% rename from data/027.xml rename to data/practice/027.xml diff --git a/data/028.xml b/data/practice/028.xml similarity index 100% rename from data/028.xml rename to data/practice/028.xml diff --git a/data/029.xml b/data/practice/029.xml similarity index 100% rename from data/029.xml rename to data/practice/029.xml diff --git a/data/030.xml b/data/practice/030.xml similarity index 100% rename from data/030.xml rename to data/practice/030.xml diff --git a/data/031.xml b/data/practice/031.xml similarity index 100% rename from data/031.xml rename to data/practice/031.xml diff --git a/data/032.xml b/data/practice/032.xml similarity index 100% rename from data/032.xml rename to data/practice/032.xml diff --git a/data/033.xml b/data/practice/033.xml similarity index 100% rename from data/033.xml rename to data/practice/033.xml diff --git a/data/034.xml b/data/practice/034.xml similarity index 100% rename from data/034.xml rename to data/practice/034.xml diff --git a/data/035.xml b/data/practice/035.xml similarity index 100% rename from data/035.xml rename to data/practice/035.xml diff --git a/data/036.xml b/data/practice/036.xml similarity index 100% rename from data/036.xml rename to data/practice/036.xml diff --git a/data/037.xml b/data/practice/037.xml similarity index 100% rename from data/037.xml rename to data/practice/037.xml diff --git a/data/038.xml b/data/practice/038.xml similarity index 100% rename from data/038.xml rename to data/practice/038.xml diff --git a/data/039.xml b/data/practice/039.xml similarity index 100% rename from data/039.xml rename to data/practice/039.xml diff --git a/data/040.xml b/data/practice/040.xml similarity index 100% rename from data/040.xml rename to data/practice/040.xml diff --git a/data/041.xml b/data/practice/041.xml similarity index 100% rename from data/041.xml rename to data/practice/041.xml diff --git a/data/042.xml b/data/practice/042.xml similarity index 100% rename from data/042.xml rename to data/practice/042.xml diff --git a/data/043.xml b/data/practice/043.xml similarity index 100% rename from data/043.xml rename to data/practice/043.xml diff --git a/data/044.xml b/data/practice/044.xml similarity index 100% rename from data/044.xml rename to data/practice/044.xml diff --git a/data/045.xml b/data/practice/045.xml similarity index 100% rename from data/045.xml rename to data/practice/045.xml diff --git a/data/046.xml b/data/practice/046.xml similarity index 100% rename from data/046.xml rename to data/practice/046.xml diff --git a/data/047.xml b/data/practice/047.xml similarity index 100% rename from data/047.xml rename to data/practice/047.xml diff --git a/data/048.xml b/data/practice/048.xml similarity index 100% rename from data/048.xml rename to data/practice/048.xml diff --git a/data/049.xml b/data/practice/049.xml similarity index 100% rename from data/049.xml rename to data/practice/049.xml diff --git a/data/050.xml b/data/practice/050.xml similarity index 100% rename from data/050.xml rename to data/practice/050.xml diff --git a/data/051.xml b/data/practice/051.xml similarity index 100% rename from data/051.xml rename to data/practice/051.xml diff --git a/data/052.xml b/data/practice/052.xml similarity index 100% rename from data/052.xml rename to data/practice/052.xml diff --git a/data/053.xml b/data/practice/053.xml similarity index 100% rename from data/053.xml rename to data/practice/053.xml diff --git a/data/054.xml b/data/practice/054.xml similarity index 100% rename from data/054.xml rename to data/practice/054.xml diff --git a/data/055.xml b/data/practice/055.xml similarity index 100% rename from data/055.xml rename to data/practice/055.xml diff --git a/data/056.xml b/data/practice/056.xml similarity index 100% rename from data/056.xml rename to data/practice/056.xml diff --git a/data/057.xml b/data/practice/057.xml similarity index 100% rename from data/057.xml rename to data/practice/057.xml diff --git a/data/058.xml b/data/practice/058.xml similarity index 100% rename from data/058.xml rename to data/practice/058.xml diff --git a/data/059.xml b/data/practice/059.xml similarity index 100% rename from data/059.xml rename to data/practice/059.xml diff --git a/data/060.xml b/data/practice/060.xml similarity index 100% rename from data/060.xml rename to data/practice/060.xml diff --git a/data/061.xml b/data/practice/061.xml similarity index 100% rename from data/061.xml rename to data/practice/061.xml diff --git a/data/062.xml b/data/practice/062.xml similarity index 100% rename from data/062.xml rename to data/practice/062.xml diff --git a/data/063.xml b/data/practice/063.xml similarity index 100% rename from data/063.xml rename to data/practice/063.xml diff --git a/data/064.xml b/data/practice/064.xml similarity index 100% rename from data/064.xml rename to data/practice/064.xml diff --git a/data/065.xml b/data/practice/065.xml similarity index 100% rename from data/065.xml rename to data/practice/065.xml diff --git a/data/066.xml b/data/practice/066.xml similarity index 100% rename from data/066.xml rename to data/practice/066.xml diff --git a/data/067.xml b/data/practice/067.xml similarity index 100% rename from data/067.xml rename to data/practice/067.xml diff --git a/data/068.xml b/data/practice/068.xml similarity index 100% rename from data/068.xml rename to data/practice/068.xml diff --git a/data/069.xml b/data/practice/069.xml similarity index 100% rename from data/069.xml rename to data/practice/069.xml diff --git a/data/070.xml b/data/practice/070.xml similarity index 100% rename from data/070.xml rename to data/practice/070.xml diff --git a/data/071.xml b/data/practice/071.xml similarity index 100% rename from data/071.xml rename to data/practice/071.xml diff --git a/data/072.xml b/data/practice/072.xml similarity index 100% rename from data/072.xml rename to data/practice/072.xml diff --git a/data/073.xml b/data/practice/073.xml similarity index 100% rename from data/073.xml rename to data/practice/073.xml diff --git a/data/074.xml b/data/practice/074.xml similarity index 100% rename from data/074.xml rename to data/practice/074.xml diff --git a/data/075.xml b/data/practice/075.xml similarity index 100% rename from data/075.xml rename to data/practice/075.xml diff --git a/data/076.xml b/data/practice/076.xml similarity index 100% rename from data/076.xml rename to data/practice/076.xml diff --git a/data/077.xml b/data/practice/077.xml similarity index 100% rename from data/077.xml rename to data/practice/077.xml diff --git a/data/078.xml b/data/practice/078.xml similarity index 100% rename from data/078.xml rename to data/practice/078.xml diff --git a/data/079.xml b/data/practice/079.xml similarity index 100% rename from data/079.xml rename to data/practice/079.xml diff --git a/data/080.xml b/data/practice/080.xml similarity index 100% rename from data/080.xml rename to data/practice/080.xml diff --git a/data/081.xml b/data/practice/081.xml similarity index 100% rename from data/081.xml rename to data/practice/081.xml diff --git a/data/082.xml b/data/practice/082.xml similarity index 100% rename from data/082.xml rename to data/practice/082.xml diff --git a/data/083.xml b/data/practice/083.xml similarity index 100% rename from data/083.xml rename to data/practice/083.xml diff --git a/data/084.xml b/data/practice/084.xml similarity index 100% rename from data/084.xml rename to data/practice/084.xml diff --git a/data/085.xml b/data/practice/085.xml similarity index 100% rename from data/085.xml rename to data/practice/085.xml diff --git a/data/086.xml b/data/practice/086.xml similarity index 100% rename from data/086.xml rename to data/practice/086.xml diff --git a/data/087.xml b/data/practice/087.xml similarity index 100% rename from data/087.xml rename to data/practice/087.xml diff --git a/data/088.xml b/data/practice/088.xml similarity index 100% rename from data/088.xml rename to data/practice/088.xml diff --git a/data/089.xml b/data/practice/089.xml similarity index 100% rename from data/089.xml rename to data/practice/089.xml diff --git a/data/090.xml b/data/practice/090.xml similarity index 100% rename from data/090.xml rename to data/practice/090.xml diff --git a/data/091.xml b/data/practice/091.xml similarity index 100% rename from data/091.xml rename to data/practice/091.xml diff --git a/data/092.xml b/data/practice/092.xml similarity index 100% rename from data/092.xml rename to data/practice/092.xml diff --git a/data/093.xml b/data/practice/093.xml similarity index 100% rename from data/093.xml rename to data/practice/093.xml diff --git a/data/094.xml b/data/practice/094.xml similarity index 100% rename from data/094.xml rename to data/practice/094.xml diff --git a/data/095.xml b/data/practice/095.xml similarity index 100% rename from data/095.xml rename to data/practice/095.xml diff --git a/data/096.xml b/data/practice/096.xml similarity index 100% rename from data/096.xml rename to data/practice/096.xml diff --git a/data/097.xml b/data/practice/097.xml similarity index 100% rename from data/097.xml rename to data/practice/097.xml diff --git a/data/098.xml b/data/practice/098.xml similarity index 100% rename from data/098.xml rename to data/practice/098.xml diff --git a/data/099.xml b/data/practice/099.xml similarity index 100% rename from data/099.xml rename to data/practice/099.xml diff --git a/data/100.xml b/data/practice/100.xml similarity index 100% rename from data/100.xml rename to data/practice/100.xml diff --git a/data/101.xml b/data/practice/101.xml similarity index 100% rename from data/101.xml rename to data/practice/101.xml diff --git a/data/102.xml b/data/practice/102.xml similarity index 100% rename from data/102.xml rename to data/practice/102.xml diff --git a/data/103.xml b/data/practice/103.xml similarity index 100% rename from data/103.xml rename to data/practice/103.xml diff --git a/data/104.xml b/data/practice/104.xml similarity index 100% rename from data/104.xml rename to data/practice/104.xml diff --git a/data/105.xml b/data/practice/105.xml similarity index 100% rename from data/105.xml rename to data/practice/105.xml diff --git a/data/106.xml b/data/practice/106.xml similarity index 100% rename from data/106.xml rename to data/practice/106.xml diff --git a/data/107.xml b/data/practice/107.xml similarity index 100% rename from data/107.xml rename to data/practice/107.xml diff --git a/data/108.xml b/data/practice/108.xml similarity index 100% rename from data/108.xml rename to data/practice/108.xml diff --git a/data/109.xml b/data/practice/109.xml similarity index 100% rename from data/109.xml rename to data/practice/109.xml diff --git a/data/110.xml b/data/practice/110.xml similarity index 100% rename from data/110.xml rename to data/practice/110.xml diff --git a/data/111.xml b/data/practice/111.xml similarity index 100% rename from data/111.xml rename to data/practice/111.xml diff --git a/data/112.xml b/data/practice/112.xml similarity index 100% rename from data/112.xml rename to data/practice/112.xml diff --git a/data/113.xml b/data/practice/113.xml similarity index 100% rename from data/113.xml rename to data/practice/113.xml diff --git a/data/114.xml b/data/practice/114.xml similarity index 100% rename from data/114.xml rename to data/practice/114.xml diff --git a/data/115.xml b/data/practice/115.xml similarity index 100% rename from data/115.xml rename to data/practice/115.xml diff --git a/data/116.xml b/data/practice/116.xml similarity index 100% rename from data/116.xml rename to data/practice/116.xml diff --git a/data/117.xml b/data/practice/117.xml similarity index 100% rename from data/117.xml rename to data/practice/117.xml diff --git a/data/118.xml b/data/practice/118.xml similarity index 100% rename from data/118.xml rename to data/practice/118.xml diff --git a/data/119.xml b/data/practice/119.xml similarity index 100% rename from data/119.xml rename to data/practice/119.xml diff --git a/data/120.xml b/data/practice/120.xml similarity index 100% rename from data/120.xml rename to data/practice/120.xml diff --git a/data/121.xml b/data/practice/121.xml similarity index 100% rename from data/121.xml rename to data/practice/121.xml diff --git a/data/122.xml b/data/practice/122.xml similarity index 100% rename from data/122.xml rename to data/practice/122.xml diff --git a/data/123.xml b/data/practice/123.xml similarity index 100% rename from data/123.xml rename to data/practice/123.xml diff --git a/data/124.xml b/data/practice/124.xml similarity index 100% rename from data/124.xml rename to data/practice/124.xml diff --git a/data/125.xml b/data/practice/125.xml similarity index 100% rename from data/125.xml rename to data/practice/125.xml diff --git a/data/126.xml b/data/practice/126.xml similarity index 100% rename from data/126.xml rename to data/practice/126.xml diff --git a/data/127.xml b/data/practice/127.xml similarity index 100% rename from data/127.xml rename to data/practice/127.xml diff --git a/data/128.xml b/data/practice/128.xml similarity index 100% rename from data/128.xml rename to data/practice/128.xml diff --git a/data/129.xml b/data/practice/129.xml similarity index 100% rename from data/129.xml rename to data/practice/129.xml diff --git a/data/130.xml b/data/practice/130.xml similarity index 100% rename from data/130.xml rename to data/practice/130.xml diff --git a/data/131.xml b/data/practice/131.xml similarity index 100% rename from data/131.xml rename to data/practice/131.xml diff --git a/data/132.xml b/data/practice/132.xml similarity index 100% rename from data/132.xml rename to data/practice/132.xml diff --git a/data/133.xml b/data/practice/133.xml similarity index 100% rename from data/133.xml rename to data/practice/133.xml diff --git a/data/134.xml b/data/practice/134.xml similarity index 100% rename from data/134.xml rename to data/practice/134.xml diff --git a/data/135.xml b/data/practice/135.xml similarity index 100% rename from data/135.xml rename to data/practice/135.xml diff --git a/data/136.xml b/data/practice/136.xml similarity index 100% rename from data/136.xml rename to data/practice/136.xml diff --git a/data/137.xml b/data/practice/137.xml similarity index 100% rename from data/137.xml rename to data/practice/137.xml diff --git a/data/138.xml b/data/practice/138.xml similarity index 100% rename from data/138.xml rename to data/practice/138.xml diff --git a/data/139.xml b/data/practice/139.xml similarity index 100% rename from data/139.xml rename to data/practice/139.xml diff --git a/data/140.xml b/data/practice/140.xml similarity index 100% rename from data/140.xml rename to data/practice/140.xml diff --git a/data/141.xml b/data/practice/141.xml similarity index 100% rename from data/141.xml rename to data/practice/141.xml diff --git a/data/142.xml b/data/practice/142.xml similarity index 100% rename from data/142.xml rename to data/practice/142.xml diff --git a/data/143.xml b/data/practice/143.xml similarity index 100% rename from data/143.xml rename to data/practice/143.xml diff --git a/data/144.xml b/data/practice/144.xml similarity index 100% rename from data/144.xml rename to data/practice/144.xml diff --git a/data/145.xml b/data/practice/145.xml similarity index 100% rename from data/145.xml rename to data/practice/145.xml diff --git a/data/146.xml b/data/practice/146.xml similarity index 100% rename from data/146.xml rename to data/practice/146.xml diff --git a/data/147.xml b/data/practice/147.xml similarity index 100% rename from data/147.xml rename to data/practice/147.xml diff --git a/data/148.xml b/data/practice/148.xml similarity index 100% rename from data/148.xml rename to data/practice/148.xml diff --git a/data/149.xml b/data/practice/149.xml similarity index 100% rename from data/149.xml rename to data/practice/149.xml diff --git a/data/150.xml b/data/practice/150.xml similarity index 100% rename from data/150.xml rename to data/practice/150.xml diff --git a/data/151.xml b/data/practice/151.xml similarity index 100% rename from data/151.xml rename to data/practice/151.xml diff --git a/data/152.xml b/data/practice/152.xml similarity index 100% rename from data/152.xml rename to data/practice/152.xml diff --git a/data/153.xml b/data/practice/153.xml similarity index 100% rename from data/153.xml rename to data/practice/153.xml diff --git a/data/154.xml b/data/practice/154.xml similarity index 100% rename from data/154.xml rename to data/practice/154.xml diff --git a/data/155.xml b/data/practice/155.xml similarity index 100% rename from data/155.xml rename to data/practice/155.xml diff --git a/data/156.xml b/data/practice/156.xml similarity index 100% rename from data/156.xml rename to data/practice/156.xml diff --git a/data/157.xml b/data/practice/157.xml similarity index 100% rename from data/157.xml rename to data/practice/157.xml diff --git a/data/158.xml b/data/practice/158.xml similarity index 100% rename from data/158.xml rename to data/practice/158.xml diff --git a/data/159.xml b/data/practice/159.xml similarity index 100% rename from data/159.xml rename to data/practice/159.xml diff --git a/data/160.xml b/data/practice/160.xml similarity index 100% rename from data/160.xml rename to data/practice/160.xml diff --git a/data/161.xml b/data/practice/161.xml similarity index 100% rename from data/161.xml rename to data/practice/161.xml diff --git a/data/162.xml b/data/practice/162.xml similarity index 100% rename from data/162.xml rename to data/practice/162.xml diff --git a/data/163.xml b/data/practice/163.xml similarity index 100% rename from data/163.xml rename to data/practice/163.xml diff --git a/data/164.xml b/data/practice/164.xml similarity index 100% rename from data/164.xml rename to data/practice/164.xml diff --git a/data/165.xml b/data/practice/165.xml similarity index 100% rename from data/165.xml rename to data/practice/165.xml diff --git a/data/166.xml b/data/practice/166.xml similarity index 100% rename from data/166.xml rename to data/practice/166.xml diff --git a/data/167.xml b/data/practice/167.xml similarity index 100% rename from data/167.xml rename to data/practice/167.xml diff --git a/data/168.xml b/data/practice/168.xml similarity index 100% rename from data/168.xml rename to data/practice/168.xml diff --git a/data/169.xml b/data/practice/169.xml similarity index 100% rename from data/169.xml rename to data/practice/169.xml diff --git a/data/170.xml b/data/practice/170.xml similarity index 100% rename from data/170.xml rename to data/practice/170.xml diff --git a/data/171.xml b/data/practice/171.xml similarity index 100% rename from data/171.xml rename to data/practice/171.xml diff --git a/data/172.xml b/data/practice/172.xml similarity index 100% rename from data/172.xml rename to data/practice/172.xml diff --git a/data/173.xml b/data/practice/173.xml similarity index 100% rename from data/173.xml rename to data/practice/173.xml diff --git a/data/174.xml b/data/practice/174.xml similarity index 100% rename from data/174.xml rename to data/practice/174.xml diff --git a/data/175.xml b/data/practice/175.xml similarity index 100% rename from data/175.xml rename to data/practice/175.xml diff --git a/data/176.xml b/data/practice/176.xml similarity index 100% rename from data/176.xml rename to data/practice/176.xml diff --git a/data/177.xml b/data/practice/177.xml similarity index 100% rename from data/177.xml rename to data/practice/177.xml diff --git a/data/178.xml b/data/practice/178.xml similarity index 100% rename from data/178.xml rename to data/practice/178.xml diff --git a/data/179.xml b/data/practice/179.xml similarity index 100% rename from data/179.xml rename to data/practice/179.xml diff --git a/data/180.xml b/data/practice/180.xml similarity index 100% rename from data/180.xml rename to data/practice/180.xml diff --git a/data/181.xml b/data/practice/181.xml similarity index 100% rename from data/181.xml rename to data/practice/181.xml diff --git a/data/182.xml b/data/practice/182.xml similarity index 100% rename from data/182.xml rename to data/practice/182.xml diff --git a/data/183.xml b/data/practice/183.xml similarity index 100% rename from data/183.xml rename to data/practice/183.xml diff --git a/data/184.xml b/data/practice/184.xml similarity index 100% rename from data/184.xml rename to data/practice/184.xml diff --git a/data/185.xml b/data/practice/185.xml similarity index 100% rename from data/185.xml rename to data/practice/185.xml diff --git a/data/186.xml b/data/practice/186.xml similarity index 100% rename from data/186.xml rename to data/practice/186.xml diff --git a/data/187.xml b/data/practice/187.xml similarity index 100% rename from data/187.xml rename to data/practice/187.xml diff --git a/data/188.xml b/data/practice/188.xml similarity index 100% rename from data/188.xml rename to data/practice/188.xml diff --git a/data/189.xml b/data/practice/189.xml similarity index 100% rename from data/189.xml rename to data/practice/189.xml diff --git a/data/190.xml b/data/practice/190.xml similarity index 100% rename from data/190.xml rename to data/practice/190.xml diff --git a/data/191.xml b/data/practice/191.xml similarity index 100% rename from data/191.xml rename to data/practice/191.xml diff --git a/data/192.xml b/data/practice/192.xml similarity index 100% rename from data/192.xml rename to data/practice/192.xml diff --git a/data/193.xml b/data/practice/193.xml similarity index 100% rename from data/193.xml rename to data/practice/193.xml diff --git a/data/194.xml b/data/practice/194.xml similarity index 100% rename from data/194.xml rename to data/practice/194.xml diff --git a/data/195.xml b/data/practice/195.xml similarity index 100% rename from data/195.xml rename to data/practice/195.xml diff --git a/data/196.xml b/data/practice/196.xml similarity index 100% rename from data/196.xml rename to data/practice/196.xml diff --git a/data/197.xml b/data/practice/197.xml similarity index 100% rename from data/197.xml rename to data/practice/197.xml diff --git a/data/198.xml b/data/practice/198.xml similarity index 100% rename from data/198.xml rename to data/practice/198.xml diff --git a/data/199.xml b/data/practice/199.xml similarity index 100% rename from data/199.xml rename to data/practice/199.xml diff --git a/data/200.xml b/data/practice/200.xml similarity index 100% rename from data/200.xml rename to data/practice/200.xml diff --git a/data/201.xml b/data/practice/201.xml similarity index 100% rename from data/201.xml rename to data/practice/201.xml diff --git a/data/202.xml b/data/practice/202.xml similarity index 100% rename from data/202.xml rename to data/practice/202.xml diff --git a/data/203.xml b/data/practice/203.xml similarity index 100% rename from data/203.xml rename to data/practice/203.xml diff --git a/data/204.xml b/data/practice/204.xml similarity index 100% rename from data/204.xml rename to data/practice/204.xml diff --git a/data/205.xml b/data/practice/205.xml similarity index 100% rename from data/205.xml rename to data/practice/205.xml diff --git a/data/206.xml b/data/practice/206.xml similarity index 100% rename from data/206.xml rename to data/practice/206.xml diff --git a/data/207.xml b/data/practice/207.xml similarity index 100% rename from data/207.xml rename to data/practice/207.xml diff --git a/data/208.xml b/data/practice/208.xml similarity index 100% rename from data/208.xml rename to data/practice/208.xml diff --git a/data/209.xml b/data/practice/209.xml similarity index 100% rename from data/209.xml rename to data/practice/209.xml diff --git a/data/210.xml b/data/practice/210.xml similarity index 100% rename from data/210.xml rename to data/practice/210.xml diff --git a/data/211.xml b/data/practice/211.xml similarity index 100% rename from data/211.xml rename to data/practice/211.xml diff --git a/data/212.xml b/data/practice/212.xml similarity index 100% rename from data/212.xml rename to data/practice/212.xml diff --git a/data/213.xml b/data/practice/213.xml similarity index 100% rename from data/213.xml rename to data/practice/213.xml diff --git a/data/214.xml b/data/practice/214.xml similarity index 100% rename from data/214.xml rename to data/practice/214.xml diff --git a/data/215.xml b/data/practice/215.xml similarity index 100% rename from data/215.xml rename to data/practice/215.xml diff --git a/data/216.xml b/data/practice/216.xml similarity index 100% rename from data/216.xml rename to data/practice/216.xml diff --git a/data/217.xml b/data/practice/217.xml similarity index 100% rename from data/217.xml rename to data/practice/217.xml diff --git a/data/218.xml b/data/practice/218.xml similarity index 100% rename from data/218.xml rename to data/practice/218.xml diff --git a/data/219.xml b/data/practice/219.xml similarity index 100% rename from data/219.xml rename to data/practice/219.xml diff --git a/data/220.xml b/data/practice/220.xml similarity index 100% rename from data/220.xml rename to data/practice/220.xml diff --git a/data/221.xml b/data/practice/221.xml similarity index 100% rename from data/221.xml rename to data/practice/221.xml diff --git a/data/222.xml b/data/practice/222.xml similarity index 100% rename from data/222.xml rename to data/practice/222.xml diff --git a/data/223.xml b/data/practice/223.xml similarity index 100% rename from data/223.xml rename to data/practice/223.xml diff --git a/data/224.xml b/data/practice/224.xml similarity index 100% rename from data/224.xml rename to data/practice/224.xml diff --git a/data/225.xml b/data/practice/225.xml similarity index 100% rename from data/225.xml rename to data/practice/225.xml diff --git a/data/226.xml b/data/practice/226.xml similarity index 100% rename from data/226.xml rename to data/practice/226.xml diff --git a/data/227.xml b/data/practice/227.xml similarity index 100% rename from data/227.xml rename to data/practice/227.xml diff --git a/data/228.xml b/data/practice/228.xml similarity index 100% rename from data/228.xml rename to data/practice/228.xml diff --git a/data/229.xml b/data/practice/229.xml similarity index 100% rename from data/229.xml rename to data/practice/229.xml diff --git a/data/230.xml b/data/practice/230.xml similarity index 100% rename from data/230.xml rename to data/practice/230.xml diff --git a/data/231.xml b/data/practice/231.xml similarity index 100% rename from data/231.xml rename to data/practice/231.xml diff --git a/data/232.xml b/data/practice/232.xml similarity index 100% rename from data/232.xml rename to data/practice/232.xml diff --git a/data/233.xml b/data/practice/233.xml similarity index 100% rename from data/233.xml rename to data/practice/233.xml diff --git a/data/234.xml b/data/practice/234.xml similarity index 100% rename from data/234.xml rename to data/practice/234.xml diff --git a/data/235.xml b/data/practice/235.xml similarity index 100% rename from data/235.xml rename to data/practice/235.xml diff --git a/data/236.xml b/data/practice/236.xml similarity index 100% rename from data/236.xml rename to data/practice/236.xml diff --git a/data/237.xml b/data/practice/237.xml similarity index 100% rename from data/237.xml rename to data/practice/237.xml diff --git a/data/238.xml b/data/practice/238.xml similarity index 100% rename from data/238.xml rename to data/practice/238.xml diff --git a/data/239.xml b/data/practice/239.xml similarity index 100% rename from data/239.xml rename to data/practice/239.xml diff --git a/data/240.xml b/data/practice/240.xml similarity index 100% rename from data/240.xml rename to data/practice/240.xml diff --git a/data/241.xml b/data/practice/241.xml similarity index 100% rename from data/241.xml rename to data/practice/241.xml diff --git a/data/242.xml b/data/practice/242.xml similarity index 100% rename from data/242.xml rename to data/practice/242.xml diff --git a/data/243.xml b/data/practice/243.xml similarity index 100% rename from data/243.xml rename to data/practice/243.xml diff --git a/data/244.xml b/data/practice/244.xml similarity index 100% rename from data/244.xml rename to data/practice/244.xml diff --git a/data/245.xml b/data/practice/245.xml similarity index 100% rename from data/245.xml rename to data/practice/245.xml diff --git a/data/246.xml b/data/practice/246.xml similarity index 100% rename from data/246.xml rename to data/practice/246.xml diff --git a/data/247.xml b/data/practice/247.xml similarity index 100% rename from data/247.xml rename to data/practice/247.xml diff --git a/data/248.xml b/data/practice/248.xml similarity index 100% rename from data/248.xml rename to data/practice/248.xml diff --git a/data/249.xml b/data/practice/249.xml similarity index 100% rename from data/249.xml rename to data/practice/249.xml diff --git a/data/250.xml b/data/practice/250.xml similarity index 100% rename from data/250.xml rename to data/practice/250.xml diff --git a/data/251.xml b/data/practice/251.xml similarity index 100% rename from data/251.xml rename to data/practice/251.xml diff --git a/data/252.xml b/data/practice/252.xml similarity index 100% rename from data/252.xml rename to data/practice/252.xml diff --git a/data/253.xml b/data/practice/253.xml similarity index 100% rename from data/253.xml rename to data/practice/253.xml diff --git a/data/254.xml b/data/practice/254.xml similarity index 100% rename from data/254.xml rename to data/practice/254.xml diff --git a/data/255.xml b/data/practice/255.xml similarity index 100% rename from data/255.xml rename to data/practice/255.xml diff --git a/data/256.xml b/data/practice/256.xml similarity index 100% rename from data/256.xml rename to data/practice/256.xml diff --git a/data/257.xml b/data/practice/257.xml similarity index 100% rename from data/257.xml rename to data/practice/257.xml diff --git a/data/258.xml b/data/practice/258.xml similarity index 100% rename from data/258.xml rename to data/practice/258.xml diff --git a/data/259.xml b/data/practice/259.xml similarity index 100% rename from data/259.xml rename to data/practice/259.xml diff --git a/data/260.xml b/data/practice/260.xml similarity index 100% rename from data/260.xml rename to data/practice/260.xml diff --git a/data/261.xml b/data/practice/261.xml similarity index 100% rename from data/261.xml rename to data/practice/261.xml diff --git a/data/262.xml b/data/practice/262.xml similarity index 100% rename from data/262.xml rename to data/practice/262.xml diff --git a/data/263.xml b/data/practice/263.xml similarity index 100% rename from data/263.xml rename to data/practice/263.xml diff --git a/data/264.xml b/data/practice/264.xml similarity index 100% rename from data/264.xml rename to data/practice/264.xml diff --git a/data/265.xml b/data/practice/265.xml similarity index 100% rename from data/265.xml rename to data/practice/265.xml diff --git a/data/266.xml b/data/practice/266.xml similarity index 100% rename from data/266.xml rename to data/practice/266.xml diff --git a/data/267.xml b/data/practice/267.xml similarity index 100% rename from data/267.xml rename to data/practice/267.xml diff --git a/data/268.xml b/data/practice/268.xml similarity index 100% rename from data/268.xml rename to data/practice/268.xml diff --git a/data/269.xml b/data/practice/269.xml similarity index 100% rename from data/269.xml rename to data/practice/269.xml diff --git a/data/270.xml b/data/practice/270.xml similarity index 100% rename from data/270.xml rename to data/practice/270.xml diff --git a/data/271.xml b/data/practice/271.xml similarity index 100% rename from data/271.xml rename to data/practice/271.xml diff --git a/data/272.xml b/data/practice/272.xml similarity index 100% rename from data/272.xml rename to data/practice/272.xml diff --git a/data/273.xml b/data/practice/273.xml similarity index 100% rename from data/273.xml rename to data/practice/273.xml diff --git a/data/274.xml b/data/practice/274.xml similarity index 100% rename from data/274.xml rename to data/practice/274.xml diff --git a/data/275.xml b/data/practice/275.xml similarity index 100% rename from data/275.xml rename to data/practice/275.xml diff --git a/data/276.xml b/data/practice/276.xml similarity index 100% rename from data/276.xml rename to data/practice/276.xml diff --git a/data/277.xml b/data/practice/277.xml similarity index 100% rename from data/277.xml rename to data/practice/277.xml diff --git a/data/278.xml b/data/practice/278.xml similarity index 100% rename from data/278.xml rename to data/practice/278.xml diff --git a/data/279.xml b/data/practice/279.xml similarity index 100% rename from data/279.xml rename to data/practice/279.xml diff --git a/data/280.xml b/data/practice/280.xml similarity index 100% rename from data/280.xml rename to data/practice/280.xml diff --git a/data/281.xml b/data/practice/281.xml similarity index 100% rename from data/281.xml rename to data/practice/281.xml diff --git a/data/282.xml b/data/practice/282.xml similarity index 100% rename from data/282.xml rename to data/practice/282.xml diff --git a/data/283.xml b/data/practice/283.xml similarity index 100% rename from data/283.xml rename to data/practice/283.xml diff --git a/data/284.xml b/data/practice/284.xml similarity index 100% rename from data/284.xml rename to data/practice/284.xml diff --git a/data/285.xml b/data/practice/285.xml similarity index 100% rename from data/285.xml rename to data/practice/285.xml diff --git a/data/286.xml b/data/practice/286.xml similarity index 100% rename from data/286.xml rename to data/practice/286.xml diff --git a/data/287.xml b/data/practice/287.xml similarity index 100% rename from data/287.xml rename to data/practice/287.xml diff --git a/data/288.xml b/data/practice/288.xml similarity index 100% rename from data/288.xml rename to data/practice/288.xml diff --git a/data/289.xml b/data/practice/289.xml similarity index 100% rename from data/289.xml rename to data/practice/289.xml diff --git a/data/290.xml b/data/practice/290.xml similarity index 100% rename from data/290.xml rename to data/practice/290.xml diff --git a/data/291.xml b/data/practice/291.xml similarity index 100% rename from data/291.xml rename to data/practice/291.xml diff --git a/data/292.xml b/data/practice/292.xml similarity index 100% rename from data/292.xml rename to data/practice/292.xml diff --git a/data/293.xml b/data/practice/293.xml similarity index 100% rename from data/293.xml rename to data/practice/293.xml diff --git a/data/294.xml b/data/practice/294.xml similarity index 100% rename from data/294.xml rename to data/practice/294.xml diff --git a/data/295.xml b/data/practice/295.xml similarity index 100% rename from data/295.xml rename to data/practice/295.xml diff --git a/data/296.xml b/data/practice/296.xml similarity index 100% rename from data/296.xml rename to data/practice/296.xml diff --git a/data/297.xml b/data/practice/297.xml similarity index 100% rename from data/297.xml rename to data/practice/297.xml diff --git a/data/298.xml b/data/practice/298.xml similarity index 100% rename from data/298.xml rename to data/practice/298.xml diff --git a/data/299.xml b/data/practice/299.xml similarity index 100% rename from data/299.xml rename to data/practice/299.xml diff --git a/data/300.xml b/data/practice/300.xml similarity index 100% rename from data/300.xml rename to data/practice/300.xml diff --git a/data/301.xml b/data/practice/301.xml similarity index 100% rename from data/301.xml rename to data/practice/301.xml diff --git a/data/302.xml b/data/practice/302.xml similarity index 100% rename from data/302.xml rename to data/practice/302.xml diff --git a/data/303.xml b/data/practice/303.xml similarity index 100% rename from data/303.xml rename to data/practice/303.xml diff --git a/data/304.xml b/data/practice/304.xml similarity index 100% rename from data/304.xml rename to data/practice/304.xml diff --git a/data/305.xml b/data/practice/305.xml similarity index 100% rename from data/305.xml rename to data/practice/305.xml diff --git a/data/306.xml b/data/practice/306.xml similarity index 100% rename from data/306.xml rename to data/practice/306.xml diff --git a/data/307.xml b/data/practice/307.xml similarity index 100% rename from data/307.xml rename to data/practice/307.xml diff --git a/data/308.xml b/data/practice/308.xml similarity index 100% rename from data/308.xml rename to data/practice/308.xml diff --git a/data/309.xml b/data/practice/309.xml similarity index 100% rename from data/309.xml rename to data/practice/309.xml diff --git a/data/310.xml b/data/practice/310.xml similarity index 100% rename from data/310.xml rename to data/practice/310.xml diff --git a/data/311.xml b/data/practice/311.xml similarity index 100% rename from data/311.xml rename to data/practice/311.xml diff --git a/data/312.xml b/data/practice/312.xml similarity index 100% rename from data/312.xml rename to data/practice/312.xml diff --git a/data/313.xml b/data/practice/313.xml similarity index 100% rename from data/313.xml rename to data/practice/313.xml diff --git a/data/314.xml b/data/practice/314.xml similarity index 100% rename from data/314.xml rename to data/practice/314.xml diff --git a/data/315.xml b/data/practice/315.xml similarity index 100% rename from data/315.xml rename to data/practice/315.xml diff --git a/data/316.xml b/data/practice/316.xml similarity index 100% rename from data/316.xml rename to data/practice/316.xml diff --git a/data/317.xml b/data/practice/317.xml similarity index 100% rename from data/317.xml rename to data/practice/317.xml diff --git a/data/318.xml b/data/practice/318.xml similarity index 100% rename from data/318.xml rename to data/practice/318.xml diff --git a/data/319.xml b/data/practice/319.xml similarity index 100% rename from data/319.xml rename to data/practice/319.xml diff --git a/data/320.xml b/data/practice/320.xml similarity index 100% rename from data/320.xml rename to data/practice/320.xml diff --git a/data/321.xml b/data/practice/321.xml similarity index 100% rename from data/321.xml rename to data/practice/321.xml diff --git a/data/322.xml b/data/practice/322.xml similarity index 100% rename from data/322.xml rename to data/practice/322.xml diff --git a/data/323.xml b/data/practice/323.xml similarity index 100% rename from data/323.xml rename to data/practice/323.xml diff --git a/data/324.xml b/data/practice/324.xml similarity index 100% rename from data/324.xml rename to data/practice/324.xml diff --git a/data/325.xml b/data/practice/325.xml similarity index 100% rename from data/325.xml rename to data/practice/325.xml diff --git a/data/326.xml b/data/practice/326.xml similarity index 100% rename from data/326.xml rename to data/practice/326.xml diff --git a/data/327.xml b/data/practice/327.xml similarity index 100% rename from data/327.xml rename to data/practice/327.xml diff --git a/data/328.xml b/data/practice/328.xml similarity index 100% rename from data/328.xml rename to data/practice/328.xml diff --git a/data/329.xml b/data/practice/329.xml similarity index 100% rename from data/329.xml rename to data/practice/329.xml diff --git a/data/330.xml b/data/practice/330.xml similarity index 100% rename from data/330.xml rename to data/practice/330.xml diff --git a/data/331.xml b/data/practice/331.xml similarity index 100% rename from data/331.xml rename to data/practice/331.xml diff --git a/data/332.xml b/data/practice/332.xml similarity index 100% rename from data/332.xml rename to data/practice/332.xml diff --git a/data/333.xml b/data/practice/333.xml similarity index 100% rename from data/333.xml rename to data/practice/333.xml diff --git a/data/334.xml b/data/practice/334.xml similarity index 100% rename from data/334.xml rename to data/practice/334.xml diff --git a/data/335.xml b/data/practice/335.xml similarity index 100% rename from data/335.xml rename to data/practice/335.xml diff --git a/data/336.xml b/data/practice/336.xml similarity index 100% rename from data/336.xml rename to data/practice/336.xml diff --git a/data/337.xml b/data/practice/337.xml similarity index 100% rename from data/337.xml rename to data/practice/337.xml diff --git a/data/338.xml b/data/practice/338.xml similarity index 100% rename from data/338.xml rename to data/practice/338.xml diff --git a/data/339.xml b/data/practice/339.xml similarity index 100% rename from data/339.xml rename to data/practice/339.xml diff --git a/data/340.xml b/data/practice/340.xml similarity index 100% rename from data/340.xml rename to data/practice/340.xml diff --git a/data/341.xml b/data/practice/341.xml similarity index 100% rename from data/341.xml rename to data/practice/341.xml diff --git a/data/342.xml b/data/practice/342.xml similarity index 100% rename from data/342.xml rename to data/practice/342.xml diff --git a/data/343.xml b/data/practice/343.xml similarity index 100% rename from data/343.xml rename to data/practice/343.xml diff --git a/data/344.xml b/data/practice/344.xml similarity index 100% rename from data/344.xml rename to data/practice/344.xml diff --git a/data/345.xml b/data/practice/345.xml similarity index 100% rename from data/345.xml rename to data/practice/345.xml diff --git a/data/346.xml b/data/practice/346.xml similarity index 100% rename from data/346.xml rename to data/practice/346.xml diff --git a/data/347.xml b/data/practice/347.xml similarity index 100% rename from data/347.xml rename to data/practice/347.xml diff --git a/data/348.xml b/data/practice/348.xml similarity index 100% rename from data/348.xml rename to data/practice/348.xml diff --git a/data/349.xml b/data/practice/349.xml similarity index 100% rename from data/349.xml rename to data/practice/349.xml diff --git a/data/350.xml b/data/practice/350.xml similarity index 100% rename from data/350.xml rename to data/practice/350.xml diff --git a/data/351.xml b/data/practice/351.xml similarity index 100% rename from data/351.xml rename to data/practice/351.xml diff --git a/data/352.xml b/data/practice/352.xml similarity index 100% rename from data/352.xml rename to data/practice/352.xml diff --git a/data/353.xml b/data/practice/353.xml similarity index 100% rename from data/353.xml rename to data/practice/353.xml diff --git a/data/354.xml b/data/practice/354.xml similarity index 100% rename from data/354.xml rename to data/practice/354.xml diff --git a/data/355.xml b/data/practice/355.xml similarity index 100% rename from data/355.xml rename to data/practice/355.xml diff --git a/data/356.xml b/data/practice/356.xml similarity index 100% rename from data/356.xml rename to data/practice/356.xml diff --git a/data/357.xml b/data/practice/357.xml similarity index 100% rename from data/357.xml rename to data/practice/357.xml diff --git a/data/358.xml b/data/practice/358.xml similarity index 100% rename from data/358.xml rename to data/practice/358.xml diff --git a/data/359.xml b/data/practice/359.xml similarity index 100% rename from data/359.xml rename to data/practice/359.xml diff --git a/data/360.xml b/data/practice/360.xml similarity index 100% rename from data/360.xml rename to data/practice/360.xml diff --git a/data/361.xml b/data/practice/361.xml similarity index 100% rename from data/361.xml rename to data/practice/361.xml diff --git a/data/362.xml b/data/practice/362.xml similarity index 100% rename from data/362.xml rename to data/practice/362.xml diff --git a/data/363.xml b/data/practice/363.xml similarity index 100% rename from data/363.xml rename to data/practice/363.xml diff --git a/data/364.xml b/data/practice/364.xml similarity index 100% rename from data/364.xml rename to data/practice/364.xml diff --git a/data/365.xml b/data/practice/365.xml similarity index 100% rename from data/365.xml rename to data/practice/365.xml diff --git a/data/366.xml b/data/practice/366.xml similarity index 100% rename from data/366.xml rename to data/practice/366.xml diff --git a/generator/__main__.py b/generator/__main__.py index beff518..b8bcd9e 100644 --- a/generator/__main__.py +++ b/generator/__main__.py @@ -9,7 +9,7 @@ from time import monotonic from generator.arrange.generate import generate_performance_scores from generator.practice.generate import generate_practice_scores from generator.score import count_json_files, validate_generated_tree -from generator.summary import validate_expected_performance_summary, validate_practice_variants +from generator.summary import validate_expected_performance_summary, validate_practice_parts from generator.musicxml import load_musicxml from generator.engraving import engrave_tree from generator.playback import compile_tree @@ -52,19 +52,21 @@ def generate_assets(args: argparse.Namespace) -> None: report('validating MusicXML sources') for source in sorted(data_dir.rglob('*.xml')): + if source.parent not in (data_dir / 'practice', data_dir / 'songs'): + raise ValueError(f'{source}: MusicXML sources belong directly under data/practice or data/songs') load_musicxml(source) output_dir.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary: staged = Path(temporary) / 'assets' staged.mkdir(parents=True, exist_ok=True) copy_runtime_fonts(static_assets_dir, staged) - report('generating performance parts and practice variants') + report('generating performance and practice parts') performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs') - practice_count = generate_practice_scores(data_dir, staged / 'practice') + practice_count = generate_practice_scores(data_dir / 'practice', staged / 'practice') validate_generated_tree(staged) if args.check: validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs') - validate_practice_variants(staged / 'practice') + validate_practice_parts(staged / 'practice') report('compiling playback') compile_tree(staged) report('engraving pages') @@ -84,7 +86,7 @@ def generate_assets(args: argparse.Namespace) -> None: raise report('publishing complete') print(f'Generated Product Vamp assets: {performance_count} performance scores, ' - f'{practice_count} practice score variants, {count_json_files(output_dir)} total JSON files.') + f'{practice_count} practice parts, {count_json_files(output_dir)} total JSON files.') if args.check: print('Generator check passed.') diff --git a/generator/arrange/fretboard.py b/generator/arrange/fretboard.py deleted file mode 100644 index ab44664..0000000 --- a/generator/arrange/fretboard.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from itertools import product - -from generator.transposition import mod12 -from generator import harmony -from generator.model import NATURAL, alteration -from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING, TabPosition, WRITTEN_GUITAR_OCTAVE_OFFSET, within_fret_span - -def chord_pitch_classes(symbol: str | dict) -> tuple[int, ...]: - return harmony.pitch_classes(harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol) - -def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]: - result: list[TabPosition] = [] - for fret in range(MIN_FRET, MAX_FRET + 1): - midi = open_midi + fret - if mod12(midi) in pitch_classes: - result.append(TabPosition(string_number, fret, midi)) - return result - -def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int, ...]) -> int: - fretted = [position.fret for position in candidate if position.fret > 0] - if not fretted: - span = 0 - low = 0 - else: - span = max(fretted) - min(fretted) - low = min(fretted) - covered = {mod12(position.midi) for position in candidate} - missing = len(set(pitch_classes) - covered) - open_penalty = sum(1 for position in candidate if position.fret == 0) - return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate) - -def find_playable_voicing(symbol: str | dict) -> list[TabPosition]: - definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol - semantic_degrees = harmony.degrees(definition) - voicing = dict(semantic_degrees) - if 7 in voicing and voicing.get(5) == 0: - del voicing[5] - if len(voicing) > 4: - for degree in (11, 9, 5): - if len(voicing) > 4 and voicing.get(degree) == 0: - del voicing[degree] - root = definition.get('root', {'letter': 'c', 'alter': 0}) - root_pc = NATURAL[root['letter']] + alteration(root['alter']) - pitch_classes = tuple(dict.fromkeys((root_pc + harmony.degree_semitones(d, a)) % 12 for d, a in voicing.items())) - bass = definition.get('bass') - bass_pc = (NATURAL[bass['letter']] + alteration(bass['alter'])) % 12 if bass else None - if bass_pc is not None and bass_pc not in pitch_classes: - pitch_classes += (bass_pc,) - if not pitch_classes: - return [] - string_sets = ((6, 4, 3, 2), (5, 4, 3, 2), (4, 3, 2, 1), (6, 5, 4, 3), (5, 3, 2, 1)) - by_string = {string_number: positions_for_string(string_number, open_midi, pitch_classes) for string_number, open_midi in STANDARD_TUNING} - best: tuple[TabPosition, ...] | None = None - best_score = 10**9 - for string_set in string_sets: - options = [by_string[string_number] for string_number in string_set] - if any(not item for item in options): - continue - for candidate in product(*options): - if bass_pc is not None and min(candidate, key=lambda p: p.midi).midi % 12 != bass_pc: - continue - if not within_fret_span(candidate): - continue - covered = {mod12(position.midi) for position in candidate} - if not set(pitch_classes).issubset(covered): - continue - score = candidate_score(candidate, pitch_classes) - if score < best_score: - best = candidate - best_score = score - return sorted(best or [], key=lambda item: item.midi) - -def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]: - definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol - from generator.transposition import shift_pitch - root = definition.get('root', {'letter': 'c', 'alter': 0}) - spellings = {} - for degree, alter in harmony.degrees(definition).items(): - p = shift_pitch(root, degree - 1, harmony.degree_semitones(degree, alter)) - spellings[(NATURAL[p['letter']] + alteration(p['alter'])) % 12] = p - if bass := definition.get('bass'): - spellings[(NATURAL[bass['letter']] + alteration(bass['alter'])) % 12] = bass - result = [] - for position in find_playable_voicing(definition): - midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - p = spellings[midi % 12].copy() - p['octave'] = int((midi - NATURAL[p['letter']] - alteration(p['alter'])) // 12 - 1) - result.append(p) - return result diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 9834cb6..e18b231 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -1,96 +1,24 @@ from __future__ import annotations import copy -import warnings from fractions import Fraction from pathlib import Path from generator.arrange.form import full_arrangement_sections -from generator.arrange.fretboard import pitches_for_voicing -from generator.model import beat_lengths, fraction, key_label, make_event, meter_label, rational +from generator.guitar import place_guitar +from generator.parts import PART_TYPES, build_measure, propagated_chords, validate_source +from generator.model import fraction, key_label, make_event, meter_label, rational from generator.musicxml import load_musicxml -from generator.transposition import concert_document, shift_pitch -from generator.score import copy_source_voices, make_arranged_document, make_measure, measure_notes, reidentify, slugify, source_staff, write_json +from generator.pitch import concert_document +from generator.score import copy_source_voices, make_arranged_document, reidentify, slugify, source_staff, write_json -PART_TYPES = ('acoustic', 'electric', 'vocal') FINAL_SECTION_INDEX = 6 -def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]: - result = [] - current = None - for measure in measures: - result.append(current) - for h in measure.get('harmonies', []): - if include_optional or not h.get('chord_optional'): - current = h - return result - -def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None: - active = previous - for h in measure['harmonies']: - if fraction(h['onset']) > onset: - break - active = h - return active if active and active['kind'] != 'none' else None - -def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]: - length = fraction(measure['duration']) - if role == 'rest': - return [make_event('rest', Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}] - if role == 'rhythm' and measure['time_signature'] == '4/4' and not measure.get('attributes'): - attacks = [(Fraction(n, 2), Fraction(1, 2)) for n in (0, 3, 4, 5, 7)] - elif role == 'accompaniment' and measure['time_signature'] == '4/4' and not measure.get('attributes'): - attacks = [(Fraction(1, 2), Fraction(1, 2))] - else: - attacks = [] - states = [(Fraction(), measure['meter'])] - states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a) - for index, (onset, meter) in enumerate(states): - end = states[index + 1][0] if index + 1 < len(states) else length - beats = beat_lengths(meter) - beat_index = 0 - while onset < end: - beat = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset) - duration = beat if role == 'slash' or not beats else min(beat / 2, Fraction(1, 2)) - attacks.append((onset, duration)) - onset += beat - beat_index += 1 - events = [] - cursor = Fraction() - for onset, duration in attacks: - if onset >= length: - break - if onset > cursor: - events.append(make_event(f'rest-{len(events)}', cursor, onset - cursor, 'rest')) - duration = min(duration, length - onset) - chord = active_chord(measure, onset, previous) - source_attack = any(fraction(n['onset']) == onset and n['kind'] == 'note' and not n.get('grace') and any(not p.get('tie_from') for p in n['pitches']) for n in measure_notes(measure)) - if role == 'slash': - event = make_event(f'slash-{len(events)}', onset, duration, 'note') - event['notehead'] = 'slash' - elif not chord or role == 'accompaniment' and source_attack: - event = make_event(f'rest-{len(events)}', onset, duration, 'rest') - else: - pitches = pitches_for_voicing(chord) - event = make_event(f'chord-{len(events)}', onset, duration, 'note', pitches) - if not pitches: - warnings.warn(f'{measure["id"]}: no playable voicing for {chord["symbol"]}; using labelled rhythmic slashes', stacklevel=2) - event['notehead'] = 'slash' - events.append(event) - cursor = onset + duration - if cursor < length: - events.append(make_event(f'rest-{len(events)}', cursor, length - cursor, 'rest')) - return events - def arrange_document(document: dict, part_type: str) -> dict: if part_type not in PART_TYPES: raise ValueError(f'unknown arrangement part: {part_type}') - for measure in source_staff(document)['measures']: - specialized_staff = any(state.get('clef', {}).get('sign') in ('TAB', 'percussion') for state in [measure, *measure.get('attributes', [])]) - specialized_notes = any('unpitched' in pitch or 'tablature' in pitch for note in measure_notes(measure) for pitch in note['pitches']) - if specialized_staff or specialized_notes: - raise ValueError(f'{measure["id"]}: automatic performance arranging requires a pitched non-TAB lead; percussion/TAB conversion is not qualified') - document = concert_document(document) + validate_source(document) + document = concert_document(document, octave_transposing=True) staff = source_staff(document) source = staff['measures'] sections = full_arrangement_sections(source, part_type) @@ -109,20 +37,7 @@ def arrange_document(document: dict, part_type: str) -> dict: m['harmonies'] = [h for h in m['harmonies'] if section.section_index != FINAL_SECTION_INDEX or not h.get('chord_optional')] for h in m['harmonies']: h.pop('chord_optional', None) - source_voices = copy_source_voices(m, strip_lyrics=section.role == 'melody') if section.role in ('vocal', 'melody') else None - notes = [n for v in source_voices for n in v['notes']] if source_voices else generated_voice(m, section.role, previous[si]) - if section.role == 'melody': - for note in notes: - note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']] - result = make_measure(f'measure-{len(output) + len(segment) + 1}', m, [] if source_voices else notes, section_label=section.label if not segment else '') - if source_voices: - result['voices'] = source_voices - if part_type != 'vocal': - result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0} - result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1} - for change in result.get('attributes', []): - change.pop('clef', None) - change.pop('transpose', None) + result = build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '') segment.append(result) identity_segment = segment segment_length = sum((fraction(m['duration']) for m in segment), Fraction()) @@ -133,7 +48,7 @@ def arrange_document(document: dict, part_type: str) -> dict: if low < high: octave_shifts.append({**shift, 'id': f'section-{section.section_index}-{shift["id"]}', 'start': rational(absolute + low - start), 'end': rational(absolute + high - start)}) if part_type == 'vocal' and section.section_index in (1, 5) and section.source_measure_index == 1 and source[0].get('implicit') and output: - pickup = copy.deepcopy(source[0]) + pickup = build_measure(source[0], source[0]['id'], 'vocal', part_type, None) destination = output[-1] pad = fraction(destination['duration']) - fraction(pickup['duration']) if pad < 0: @@ -170,6 +85,8 @@ def arrange_document(document: dict, part_type: str) -> dict: absolute += segment_length result = make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) result['metadata'].setdefault('tempo', 124) + if part_type != 'vocal': + place_guitar(result) return result def generate_performance_scores(source_dir: Path, output_dir: Path) -> int: @@ -179,6 +96,9 @@ def generate_performance_scores(source_dir: Path, output_dir: Path) -> int: for path in sorted(source_dir.glob('*.xml')): document = load_musicxml(path) for part in PART_TYPES: - write_json(output_dir / f'{slugify(path.stem)}-{part}.json', arrange_document(document, part)) + try: + write_json(output_dir / f'{slugify(path.stem)}-{part}.json', arrange_document(document, part)) + except ValueError as error: + raise ValueError(f'{path}, part {part}: {error}') from error count += 1 return count diff --git a/generator/catalogue.py b/generator/catalogue.py index 2f54417..8587fda 100644 --- a/generator/catalogue.py +++ b/generator/catalogue.py @@ -7,13 +7,12 @@ import tempfile from pathlib import Path APPLICATION_ID = 0x56414D50 -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 COMMON_COLUMNS = """ id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160), title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200), composer TEXT NOT NULL, key_signature TEXT NOT NULL, - authored_key_signature TEXT NOT NULL, source_path TEXT NOT NULL UNIQUE CHECK(length(source_path) > 0), source_json TEXT NOT NULL CHECK(json_valid(source_json)), measure_count INTEGER NOT NULL CHECK(measure_count > 0), @@ -34,19 +33,12 @@ def metadata(path: Path, root: Path) -> dict: for name in ('title', 'composer', 'key_signature', 'time_signature'): if not isinstance(meta[name], str): raise ValueError(f'{name} must be a string') - authored = meta.get('authored_key_signature', '') - if not isinstance(authored, str) or ('authored_key_signature' in meta and not authored): - raise ValueError('authored_key_signature must be a nonempty string') key = meta['key_signature'] - if staff.get('arrangement_part_type') == 'practice' and key == 'No key' and 'variant_tonic' in meta: - if not isinstance(meta['variant_tonic'], str): - raise ValueError('variant_tonic must be a string') - key = meta['variant_tonic'] + ' (no key)' if not isinstance(staff['name'], str): raise ValueError('staff name must be a string') relative = path.relative_to(root) return dict(id=path.stem, title=meta['title'], composer=meta['composer'], key_signature=key, - authored_key_signature=authored, source_path=relative.as_posix(), source_json=text, + source_path=relative.as_posix(), source_json=text, measure_count=len(staff['measures']), total_pages=len(presentation['pages']), part=staff['name']) def populate(connection: sqlite3.Connection, root: Path) -> None: @@ -56,11 +48,11 @@ def populate(connection: sqlite3.Connection, root: Path) -> None: PRAGMA application_id = {APPLICATION_ID}; PRAGMA user_version = {SCHEMA_VERSION}; CREATE TABLE songs ({COMMON_COLUMNS}, part TEXT NOT NULL) STRICT; - CREATE TABLE practice_exercises ({COMMON_COLUMNS}, exercise_id TEXT NOT NULL) STRICT; + CREATE TABLE practice_exercises ({COMMON_COLUMNS}, exercise_id TEXT NOT NULL, part TEXT NOT NULL) STRICT; CREATE INDEX songs_menu ON songs(title COLLATE NOCASE, composer COLLATE NOCASE, part COLLATE NOCASE); CREATE INDEX practice_menu ON practice_exercises(exercise_id, id); ''') - authored_keys = {} + practice_parts = {} for category, table, depth in (('songs', 'songs', 1), ('practice', 'practice_exercises', 2)): folder = root / category if (folder.exists() or folder.is_symlink()) and not folder.is_dir(): @@ -73,13 +65,20 @@ def populate(connection: sqlite3.Connection, root: Path) -> None: row = metadata(path, root) if category == 'practice': identity = parts[0] - if authored_keys.setdefault(identity, row['authored_key_signature']) != row['authored_key_signature']: - raise ValueError(f'practice variants disagree on authored key: {identity}') - row.pop('part') + if path.stem not in ('acoustic', 'electric', 'vocal') or row['part'] != path.stem.capitalize(): + raise ValueError('practice requires an acoustic, electric, or vocal part') + identity_metadata = (row['title'], row['composer'], row['key_signature']) + group = practice_parts.setdefault(identity, (identity_metadata, set())) + if group[0] != identity_metadata: + raise ValueError(f'practice parts disagree on metadata: {identity}') + group[1].add(path.stem) row.update(id=identity + '-' + path.stem, exercise_id=identity) connection.execute(f'INSERT INTO {table} ({", ".join(row)}) VALUES ({", ".join("?" for _ in row)})', tuple(row.values())) except (ValueError, KeyError, TypeError, sqlite3.Error) as error: raise ValueError(f'{path}: {error}') from error + for identity, (_, parts) in practice_parts.items(): + if parts != {'acoustic', 'electric', 'vocal'}: + raise ValueError(f'practice exercise requires all three parts: {identity}') connection.commit() if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: raise ValueError('catalogue integrity check failed') diff --git a/generator/engraving/attributes.py b/generator/engraving/attributes.py index 316aa34..f1aaab1 100644 --- a/generator/engraving/attributes.py +++ b/generator/engraving/attributes.py @@ -15,7 +15,7 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font lines = state['staff_details']['lines'] if 'clef' in fields and clef['sign'] != 'none': sign = clef['sign'] - name = {'G': 'gClef', 'F': 'fClef', 'C': 'cClef', 'percussion': 'unpitchedPercussionClef1', 'TAB': '6stringTabClef' if lines != 4 else '4stringTabClef'}[sign] + name = {'G': 'gClef', 'F': 'fClef', 'C': 'cClef', 'percussion': 'unpitchedPercussionClef1'}[sign] y = -space * (clef.get('line', 2) - 1) if sign in ('G', 'F', 'C') else -space * (lines - 1) / 2 glyph = fonts.glyph(name, x, y, space, 'clef', references) result.append(glyph) @@ -23,7 +23,7 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font label = str(abs(octave) * 7 + 1) result.append(fonts.text(label, (glyph.box.left + glyph.box.right) / 2, glyph.box.top - 2 if octave > 0 else glyph.box.bottom + 14, 14, 'clef-octave', references, True)) x = bounds(result).right + space - if 'key' in fields and clef['sign'] not in ('percussion', 'TAB'): + if 'key' in fields and clef['sign'] != 'percussion': key = state['key'] steps = key.get('steps') if steps is None: diff --git a/generator/engraving/audit.py b/generator/engraving/audit.py index e3ccd09..3b640bb 100644 --- a/generator/engraving/audit.py +++ b/generator/engraving/audit.py @@ -34,7 +34,7 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None: expected = index.pitch_position(index.tones[anchor.source][1], anchor.time) if abs(anchor.y - (baseline - expected * layout.style.space / 2)) > 0.01: raise EngravingError(f'{anchor.source}: displayed staff position changed pitch') - role = 'tab' if anchor.fragment.kind == 'tone' and index.state(anchor.time)['clef']['sign'] == 'TAB' else 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind + role = 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind candidates = [p for p in system.primitives if p.role == role and anchor.source in p.sources and abs(p.points[0][0] - anchor.x) < 0.01] if not candidates: raise EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive') @@ -43,6 +43,15 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None: expected_glyph = head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead'))) if candidates[0].value != expected_glyph: raise EngravingError(f'{anchor.source}: notehead changed written duration or shape') + if index.staff.get('tablature') and anchor.fragment.kind == 'tone': + tone = index.tones[anchor.source][1] + tab = tone['tablature'] + tab_baseline = max(p.points[0][1] for p in system.primitives if p.role == 'tab-staff') + expected_y = tab_baseline - (6 - tab['string']) * layout.style.space + 5 + expected_x = min(a.x for a in system.anchors if a.event == anchor.event and a.time == anchor.time) + tabs = [p for p in system.primitives if p.role == 'tab' and anchor.source in p.sources] + if len(tabs) != 1 or tabs[0].value != str(tab['fret']) or abs(tabs[0].points[0][1] - expected_y) > 0.01 or abs(tabs[0].points[0][0] - expected_x) > 0.01: + raise EngravingError(f'{anchor.source}: tablature changed position or rhythmic alignment') for fragment in system.fragments: fragments.setdefault(fragment.source, []).append(fragment) # Staff, ledger, stem, beam and tie contacts are intentional. Distinct @@ -50,7 +59,7 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None: checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric')] for i, item in enumerate(checked): for other in checked[i + 1:]: - if set(item.sources) & set(other.sources): + if set(item.sources) & set(other.sources) and (item.role != 'tab' or other.role != 'tab'): continue if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}: continue diff --git a/generator/engraving/layout.py b/generator/engraving/layout.py index 4afdc2f..8699029 100644 --- a/generator/engraving/layout.py +++ b/generator/engraving/layout.py @@ -5,6 +5,7 @@ from fractions import Fraction import math from generator import harmony +from generator.engraving import tablature from generator.model import fraction, validate_document from generator.engraving.attributes import barline, staff_attributes, tempo from generator.engraving.fonts import Fonts @@ -36,6 +37,7 @@ def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fo grace_width = max((p.box.right for p in drawn), default=grace_width) + 9 occupied.extend(p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot')) items.extend(drawn) + items.extend(tablature.column(index, event, placed, fonts, style)) anchors.extend(placed) fragments.extend(pieces) if event.start == time and not event.value.get('hidden'): @@ -112,11 +114,12 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts, width = available right = width - 5 last_measure = next(m for a, b, m in index.measures if a < end <= b) + tab_items = [p for p in items if p.role.startswith('tab')] + items = [p for p in items if not p.role.startswith('tab')] items = draw_beams(index, anchors, items, start, end, content_left, right, style) items = draw_spans(index, anchors, items, start, end, content_left, right, fonts, style) items = place_text_lanes(items, fonts, style) items = draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts) - # Staff lines are interrupted behind TAB numerals, not painted through them. changes = [(0, state['staff_details']['lines'])] for time, x in placements: lines = index.state(time)['staff_details']['lines'] @@ -127,12 +130,7 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts, x2 = changes[i + 1][0] if i + 1 < len(changes) else width - 1 for number in range(lines): y = -number * style.space - cuts = sorted((max(x1, p.box.left - 2), min(x2, p.box.right + 2)) for p in items if p.role == 'tab' and p.box.top <= y <= p.box.bottom and p.box.left < x2 and p.box.right > x1) - cursor = x1 - for a, b in cuts + [(x2, x2)]: - if a > cursor: - staff_lines.append(line('staff', (), ((cursor, y), (a, y)), style.space * 0.13)) - cursor = max(cursor, b) + staff_lines.append(line('staff', (), ((x1, y), (x2, y)), style.space * 0.13)) items = staff_lines + items if end in index.boundaries: items.extend(barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id'])) @@ -140,6 +138,7 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts, items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id'])) else: items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],))) + items = tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style) return System(start, end, tuple(items), tuple(anchors), tuple(fragments), bounds(items)) def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style) -> tuple[Page, ...]: diff --git a/generator/engraving/notation.py b/generator/engraving/notation.py index ba36605..fc454e0 100644 --- a/generator/engraving/notation.py +++ b/generator/engraving/notation.py @@ -70,13 +70,6 @@ class ScoreIndex: def pitch_position(self, tone: dict, time: Fraction) -> float: state = self.state(time) clef = state['clef'] - if clef['sign'] == 'TAB': - if 'tablature' not in tone: - raise EngravingError(f'{tone["id"]}: TAB notes require explicit string and fret') - string = tone['tablature']['string'] - if not 1 <= string <= state['staff_details']['lines']: - raise EngravingError(f'{tone["id"]}: TAB string is outside the staff') - return 2 * (state['staff_details']['lines'] - string) pitch = tone.get('unpitched', tone) sign = clef['sign'] reference = {'G': 4 * 7 + 4, 'F': 3 * 7 + 3, 'C': 4 * 7}.get(sign, 4 * 7 + 4) @@ -101,7 +94,7 @@ class ScoreIndex: if event.value.get('hidden') or tone.get('print', {}).get('print-object') == 'no' or hidden_head: result[tone['id']] = False continue - if 'unpitched' in tone or state['clef']['sign'] == 'TAB': + if 'unpitched' in tone: result[tone['id']] = False continue identity = (tone['letter'], tone['octave']) diff --git a/generator/engraving/notes.py b/generator/engraving/notes.py index 5a0cdcc..2760942 100644 --- a/generator/engraving/notes.py +++ b/generator/engraving/notes.py @@ -95,9 +95,7 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, fragments.append(fragment) if not displayed: continue - if state['clef']['sign'] == 'TAB': - head = fonts.text(str(tone['tablature']['fret']), x, y + 5, tone_size * 1.5, 'tab', sources) - elif written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal': + if written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal': width = tone_size * (2.8 if written['type'] == 'maxima' else 1.4) head = line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2) else: @@ -105,15 +103,14 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, group_items.append(head) if tone.get('notehead', {}).get('parentheses') == 'yes': group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head-parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head-parentheses', sources))) - if state['clef']['sign'] != 'TAB': - ledger = -2 - while position <= ledger: - group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8)) - ledger -= 2 - ledger = lines * 2 - while position >= ledger: - group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8)) - ledger += 2 + ledger = -2 + while position <= ledger: + group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8)) + ledger -= 2 + ledger = lines * 2 + while position >= ledger: + group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8)) + ledger += 2 if 'alter' in tone and index.accidentals[tone['id']] and onset == event.start: accidental_items = accidental(fonts, alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources) box = bounds(accidental_items) diff --git a/generator/engraving/spans.py b/generator/engraving/spans.py index 0e6b09c..699b2d8 100644 --- a/generator/engraving/spans.py +++ b/generator/engraving/spans.py @@ -241,8 +241,12 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An extend = next((s['attributes'].get('type', 'start') for s in verse['syllables'] if s['kind'] == 'extend'), None) if syllabic not in ('begin', 'middle') and extend not in ('start', 'continue'): continue + if 'extend_to' in verse and extend != 'start': + continue target = following[0].start if following else max(e.end for e in index.events if e.voice == voice) - if target < start or event.start >= end: + if 'extend_to' in verse: + target = fraction(verse['extend_to']) + if target <= start or event.start >= end: continue identifier = event.value['id'] + ':lyric:' + number next_id = following[0].value['id'] + ':lyric:' + number if following else '' @@ -251,6 +255,9 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An x2 = b.box.left - 5 if b else right if b is None and following and following[0].value['id'] in positions: x2 = positions[following[0].value['id']].x + if 'extend_to' in verse: + endpoints = [a.x for a in anchors if a.time >= target] + x2 = min(endpoints) - 5 if endpoints else right y = lyric_baseline + index.lyric_lanes.index((voice, number)) * 23 if x2 <= x1: continue diff --git a/generator/engraving/tablature.py b/generator/engraving/tablature.py new file mode 100644 index 0000000..539665a --- /dev/null +++ b/generator/engraving/tablature.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from generator.model import fraction +from generator.engraving.notes import rest_glyph +from generator.engraving.types import bounds, curve, line + +def column(index, event, anchors, fonts, style): + if not index.staff.get('tablature') or not anchors: + return [] + x = min(a.x for a in anchors) + sources = (event.value['id'],) + if not event.value['pitches']: + anchor = anchors[0] + glyph = 'noteheadSlashHorizontalEnds' if anchor.fragment.kind == 'slash' else rest_glyph(anchor.fragment.written_type) + return [fonts.glyph(glyph, x, -2 * style.space, style.space, 'tab-' + anchor.fragment.kind, sources)] + result = [] + for anchor in anchors: + tone = index.tones[anchor.source][1] + tab = tone['tablature'] + y = -(6 - tab['string']) * style.space + result.append(fonts.text(str(tab['fret']), x, y + 5, style.space * 1.2, 'tab', (event.value['id'], tone['id']))) + return result + +def staff(index, items, start, end, left, right, fonts, style): + notation = [p for p in items if not p.role.startswith('tab')] + numbers = [p for p in items if p.role.startswith('tab')] + if not index.staff.get('tablature'): + return items + offset = bounds(notation).bottom + 32 + 5 * style.space + numbers = [p.moved(0, offset) for p in numbers] + drawing = list(numbers) + drawing.append(fonts.text('TAB', 0, offset - 2 * style.space, 15, 'tab-label', ())) + for string in range(1, 7): + y = offset - (6 - string) * style.space + cuts = sorted((max(0, p.box.left - 2), min(right, p.box.right + 2)) for p in numbers if p.box.top <= y <= p.box.bottom) + cursor = 0 + for a, b in cuts + [(right, right)]: + if a > cursor: + drawing.append(line('tab-staff', (), ((cursor, y), (a, y)), style.space * 0.13)) + cursor = max(cursor, b) + for bar in (p for p in notation if p.role == 'barline'): + drawing.append(line('tab-barline', bar.sources, tuple((x, offset + y * 5 / 4) for x, y in bar.points), bar.thickness)) + by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]} + for identifier, (event, tone) in index.tones.items(): + target = tone.get('tie_to') + finish = event.start + fraction(tone['duration']) + if not target and not (event.start < start < finish or event.start < end < finish): + continue + if target: + finish = index.tones[target][0].start + if finish < start or event.start >= end: + continue + first, last = by_source.get(identifier), by_source.get(target) + x1 = first.box.right + 2 if first else left + x2 = last.box.left - 2 if last else right + y = offset - (6 - tone['tablature']['string']) * style.space + 8 + if x2 > x1: + drawing.append(curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y)))) + return notation + drawing diff --git a/generator/guitar.py b/generator/guitar.py index 52ae785..b3d0bf6 100644 --- a/generator/guitar.py +++ b/generator/guitar.py @@ -1,8 +1,14 @@ from __future__ import annotations from fractions import Fraction +from dataclasses import dataclass +from functools import lru_cache +from itertools import groupby from typing import NamedTuple +from generator import harmony, pitch +from generator.model import NATURAL, alteration, fraction, make_event, pitch_number + STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64)) MIN_FRET = 0 MAX_FRET = 20 @@ -22,3 +28,260 @@ def positions_for_pitch(midi: Fraction) -> tuple[TabPosition, ...]: if midi.denominator != 1: return () return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET) + +def validate_tuning(state: dict, location: str) -> None: + details = state['staff_details'] + if details.get('capo', 0): + raise ValueError(f'{location}: guitar generation does not support capo configurations') + tunings = details.get('tunings', []) + expected = {7 - string: midi for string, midi in STANDARD_TUNING} + if tunings and (len(tunings) != 6 or {t['line']: pitch_number(t) for t in tunings} != expected): + raise ValueError(f'{location}: guitar generation requires standard six-string tuning') + +def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ...], int | None]: + degrees = harmony.degrees(definition) + root = definition['root'] + root_pc = NATURAL[root['letter']] + alteration(root['alter']) + explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'} + optional = {d for d, a in degrees.items() if not a and d not in explicit and (d == 5 and len(degrees) > 3 or d in (9, 11) and d < max(degrees))} + classes = {d: (root_pc + harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()} + required = {value for d, value in classes.items() if d not in optional} + allowed = set(classes.values()) + bass = definition.get('bass') + bass_pc = (NATURAL[bass['letter']] + alteration(bass['alter'])) % 12 if bass else None + if bass_pc is None and 'inversion' in definition: + ordered = sorted(degrees) + inversion = definition['inversion'] + if not 0 <= inversion < len(ordered): + raise ValueError('harmony inversion exceeds its chord degrees') + bass_pc = classes[ordered[inversion]] + if bass_pc is not None: + allowed.add(bass_pc) + required.add(bass_pc) + return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc + +@lru_cache(maxsize=256) +def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True) -> tuple[tuple[TabPosition, ...], ...]: + result = [] + options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed) for string, open_midi in STANDARD_TUNING] + + def visit(index, selected, covered): + if len(set(required) - covered) > len(options) - index: + return + if index == len(options): + if selected and set(required) <= covered and (bass is None or min(selected, key=lambda p: p.midi).midi % 12 == bass): + result.append(tuple(selected)) + return + visit(index + 1, selected, covered) + for position in options[index]: + if position.midi % 12 not in covered and within_fret_span((*selected, position)): + visit(index + 1, (*selected, position), covered | {position.midi % 12}) + + visit(0, (), set()) + if not prune: + return tuple(sorted(result)) + # Independent attacks with equal centers have identical movement costs. + # Held strings require the full candidate set instead of this reduction. + best = {} + for positions in result: + center = position_center(positions) + rank = (compactness(positions), len(set(allowed) - {p.midi % 12 for p in positions}), positions) + if center not in best or rank < best[center][0]: + best[center] = (rank, positions) + return tuple(sorted(item[1] for item in best.values())) + +def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]: + spellings = {} + for degree, alter in harmony.degrees(definition).items(): + value = pitch.shift_pitch(definition['root'], degree - 1, harmony.degree_semitones(degree, alter)) + spellings[(NATURAL[value['letter']] + alteration(value['alter'])) % 12] = value + if bass := definition.get('bass'): + spellings[(NATURAL[bass['letter']] + alteration(bass['alter'])) % 12] = bass + result = [] + for position in sorted(positions, key=lambda p: p.midi): + value = spellings[position.midi % 12].copy() + value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - NATURAL[value['letter']] - alteration(value['alter'])) // 12 - 1) + value['tablature'] = {'string': position.string_number, 'fret': position.fret} + result.append(value) + return result + +@lru_cache(maxsize=16384) +def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int: + if not previous or not current: + return 0 + return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current))) + +@lru_cache(maxsize=4096) +def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]: + frets = [p.fret for p in positions if p.fret] or [0] + strings = [p.string_number for p in positions] + return min(frets) + max(frets), min(strings) + max(strings) - 2 + +def compactness(positions) -> int: + frets = [p.fret for p in positions if p.fret] + return max(frets) - min(frets) if frets else 0 + +def extend_positions(held, pending): + if not pending: + yield tuple(sorted(held.items())) + return + identifier, options = pending[0] + occupied = {p.string_number for p in held.values()} + for position in options: + if position.string_number not in occupied and within_fret_span((*held.values(), position)): + yield from extend_positions({**held, identifier: position}, pending[1:]) + +@dataclass +class Placement: + cost: tuple[int, int, int] + active: tuple + previous: Placement | None + choices: tuple + +def nearest_positions(states: dict) -> list[list[tuple | None]]: + """Exact Manhattan distance transform on the small fret/string position grid.""" + grid = [[None for _ in range(11)] for _ in range(2 * MAX_FRET + 1)] + for index, previous in enumerate(states.values()): + if not previous.active: + return [[(previous.cost, index, previous) for _ in row] for row in grid] + x, y = position_center(tuple(p for _, p in previous.active)) + candidate = (previous.cost, index, previous) + if grid[x][y] is None or candidate[:2] < grid[x][y][:2]: + grid[x][y] = candidate + + def relax(x, y, a, b): + source = grid[a][b] + if source is not None: + cost, index, previous = source + candidate = ((cost[0] + 1, cost[1], cost[2]), index, previous) + if grid[x][y] is None or candidate[:2] < grid[x][y][:2]: + grid[x][y] = candidate + + for y in range(11): + for x in range(1, len(grid)): + relax(x, y, x - 1, y) + for x in range(len(grid) - 2, -1, -1): + relax(x, y, x + 1, y) + for x in range(len(grid)): + for y in range(1, 11): + relax(x, y, x, y - 1) + for y in range(9, -1, -1): + relax(x, y, x, y + 1) + return grid + +@dataclass(frozen=True) +class GuitarTone: + pitch: dict + end: Fraction + positions: tuple[TabPosition, ...] + +@dataclass(frozen=True) +class AttackCandidate: + active: tuple + choices: tuple + shape: tuple[TabPosition, ...] + missing: int + +def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool): + pending = [] + chords = [n for n in notes if '_harmony' in n] + if len(chords) > 1: + raise ValueError('generated accompaniment requires one harmonic event per onset') + chord = chords[0] if chords else None + for note in notes: + for tone in note['pitches']: + options = tones[tone['id']].positions + if source := tone.get('tie_from'): + options = tuple(p for p in options if p == active.get(source)) + pending.append((tone['id'], options)) + requirements = chord_requirements(chord['_harmony']) if chord else ((), (), None) + candidates = chord_positions(*requirements, prune=not (preserve_strings or held or pending)) if chord else [()] + for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))): + for positions in candidates: + shape = tuple(p for _, p in exact) + positions + if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape): + continue + assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact + exact_positions = dict(exact) + choices = tuple((n, positions if n is chord else tuple((p, exact_positions[p['id']]) for p in n['pitches'])) for n in notes) + yield AttackCandidate(assignment, choices, shape, len(set(requirements[1]) - {p.midi % 12 for p in positions})) + +def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]: + events, tones = [], {} + absolute = Fraction() + for measure in document['staves'][0]['measures']: + changes = sorted(measure.get('attributes', []), key=lambda a: fraction(a['onset'])) + state = {k: measure[k] for k in ('transpose', 'staff_details')} + for change in [state, *changes]: + state.update({k: change[k] for k in state if k in change}) + validate_tuning(state, measure['id']) + for voice in measure['voices']: + for order, note in enumerate(voice['notes']): + onset = fraction(note['onset']) + state = {k: measure[k] for k in ('transpose', 'staff_details')} + for change in changes: + if fraction(change['onset']) <= onset: + state.update({k: change[k] for k in state if k in change}) + for tone in note['pitches']: + sounding = pitch.sounding_pitch(tone, state) + options = positions_for_pitch(sounding) + if not options: + raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {MIN_FRET}-{MAX_FRET}') + tones[tone['id']] = GuitarTone(tone, absolute + onset + fraction(tone['duration']), options) + if '_harmony' in note: + for string, _ in STANDARD_TUNING: + tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + fraction(note['duration']), ()) + if note['pitches'] or '_harmony' in note: + events.append((absolute + onset, not bool(note.get('grace')), order if note.get('grace') else -1, note)) + absolute += fraction(measure['duration']) + return sorted(events, key=lambda e: e[:3]), tones + +def retain_tone(tone: GuitarTone, onset: Fraction) -> bool: + return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to')) + +def advance_positions(notes: list[dict], onset: Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict: + continued = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p} + held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)} + result = {} + + def keep(candidate, previous, cost): + cost = (cost[0], cost[1] + compactness(candidate.shape), cost[2] + candidate.missing) + if candidate.active not in result or cost < result[candidate.active].cost: + result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices) + + if not held_ids and not continued: + nearest = nearest_positions(states) + for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings): + x, y = position_center(candidate.shape) + cost, _, previous = nearest[x][y] + keep(candidate, previous, cost) + else: + for previous in states.values(): + active = dict(previous.active) + held = {i: p for i, p in previous.active if i not in continued and i in held_ids} + for candidate in attack_candidates(notes, tones, active, held, preserve_strings): + distance = movement(tuple(p for _, p in previous.active), candidate.shape) + keep(candidate, previous, (previous.cost[0] + distance, previous.cost[1], previous.cost[2])) + if not result: + raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement within frets {MIN_FRET}-{MAX_FRET} and four-fret span; held/tied strings must remain fixed') + return result + +def place_guitar(document: dict) -> None: + """Choose a minimum-cost path, retaining held strings in each search state.""" + events, tones = guitar_events(document) + groups = [(key[0], [item[3] for item in values]) for key, values in groupby(events, key=lambda e: e[:3])] + states = {(): Placement((0, 0, 0), (), None, ())} + for index, (onset, notes) in enumerate(groups): + next_onset = groups[index + 1][0] if index + 1 < len(groups) else None + preserve_strings = next_onset is not None and any('_harmony' in n and onset + fraction(n['duration']) > next_onset for n in notes) + states = advance_positions(notes, onset, tones, states, preserve_strings) + selected = min(states.values(), key=lambda s: (s.cost, s.active)) + while selected.previous is not None: + for note, choices in selected.choices: + if definition := note.pop('_harmony', None): + note['pitches'] = make_event(note['id'], fraction(note['onset']), fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches'] + else: + for tone, position in choices: + tone['tablature'] = {'string': position.string_number, 'fret': position.fret} + selected = selected.previous + document['staves'][0]['tablature'] = True diff --git a/generator/model.py b/generator/model.py index f164fc4..e0a3888 100644 --- a/generator/model.py +++ b/generator/model.py @@ -202,6 +202,19 @@ def validate_document(document: dict) -> None: raise ValueError('invalid unpitched display position') else: pitch_number(p) + if staves[0].get('tablature'): + from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING + from generator.pitch import sounding_pitch + tab = p.get('tablature', {}) + string, fret = tab.get('string'), tab.get('fret') + if type(string) is not int or type(fret) is not int or string not in dict(STANDARD_TUNING) or not MIN_FRET <= fret <= MAX_FRET: + raise ValueError(f'{p["id"]}: invalid generated tablature position') + state = dict(measure) + for change in measure.get('attributes', []): + if fraction(change['onset']) <= onset: + state.update(change) + if dict(STANDARD_TUNING)[string] + fret != sounding_pitch(p, state): + raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch') pd = fraction(p['duration']) if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones: raise ValueError('invalid chord-tone duration or duplicate ID') @@ -222,7 +235,7 @@ def validate_document(document: dict) -> None: raise ValueError('key alterations must contain seven exact alterations') for value in state['key']['alterations']: alteration(value) - if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', 'TAB', 'none') or not 1 <= state['clef']['line'] <= 6 or not -3 <= state['clef']['octave_change'] <= 3): + if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', 'none') or not 1 <= state['clef']['line'] <= 6 or not -3 <= state['clef']['octave_change'] <= 3): raise ValueError('invalid clef') absolute += length for identifier, (p, start, duration, voice) in tones.items(): @@ -233,6 +246,8 @@ def validate_document(document: dict) -> None: same_pitch = all(p.get(k) == other.get(k) for k in ('unpitched', 'instrument')) if 'unpitched' in p else all(p.get(k) == other.get(k) for k in ('letter', 'alter', 'octave')) if other.get('tie_from') != identifier or not same_pitch or next_voice != voice or next_start != start + duration: raise ValueError(f'invalid tie: {identifier} -> {target}') + if staves[0].get('tablature') and p['tablature'] != other['tablature']: + raise ValueError(f'{identifier}: tied guitar tones must retain their string and fret') if source := p.get('tie_from'): if source not in tones or tones[source][0].get('tie_to') != identifier: raise ValueError(f'dangling incoming tie: {identifier}') @@ -250,6 +265,10 @@ def validate_document(document: dict) -> None: raise ValueError('invalid tuplet appearance') span_ids.add(span['id']) for note in events.values(): + for verse in note.get('lyrics', []): + start = tones[note['pitches'][0]['id']][1] if note['pitches'] else Fraction() + if 'extend_to' in verse and not start < fraction(verse['extend_to']) <= absolute: + raise ValueError('lyric extension endpoint is outside the score') if any(t not in span_ids for t in note.get('tuplet_ids', [])): raise ValueError('event refers to an unknown tuplet') if tremolo := note.get('tremolo'): diff --git a/generator/musicxml.py b/generator/musicxml.py index 44ff776..fddfd68 100644 --- a/generator/musicxml.py +++ b/generator/musicxml.py @@ -141,6 +141,8 @@ class Importer: self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'}) self.children(item, {'sign', 'line', 'clef-octave-change'}) sign = item.findtext('sign') + if sign == 'TAB': + self.fail('authored TAB is unsupported; guitar tablature is generated from pitches') if sign not in ('G', 'F', 'C', 'percussion', 'TAB', 'none'): self.fail(f'unsupported clef {sign!r}') line = self.number(item.findtext('line', {'G': '2', 'F': '4'}.get(sign, '3')), integer=True) @@ -434,10 +436,7 @@ class Importer: self.children(notations, {'tied', 'tuplet', 'ornaments', 'technical'}) for item in notations: if item.tag == 'technical': - self.children(item, {'string', 'fret'}) - if note['kind'] != 'note' or item.find('string') is None or item.find('fret') is None: - self.fail('tablature requires a string and fret on a note') - note['pitches'][-1]['tablature'] = {'string': self.number(item.findtext('string'), integer=True, positive=True), 'fret': self.number(item.findtext('fret'), integer=True)} + self.fail('authored TAB and finger instructions are unsupported; guitar positions are generated') if item.tag == 'tuplet' and not is_chord: number, action = (voice, item.get('number', '1')), item.get('type') self.attrs(item, {'number', 'type', 'bracket', 'show-number', 'show-type', 'line-shape'}) @@ -524,13 +523,10 @@ class Importer: for item in root.findall('identification/miscellaneous/miscellaneous-field'): name = item.get('name', '') if name.startswith('vamp:'): - if name not in ('vamp:style', 'vamp:tonic') or name in fields: + if name != 'vamp:style' or name in fields: self.fail(f'unknown or duplicate Vamp property {name!r}') fields[name] = item.text or '' metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text), 'style': canonical_style(fields.get('vamp:style'))} - if 'vamp:tonic' in fields: - from generator.model import parse_pitch_name - metadata['transpose_origin'] = parse_pitch_name(fields['vamp:tonic']) measures = [] self.children(parts[0], {'measure'}) for index, bar in enumerate(parts[0]): diff --git a/generator/musicxml_writer.py b/generator/musicxml_writer.py index 7bdaf74..90a4ac0 100644 --- a/generator/musicxml_writer.py +++ b/generator/musicxml_writer.py @@ -190,10 +190,6 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di element(group, 'tuplet-dot') if tremolo := event.get('tremolo'): element(element(notations, 'ornaments'), 'tremolo', tremolo['marks'], type=tremolo['type']) - if pitch and (tab := pitch.get('tablature')): - technical = element(notations, 'technical') - element(technical, 'string', tab['string']) - element(technical, 'fret', tab['fret']) if not len(notations): node.remove(notations) if index == 0: @@ -217,9 +213,6 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree: element(identification, 'creator', metadata.get('composer', ''), type='composer') miscellaneous = element(identification, 'miscellaneous') element(miscellaneous, 'miscellaneous-field', metadata.get('style', 'Medium Swing'), name='vamp:style') - if metadata.get('transpose_origin'): - from generator.model import pitch_name - element(miscellaneous, 'miscellaneous-field', pitch_name(metadata['transpose_origin']), name='vamp:tonic') defaults = element(root, 'defaults') element(defaults, 'music-font', font_family='Petaluma') element(defaults, 'word-font', font_family='Petaluma Script') diff --git a/generator/parts.py b/generator/parts.py new file mode 100644 index 0000000..8f90a44 --- /dev/null +++ b/generator/parts.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from fractions import Fraction + +from generator.guitar import validate_tuning +from generator.model import beat_lengths, fraction, make_event +from generator.pitch import shift_pitch +from generator.score import copy_source_voices, make_measure, measure_notes, source_staff + +PART_TYPES = ('acoustic', 'electric', 'vocal') + +def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]: + result = [] + current = None + for measure in measures: + result.append(current) + for h in measure.get('harmonies', []): + if include_optional or not h.get('chord_optional'): + current = h + return result + +def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None: + active = previous + for h in measure['harmonies']: + if fraction(h['onset']) > onset: + break + active = h + return active if active and active['kind'] != 'none' else None + +def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]: + length = fraction(measure['duration']) + if role == 'rest': + return [make_event('rest', Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}] + common_time = measure['time_signature'] == '4/4' and not any('meter' in a for a in measure.get('attributes', [])) + if role == 'rhythm' and common_time: + attacks = [(Fraction(n, 2), Fraction(1, 2)) for n in (0, 3, 4, 5, 7)] + elif role == 'accompaniment' and common_time: + attacks = [(Fraction(1, 2), Fraction(1, 2))] + else: + attacks = [] + states = [(Fraction(), measure['meter'])] + states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a) + for index, (onset, meter) in enumerate(states): + end = states[index + 1][0] if index + 1 < len(states) else length + beats = beat_lengths(meter) + beat_index = 0 + while onset < end: + beat = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset) + duration = beat if role == 'slash' or not beats else min(beat / 2, Fraction(1, 2)) + attacks.append((onset, duration)) + onset += beat + beat_index += 1 + events = [] + cursor = Fraction() + for onset, duration in attacks: + if onset >= length: + break + if onset > cursor: + events.append(make_event(f'rest-{len(events)}', cursor, onset - cursor, 'rest')) + duration = min(duration, length - onset) + chord = active_chord(measure, onset, previous) + source_attack = any(fraction(n['onset']) == onset and n['kind'] == 'note' and not n.get('grace') and any(not p.get('tie_from') for p in n['pitches']) for n in measure_notes(measure)) + if role == 'slash': + event = make_event(f'slash-{len(events)}', onset, duration, 'note') + event['notehead'] = 'slash' + elif not chord or role == 'accompaniment' and source_attack: + event = make_event(f'rest-{len(events)}', onset, duration, 'rest') + else: + event = make_event(f'chord-{len(events)}', onset, duration, 'note') + event['_harmony'] = chord + events.append(event) + cursor = onset + duration + if cursor < length: + events.append(make_event(f'rest-{len(events)}', cursor, length - cursor, 'rest')) + return events + +def validate_source(document: dict, *, practice: bool = False) -> None: + voices = set() + for measure in source_staff(document)['measures']: + for state in [measure, *measure.get('attributes', [])]: + if state.get('clef', {}).get('sign') in ('TAB', 'percussion'): + raise ValueError(f'{measure["id"]}: parts require a pitched, non-TAB source') + if 'staff_details' in state: + validate_tuning(state, measure['id']) + for voice in measure['voices']: + voices.add(voice['id']) + for note in voice['notes']: + if practice and len(note['pitches']) > 1: + raise ValueError(f'{measure["id"]}: practice requires a monophonic melody, not chord stacks') + if any('tablature' in p or 'unpitched' in p for p in note['pitches']): + raise ValueError(f'{measure["id"]}: authored TAB and percussion are unsupported') + if practice and len(voices) > 1: + raise ValueError('practice requires one monophonic voice') + +def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '') -> dict: + voices = copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None + notes = [n for v in voices for n in v['notes']] if voices else generated_voice(source, role, previous) + if role in ('melody', 'vocal'): + for note in notes: + note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']] + result = make_measure(identifier, source, [] if voices else notes, section_label=label) + if voices: + result['voices'] = voices + result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1, 'explicit': True} + result['clef']['octave_change'] = 0 + for change in result.get('attributes', []): + change.pop('transpose', None) + if 'clef' in change: + change['clef']['octave_change'] = 0 + if part != 'vocal': + result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0} + result['staff_details'] = {'lines': 5, 'tunings': [], 'capo': 0} + for change in result.get('attributes', []): + change.pop('clef', None) + change.pop('staff_details', None) + return result diff --git a/generator/pitch.py b/generator/pitch.py new file mode 100644 index 0000000..7d2e947 --- /dev/null +++ b/generator/pitch.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import copy + +from generator import harmony +from generator.model import LETTERS, NATURAL, alteration, fraction, key_from_fifths, key_label, pitch_number, rational + +def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict: + output = copy.deepcopy(pitch) + if 'unpitched' in pitch: + return output + original_octave = pitch.get('octave', 4) + diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps + octave, index = divmod(diatonic, 7) + output['letter'] = LETTERS[index] + output['octave'] = octave + output['alter'] = rational(pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']])) + if 'octave' not in pitch: + output.pop('octave') + if 'accidental_display' in output: + from generator.model import accidental_name + output['accidental_display']['name'] = accidental_name(alteration(output['alter'])) + return output + +def shift_key(key: dict, steps: int, semitones: int) -> dict: + tonic = shift_pitch(key['tonic'], steps, semitones) if key.get('tonic') else None + alterations = [0] * 7 + for index, letter in enumerate(LETTERS): + p = shift_pitch({'letter': letter, 'alter': key['alterations'][index]}, steps, semitones) + alterations[LETTERS.index(p['letter'])] = p['alter'] + result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations} + if 'steps' in key: + result['steps'] = [shift_pitch(step, steps, semitones) for step in key['steps']] + if not key.get('custom') and tonic: + for fifths in range(-14, 15): + candidate = key_from_fifths(fifths, key['mode']) + if candidate['tonic'] == tonic and candidate['alterations'] == alterations: + result['fifths'] = fifths + break + else: + result['custom'] = True + if 'cancel' in key: + # A cancellation references the old signature, not a count of naturals. + cancelled = shift_key(key_from_fifths(key['cancel']), steps, semitones) + if cancelled.get('custom'): + raise ValueError('transposed key cancellation cannot be represented by fifths') + result['cancel'] = cancelled['fifths'] + return result + +def concert_document(document: dict, *, octave_transposing: bool = False) -> dict: + """Resolve source instrument pitch once, before arranging in concert pitch.""" + output = copy.deepcopy(document) + zero = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0, 'explicit': True} + for staff in output['staves']: + for measure in staff['measures']: + initial = {key: measure[key] for key in ('transpose', 'clef')} + changes = copy.deepcopy(measure.get('attributes', [])) + + def interval(onset): + state = dict(initial) + for change in changes: + if fraction(change['onset']) <= onset: + state.update({key: change[key] for key in initial if key in change}) + transpose, clef = state['transpose'], state['clef'] + steps = transpose['diatonic'] + 7 * transpose['octave_change'] + semitones = transpose['chromatic'] + 12 * transpose['octave_change'] + if octave_transposing and not transpose.get('explicit') and not (steps or semitones) and not clef['octave_change']: + return -7, -12 + return steps, semitones + + measure['key'] = shift_key(measure['key'], *interval(0)) + measure['key_signature'] = key_label(measure['key']) + for n in (n for v in measure['voices'] for n in v['notes']): + n['pitches'] = [shift_pitch(p, *interval(fraction(n['onset']))) for p in n['pitches']] + for h in measure['harmonies']: + for field in ('root', 'bass'): + if field in h: + h[field] = shift_pitch(h[field], *interval(fraction(h['onset']))) + h['symbol'] = harmony.symbol(h) + for change in measure.get('attributes', []): + if 'key' in change: + change['key'] = shift_key(change['key'], *interval(fraction(change['onset']))) + if 'transpose' in change: + change['transpose'] = copy.deepcopy(zero) + measure['transpose'] = copy.deepcopy(zero) + first = output['staves'][0]['measures'][0] + output['metadata']['key'] = copy.deepcopy(first['key']) + output['metadata']['key_signature'] = first['key_signature'] + return output + +def sounding_pitch(pitch: dict, state: dict): + if 'unpitched' in pitch: + raise ValueError('unpitched notes have no fretted or generated-tone interpretation') + transpose = state['transpose'] + return pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change'] diff --git a/generator/playback.py b/generator/playback.py index 348e74f..0870269 100644 --- a/generator/playback.py +++ b/generator/playback.py @@ -9,7 +9,8 @@ from pathlib import Path import sys import wave -from generator.model import TYPE_LENGTHS, fraction, pitch_number, rational +from generator.model import TYPE_LENGTHS, fraction, rational +from generator import pitch SAMPLE_RATE = 48000 PROFILE = 'soft-struck-1' @@ -18,21 +19,16 @@ MAX_EVENTS = 1000000 class PlaybackUnavailable(ValueError): pass -def sounding_pitch(pitch: dict, state: dict, guitar: bool) -> Fraction: - if 'unpitched' in pitch: +def sounding_pitch(tone: dict, state: dict) -> Fraction: + if 'unpitched' in tone: raise PlaybackUnavailable('unpitched notation has no generated-tone interpretation') - if guitar: - import generator.practice.guitar - value = generator.practice.guitar.sounding_pitch(pitch, state) - else: - transpose = state['transpose'] - value = pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change'] + value = pitch.sounding_pitch(tone, state) frequency = 440 * 2 ** (float(value - 69) / 12) if not 20 <= frequency <= 18000: raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated-tone range (20-18000 Hz)') return value -def compile_timeline(document: dict, *, guitar: bool = False) -> dict: +def compile_timeline(document: dict) -> dict: events, notes, linked = [], {}, {} absolute = Fraction() for measure in document['staves'][0]['measures']: @@ -108,7 +104,7 @@ def compile_timeline(document: dict, *, guitar: bool = False) -> dict: length += sum((amount for time, amount in insertions.items() if onset < time < onset + fraction(pitch['duration'])), Fraction()) if length <= 0: raise PlaybackUnavailable(f'{identifier}: grace timing consumes a chord tone') - value = sounding_pitch(pitch, state, guitar) + value = sounding_pitch(pitch, state) if pitch.get('tie_from'): previous = linked.get(pitch['tie_from']) if previous is None or fraction(previous['onset']) + fraction(previous['duration']) != performed or fraction(previous['pitch']) != value: @@ -132,7 +128,7 @@ def compile_timeline(document: dict, *, guitar: bool = False) -> dict: amount = value if tremolo['type'] == 'start' and attack % 2: other = partner[0]['pitches'][note['pitches'].index(pitch)] - amount = sounding_pitch(other, partner[2], guitar) + amount = sounding_pitch(other, partner[2]) events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': rational(amount), 'onset': rational(performed + cursor), 'duration': rational(min(step, length - cursor))}) cursor += step attack += 1 @@ -181,8 +177,7 @@ def compile_tree(root: Path) -> None: for path in sorted(root.rglob('*.json')): document = json.loads(path.read_text(encoding='utf-8')) try: - guitar = document['staves'][0].get('arrangement_part_type') in ('practice', 'acoustic', 'electric') - playback = compile_timeline(document, guitar=guitar) + playback = compile_timeline(document) used = {} for event in playback['events']: event['end'] = rational(fraction(event['onset']) + fraction(event.pop('duration'))) diff --git a/generator/practice/generate.py b/generator/practice/generate.py index cefb790..0d3f400 100644 --- a/generator/practice/generate.py +++ b/generator/practice/generate.py @@ -2,68 +2,45 @@ from __future__ import annotations from pathlib import Path -from generator.score import copy_source_voices, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json +from generator.guitar import place_guitar from generator.musicxml import load_musicxml -from generator.practice.guitar import transpose_guitar_practice -from generator.transposition import TARGET_KEYS +from generator.parts import PART_TYPES, build_measure, propagated_chords, validate_source +from generator.pitch import concert_document +from generator.scat import add_scat +from generator.score import make_arranged_document, reidentify, source_staff, write_json -def practice_document(document: dict) -> dict: +def practice_document(document: dict, part_type: str) -> dict: + if part_type not in PART_TYPES: + raise ValueError(f'unknown practice part: {part_type}') + validate_source(document, practice=True) + document = concert_document(document, octave_transposing=True) staff = source_staff(document) - source_measures = staff.get("measures") - if not isinstance(source_measures, list): - raise ValueError("practice source must contain measures") - measures: list[dict] = [] - for index, measure in enumerate(source_measure for source_measure in source_measures if isinstance(source_measure, dict)): - voices = copy_source_voices(measure, strip_lyrics=True) - output_measure = make_measure( - f"measure-{index + 1}", - measure, - [], - section_label=str(measure.get("section_label", "")), - ) - output_measure['voices'] = voices - for chord in output_measure['harmonies']: + previous = propagated_chords(staff['measures']) + role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type] + measures = [build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', '')) for i, m in enumerate(staff['measures'])] + for measure in measures: + for chord in measure['harmonies']: chord.pop('chord_optional', None) - measures.append(output_measure) tuplets = reidentify(measures, staff.get('tuplets', [])) - result = make_arranged_document( - document, - staff, - staff_id="staff-arranged-practice", - staff_name="Practice", - role="guitar", - arrangement_part_type="practice", - measures=measures, - tuplets=tuplets, - octave_shifts=staff.get('octave_shifts', []), - ) - result['metadata'].setdefault('authored_key_signature', document['metadata']['key_signature']) + result = make_arranged_document(document, staff, staff_id='staff-practice-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=measures, tuplets=tuplets, octave_shifts=staff.get('octave_shifts', []) if role in ('melody', 'vocal') else []) + if part_type == 'vocal': + add_scat(result) + else: + place_guitar(result) return result -def key_file_name(key: str) -> str: - return slugify(key.replace("#", "sharp")) + ".json" - -def generate_key_variants(source_path: Path, output_dir: Path) -> int: - base_document = practice_document(load_musicxml(source_path)) - count = 0 - for key in TARGET_KEYS: - try: - variant = transpose_guitar_practice(base_document, key) - except ValueError as error: - raise ValueError(f'{source_path}, target key {key}: {error}') from error - write_json(output_dir / source_path.stem / key_file_name(key), variant) - count += 1 - return count - def generate_practice_scores(source_dir: Path, output_dir: Path) -> int: if not source_dir.is_dir(): raise ValueError(f'practice source directory not found: {source_dir}') - sources = sorted(source_dir.glob('*.xml')) - for path in source_dir.rglob('*.xml'): - parts = path.relative_to(source_dir).parts - if len(parts) != 1 and (len(parts) != 2 or parts[0] != 'songs'): - raise ValueError(f'{path}: practice sources belong directly under the data directory') + sources = sorted(source_dir.rglob('*.xml')) for path in sources: - if len(path.stem) != 3 or not path.stem.isascii() or not path.stem.isdecimal() or int(path.stem) == 0: - raise ValueError(f'{path}: practice sources require a three-digit positive filename such as 001.xml') - return sum(generate_key_variants(path, output_dir) for path in sources) + if path.parent != source_dir or len(path.stem) != 3 or not path.stem.isascii() or not path.stem.isdecimal(): + raise ValueError(f'{path}: practice sources require a three-digit filename directly under data/practice') + for path in sources: + document = load_musicxml(path) + for part in PART_TYPES: + try: + write_json(output_dir / path.stem / (part + '.json'), practice_document(document, part)) + except ValueError as error: + raise ValueError(f'{path}, part {part}: {error}') from error + return len(sources) * len(PART_TYPES) diff --git a/generator/practice/guitar.py b/generator/practice/guitar.py deleted file mode 100644 index 9a62944..0000000 --- a/generator/practice/guitar.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from fractions import Fraction -from itertools import groupby -from math import ceil, floor - -from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING, TabPosition, WRITTEN_GUITAR_OCTAVE_OFFSET, positions_for_pitch, within_fret_span -from generator.model import fraction, pitch_number -from generator.transposition import transpose_document, transposition_interval - -@dataclass(frozen=True) -class GuitarTone: - pitch: dict - onset: Fraction - end: Fraction - midi: Fraction - location: str - grace_order: int - -def validate_staff_details(state: dict, location: str) -> None: - details = state['staff_details'] - if details.get('capo', 0): - raise ValueError(f'{location}: guitar practice does not support capo configurations') - if state['clef']['sign'] == 'TAB' and details.get('lines') != 6: - raise ValueError(f'{location}: guitar practice requires six-string tablature') - tunings = details.get('tunings', []) - expected = {7 - string: midi for string, midi in STANDARD_TUNING} - if tunings and (len(tunings) != 6 or {t['line']: pitch_number(t) for t in tunings} != expected): - raise ValueError(f'{location}: guitar practice requires standard six-string tuning') - -def sounding_pitch(pitch: dict, state: dict) -> Fraction: - if 'unpitched' in pitch: - raise ValueError('unpitched notes cannot be checked on a fretted guitar') - transpose, clef = state['transpose'], state['clef'] - offset = transpose['chromatic'] + 12 * transpose['octave_change'] - if not any(transpose.values()) and clef['sign'] == 'G' and not clef['octave_change'] and 'tablature' not in pitch: - offset = -WRITTEN_GUITAR_OCTAVE_OFFSET - return pitch_number(pitch) + offset - -def guitar_tones(document: dict) -> list[GuitarTone]: - tones = [] - absolute = Fraction() - for measure in document['staves'][0]['measures']: - location = f'measure {measure.get("number", measure["id"])}' - state = {key: measure[key] for key in ('clef', 'transpose', 'staff_details')} - validate_staff_details(state, location) - changes = sorted(measure.get('attributes', []), key=lambda change: fraction(change['onset'])) - for change in changes: - state.update({key: change[key] for key in state if key in change}) - validate_staff_details(state, location) - for voice in measure['voices']: - state = {key: measure[key] for key in ('clef', 'transpose', 'staff_details')} - change_index = 0 - for index, note in enumerate(voice['notes']): - onset = fraction(note['onset']) - while change_index < len(changes) and fraction(changes[change_index]['onset']) <= onset: - state.update({key: changes[change_index][key] for key in state if key in changes[change_index]}) - change_index += 1 - context = f'{location}, voice {voice["id"]}, event {note["id"]}, onset {onset}' - for pitch in note['pitches']: - try: - midi = sounding_pitch(pitch, state) - except ValueError as error: - raise ValueError(f'{context}: {error}') from error - tones.append(GuitarTone(pitch, absolute + onset, absolute + onset + fraction(pitch['duration']), midi, context, index if note.get('grace') else -1)) - absolute += fraction(measure['duration']) - return tones - -def tone_positions(tone: GuitarTone) -> tuple[TabPosition, ...]: - positions = positions_for_pitch(tone.midi) - if tab := tone.pitch.get('tablature'): - positions = tuple(p for p in positions if p.string_number == tab['string'] and p.fret == tab['fret']) - if not positions: - raise ValueError(f'{tone.location}: pitch {tone.midi} has no matching string/fret in {MIN_FRET}-{MAX_FRET}') - return positions - -def extend_fingerings(held: dict[str, TabPosition], pending: list[tuple[str, tuple[TabPosition, ...]]]): - if not pending: - yield tuple(sorted(held.items())) - return - identifier, options = pending[0] - occupied = {position.string_number for position in held.values()} - for position in options: - if position.string_number not in occupied and within_fret_span((*held.values(), position)): - yield from extend_fingerings({**held, identifier: position}, pending[1:]) - -def validate_guitar_practice(document: dict) -> None: - tones = guitar_tones(document) - by_id = {tone.pitch['id']: tone for tone in tones} - options = {tone.pitch['id']: tone_positions(tone) for tone in tones} - states = {()} - # Keep every feasible assignment, so later entries cannot move a held or tied tone. - ordered = sorted(tones, key=lambda tone: (tone.onset, tone.grace_order < 0, tone.grace_order)) - for (onset, _, _), group in groupby(ordered, key=lambda tone: (tone.onset, tone.grace_order < 0, tone.grace_order)): - attacks = list(group) - continued = {tone.pitch['tie_from'] for tone in attacks if 'tie_from' in tone.pitch} - next_states = set() - visited = set() - for previous in states: - previous = dict(previous) - held = {identifier: position for identifier, position in previous.items() if identifier not in continued and (by_id[identifier].end > onset or by_id[identifier].end == onset and by_id[identifier].pitch.get('tie_to'))} - pending = [] - for tone in attacks: - positions = options[tone.pitch['id']] - if source := tone.pitch.get('tie_from'): - positions = tuple(p for p in positions if p == previous.get(source)) - pending.append((tone.pitch['id'], positions)) - pending.sort(key=lambda item: len(item[1])) - transition = (tuple(sorted(held.items())), tuple(pending)) - if transition not in visited: - next_states.update(extend_fingerings(held, pending)) - visited.add(transition) - if not next_states: - raise ValueError(f'{attacks[0].location}: no simultaneous fingering within frets {MIN_FRET}-{MAX_FRET} and the four-fret span; held/tied strings must remain fixed') - states = next_states - -def transpose_guitar_practice(document: dict, target_key: str) -> dict: - _, semitones = transposition_interval(document, target_key) - tones = guitar_tones(document) - if not tones: - return transpose_document(document, target_key) - low = min(midi + MIN_FRET for _, midi in STANDARD_TUNING) - high = max(midi + MAX_FRET for _, midi in STANDARD_TUNING) - first = ceil((low - min(tone.midi for tone in tones) - semitones) / 12) - last = floor((high - max(tone.midi for tone in tones) - semitones) / 12) - candidates = sorted(range(first, last + 1), key=lambda octave: (abs(semitones + 12 * octave), semitones + 12 * octave < 0)) - failures = [] - for octave in candidates: - try: - result = transpose_document(document, target_key, octave_shift=octave) - validate_guitar_practice(result) - return result - except ValueError as error: - failures.append(f'octave {octave:+}: {error}') - detail = '; '.join(failures) if failures else f'{min(tones, key=lambda tone: tone.midi).location} through {max(tones, key=lambda tone: tone.midi).location}: pitch range exceeds the guitar range in every octave' - raise ValueError(f'no whole-exercise guitar register for {target_key}: {detail}') diff --git a/generator/scat.py b/generator/scat.py new file mode 100644 index 0000000..b3da9a7 --- /dev/null +++ b/generator/scat.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from fractions import Fraction + +from generator.model import beat_lengths, fraction, rational + +def pulse(measure: dict, onset: Fraction) -> tuple[Fraction | None, bool]: + start, meter = Fraction(), measure['meter'] + for change in measure.get('attributes', []): + if fraction(change['onset']) <= onset and 'meter' in change: + start, meter = fraction(change['onset']), change['meter'] + beats = beat_lengths(meter) + if not beats: + return None, False + # Pickups occupy the final beats of their meter. + phase = onset - start + if measure.get('implicit') and not start: + phase += max(Fraction(), sum(beats) - fraction(measure['duration'])) + phase %= sum(beats) + for beat in beats: + if phase < beat: + return beat, phase == 0 + phase -= beat + raise ValueError('invalid meter pulse') + +def syllable(duration: Fraction, beat: Fraction | None, onbeat: bool, final: bool, previous: str) -> str: + if duration >= (beat if beat is not None else 1): + return 'doo' + if final: + return 'dat' + result = 'dit' if beat is not None and not onbeat else 'ba' + return 'da' if result == previous else result + +def add_scat(document: dict) -> None: + events, pitches = [], {} + absolute = Fraction() + boundaries = set() + for measure in document['staves'][0]['measures']: + if measure.get('section_label'): + boundaries.add(absolute) + for voice in measure['voices']: + for note in voice['notes']: + for field in ('lyrics', 'lyric', 'lyric_notation'): + note.pop(field, None) + onset = absolute + fraction(note['onset']) + for tone in note['pitches']: + pitches[tone['id']] = (note, tone, onset) + events.append((note, measure, onset)) + absolute += fraction(measure['duration']) + attacks = [(n, m, t) for n, m, t in events if n['pitches'] and not n.get('cue') and not n['pitches'][0].get('cue') and not n['pitches'][0].get('tie_from')] + previous, previous_end = '', None + for index, (note, measure, onset) in enumerate(attacks): + tone = note['pitches'][0] + end = onset + fraction(tone['duration']) + chain = [] + while target := tone.get('tie_to'): + continuation, tone, time = pitches[target] + chain.append(continuation) + end = time + fraction(tone['duration']) + following = attacks[index + 1][2] if index + 1 < len(attacks) else None + final = following is None or following > end or any(onset < b <= following for b in boundaries) + if previous_end is None or onset > previous_end or onset in boundaries: + previous = '' + beat, onbeat = pulse(measure, fraction(note['onset'])) + text = syllable(end - onset, beat, onbeat, final, previous) + values = [{'kind': 'syllabic', 'text': 'single', 'attributes': {}}, {'kind': 'text', 'text': text, 'attributes': {}}] + verse = {'number': '1', 'syllables': values} + if chain: + values.append({'kind': 'extend', 'text': '', 'attributes': {'type': 'start'}}) + verse['extend_to'] = rational(end) + for position, continuation in enumerate(chain): + continuation['lyrics'] = [{'number': '1', 'syllables': [{'kind': 'extend', 'text': '', 'attributes': {'type': 'stop' if position == len(chain) - 1 else 'continue'}}], 'extend_to': rational(end)}] + note['lyrics'] = [verse] + note['lyric'] = text + note['lyric_notation'] = {'text': text, 'syllabic': 'single', 'extend': 'start' if chain else ''} + previous, previous_end = text, end diff --git a/generator/summary.py b/generator/summary.py index 57f94d9..4325ae8 100644 --- a/generator/summary.py +++ b/generator/summary.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any from generator.score import load_json, measure_notes -from generator.transposition import TARGET_KEYS +from generator.parts import PART_TYPES from generator.model import fraction, pitch_name def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]: @@ -85,10 +85,10 @@ def validate_expected_performance_summary(expected_path: Path, generated_songs_d raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally") raise ValueError("performance generator drift") -def validate_practice_variants(generated_practice_dir: Path) -> None: +def validate_practice_parts(generated_practice_dir: Path) -> None: if not generated_practice_dir.exists(): return - expected_names = {key.lower().replace("#", "sharp") + ".json" for key in TARGET_KEYS} + expected_names = {part + '.json' for part in PART_TYPES} for exercise_dir in sorted(path for path in generated_practice_dir.iterdir() if path.is_dir()): json_names = {path.name for path in exercise_dir.glob("*.json")} if json_names != expected_names: diff --git a/generator/transposition.py b/generator/transposition.py deleted file mode 100644 index ee508d1..0000000 --- a/generator/transposition.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -import copy - -from generator import harmony -from generator.model import LETTERS, NATURAL, alteration, key_from_fifths, key_label, parse_pitch_name, pitch_number, rational - -TARGET_KEYS = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B') - -def mod12(value: int) -> int: - return value % 12 - -def interval_between(source: dict, target: dict) -> tuple[int, int]: - semitones = (NATURAL[target['letter']] + alteration(target['alter']) - NATURAL[source['letter']] - alteration(source['alter'])) % 12 - if semitones > 6: - semitones -= 12 - steps = (LETTERS.index(target['letter']) - LETTERS.index(source['letter'])) % 7 - if semitones < 0 or semitones == 0 and steps > 3: - steps -= 7 - return steps, semitones - -def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict: - output = copy.deepcopy(pitch) - if 'unpitched' in pitch: - return output - original_octave = pitch.get('octave', 4) - diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps - octave, index = divmod(diatonic, 7) - output['letter'] = LETTERS[index] - output['octave'] = octave - output['alter'] = rational(pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']])) - if 'octave' not in pitch: - output.pop('octave') - if 'accidental_display' in output: - from generator.model import accidental_name - output['accidental_display']['name'] = accidental_name(alteration(output['alter'])) - if 'tablature' in output and semitones: - fret = output['tablature']['fret'] + semitones - if fret < 0 or fret.denominator != 1: - raise ValueError('transposition cannot preserve the authored tablature string/fret') - output['tablature']['fret'] = int(fret) - return output - -def shift_key(key: dict, steps: int, semitones: int) -> dict: - tonic = shift_pitch(key['tonic'], steps, semitones) if key.get('tonic') else None - alterations = [0] * 7 - for index, letter in enumerate(LETTERS): - p = shift_pitch({'letter': letter, 'alter': key['alterations'][index]}, steps, semitones) - alterations[LETTERS.index(p['letter'])] = p['alter'] - result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations} - if 'steps' in key: - result['steps'] = [shift_pitch(step, steps, semitones) for step in key['steps']] - if not key.get('custom') and tonic: - for fifths in range(-14, 15): - candidate = key_from_fifths(fifths, key['mode']) - if candidate['tonic'] == tonic and candidate['alterations'] == alterations: - result['fifths'] = fifths - break - else: - result['custom'] = True - if 'cancel' in key: - # A cancellation references the old signature, not a count of naturals. - cancelled = shift_key(key_from_fifths(key['cancel']), steps, semitones) - if cancelled.get('custom'): - raise ValueError('transposed key cancellation cannot be represented by fifths') - result['cancel'] = cancelled['fifths'] - return result - -def transposition_interval(document: dict, target_key_name: str) -> tuple[int, int]: - metadata = document['metadata'] - origin = metadata['key'].get('tonic') or metadata.get('transpose_origin') - if origin is None: - raise ValueError('keyless practice requires a vamp:tonic score property') - return interval_between(origin, parse_pitch_name(target_key_name)) - -def transpose_document(document: dict, target_key_name: str, *, octave_shift: int = 0) -> dict: - if type(octave_shift) is not int: - raise ValueError('octave adjustment must be an integer') - steps, semitones = transposition_interval(document, target_key_name) - output = copy.deepcopy(document) - metadata = output['metadata'] - key = metadata['key'] - metadata['key'] = shift_key(key, steps, semitones) - metadata['key_signature'] = key_label(metadata['key']) - metadata['variant_tonic'] = target_key_name - for staff in output['staves']: - for measure in staff['measures']: - measure['key'] = shift_key(measure['key'], steps, semitones) - measure['key_signature'] = key_label(measure['key']) - for change in measure.get('attributes', []): - if 'key' in change: - change['key'] = shift_key(change['key'], steps, semitones) - for h in measure.get('harmonies', []): - for field in ('root', 'bass'): - if field in h: - h[field] = shift_pitch(h[field], steps, semitones) - h['symbol'] = harmony.symbol(h) - for voice in measure['voices']: - for note in voice['notes']: - try: - note['pitches'] = [shift_pitch(p, steps + 7 * octave_shift, semitones + 12 * octave_shift) for p in note.get('pitches', [])] - except ValueError as error: - raise ValueError(f'measure {measure.get("number", measure["id"])}, voice {voice["id"]}, event {note["id"]}: {error}') from error - return output - -def concert_document(document: dict) -> dict: - """Normalize MusicXML instrument transposition before concert-pitch arranging.""" - output = copy.deepcopy(document) - zero = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0} - for staff in output['staves']: - for measure in staff['measures']: - initial = measure['transpose'] - changes = copy.deepcopy(measure.get('attributes', [])) - - def interval(onset): - state = initial - from generator.model import fraction - for change in changes: - if fraction(change['onset']) <= onset and 'transpose' in change: - state = change['transpose'] - return state['diatonic'] + 7 * state['octave_change'], state['chromatic'] + 12 * state['octave_change'] - - from generator.model import fraction - measure['key'] = shift_key(measure['key'], *interval(0)) - measure['key_signature'] = key_label(measure['key']) - for n in (n for v in measure['voices'] for n in v['notes']): - n['pitches'] = [shift_pitch(p, *interval(fraction(n['onset']))) for p in n['pitches']] - for h in measure['harmonies']: - for field in ('root', 'bass'): - if field in h: - h[field] = shift_pitch(h[field], *interval(fraction(h['onset']))) - h['symbol'] = harmony.symbol(h) - for change in measure.get('attributes', []): - if 'key' in change: - change['key'] = shift_key(change['key'], *interval(fraction(change['onset']))) - if 'transpose' in change: - change['transpose'] = copy.deepcopy(zero) - measure['transpose'] = copy.deepcopy(zero) - first = output['staves'][0]['measures'][0] - output['metadata']['key'] = copy.deepcopy(first['key']) - output['metadata']['key_signature'] = first['key_signature'] - return output diff --git a/include/vamp_zero/navigation.hpp b/include/vamp_zero/navigation.hpp index c1520cc..a171fc8 100644 --- a/include/vamp_zero/navigation.hpp +++ b/include/vamp_zero/navigation.hpp @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include @@ -17,10 +16,8 @@ auto selected_part(vamp_zero::app_state const &state) -> vamp_zero::library_part auto active_part(vamp_zero::app_state const &state) -> vamp_zero::library_part const *; auto selected_practice_exercise(vamp_zero::app_state const &state) -> vamp_zero::practice_exercise const *; auto active_practice(vamp_zero::app_state const &state) -> vamp_zero::practice_exercise const *; -auto selected_practice_variant(vamp_zero::app_state const &state, vamp_zero::practice_exercise const &exercise) -> vamp_zero::practice_exercise::score_variant const *; -auto selected_practice_key(vamp_zero::app_state const &state, vamp_zero::practice_exercise const &exercise) -> std::optional; -auto practice_key_row_count(vamp_zero::app_state const &state) -> std::size_t; -auto effective_practice_key(vamp_zero::app_state const &state, vamp_zero::practice_exercise const &exercise) -> std::string; +auto selected_practice_part(vamp_zero::app_state const &state, vamp_zero::practice_exercise const &exercise) -> vamp_zero::library_part const *; +auto practice_part_row_count(vamp_zero::app_state const &state) -> std::size_t; auto active_pages(vamp_zero::app_state const &state) -> std::size_t; } diff --git a/include/vamp_zero/types.hpp b/include/vamp_zero/types.hpp index 2287bfe..11c308d 100644 --- a/include/vamp_zero/types.hpp +++ b/include/vamp_zero/types.hpp @@ -27,7 +27,6 @@ struct score_document { std::string title{}; std::string composer{}; std::string key_signature{}; - std::string authored_key_signature{}; std::string time_signature{}; std::string part{}; std::string arrangement_part_type{}; @@ -60,18 +59,10 @@ struct library_state { }; struct practice_exercise { - struct score_variant { - std::string id{}; - std::string key_signature{}; - std::string source_path{}; - std::size_t measure_count{}; - std::size_t total_pages{}; - }; std::string id{}; std::string title{}; std::string composer{}; - std::string authored_key_signature{}; - std::vector variants{}; + std::vector parts{}; }; struct practice_library { @@ -102,7 +93,7 @@ enum class app_mode { PERFORMANCE_PART_MENU, PERFORMANCE_SCORE, PRACTICE_EXERCISE_MENU, - PRACTICE_KEY_MENU, + PRACTICE_PART_MENU, PRACTICE_SCORE, }; @@ -125,7 +116,7 @@ struct app_state { std::size_t selected_song_index{}; std::size_t selected_part_index{}; std::size_t selected_practice_exercise_index{}; - std::size_t selected_practice_key_index{}; + std::size_t selected_practice_part_index{}; std::string active_part_id{}; std::string active_practice_id{}; std::size_t page_index{}; @@ -140,6 +131,6 @@ struct app_state { auto to_string(vamp_zero::hardware_mode value) -> std::string; auto to_string(vamp_zero::app_mode value) -> std::string; -auto practice_key_names() -> std::vector const &; +auto practice_part_names() -> std::vector const &; } diff --git a/src/vamp_zero/app.cpp b/src/vamp_zero/app.cpp index b6d90e2..9e4ad0d 100644 --- a/src/vamp_zero/app.cpp +++ b/src/vamp_zero/app.cpp @@ -213,8 +213,8 @@ auto validate_catalogue(boost::asio::any_io_executor executor, vamp_zero::config } } for (auto const &exercise : catalog.practice.exercises) { - for (auto const &variant : exercise.variants) { - auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = variant.id}; + for (auto const &practice_part : exercise.parts) { + auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = practice_part.id}; auto valid = co_await validate_score_pages(executor, config, std::move(reference)); if (!valid) { co_return valid; diff --git a/src/vamp_zero/audio.cpp b/src/vamp_zero/audio.cpp index 8583ce8..46656f2 100644 --- a/src/vamp_zero/audio.cpp +++ b/src/vamp_zero/audio.cpp @@ -22,7 +22,7 @@ auto score_active(vamp_zero::app_state const &state) -> std::bool_t { } if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { auto const *exercise = vamp_zero::active_practice(state); - return exercise != nullptr && vamp_zero::selected_practice_variant(state, *exercise) != nullptr; + return exercise != nullptr && vamp_zero::selected_practice_part(state, *exercise) != nullptr; } return false; } diff --git a/src/vamp_zero/database.cpp b/src/vamp_zero/database.cpp index 22a3ed7..758af2f 100644 --- a/src/vamp_zero/database.cpp +++ b/src/vamp_zero/database.cpp @@ -25,7 +25,7 @@ namespace { constexpr auto application_id = std::int64_t{0x56414D50}; -constexpr auto catalogue_schema_version = std::int64_t{2}; +constexpr auto catalogue_schema_version = std::int64_t{3}; auto connection_config(vamp_zero::database_config const &config) -> noto::connection_config { auto result = noto::file_connection_config(config.path, "product-vamp"); @@ -183,7 +183,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo if (!connected) { co_return std::unexpected(connected.error()); } - auto selected = co_await connection.async_prepare("SELECT id, title, composer, key_signature, source_path, measure_count, total_pages, authored_key_signature, exercise_id " + auto selected = co_await connection.async_prepare("SELECT id, title, composer, key_signature, source_path, measure_count, total_pages, part, exercise_id " "FROM practice_exercises ORDER BY exercise_id, id"); if (!selected) { auto close = co_await connection.async_close(); @@ -213,9 +213,12 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo status = std::unexpected(source.error()); break; } - auto variant = vamp_zero::practice_exercise::score_variant{ + auto practice_part = vamp_zero::library_part{ .id = text_value(row[0]), + .title = text_value(row[1]), + .composer = text_value(row[2]), .key_signature = text_value(row[3]), + .part = text_value(row[7]), .source_path = std::move(*source), .measure_count = count_value(row[5]), .total_pages = count_value(row[6]), @@ -224,8 +227,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo .id = text_value(row[8]), .title = text_value(row[1]), .composer = text_value(row[2]), - .authored_key_signature = text_value(row[7]), - .variants = {std::move(variant)}, + .parts = {std::move(practice_part)}, }); } auto closed = co_await close_catalogue_query(connection, *selected, std::move(status)); diff --git a/src/vamp_zero/library.cpp b/src/vamp_zero/library.cpp index 68f87df..7a35278 100644 --- a/src/vamp_zero/library.cpp +++ b/src/vamp_zero/library.cpp @@ -19,10 +19,9 @@ auto invalid(std::string message) -> modulus::error { return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message)); } -auto key_rank(std::string const &key) -> std::size_t { - auto const &keys = vamp_zero::practice_key_names(); - auto tonic = key.substr(0, key.find(' ')); - return static_cast(std::distance(keys.begin(), std::find(keys.begin(), keys.end(), tonic))); +auto part_rank(std::string const &part) -> std::size_t { + auto const &parts = vamp_zero::practice_part_names(); + return static_cast(std::distance(parts.begin(), std::find(parts.begin(), parts.end(), part))); } } @@ -53,24 +52,27 @@ auto vamp_zero::group_practice_exercises(std::vector{}; for (auto &exercise : exercises) { if (!merged.empty() && merged.back().id == exercise.id) { - if (merged.back().authored_key_signature != exercise.authored_key_signature) { - return std::unexpected(invalid("practice variants disagree on authored key: " + exercise.id)); + if (merged.back().title != exercise.title || merged.back().composer != exercise.composer) { + return std::unexpected(invalid("practice parts disagree on exercise identity: " + exercise.id)); } - merged.back().variants.insert(merged.back().variants.end(), std::make_move_iterator(exercise.variants.begin()), std::make_move_iterator(exercise.variants.end())); + merged.back().parts.insert(merged.back().parts.end(), std::make_move_iterator(exercise.parts.begin()), std::make_move_iterator(exercise.parts.end())); } else { merged.push_back(std::move(exercise)); } } for (auto &exercise : merged) { - std::sort(exercise.variants.begin(), exercise.variants.end(), [](auto const &left, auto const &right) { - return std::tuple{key_rank(left.key_signature), left.id} < std::tuple{key_rank(right.key_signature), right.id}; + std::sort(exercise.parts.begin(), exercise.parts.end(), [](auto const &left, auto const &right) { + return std::tuple{part_rank(left.part), left.id} < std::tuple{part_rank(right.part), right.id}; }); - if (exercise.variants.size() != vamp_zero::practice_key_names().size()) { - return std::unexpected(invalid("practice exercise requires twelve key variants: " + exercise.id)); + if (exercise.parts.size() != vamp_zero::practice_part_names().size()) { + return std::unexpected(invalid("practice exercise requires acoustic, electric, and vocal parts: " + exercise.id)); } - for (auto index = std::size_t{}; index < exercise.variants.size(); ++index) { - if (key_rank(exercise.variants[index].key_signature) != index) { - return std::unexpected(invalid("practice exercise has missing or duplicate keys: " + exercise.id)); + for (auto index = std::size_t{}; index < exercise.parts.size(); ++index) { + if (exercise.parts[index].key_signature != exercise.parts.front().key_signature) { + return std::unexpected(invalid("practice parts disagree on key: " + exercise.id)); + } + if (part_rank(exercise.parts[index].part) != index) { + return std::unexpected(invalid("practice exercise has missing or duplicate parts: " + exercise.id)); } } } diff --git a/src/vamp_zero/navigation.cpp b/src/vamp_zero/navigation.cpp index 0f64dc9..affefd0 100644 --- a/src/vamp_zero/navigation.cpp +++ b/src/vamp_zero/navigation.cpp @@ -66,42 +66,26 @@ auto vamp_zero::active_practice(vamp_zero::app_state const &state) -> vamp_zero: return found == state.catalog->practice.exercises.end() ? nullptr : &*found; } -auto vamp_zero::selected_practice_variant(vamp_zero::app_state const &state, vamp_zero::practice_exercise const &exercise) -> vamp_zero::practice_exercise::score_variant const * { - if (exercise.variants.empty()) { +auto vamp_zero::selected_practice_part(vamp_zero::app_state const &state, vamp_zero::practice_exercise const &exercise) -> vamp_zero::library_part const * { + if (exercise.parts.empty()) { return nullptr; } - if (state.selected_practice_key_index == 0U) { + if (state.selected_practice_part_index == 0U) { return nullptr; } - auto index = state.selected_practice_key_index - 1U; - if (index >= exercise.variants.size()) { + auto index = state.selected_practice_part_index - 1U; + if (index >= exercise.parts.size()) { return nullptr; } - return &exercise.variants[index]; + return &exercise.parts[index]; } -auto vamp_zero::selected_practice_key(vamp_zero::app_state const &state, vamp_zero::practice_exercise const &exercise) -> std::optional { - auto const *variant = vamp_zero::selected_practice_variant(state, exercise); - if (variant == nullptr) { - return std::nullopt; - } - return variant->key_signature; -} - -auto vamp_zero::practice_key_row_count(vamp_zero::app_state const &state) -> std::size_t { +auto vamp_zero::practice_part_row_count(vamp_zero::app_state const &state) -> std::size_t { auto const *exercise = vamp_zero::selected_practice_exercise(state); if (exercise == nullptr) { return 1U; } - return menu_row_count(exercise->variants.size()); -} - -auto vamp_zero::effective_practice_key(vamp_zero::app_state const &state, vamp_zero::practice_exercise const &exercise) -> std::string { - if (auto const *variant = vamp_zero::selected_practice_variant(state, exercise); - variant != nullptr && !variant->key_signature.empty()) { - return variant->key_signature; - } - return exercise.variants.empty() ? std::string{} : exercise.variants.front().key_signature; + return menu_row_count(exercise->parts.size()); } auto vamp_zero::active_pages(vamp_zero::app_state const &state) -> std::size_t { @@ -111,8 +95,8 @@ auto vamp_zero::active_pages(vamp_zero::app_state const &state) -> std::size_t { } if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { auto const *exercise = vamp_zero::active_practice(state); - auto const *variant = exercise == nullptr ? nullptr : vamp_zero::selected_practice_variant(state, *exercise); - return variant == nullptr ? 0U : variant->total_pages; + auto const *practice_part = exercise == nullptr ? nullptr : vamp_zero::selected_practice_part(state, *exercise); + return practice_part == nullptr ? 0U : practice_part->total_pages; } return 0U; } diff --git a/src/vamp_zero/notation.cpp b/src/vamp_zero/notation.cpp index af5cd90..3baddbe 100644 --- a/src/vamp_zero/notation.cpp +++ b/src/vamp_zero/notation.cpp @@ -86,13 +86,6 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc return invalid(std::string{part.error().message()}); } document.part = std::move(*part); - if (metadata->if_contains("authored_key_signature")) { - auto key = string_field(*metadata, "authored_key_signature"); - if (!key || key->empty()) { - return invalid("authored_key_signature must be a nonempty string"); - } - document.authored_key_signature = std::move(*key); - } if (staff.if_contains("arrangement_part_type")) { auto type = string_field(staff, "arrangement_part_type"); if (!type) { @@ -100,13 +93,6 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc } document.arrangement_part_type = std::move(*type); } - if (document.arrangement_part_type == "practice" && document.key_signature == "No key" && metadata->if_contains("variant_tonic")) { - auto tonic = string_field(*metadata, "variant_tonic"); - if (!tonic) { - return invalid(std::string{tonic.error().message()}); - } - document.key_signature = *tonic + " (no key)"; - } for (auto const &item : *pages) { if (!item.is_object()) { return invalid("page must be an object"); diff --git a/src/vamp_zero/page_control.cpp b/src/vamp_zero/page_control.cpp index daeddb4..a17560a 100644 --- a/src/vamp_zero/page_control.cpp +++ b/src/vamp_zero/page_control.cpp @@ -78,15 +78,15 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { state.selected_part_index = 0U; } state.selected_practice_exercise_index = std::min(state.selected_practice_exercise_index, state.catalog->practice.exercises.size()); - state.selected_practice_key_index = std::min(state.selected_practice_key_index, practice_key_row_count(state) - 1U); + state.selected_practice_part_index = std::min(state.selected_practice_part_index, practice_part_row_count(state) - 1U); if (state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU && selected_group(state) == nullptr) { state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU; } - if (state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) { + if (state.mode == vamp_zero::app_mode::PRACTICE_PART_MENU) { auto const *exercise = selected_practice_exercise(state); - if (exercise == nullptr || exercise->variants.empty()) { + if (exercise == nullptr || exercise->parts.empty()) { state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; - state.selected_practice_key_index = 0U; + state.selected_practice_part_index = 0U; } } if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && active_part(state) == nullptr) { @@ -100,8 +100,8 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; state.active_practice_id.clear(); state.page_index = 0; - } else if (selected_practice_variant(state, *exercise) == nullptr) { - state.mode = vamp_zero::app_mode::PRACTICE_KEY_MENU; + } else if (selected_practice_part(state, *exercise) == nullptr) { + state.mode = vamp_zero::app_mode::PRACTICE_PART_MENU; state.active_practice_id.clear(); state.page_index = 0; } @@ -155,30 +155,16 @@ auto part_json(vamp_zero::library_part const &part) -> boost::json::object { } auto exercise_json(vamp_zero::practice_exercise const &exercise) -> boost::json::object { - auto variants = boost::json::array{}; - for (auto const &variant : exercise.variants) { - auto item = boost::json::object{}; - item["id"] = variant.id; - item["key_signature"] = variant.key_signature; - item["source_path"] = variant.source_path; - item["measures"] = static_cast(variant.measure_count); - item["pages"] = static_cast(variant.total_pages); - variants.push_back(std::move(item)); + auto parts = boost::json::array{}; + for (auto const &part : exercise.parts) { + parts.push_back(part_json(part)); } auto result = boost::json::object{}; result["id"] = exercise.id; result["title"] = exercise.title; result["composer"] = exercise.composer; - result["authored_key_signature"] = exercise.authored_key_signature; - if (!exercise.variants.empty()) { - auto const &primary = exercise.variants.front(); - result["key_signature"] = primary.key_signature; - result["source_path"] = primary.source_path; - result["measures"] = static_cast(primary.measure_count); - result["pages"] = static_cast(primary.total_pages); - } - result["variant_count"] = static_cast(exercise.variants.size()); - result["variants"] = std::move(variants); + result["part_count"] = static_cast(exercise.parts.size()); + result["parts"] = std::move(parts); return result; } @@ -207,36 +193,19 @@ auto practice_json(vamp_zero::app_state const &state) -> boost::json::object { for (auto const &exercise : state.catalog->practice.exercises) { exercises.push_back(exercise_json(exercise)); } - auto keys = boost::json::array{}; - for (auto key : vamp_zero::practice_key_names()) { - keys.push_back(boost::json::value{std::string{key}}); - } auto selection = boost::json::object{}; selection["exercise_row_index"] = static_cast(state.selected_practice_exercise_index); - selection["key_row_index"] = static_cast(state.selected_practice_key_index); + selection["part_row_index"] = static_cast(state.selected_practice_part_index); if (auto const *exercise = selected_practice_exercise(state); exercise != nullptr) { selection["exercise"] = exercise_json(*exercise); - auto variant = selected_practice_variant(state, *exercise); - if (variant != nullptr) { - auto item = boost::json::object{}; - item["id"] = variant->id; - item["key_signature"] = variant->key_signature; - item["source_path"] = variant->source_path; - item["measures"] = static_cast(variant->measure_count); - item["pages"] = static_cast(variant->total_pages); - selection["variant"] = std::move(item); - } - } - if (auto const *exercise = selected_practice_exercise(state); exercise != nullptr) { - if (auto key = selected_practice_key(state, *exercise)) { - selection["key"] = std::string{*key}; + auto practice_part = selected_practice_part(state, *exercise); + if (practice_part != nullptr) { + selection["part"] = part_json(*practice_part); } } auto practice = boost::json::object{}; practice["exercise_count"] = static_cast(state.catalog->practice.exercises.size()); practice["exercises"] = std::move(exercises); - practice["key_count"] = static_cast(vamp_zero::practice_key_names().size()); - practice["keys"] = std::move(keys); practice["selection"] = std::move(selection); return practice; } @@ -285,7 +254,7 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag } else if (state.selected_main_menu_index == main_menu_practice) { state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; state.selected_practice_exercise_index = 0; - state.selected_practice_key_index = 0; + state.selected_practice_part_index = 0; state.active_practice_id.clear(); state.page_index = 0; } else if (state.selected_main_menu_index == main_menu_playback) { @@ -347,10 +316,10 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag case vamp_zero::app_mode::PRACTICE_EXERCISE_MENU: if (command == vamp_protocol::page_command::NEXT) { state.selected_practice_exercise_index = wrap_next(state.selected_practice_exercise_index, menu_row_count(state.catalog->practice.exercises.size())); - state.selected_practice_key_index = 0; + state.selected_practice_part_index = 0; } else if (command == vamp_protocol::page_command::PREVIOUS) { state.selected_practice_exercise_index = wrap_previous(state.selected_practice_exercise_index, menu_row_count(state.catalog->practice.exercises.size())); - state.selected_practice_key_index = 0; + state.selected_practice_part_index = 0; } else if (state.selected_practice_exercise_index == 0U) { state.mode = vamp_zero::app_mode::MAIN_MENU; state.active_practice_id.clear(); @@ -359,24 +328,24 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag state.active_part_id.clear(); state.active_practice_id.clear(); state.page_index = 0; - state.mode = vamp_zero::app_mode::PRACTICE_KEY_MENU; - state.selected_practice_key_index = 0; + state.mode = vamp_zero::app_mode::PRACTICE_PART_MENU; + state.selected_practice_part_index = 0; } return clamp_state(std::move(state)); - case vamp_zero::app_mode::PRACTICE_KEY_MENU: + case vamp_zero::app_mode::PRACTICE_PART_MENU: if (auto const *exercise = selected_practice_exercise(state); exercise != nullptr) { if (command == vamp_protocol::page_command::NEXT) { - state.selected_practice_key_index = - wrap_next(state.selected_practice_key_index, menu_row_count(exercise->variants.size())); + state.selected_practice_part_index = + wrap_next(state.selected_practice_part_index, menu_row_count(exercise->parts.size())); } else if (command == vamp_protocol::page_command::PREVIOUS) { - state.selected_practice_key_index = - wrap_previous(state.selected_practice_key_index, menu_row_count(exercise->variants.size())); - } else if (state.selected_practice_key_index == 0U) { + state.selected_practice_part_index = + wrap_previous(state.selected_practice_part_index, menu_row_count(exercise->parts.size())); + } else if (state.selected_practice_part_index == 0U) { state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; state.active_practice_id.clear(); state.page_index = 0; - } else if (selected_practice_variant(state, *exercise) != nullptr) { + } else if (selected_practice_part(state, *exercise) != nullptr) { state.active_practice_id = exercise->id; state.active_part_id.clear(); state.page_index = 0; @@ -388,14 +357,14 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag return clamp_state(std::move(state)); case vamp_zero::app_mode::PRACTICE_SCORE: if (auto const *exercise = active_practice(state); exercise != nullptr) { - auto const *variant = selected_practice_variant(state, *exercise); + auto const *practice_part = selected_practice_part(state, *exercise); if (command == vamp_protocol::page_command::NEXT) { - auto pages = variant == nullptr ? 0U : variant->total_pages; + auto pages = practice_part == nullptr ? 0U : practice_part->total_pages; state.page_index = std::min(state.page_index + 1U, pages == 0U ? 0U : pages - 1U); } else if (command == vamp_protocol::page_command::PREVIOUS) { state.page_index = state.page_index == 0U ? 0U : state.page_index - 1U; } else { - state.mode = vamp_zero::app_mode::PRACTICE_KEY_MENU; + state.mode = vamp_zero::app_mode::PRACTICE_PART_MENU; state.active_practice_id.clear(); state.page_index = 0; } @@ -429,14 +398,16 @@ auto vamp_zero::app_status_json(vamp_zero::app_state const &state) -> boost::jso } } else if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { if (auto const *exercise = active_practice(state); exercise != nullptr) { - auto const *variant = selected_practice_variant(state, *exercise); + auto const *practice_part = selected_practice_part(state, *exercise); active["selected"] = true; active["kind"] = "practice"; active["exercise"] = exercise_json(*exercise); - active["key"] = effective_practice_key(state, *exercise); + if (practice_part != nullptr) { + active["part"] = part_json(*practice_part); + } active["page_index"] = static_cast(state.page_index); active["page_number"] = static_cast(state.page_index + 1U); - active["page_total"] = static_cast(variant == nullptr ? 0U : variant->total_pages); + active["page_total"] = static_cast(practice_part == nullptr ? 0U : practice_part->total_pages); } } if (active.empty()) { @@ -478,12 +449,12 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s } } else if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { if (auto const *selected = active_practice(state); selected != nullptr) { - auto const *variant = selected_practice_variant(state, *selected); + auto const *practice_part = selected_practice_part(state, *selected); active = true; title = selected->title; - part = "Key: " + effective_practice_key(state, *selected); + part = practice_part == nullptr ? std::string{} : practice_part->part; page = state.page_index + 1U; - pages = variant == nullptr ? 0U : variant->total_pages; + pages = practice_part == nullptr ? 0U : practice_part->total_pages; } } auto output = std::ostringstream{}; @@ -529,13 +500,13 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex if (state.selected_practice_exercise_index >= menu_row_count(state.catalog->practice.exercises.size())) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice exercise row is out of range")); } - if (state.selected_practice_key_index >= practice_key_row_count(state)) { - return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice key row is out of range")); + if (state.selected_practice_part_index >= practice_part_row_count(state)) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice part row is out of range")); } - if (state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) { + if (state.mode == vamp_zero::app_mode::PRACTICE_PART_MENU) { auto const *exercise = selected_practice_exercise(state); - if (exercise == nullptr || exercise->variants.empty()) { - return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice key mode requires an exercise with key variants")); + if (exercise == nullptr || exercise->parts.empty()) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice part mode requires an exercise with parts")); } } if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { @@ -552,11 +523,11 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex if (exercise == nullptr) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice score mode requires an active exercise")); } - auto const *variant = selected_practice_variant(state, *exercise); - if (variant == nullptr) { - return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice score mode requires a selected generated variant")); + auto const *practice_part = selected_practice_part(state, *exercise); + if (practice_part == nullptr) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice score mode requires a selected generated part")); } - if (variant->total_pages == 0U || state.page_index >= variant->total_pages) { + if (practice_part->total_pages == 0U || state.page_index >= practice_part->total_pages) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice score page index is out of range")); } } diff --git a/src/vamp_zero/score_loader.cpp b/src/vamp_zero/score_loader.cpp index 2320642..bfb21c1 100644 --- a/src/vamp_zero/score_loader.cpp +++ b/src/vamp_zero/score_loader.cpp @@ -135,8 +135,8 @@ auto vamp_zero::selected_score_reference(vamp_zero::app_state const &state) -> s } } else if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { if (auto exercise = vamp_zero::active_practice(state)) { - if (auto variant = vamp_zero::selected_practice_variant(state, *exercise)) { - return vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = variant->id}; + if (auto practice_part = vamp_zero::selected_practice_part(state, *exercise)) { + return vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = practice_part->id}; } } } diff --git a/src/vamp_zero/types.cpp b/src/vamp_zero/types.cpp index 496017e..dd290fc 100644 --- a/src/vamp_zero/types.cpp +++ b/src/vamp_zero/types.cpp @@ -31,28 +31,15 @@ auto vamp_zero::to_string(vamp_zero::app_mode value) -> std::string { return "performance_score"; case vamp_zero::app_mode::PRACTICE_EXERCISE_MENU: return "practice_exercise_menu"; - case vamp_zero::app_mode::PRACTICE_KEY_MENU: - return "practice_key_menu"; + case vamp_zero::app_mode::PRACTICE_PART_MENU: + return "practice_part_menu"; case vamp_zero::app_mode::PRACTICE_SCORE: return "practice_score"; } return "main_menu"; } -auto vamp_zero::practice_key_names() -> std::vector const & { - static auto const keys = std::vector{ - "C", - "Db", - "D", - "Eb", - "E", - "F", - "Gb", - "G", - "Ab", - "A", - "Bb", - "B", - }; - return keys; +auto vamp_zero::practice_part_names() -> std::vector const & { + static auto const parts = std::vector{"Acoustic", "Electric", "Vocal"}; + return parts; } diff --git a/src/vamp_zero/ui.cpp b/src/vamp_zero/ui.cpp index 9d1a77e..e038d8d 100644 --- a/src/vamp_zero/ui.cpp +++ b/src/vamp_zero/ui.cpp @@ -314,22 +314,22 @@ auto draw_practice_exercise_menu(luma::draw_context &context, luma::rect bounds, } } -auto draw_practice_key_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { +auto draw_practice_part_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { auto const *exercise = selected_practice_exercise(state); draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, - exercise == nullptr ? std::string{"KEY"} : exercise->title, + exercise == nullptr ? std::string{"PART"} : exercise->title, 4, vamp_style::ink, bounds.width - 72.0F); - draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT PRACTICE KEY", 1, vamp_style::muted, 360.0F); + draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT PRACTICE PART", 1, vamp_style::muted, 360.0F); auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; - auto total_rows = (exercise == nullptr ? 0U : exercise->variants.size()) + 1U; + auto total_rows = (exercise == nullptr ? 0U : exercise->parts.size()) + 1U; auto max_rows = std::min(total_rows, 7U); - auto first = first_visible_row(state.selected_practice_key_index, total_rows, max_rows); + auto first = first_visible_row(state.selected_practice_part_index, total_rows, max_rows); for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { @@ -340,20 +340,19 @@ auto draw_practice_key_menu(luma::draw_context &context, luma::rect bounds, vamp .height = row_height}, "BACK", "", - state.selected_practice_key_index == 0U); + state.selected_practice_part_index == 0U); continue; } - auto key = exercise == nullptr ? std::string{"C"} : exercise->variants[index - 1U].key_signature; - auto source = exercise == nullptr || exercise->authored_key_signature.empty() ? std::string{} - : "SOURCE " + exercise->authored_key_signature; + auto part = exercise == nullptr ? std::string{} : exercise->parts[index - 1U].part; + auto key = exercise == nullptr ? std::string{} : exercise->parts[index - 1U].key_signature; draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, + part, key, - source, - index == state.selected_practice_key_index); + index == state.selected_practice_part_index); } } @@ -370,9 +369,9 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s draw_practice_exercise_menu(context, bounds, state); return; } - auto const *variant = selected_practice_variant(state, *exercise); - if (variant == nullptr) { - draw_practice_key_menu(context, bounds, state); + auto const *practice_part = selected_practice_part(state, *exercise); + if (practice_part == nullptr) { + draw_practice_part_menu(context, bounds, state); return; } } @@ -435,8 +434,8 @@ public: draw_performance_part_menu(context, content, state.app); } else if (state.app.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU) { draw_practice_exercise_menu(context, content, state.app); - } else if (state.app.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) { - draw_practice_key_menu(context, content, state.app); + } else if (state.app.mode == vamp_zero::app_mode::PRACTICE_PART_MENU) { + draw_practice_part_menu(context, content, state.app); } else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE || state.app.mode == vamp_zero::app_mode::PRACTICE_SCORE) { draw_score(context, content, state.app, loaded, score_cache_); diff --git a/tests/catalogue_fixture.hpp b/tests/catalogue_fixture.hpp index 1cefa5b..91bb0c8 100644 --- a/tests/catalogue_fixture.hpp +++ b/tests/catalogue_fixture.hpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -13,6 +15,12 @@ namespace vamp_test { +inline auto part_filename(std::string_view part) -> std::string { + auto result = std::string{part}; + result.front() = static_cast(result.front() - 'A' + 'a'); + return result + ".json"; +} + inline auto build_catalogue(std::filesystem::path const &assets, std::filesystem::path const &database) -> std::bool_t { auto source = assets.string(); auto destination = database.string(); diff --git a/tests/test.cpp b/tests/test.cpp index 41e3d8f..668ad46 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -92,7 +92,7 @@ auto has_value(noto::query_result const &result, std::size_t column, std::string }); } -auto score_asset_json(std::string title, std::string key = "C") -> std::string { +auto score_asset_json(std::string title, std::string key = "C", std::string part = "Lead") -> std::string { auto value = boost::json::parse(R"json({ "schema_version":3, "music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"}, @@ -103,6 +103,7 @@ auto score_asset_json(std::string title, std::string key = "C") -> std::string { auto &metadata = value.as_object()["music"].as_object()["metadata"].as_object(); 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); return boost::json::serialize(value); } @@ -117,8 +118,7 @@ auto navigation_state() -> vamp_zero::app_state { .measure_count = 1, .total_pages = 2, }; - auto variant = vamp_zero::practice_exercise::score_variant{ - .id = "exercise-c", + auto practice_part = vamp_zero::library_part{ .key_signature = "C", .source_path = "exercise.json", .measure_count = 1, @@ -128,14 +128,11 @@ auto navigation_state() -> vamp_zero::app_state { .id = "exercise-1", .title = "C Major", .composer = "Composer", - .authored_key_signature = "C", - .variants = {variant}, }; - exercise.variants.clear(); - for (auto key : vamp_zero::practice_key_names()) { - variant.id = "exercise-" + std::string{key}; - variant.key_signature = key; - exercise.variants.push_back(variant); + for (auto part_name : vamp_zero::practice_part_names()) { + practice_part.id = "exercise-" + std::string{part_name}; + practice_part.part = part_name; + exercise.parts.push_back(practice_part); } auto practice = vamp_zero::group_practice_exercises({exercise}); BOOST_REQUIRE(practice); @@ -286,12 +283,12 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) { BOOST_TEST(count > 0U); } -BOOST_AUTO_TEST_CASE(practice_key_navigation_and_remote_display) { +BOOST_AUTO_TEST_CASE(practice_part_navigation_and_remote_display) { auto state = navigation_state(); auto catalog = std::make_shared(*state.catalog); - auto &variant = catalog->practice.exercises.front().variants[2]; - variant.source_path = "exercise-d.json"; - variant.total_pages = 3U; + auto &practice_part = catalog->practice.exercises.front().parts[2]; + practice_part.source_path = "exercise-vocal.json"; + practice_part.total_pages = 3U; state.catalog = catalog; state.selected_main_menu_index = 1U; state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); @@ -299,23 +296,23 @@ BOOST_AUTO_TEST_CASE(practice_key_navigation_and_remote_display) { state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); state.audio.metronome_enabled = true; state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); - BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU); - BOOST_TEST(vamp_zero::practice_key_row_count(state) == 13U); + BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_PART_MENU); + BOOST_TEST(vamp_zero::practice_part_row_count(state) == 4U); for (auto index = std::uint32_t{}; index < 3U; ++index) { state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); } state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_SCORE); BOOST_TEST(state.audio.metronome_enabled); - auto selected = vamp_zero::selected_practice_variant(state, state.catalog->practice.exercises.front()); + auto selected = vamp_zero::selected_practice_part(state, state.catalog->practice.exercises.front()); BOOST_REQUIRE(selected != nullptr); - BOOST_TEST(selected->id == "exercise-D"); - BOOST_TEST(selected->source_path == "exercise-d.json"); + BOOST_TEST(selected->id == "exercise-Vocal"); + BOOST_TEST(selected->source_path == "exercise-vocal.json"); BOOST_TEST(vamp_zero::active_pages(state) == 3U); auto remote = vamp_pico::remote_display_state{}; BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote)); BOOST_TEST(remote.active); - BOOST_TEST(std::string{remote.part} == "Key: D"); + BOOST_TEST(std::string{remote.part} == "Vocal"); BOOST_TEST(remote.pages == 3U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); BOOST_TEST(state.page_index == 1U); @@ -323,11 +320,11 @@ BOOST_AUTO_TEST_CASE(practice_key_navigation_and_remote_display) { playing.audio.playback_enabled = true; auto control = vamp_zero::control_state{{}, playing}; control.finish_playback(playing.score_session); - BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PRACTICE_KEY_MENU); - BOOST_TEST(control.snapshot().selected_practice_key_index == 3U); + BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PRACTICE_PART_MENU); + BOOST_TEST(control.snapshot().selected_practice_part_index == 3U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); - BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU); - BOOST_TEST(state.selected_practice_key_index == 3U); + BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_PART_MENU); + BOOST_TEST(state.selected_practice_part_index == 3U); BOOST_TEST(state.active_practice_id.empty()); for (auto index = std::uint32_t{}; index < 3U; ++index) { state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS); @@ -362,7 +359,7 @@ BOOST_AUTO_TEST_CASE(flat_practice_wraps_and_empty_catalogue_returns_to_main) { BOOST_TEST(state.selected_practice_exercise_index == 0U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); - BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU); + BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_PART_MENU); state.catalog = vamp_zero::empty_catalogue(); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU); @@ -372,11 +369,11 @@ BOOST_AUTO_TEST_CASE(flat_practice_wraps_and_empty_catalogue_returns_to_main) { BOOST_CHECK(state.mode == vamp_zero::app_mode::MAIN_MENU); } -BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) { - auto root = std::filesystem::temp_directory_path() / ("product-vamp-keys-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); +BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_parts) { + auto root = std::filesystem::temp_directory_path() / ("product-vamp-parts-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); auto config = test_database_config(root / "vamp.db"); auto expected_exercises = std::size_t{}; - for (auto const &entry : std::filesystem::directory_iterator{std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "data"}) { + for (auto const &entry : std::filesystem::directory_iterator{std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "data" / "practice"}) { if (entry.is_regular_file() && entry.path().extension() == ".xml") { ++expected_exercises; } @@ -391,27 +388,26 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) { BOOST_REQUIRE_MESSAGE(library.has_value(), (library ? "" : library.error().describe())); BOOST_TEST(library->exercises.size() == expected_exercises); for (auto const &exercise : library->exercises) { - BOOST_REQUIRE_EQUAL(exercise.variants.size(), 12U); - auto keys = vamp_zero::practice_key_names(); - for (auto index = std::size_t{}; index < keys.size(); ++index) { - auto const &key = exercise.variants[index].key_signature; - BOOST_TEST(key.substr(0, key.find(' ')) == keys[index]); - BOOST_TEST(exercise.variants[index].total_pages > 0U); - BOOST_TEST(exercise.variants[index].measure_count > 0U); + BOOST_REQUIRE_EQUAL(exercise.parts.size(), 3U); + auto parts = vamp_zero::practice_part_names(); + for (auto index = std::size_t{}; index < parts.size(); ++index) { + BOOST_TEST(exercise.parts[index].part == parts[index]); + BOOST_TEST(exercise.parts[index].total_pages > 0U); + BOOST_TEST(exercise.parts[index].measure_count > 0U); if (iteration == 0U) { - auto const &variant = exercise.variants[index]; + auto const &practice_part = exercise.parts[index]; io.restart(); - auto loaded = boost::asio::co_spawn(io, vamp_zero::async_load_score(io.get_executor(), config, {vamp_zero::score_category::PRACTICE, variant.id}, VAMP_TEST_ASSETS_DIR), boost::asio::use_future); + auto loaded = boost::asio::co_spawn(io, vamp_zero::async_load_score(io.get_executor(), config, {vamp_zero::score_category::PRACTICE, practice_part.id}, VAMP_TEST_ASSETS_DIR), boost::asio::use_future); io.run(); auto score = loaded.get(); BOOST_REQUIRE_MESSAGE(score, (score ? "" : score.error().describe())); BOOST_TEST(score->title == exercise.title); BOOST_TEST(score->composer == exercise.composer); - BOOST_TEST(score->key_signature == variant.key_signature); - BOOST_TEST(score->authored_key_signature == exercise.authored_key_signature); - BOOST_TEST(score->measure_count == variant.measure_count); - BOOST_TEST(score->pages.size() == variant.total_pages); - BOOST_TEST(score->source_path == variant.source_path); + BOOST_TEST(score->key_signature == practice_part.key_signature); + BOOST_TEST(score->part == practice_part.part); + BOOST_TEST(score->measure_count == practice_part.measure_count); + BOOST_TEST(score->pages.size() == practice_part.total_pages); + BOOST_TEST(score->source_path == practice_part.source_path); } } } @@ -425,8 +421,8 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_ auto config = test_database_config(root / "vamp.db"); std::filesystem::create_directories(assets / "songs"); for (auto name : {"010", "002", "001", "127"}) { - for (auto key : vamp_zero::practice_key_names()) { - write_text(assets / "practice" / name / (std::string{key} + ".json"), score_asset_json("Shared Title", std::string{key})); + for (auto part_name : vamp_zero::practice_part_names()) { + write_text(assets / "practice" / name / vamp_test::part_filename(part_name), score_asset_json("Shared Title", "C", std::string{part_name})); } } BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path)); @@ -448,25 +444,21 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_ BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path)); auto rows = query_database(root / "vamp.db", "SELECT id FROM practice_exercises ORDER BY id"); BOOST_REQUIRE(rows); - BOOST_REQUIRE_EQUAL(rows->rows.size(), 48U); - BOOST_TEST(!has_value(*rows, 0U, "002-C")); - BOOST_TEST(has_value(*rows, 0U, "099-C")); - BOOST_TEST(has_value(*rows, 0U, "010-C")); - BOOST_TEST(has_value(*rows, 0U, "001-C")); - BOOST_TEST(has_value(*rows, 0U, "127-C")); + BOOST_REQUIRE_EQUAL(rows->rows.size(), 12U); + BOOST_TEST(!has_value(*rows, 0U, "002-acoustic")); + BOOST_TEST(has_value(*rows, 0U, "099-acoustic")); + BOOST_TEST(has_value(*rows, 0U, "010-acoustic")); + BOOST_TEST(has_value(*rows, 0U, "001-acoustic")); + BOOST_TEST(has_value(*rows, 0U, "127-acoustic")); std::filesystem::remove_all(root); } -BOOST_AUTO_TEST_CASE(keyless_practice_variants_display_their_transposition_reference) { - auto source = boost::json::parse(score_asset_json("Keyless", "No key")); - auto &music = source.as_object()["music"].as_object(); - music["metadata"].as_object()["variant_tonic"] = "D"; - music["staves"].as_array().front().as_object()["arrangement_part_type"] = "practice"; - auto document = vamp_zero::parse_score_document(boost::json::serialize(source), "d.json"); +BOOST_AUTO_TEST_CASE(keyless_scores_keep_their_authored_key) { + auto source = score_asset_json("Keyless", "No key", "Vocal"); + auto document = vamp_zero::parse_score_document(source, "vocal.json"); BOOST_REQUIRE(document); - BOOST_TEST(document->key_signature == "D (no key)"); - auto retained = boost::json::parse(document->source_json); - BOOST_TEST(retained.as_object()["music"].as_object()["metadata"].as_object()["key_signature"].as_string() == "No key"); + BOOST_TEST(document->key_signature == "No key"); + BOOST_TEST(document->part == "Vocal"); } BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) { diff --git a/tests/test_catalogue.py b/tests/test_catalogue.py index 921f832..f854a1d 100644 --- a/tests/test_catalogue.py +++ b/tests/test_catalogue.py @@ -3,39 +3,40 @@ from pathlib import Path import tempfile import unittest -from generator.practice.generate import generate_practice_scores, key_file_name +from generator.practice.generate import generate_practice_scores from generator.arrange.generate import generate_performance_scores from generator.score import validate_generated_tree -from generator.summary import validate_practice_variants -from generator.transposition import TARGET_KEYS +from generator.summary import validate_practice_parts +from generator.parts import PART_TYPES +from unittest.mock import patch from tests.test_musicxml import xml_note, xml_score class CatalogueTests(unittest.TestCase): - def test_every_numbered_source_has_its_own_complete_output(self): - source = Path('data') + def test_every_numbered_source_is_wired_to_three_part_paths(self): + source = Path('data/practice') sources = sorted(source.glob('*.xml')) self.assertEqual([p.name for p in sources], [f'{i:03}.xml' for i in range(1, 367)]) - expected = {Path(path.stem) / key_file_name(key) for path in sources for key in TARGET_KEYS} - self.assertEqual(len(expected), 4392) - with tempfile.TemporaryDirectory() as temporary: + expected = {Path(path.stem) / (part + '.json') for path in sources for part in PART_TYPES} + self.assertEqual(len(expected), 1098) + with tempfile.TemporaryDirectory() as temporary, patch('generator.practice.generate.practice_document', side_effect=lambda document, part: document): output = Path(temporary) self.assertEqual(generate_practice_scores(source, output), len(expected)) self.assertEqual({p.relative_to(output) for p in output.rglob('*.json')}, expected) - validate_practice_variants(output) + validate_practice_parts(output) validate_generated_tree(output) def test_numbers_and_duplicate_titles_remain_distinct_and_songs_are_excluded(self): with tempfile.TemporaryDirectory() as temporary: source, output = Path(temporary) / 'source', Path(temporary) / 'output' - (source / 'songs').mkdir(parents=True) - for name in ('001.xml', '010.xml', '366.xml', 'songs/song.xml'): + source.mkdir(parents=True) + for name in ('000.xml', '001.xml', '010.xml', '366.xml'): (source / name).write_text(xml_score(xml_note()), encoding='utf-8') - self.assertEqual(generate_practice_scores(source, output), 36) - self.assertEqual(sorted(p.name for p in output.iterdir()), ['001', '010', '366']) + self.assertEqual(generate_practice_scores(source, output), 12) + self.assertEqual(sorted(p.name for p in output.iterdir()), ['000', '001', '010', '366']) for directory in output.iterdir(): - self.assertEqual({p.name for p in directory.glob('*.json')}, {key_file_name(key) for key in TARGET_KEYS}) - self.assertEqual(json.loads((directory / 'c.json').read_text())['metadata']['title'], 'Fixture') + self.assertEqual({p.name for p in directory.glob('*.json')}, {(part + '.json') for part in PART_TYPES}) + self.assertEqual(json.loads((directory / 'vocal.json').read_text())['metadata']['title'], 'Fixture') def test_invalid_names_and_legacy_nesting_preserve_output(self): - for name in ('000.xml', '1.xml', '001-copy.xml', '001_copy.xml', 'chord/001.xml', 'practice/melody/lesson/001.xml'): + for name in ('1.xml', '001-copy.xml', '001_copy.xml', 'chord/001.xml', 'practice/melody/lesson/001.xml'): with self.subTest(name=name), tempfile.TemporaryDirectory() as temporary: source, output = Path(temporary) / 'source', Path(temporary) / 'output' for relative in ('002.xml', name): @@ -49,12 +50,12 @@ class CatalogueTests(unittest.TestCase): generate_practice_scores(source, output) self.assertEqual(list(output.iterdir()), [sentinel]) self.assertEqual(sentinel.read_text(), 'unchanged') - def test_missing_and_empty_variant_directories_fail(self): + def test_empty_part_directory_fails(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) (root / '001').mkdir() with self.assertRaisesRegex(ValueError, 'practice generator drift'): - validate_practice_variants(root) + validate_practice_parts(root) def test_songs_generate_only_authored_key_parts(self): with tempfile.TemporaryDirectory() as temporary: source, output = Path(temporary) / 'songs', Path(temporary) / 'output' diff --git a/tests/test_catalogue_database.py b/tests/test_catalogue_database.py index 03c75da..916627c 100644 --- a/tests/test_catalogue_database.py +++ b/tests/test_catalogue_database.py @@ -7,11 +7,11 @@ from pathlib import Path from generator.catalogue import APPLICATION_ID, SCHEMA_VERSION, build_catalogue -def bundle(title='Fixture', key='C', authored='Eb'): +def bundle(title='Fixture', key='C', part='Lead'): return { 'schema_version': 3, - 'music': {'metadata': {'title': title, 'composer': 'Composer', 'key_signature': key, 'time_signature': '4/4', 'authored_key_signature': authored}, - 'staves': [{'name': 'Lead', 'arrangement_part_type': 'practice', 'measures': [{'voices': []}]}]}, + 'music': {'metadata': {'title': title, 'composer': 'Composer', 'key_signature': key, 'time_signature': '4/4'}, + 'staves': [{'name': part, 'arrangement_part_type': part.lower(), 'measures': [{'voices': []}]}]}, 'presentation': {'width': 976, 'height': 484, 'pages': [{'path': 'fixture.pages/1.png', 'sha256': '0' * 64}]}, } @@ -34,19 +34,20 @@ class CatalogueTests(unittest.TestCase): def test_metadata_relative_paths_and_complete_documents(self): document = bundle('Song', 'F') write_score(self.root, 'songs/tune.json', document) - for key in ('c', 'd'): - write_score(self.root, f'practice/001/{key}.json', bundle('Exercise', key.upper())) + for part in ('Acoustic', 'Electric', 'Vocal'): + write_score(self.root, f'practice/001/{part.lower()}.json', bundle('Exercise', 'Eb', part)) database = build_catalogue(self.root) song = self.rows('songs')[0] self.assertEqual(json.loads(song['source_json']), document) self.assertEqual((song['id'], song['part'], song['source_path']), ('tune', 'Lead', 'songs/tune.json')) self.assertEqual((song['measure_count'], song['total_pages']), (1, 1)) practice = self.rows('practice_exercises') - self.assertEqual([row['id'] for row in practice], ['001-c', '001-d']) + self.assertEqual([row['id'] for row in practice], ['001-acoustic', '001-electric', '001-vocal']) self.assertEqual(practice[0]['exercise_id'], '001') self.assertNotIn('practice_type_id', practice[0]) self.assertNotIn('lesson_group_id', practice[0]) - self.assertEqual(practice[0]['authored_key_signature'], 'Eb') + self.assertEqual(practice[0]['key_signature'], 'Eb') + self.assertNotIn('authored_key_signature', practice[0]) with closing(sqlite3.connect(database)) as connection: self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], APPLICATION_ID) self.assertEqual(connection.execute('PRAGMA user_version').fetchone()[0], SCHEMA_VERSION) @@ -74,29 +75,25 @@ class CatalogueTests(unittest.TestCase): score = write_score(self.root, 'songs/tune.json') database = build_catalogue(self.root) previous = database.read_bytes() - for document in ({}, bundle(title=''), bundle(authored='')): + for document in ({}, bundle(title=''), bundle(key=None)): score.write_text(json.dumps(document)) with self.assertRaises(ValueError): build_catalogue(self.root) self.assertEqual(database.read_bytes(), previous) self.assertFalse(list(self.root.glob('.vamp-catalogue-*'))) - def test_legacy_paths_and_authored_key_disagreement_fail(self): - write_score(self.root, 'practice/001/c.json') - conflicting = write_score(self.root, 'practice/chord/one/two-three/c.json') - with self.assertRaisesRegex(ValueError, 'unexpected practice'): + def test_missing_parts_and_metadata_disagreement_fail(self): + for part in ('Acoustic', 'Electric'): + write_score(self.root, f'practice/001/{part.lower()}.json', bundle(part=part)) + with self.assertRaisesRegex(ValueError, 'all three parts'): build_catalogue(self.root) - conflicting.unlink() - write_score(self.root, 'practice/001/d.json', bundle(key='D', authored='F')) - with self.assertRaisesRegex(ValueError, 'authored key'): + write_score(self.root, 'practice/001/vocal.json', bundle(key='F', part='Vocal')) + with self.assertRaisesRegex(ValueError, 'disagree'): build_catalogue(self.root) - def test_keyless_display_metadata_keeps_original_music(self): - document = bundle(key='No key', authored='No key') - document['music']['metadata']['variant_tonic'] = 'D' - write_score(self.root, 'practice/001/d.json', document) + def test_keyless_parts_preserve_their_original_key(self): + for part in ('Acoustic', 'Electric', 'Vocal'): + write_score(self.root, f'practice/001/{part.lower()}.json', bundle(key='No key', part=part)) build_catalogue(self.root) - row = self.rows('practice_exercises')[0] - self.assertEqual(row['key_signature'], 'D (no key)') - self.assertEqual(json.loads(row['source_json']), document) + self.assertTrue(all(row['key_signature'] == 'No key' for row in self.rows('practice_exercises'))) def test_invalid_layout_and_escaping_source_fail(self): with self.assertRaisesRegex(ValueError, 'directory not found'): build_catalogue(self.root / 'missing') diff --git a/tests/test_catalogue_runtime.cpp b/tests/test_catalogue_runtime.cpp index 555f097..84f72fd 100644 --- a/tests/test_catalogue_runtime.cpp +++ b/tests/test_catalogue_runtime.cpp @@ -38,7 +38,7 @@ concept retains_score_json = requires(T value) { value.source_json; }; static_assert(!retains_score_json); static_assert(!retains_score_json); -static_assert(!retains_score_json); +static_assert(!retains_score_json); struct catalogue_directory { std::filesystem::path path; @@ -79,7 +79,7 @@ auto catalogue_query(boost::asio::any_io_executor executor, vamp_zero::database_ co_return result; } -auto catalogue_bundle(std::string_view title, std::string_view key = "C", std::optional authored = std::nullopt) -> std::string { +auto catalogue_bundle(std::string_view title, std::string_view key = "C", std::string_view part = "Lead") -> std::string { auto value = boost::json::parse(R"json({ "schema_version":3, "music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"}, @@ -91,9 +91,7 @@ auto catalogue_bundle(std::string_view title, std::string_view key = "C", std::o auto &metadata = value.at("music").at("metadata").as_object(); metadata["title"] = title; metadata["key_signature"] = key; - if (authored) { - metadata["authored_key_signature"] = *authored; - } + value.at("music").at("staves").as_array().front().as_object()["name"] = part; return boost::json::serialize(value); } @@ -111,7 +109,7 @@ struct catalogue_database { } auto rows(std::string const &table, std::string const &where = "") const -> std::vector { auto result = query("SELECT json_array(id, title, composer, key_signature, source_path, source_json, " - "measure_count, total_pages, authored_key_signature) FROM " + + "measure_count, total_pages) FROM " + table + where + " ORDER BY id"); auto rows = std::vector{}; for (auto const &row : result.rows) { @@ -146,10 +144,9 @@ auto catalogue_exercise(std::string authored = "Eb", std::string id = "001") -> .id = std::move(id), .title = "Exercise", .composer = "Composer", - .authored_key_signature = std::move(authored), }; - for (auto key : vamp_zero::practice_key_names()) { - result.variants.push_back({.id = result.id + "-" + std::string{key}, .key_signature = std::string{key}, .measure_count = 3, .total_pages = 2}); + for (auto part_name : vamp_zero::practice_part_names()) { + result.parts.push_back({.id = result.id + "-" + std::string{part_name}, .key_signature = authored, .part = std::string{part_name}, .measure_count = 3, .total_pages = 2}); } return result; } @@ -199,19 +196,19 @@ BOOST_AUTO_TEST_CASE(catalogue_snapshots_heartbeat_and_navigation_share_immutabl } original.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; auto practice_control = vamp_zero::control_state{{}, original}; - auto keys = practice_control.apply(vamp_protocol::page_command::SELECT); - BOOST_TEST(vamp_zero::to_string(keys.mode) == "practice_key_menu"); + auto parts = practice_control.apply(vamp_protocol::page_command::SELECT); + BOOST_TEST(vamp_zero::to_string(parts.mode) == "practice_part_menu"); auto selected = practice_control.apply(vamp_protocol::page_command::NEXT); BOOST_TEST(selected.catalog.get() == catalog.get()); - BOOST_TEST(vamp_zero::selected_practice_variant(selected, catalog->practice.exercises[1]) == &catalog->practice.exercises[1].variants[0]); + BOOST_TEST(vamp_zero::selected_practice_part(selected, catalog->practice.exercises[1]) == &catalog->practice.exercises[1].parts[0]); auto exercise = practice_control.apply(vamp_protocol::page_command::SELECT); BOOST_TEST(vamp_zero::to_string(exercise.mode) == "practice_score"); BOOST_TEST(exercise.catalog.get() == catalog.get()); BOOST_TEST(vamp_zero::active_practice(exercise) == &catalog->practice.exercises[1]); BOOST_TEST(practice_control.apply(vamp_protocol::page_command::NEXT).page_index == 1U); BOOST_TEST(practice_control.heartbeat().catalog.get() == catalog.get()); - BOOST_TEST(keys.active_practice_id.empty()); - BOOST_TEST(keys.selected_practice_key_index == 0U); + BOOST_TEST(parts.active_practice_id.empty()); + BOOST_TEST(parts.selected_practice_part_index == 0U); } BOOST_AUTO_TEST_CASE(catalogue_performance_groups_use_title_and_composer_with_part_indices) { @@ -245,66 +242,53 @@ BOOST_AUTO_TEST_CASE(catalogue_performance_groups_use_title_and_composer_with_pa BOOST_TEST(library.parts[library.groups[1].parts[1]].id == "vocal"); } -BOOST_AUTO_TEST_CASE(catalogue_practice_requires_complete_ordered_keys_and_authored_key_agreement) { +BOOST_AUTO_TEST_CASE(catalogue_practice_requires_complete_ordered_parts_and_key_agreement) { for (auto authored : {"Eb", "C dorian", "No key", ""}) { auto first = catalogue_exercise(authored); - std::reverse(first.variants.begin(), first.variants.end()); - auto last = first.variants.back(); - first.variants.pop_back(); + std::reverse(first.parts.begin(), first.parts.end()); + auto last = first.parts.back(); + first.parts.pop_back(); auto fragment = catalogue_exercise(authored); - fragment.variants = {last}; + fragment.parts = {last}; auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise("F", "002"), first, fragment}); BOOST_REQUIRE(grouped); BOOST_REQUIRE_EQUAL(grouped->exercises.size(), 2U); auto const &exercise = grouped->exercises[0]; BOOST_TEST(exercise.id == "001"); - BOOST_TEST(exercise.authored_key_signature == authored); - BOOST_REQUIRE_EQUAL(exercise.variants.size(), 12U); - for (auto index = std::size_t{}; index < exercise.variants.size(); ++index) { - BOOST_TEST(exercise.variants[index].key_signature == vamp_zero::practice_key_names()[index]); + BOOST_TEST(exercise.parts.front().key_signature == authored); + BOOST_REQUIRE_EQUAL(exercise.parts.size(), 3U); + for (auto index = std::size_t{}; index < exercise.parts.size(); ++index) { + BOOST_TEST(exercise.parts[index].part == vamp_zero::practice_part_names()[index]); } } for (auto conflict : {"F", ""}) { - auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise(), catalogue_exercise(conflict)}); + auto first = catalogue_exercise(); + auto second = catalogue_exercise(conflict); + second.parts = {second.parts.back()}; + first.parts.pop_back(); + auto grouped = vamp_zero::group_practice_exercises({first, second}); BOOST_REQUIRE(!grouped); BOOST_TEST(grouped.error().code() == static_cast(vamp_zero::error_code::INVALID_ARGUMENT)); - BOOST_TEST(grouped.error().message().find("authored key") != std::string_view::npos); + BOOST_TEST(grouped.error().message().find("disagree on key") != std::string_view::npos); } auto missing = catalogue_exercise(); - missing.variants.pop_back(); + missing.parts.pop_back(); BOOST_TEST(!vamp_zero::group_practice_exercises({missing})); auto duplicate = catalogue_exercise(); - duplicate.variants.back().key_signature = "C"; + duplicate.parts.back().part = "Acoustic"; BOOST_TEST(!vamp_zero::group_practice_exercises({duplicate})); } -BOOST_AUTO_TEST_CASE(catalogue_authored_key_metadata_is_optional_but_must_be_a_nonempty_string) { - auto legacy = vamp_zero::parse_score_document(catalogue_bundle("Legacy", "D"), "legacy.json"); - BOOST_REQUIRE(legacy); - BOOST_TEST(legacy->authored_key_signature.empty()); - for (auto authored : {"Eb", "C minor", "C dorian", "No key"}) { - auto document = vamp_zero::parse_score_document(catalogue_bundle("Exercise", "D", authored), "/assets/practice/001/d.json"); - BOOST_REQUIRE(document); - BOOST_TEST(document->authored_key_signature == authored); - } - for (auto invalid : {boost::json::value{}, boost::json::value{false}, boost::json::value{42}, boost::json::value{""}, boost::json::value{boost::json::array{}}}) { - auto value = boost::json::parse(catalogue_bundle("Invalid")); - value.at("music").at("metadata").as_object()["authored_key_signature"] = invalid; - auto document = vamp_zero::parse_score_document(boost::json::serialize(value), "invalid.json"); - BOOST_REQUIRE(!document); - BOOST_TEST(document.error().code() == static_cast(vamp_zero::error_code::INVALID_ARGUMENT)); - BOOST_TEST(document.error().message().find("authored_key_signature") != std::string_view::npos); - } -} - BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports_missing_or_invalid) { auto db = catalogue_database{}; auto song = catalogue_bundle("Song", "F"); - auto exercise = catalogue_bundle("Exercise", "D", "Eb"); + auto exercise = catalogue_bundle("Exercise", "D", "Electric"); db.write("songs/shared.json", song); - db.write("practice/001/d.json", exercise); + for (auto part : vamp_zero::practice_part_names()) { + db.write(std::filesystem::path{"practice/001"} / vamp_test::part_filename(part), catalogue_bundle("Exercise", "D", part)); + } BOOST_REQUIRE(db.build()); - db.query("UPDATE practice_exercises SET id = 'shared'"); + db.query("UPDATE practice_exercises SET id = 'shared' WHERE part = 'Electric'"); std::filesystem::remove_all(db.assets); for (auto category : {vamp_zero::score_category::PERFORMANCE, vamp_zero::score_category::PRACTICE}) { auto is_song = category == vamp_zero::score_category::PERFORMANCE; @@ -313,9 +297,8 @@ BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports BOOST_TEST(document->id == "shared"); BOOST_TEST(document->title == (is_song ? "Song" : "Exercise")); BOOST_TEST(document->key_signature == (is_song ? "F" : "D")); - BOOST_TEST(document->authored_key_signature == (is_song ? "" : "Eb")); BOOST_TEST(document->source_json == (is_song ? song : exercise)); - BOOST_TEST(document->source_path == (db.assets / (is_song ? "songs/shared.json" : "practice/001/d.json")).string()); + BOOST_TEST(document->source_path == (db.assets / (is_song ? "songs/shared.json" : "practice/001/electric.json")).string()); BOOST_TEST(document->measure_count == 3U); BOOST_REQUIRE_EQUAL(document->pages.size(), 2U); BOOST_TEST(document->pages[1].path == "fixture.pages/2.png"); @@ -359,14 +342,14 @@ BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score BOOST_REQUIRE(db.build()); // Complete cached metadata must not require parsing or retaining the score body. db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, " - "key_signature, measure_count, total_pages, authored_key_signature) " - "VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2, '')"); - for (auto key : vamp_zero::practice_key_names()) { + "key_signature, measure_count, total_pages) " + "VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2)"); + for (auto part_name : vamp_zero::practice_part_names()) { db.query("INSERT INTO practice_exercises(id, exercise_id, " - "title, composer, key_signature, source_json, source_path, measure_count, total_pages, authored_key_signature) " - "VALUES(?1, '001', 'Metadata Exercise', 'Composer', ?2, '{}', ?3, 3, 2, 'Eb')", - {noto::sqlite_value{std::string{"variant-"} + std::string{key}}, noto::sqlite_value{std::string{key}}, - noto::sqlite_value{std::string{"practice/001/"} + std::string{key} + ".json"}}); + "title, composer, key_signature, source_json, source_path, measure_count, total_pages, part) " + "VALUES(?1, '001', 'Metadata Exercise', 'Composer', 'Eb', '{}', ?3, 3, 2, ?2)", + {noto::sqlite_value{std::string{"practice_part-"} + std::string{part_name}}, noto::sqlite_value{std::string{part_name}}, + noto::sqlite_value{std::string{"practice/001/"} + std::string{part_name} + ".json"}}); } auto songs = db.rows("songs"); auto exercises = db.rows("practice_exercises"); @@ -382,27 +365,27 @@ BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score BOOST_TEST(performance->parts[0].total_pages == 2U); BOOST_REQUIRE_EQUAL(practice->exercises.size(), 1U); BOOST_TEST(practice->exercises[0].title == "Metadata Exercise"); - BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb"); - BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 12U); - BOOST_TEST(practice->exercises[0].variants[0].key_signature == "C"); - BOOST_TEST(practice->exercises[0].variants[2].key_signature == "D"); - for (auto const &variant : practice->exercises[0].variants) { - BOOST_TEST(variant.measure_count == 3U); - BOOST_TEST(variant.total_pages == 2U); + BOOST_TEST(practice->exercises[0].parts.front().key_signature == "Eb"); + BOOST_REQUIRE_EQUAL(practice->exercises[0].parts.size(), 3U); + BOOST_TEST(practice->exercises[0].parts[0].part == "Acoustic"); + BOOST_TEST(practice->exercises[0].parts[2].part == "Vocal"); + for (auto const &practice_part : practice->exercises[0].parts) { + BOOST_TEST(practice_part.measure_count == 3U); + BOOST_TEST(practice_part.total_pages == 2U); } } BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element()); BOOST_TEST(db.rows("practice_exercises") == exercises, boost::test_tools::per_element()); auto song = db.score(vamp_zero::score_category::PERFORMANCE, "tune"); - auto exercise = db.score(vamp_zero::score_category::PRACTICE, "variant-D"); + auto exercise = db.score(vamp_zero::score_category::PRACTICE, "practice_part-Vocal"); BOOST_REQUIRE(!song); BOOST_REQUIRE(!exercise); BOOST_TEST(song.error().code() == static_cast(vamp_zero::error_code::INVALID_ARGUMENT)); BOOST_TEST(exercise.error().code() == static_cast(vamp_zero::error_code::INVALID_ARGUMENT)); - db.query("UPDATE practice_exercises SET authored_key_signature = 'F' WHERE id = 'variant-D'"); + db.query("UPDATE practice_exercises SET key_signature = 'F' WHERE id = 'practice_part-Vocal'"); auto disagreement = db.practice(); BOOST_REQUIRE(!disagreement); - BOOST_TEST(disagreement.error().message().find("authored key") != std::string_view::npos); + BOOST_TEST(disagreement.error().message().find("disagree on key") != std::string_view::npos); } BOOST_AUTO_TEST_CASE(catalogue_validation_rejects_missing_legacy_and_corrupt_files_without_repair) { @@ -418,14 +401,14 @@ BOOST_AUTO_TEST_CASE(catalogue_validation_rejects_missing_legacy_and_corrupt_fil BOOST_REQUIRE(practice); BOOST_TEST(songs->parts.empty()); BOOST_TEST(practice->exercises.empty()); - for (auto version : {0, 1, 3}) { + for (auto version : {0, 1, 2}) { db.query("PRAGMA user_version = " + std::to_string(version)); auto result = validate(); BOOST_REQUIRE(!result); BOOST_TEST(result.error().message().find("reinstall") != std::string_view::npos); BOOST_TEST(std::get(db.query("PRAGMA user_version").rows[0][0].value) == version); } - db.query("PRAGMA user_version = 2"); + db.query("PRAGMA user_version = 3"); db.query("PRAGMA application_id = 0"); BOOST_TEST(!validate()); auto output = std::ofstream{db.config.path, std::ios::trunc}; @@ -439,8 +422,8 @@ BOOST_AUTO_TEST_CASE(catalogue_validation_rejects_missing_legacy_and_corrupt_fil BOOST_AUTO_TEST_CASE(catalogue_reads_readonly_database_without_json_and_resolves_relocated_assets) { auto db = catalogue_database{}; db.write("songs/tune.json", catalogue_bundle("Relocated")); - for (auto key : vamp_zero::practice_key_names()) { - db.write(std::filesystem::path{"practice/001"} / (std::string{key} + ".json"), catalogue_bundle("Exercise", std::string{key}, "Eb")); + for (auto part_name : vamp_zero::practice_part_names()) { + db.write(std::filesystem::path{"practice/001"} / vamp_test::part_filename(part_name), catalogue_bundle("Exercise", "Eb", part_name)); } BOOST_REQUIRE(db.build()); std::filesystem::remove(db.assets / "songs/tune.json"); @@ -459,7 +442,7 @@ BOOST_AUTO_TEST_CASE(catalogue_reads_readonly_database_without_json_and_resolves BOOST_REQUIRE(score); BOOST_TEST(score->source_path == (moved / "songs/tune.json").string()); BOOST_TEST(songs->parts[0].source_path == score->source_path); - BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb"); + BOOST_TEST(practice->exercises[0].parts.front().key_signature == "Eb"); BOOST_TEST((std::filesystem::last_write_time(db.config.path) == timestamp)); BOOST_TEST(!std::filesystem::exists(db.config.path + "-wal")); BOOST_TEST(!std::filesystem::exists(db.config.path + "-shm")); diff --git a/tests/test_engraving.py b/tests/test_engraving.py index 04dcb5f..09df0b3 100644 --- a/tests/test_engraving.py +++ b/tests/test_engraving.py @@ -9,6 +9,7 @@ from generator.engraving.audit import audit_layout from generator.engraving.notation import ScoreIndex from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, rational from generator.musicxml import load_musicxml +from generator.practice.generate import practice_document from tests.test_musicxml import notes, parse, xml_note, xml_score class EngravingTests(unittest.TestCase): @@ -110,8 +111,50 @@ class EngravingTests(unittest.TestCase): percussion = 'C54wholex' source = parse(xml_score(percussion, attributes='percussion')) self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole') - tab = parse(xml_score(xml_note(extra='21'), attributes='TAB56')) - self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '1') + tab = practice_document(parse(xml_score(xml_note('C', octave=7))), 'electric') + self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '20') + def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self): + content = ''.join(xml_note('G', 1, '64th') for _ in range(64)) + source = practice_document(parse(xml_score(content, divisions=16)), 'electric') + layout = self.layout(source) + systems = [s for page in layout.pages for s in page.systems] + self.assertGreater(len(systems), 1) + self.assertEqual(sum(len(s.fragments) for s in systems), 64) + for system in systems: + heads = [p for p in system.primitives if p.role == 'head'] + tabs = [p for p in system.primitives if p.role == 'tab'] + self.assertEqual(len(heads), len(tabs)) + self.assertEqual(len({p.points[0][1] for p in system.primitives if p.role == 'tab-staff'}), 6) + 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)) + from generator.playback import compile_timeline + self.assertEqual(len(compile_timeline(source)['events']), 64) + def test_generated_tab_ties_and_barline_styles(self): + content = xml_note('E', 2, 'half', extra='') + xml_note('E', 2, 'half', extra='') + 'dashed' + source = practice_document(parse(xml_score(content)), 'electric') + layout = self.layout(source) + self.assertEqual(len(self.primitives(layout, 'tab-tie')), 1) + self.assertEqual(len(self.primitives(layout, 'barline')), len(self.primitives(layout, 'tab-barline'))) + for system in (s for page in layout.pages for s in page.systems): + baselines = {role: max(p.points[0][1] for p in system.primitives if p.role == role) for role in ('staff', 'tab-staff')} + notation = [p for p in system.primitives if p.role == 'barline'] + tabs = [p for p in system.primitives if p.role == 'tab-barline'] + for bar, tab in zip(notation, tabs): + for (x1, y1), (x2, y2) in zip(bar.points, tab.points): + self.assertAlmostEqual(x1, x2) + self.assertAlmostEqual((y1 - baselines['staff']) * 5 / 4, y2 - baselines['tab-staff']) + def test_adjacent_string_tab_numerals_do_not_overlap(self): + from generator.guitar import place_guitar + content = ''.join(xml_note(step, octave=octave, prefix='' if index else '') for index, (step, octave) in enumerate((('G', 3), ('C', 4), ('E', 4), ('G', 4), ('C', 5), ('E', 5)))) + source = parse(xml_score(content)) + place_guitar(source) + layout = self.layout(source) + tabs = self.primitives(layout, 'tab') + self.assertEqual(len(tabs), 6) + for i, first in enumerate(tabs): + for second in tabs[i + 1:]: + self.assertFalse(first.box.overlaps(second.box, 0.1)) def test_lyrics_hyphens_and_extenders(self): begin = 'beginhel' end = 'endlo' diff --git a/tests/test_engraving_cache.py b/tests/test_engraving_cache.py index e555fa5..a2a23db 100644 --- a/tests/test_engraving_cache.py +++ b/tests/test_engraving_cache.py @@ -137,7 +137,8 @@ class EngravingCacheTests(unittest.TestCase): def test_parallel_generation_preserves_assets_on_worker_failure(self): data, output = self.root / 'data', self.root / 'output' (data / 'songs').mkdir(parents=True) - source = data / '001.xml' + (data / 'practice').mkdir() + source = data / 'practice/001.xml' source.write_text(xml_score(xml_note())) args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2) generate_assets(args) diff --git a/tests/test_guitar.py b/tests/test_guitar.py new file mode 100644 index 0000000..b16dcf3 --- /dev/null +++ b/tests/test_guitar.py @@ -0,0 +1,185 @@ +import argparse +import copy +from fractions import Fraction +from pathlib import Path +import random +import tempfile +import unittest + +from generator import harmony +from generator.__main__ import generate_assets +from generator.arrange.generate import arrange_document +from generator.guitar import Placement, TabPosition, chord_positions, chord_requirements, nearest_positions, place_guitar, position_center, positions_for_pitch, within_fret_span +from generator.model import fraction, pitch_number, rational, validate_document +from generator.parts import PART_TYPES +from generator.pitch import concert_document +from generator.playback import compile_timeline +from generator.practice.generate import generate_practice_scores, practice_document +from generator.summary import validate_practice_parts +from tests.test_musicxml import notes, parse, xml_note, xml_score + +class GuitarTests(unittest.TestCase): + def test_fret_boundaries_and_span(self): + self.assertIn(TabPosition(6, 0, 40), positions_for_pitch(Fraction(40))) + self.assertIn(TabPosition(1, 20, 84), positions_for_pitch(Fraction(84))) + self.assertFalse(positions_for_pitch(Fraction(85))) + self.assertFalse(positions_for_pitch(Fraction(129, 2))) + self.assertTrue(within_fret_span((TabPosition(4, 11, 61), TabPosition(3, 12, 67), TabPosition(2, 14, 73)))) + self.assertTrue(within_fret_span((TabPosition(6, 0, 40), TabPosition(1, 20, 84)))) + self.assertFalse(within_fret_span((TabPosition(4, 10, 60), TabPosition(1, 20, 84)))) + def test_theory_controls_omissions_and_bass(self): + for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'): + with self.subTest(symbol=symbol): + required, allowed, bass = chord_requirements(harmony.parse_symbol(symbol)) + candidates = chord_positions(required, allowed, bass) + self.assertTrue(candidates) + for candidate in candidates: + covered = {p.midi % 12 for p in candidate} + self.assertTrue(set(required) <= covered <= set(allowed)) + self.assertEqual(len(candidate), len({p.string_number for p in candidate})) + self.assertTrue(within_fret_span(candidate)) + if bass is not None: + self.assertEqual(min(p.midi for p in candidate) % 12, bass) + self.assertIn(2, chord_requirements(harmony.parse_symbol('Cmadd9'))[0]) + self.assertNotIn(7, chord_requirements(harmony.parse_symbol('C7'))[0]) + self.assertIn(7, chord_requirements(harmony.parse_symbol('CM'))[0]) + self.assertEqual(chord_requirements({**harmony.parse_symbol('CM'), 'inversion': 0})[2], 0) + self.assertEqual(chord_requirements({**harmony.parse_symbol('CM'), 'inversion': 1})[2], 4) + self.assertTrue(any(p.fret > 12 for c in chord_positions(*chord_requirements(harmony.parse_symbol('CM'))) for p in c)) + def test_melody_placement_preserves_notes_timing_and_sound(self): + source = parse(xml_score(xml_note('E', 2, 'half', octave=4) + xml_note('F', 2, 'half', octave=4))) + before = copy.deepcopy(source) + electric = practice_document(source, 'electric') + vocal = practice_document(source, 'vocal') + validate_document(electric) + self.assertEqual(source, before) + self.assertEqual(compile_timeline(electric)['events'], compile_timeline(vocal)['events']) + self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in compile_timeline(electric)['events']], [(rational(fraction(e['pitch']) - 12), e['onset'], e['duration']) for e in compile_timeline(source)['events']]) + for original, generated in zip(notes(source), notes(electric)): + self.assertEqual(pitch_number(generated['pitches'][0]), pitch_number(original['pitches'][0])) + self.assertIn('tablature', generated['pitches'][0]) + def test_instrument_transposition_and_octave_clef_applied_once(self): + for attributes, expected in (('', 48), ('-12-7', 48), ('0', 60), ('G2-1', 60), ('F4', 48)): + source = parse(xml_score(xml_note(), attributes)) + for part in ('electric', 'vocal'): + document = practice_document(source, part) + self.assertEqual(fraction(compile_timeline(document)['events'][0]['pitch']), expected) + 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}), (7, 0, 84, {'string': 1, 'fret': 20})): + source = parse(xml_score(xml_note(octave=octave, alter=alter))) + document = practice_document(source, 'electric') + tone = notes(document)[0]['pitches'][0] + self.assertEqual(pitch_number(tone), 12 * (octave + 1) + alter) + self.assertEqual(tone['tablature'], tab) + self.assertEqual(fraction(compile_timeline(document)['events'][0]['pitch']), expected) + validate_document(document) + def test_guitar_normalization_is_idempotent_and_respects_timed_transposition(self): + change = '0' + source = parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half'))) + before = copy.deepcopy(source) + concert = concert_document(source, octave_transposing=True) + self.assertEqual(concert_document(concert, octave_transposing=True), concert) + self.assertEqual(source, before) + document = practice_document(source, 'electric') + self.assertEqual([fraction(e['pitch']) for e in compile_timeline(document)['events']], [48, 60]) + def test_all_parts_share_written_octave_convention_in_songs_and_practice(self): + chord = 'Cmajor' + source = parse(xml_score(chord + xml_note())) + for generate in (practice_document, arrange_document): + for part in PART_TYPES: + with self.subTest(generator=generate.__name__, part=part): + document = generate(source, part) + validate_document(document) + written = {p['id']: 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 = compile_timeline(document)['events'] + self.assertTrue(events) + self.assertTrue(all(fraction(e['pitch']) == written[e['id']] - 12 for e in events)) + if part == 'vocal': + self.assertEqual(set(written.values()), {60}) + self.assertTrue(all(m['transpose']['octave_change'] == -1 for m in document['staves'][0]['measures'])) + def test_ties_and_overlapping_voices_keep_strings(self): + content = xml_note('E', 1, 'quarter', octave=4, extra='') + xml_note('E', 3, 'half', octave=4, extra='') + content += '41' + xml_note('F', 3, 'half', octave=4, extra='2') + document = parse(xml_score(content)) + place_guitar(document) + 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(compile_timeline(document)['events']), 2) + def test_proximity_chooses_a_path_without_relocating_tied_notes(self): + source = parse(xml_score(xml_note('B', 2, 'half', octave=5) + xml_note('C', 2, 'half', octave=6))) + place_guitar(source) + tabs = [n['pitches'][0]['tablature'] for n in notes(source)] + self.assertEqual(tabs, [{'string': 1, 'fret': 19}, {'string': 1, 'fret': 20}]) + def test_grid_search_matches_exhaustive_costs_and_tie_breaks(self): + randomizer = random.Random(17) + states = {} + for index in range(40): + positions = tuple(TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3)) + states[index] = Placement(tuple(randomizer.randrange(8) for _ in range(3)), tuple((str(i), p) for i, p in enumerate(positions)), None, ()) + grid = nearest_positions(states) + for x, row in enumerate(grid): + for y, actual in enumerate(row): + candidates = [] + for index, state in enumerate(states.values()): + a, b = position_center(tuple(p for _, p in state.active)) + candidates.append(((state.cost[0] + abs(x - a) + abs(y - b), *state.cost[1:]), index)) + self.assertEqual(actual[:2], min(candidates)) + def test_generated_chord_retains_strings_when_a_melody_enters(self): + content = xml_note() + '42' + xml_note('C', 2, 'half', octave=7, extra='2') + document = parse(xml_score(content)) + measure = document['staves'][0]['measures'][0] + measure['transpose']['octave_change'] = -1 + chord, melody = [n for v in measure['voices'] for n in v['notes']] + chord['pitches'] = [] + chord['_harmony'] = harmony.parse_symbol('CM') + place_guitar(document) + validate_document(document) + self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 20}) + self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']]) + self.assertEqual(len(compile_timeline(document)['events']), 4) + def test_more_than_six_simultaneous_tones_fail_without_omission(self): + content = '4'.join(xml_note(extra=f'{i}') for i in range(7)) + document = parse(xml_score(content)) + before = copy.deepcopy(document) + with self.assertRaisesRegex(ValueError, 'no guitar placement'): + place_guitar(document) + self.assertEqual(before, document) + def test_source_contract_and_unplayable_music_fail(self): + cases = [(xml_note(octave=7, alter=1), '', '0-20'), (xml_note(alter='0.5'), '', '0-20'), + (xml_note() + xml_note('E', prefix=''), '', 'monophonic'), + (xml_note() + '4' + xml_note('E', extra='2'), '', 'one monophonic voice'), + (xml_note(), '1', 'capo')] + for content, attributes, error in cases: + with self.subTest(error=error), self.assertRaisesRegex(ValueError, error): + practice_document(parse(xml_score(content, attributes)), 'electric') + for content, attributes in [(xml_note(), 'TAB'), (xml_note(extra='1'), '')]: + with self.assertRaisesRegex(ValueError, 'authored TAB'): + parse(xml_score(content, attributes)) + def test_three_parts_are_deterministic_without_transposition(self): + with tempfile.TemporaryDirectory() as temporary: + source, output = Path(temporary) / 'source', Path(temporary) / 'output' + source.mkdir() + (source / '000.xml').write_text(xml_score(xml_note('E', alter=-1), '-3')) + self.assertEqual(generate_practice_scores(source, output), 3) + validate_practice_parts(output) + first = {p.name: p.read_bytes() for p in output.rglob('*.json')} + generate_practice_scores(source, output) + self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')}) + (output / '000/vocal.json').unlink() + with self.assertRaisesRegex(ValueError, 'practice generator drift'): + validate_practice_parts(output) + def test_unplayable_generation_preserves_previous_assets(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / 'data/songs').mkdir(parents=True) + (root / 'data/practice').mkdir() + (root / 'data/practice/001.xml').write_text(xml_score(xml_note(octave=7, alter=1))) + output = root / 'output' + output.mkdir() + (output / 'last-good').write_text('preserved') + args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=Path('package/assets'), check=False) + with self.assertRaisesRegex(ValueError, '001.xml, part electric:.*0-20'): + generate_assets(args) + self.assertEqual([p.name for p in output.iterdir()], ['last-good']) + self.assertEqual((output / 'last-good').read_text(), 'preserved') diff --git a/tests/test_guitar_practice.py b/tests/test_guitar_practice.py deleted file mode 100644 index 31463da..0000000 --- a/tests/test_guitar_practice.py +++ /dev/null @@ -1,229 +0,0 @@ -import argparse -import copy -from fractions import Fraction -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch - -from generator.__main__ import generate_assets -from generator.arrange.fretboard import find_playable_voicing, positions_for_string -from generator.guitar import TabPosition, positions_for_pitch, within_fret_span -from generator.model import fraction, key_from_fifths, pitch_number, rational, validate_document -from generator.musicxml import load_musicxml -from generator.musicxml_writer import write_musicxml -from generator.practice.generate import generate_practice_scores, practice_document -from generator.practice.guitar import guitar_tones, transpose_guitar_practice, validate_guitar_practice -from generator.summary import validate_practice_variants -from generator.transposition import TARGET_KEYS, transpose_document, transposition_interval -from tests.test_musicxml import notes, parse, xml_note, xml_score - -OCTAVE_CLEF = 'G2-1' -GUITAR_TRANSPOSE = '00-1' -TAB_STAFF = 'TAB56' - -def technical(string, fret): - return f'{string}{fret}' - -def all_pitches(document): - return [p for staff in document['staves'] for measure in staff['measures'] for voice in measure['voices'] for note in voice['notes'] for p in note['pitches']] - -class GuitarPracticeTests(unittest.TestCase): - def test_fret_boundaries_and_crossing_twelve(self): - self.assertIn(TabPosition(6, 0, 40), positions_for_pitch(Fraction(40))) - self.assertIn(TabPosition(1, 20, 84), positions_for_pitch(Fraction(84))) - self.assertFalse(positions_for_pitch(Fraction(85))) - self.assertFalse(positions_for_pitch(Fraction(129, 2))) - self.assertEqual([p.fret for p in positions_for_string(1, 64, tuple(range(12)))], list(range(21))) - for fret in range(16, 21): - self.assertIn(TabPosition(6, fret, 40 + fret), positions_for_pitch(Fraction(40 + fret))) - self.assertTrue(within_fret_span((TabPosition(4, 11, 61), TabPosition(3, 12, 67), TabPosition(2, 14, 73)))) - self.assertTrue(within_fret_span((TabPosition(6, 0, 40), TabPosition(1, 20, 84)))) - self.assertFalse(within_fret_span((TabPosition(4, 10, 60), TabPosition(1, 20, 84)))) - content = xml_note('C', extra=technical(1, 20), octave=6) - validate_guitar_practice(parse(xml_score(content, TAB_STAFF))) - with self.assertRaisesRegex(ValueError, '0-20'): - validate_guitar_practice(parse(xml_score(xml_note('C', alter=1, extra=technical(1, 21), octave=6), TAB_STAFF))) - with self.assertRaisesRegex(ValueError, '0-20'): - validate_guitar_practice(parse(xml_score(xml_note('D', alter=-1, extra=technical(6, 21)), TAB_STAFF))) - def test_arranger_does_not_truncate_candidate_positions(self): - def candidates(string, open_midi, pitch_classes): - if string == 6: - return [TabPosition(6, 15, 48)] * 8 + [TabPosition(6, 1, 48)] - return {4: [TabPosition(4, 1, 52)], 3: [TabPosition(3, 1, 58)], 2: [TabPosition(2, 1, 60)]}.get(string, []) - with patch('generator.arrange.fretboard.positions_for_string', side_effect=candidates): - voicing = find_playable_voicing('C7') - self.assertEqual(len(voicing), 4) - self.assertTrue(all(p.fret == 1 for p in voicing)) - def test_all_keys_preserve_exact_voicings_and_adjust_g_register(self): - harmony = 'Gdominant' - content = xml_note('G', 2, 'half') + xml_note('B', 2, 'half', prefix='') + xml_note('F', 2, 'half', prefix='', octave=5) - content += xml_note('A', 2, 'half', alter=-1, octave=3) + xml_note('G', 2, 'half', alter=-1, prefix='') + xml_note('C', 2, 'half', prefix='', octave=5) - source = practice_document(parse(xml_score(harmony + content))) - original = copy.deepcopy(source) - for key in TARGET_KEYS: - with self.subTest(key=key): - result = transpose_guitar_practice(source, key) - validate_document(result) - validate_guitar_practice(result) - _, semitones = transposition_interval(source, key) - adjustment = 12 if key == 'G' else 0 - self.assertEqual(result, transpose_document(source, key, octave_shift=adjustment // 12)) - shifts = {pitch_number(b) - pitch_number(a) for a, b in zip(all_pitches(source), all_pitches(result))} - self.assertEqual(shifts, {semitones + adjustment}) - if key == 'D': - self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'A7') - self.assertEqual(source, original) - def test_non_c_source_modes_and_slash_harmony(self): - harmony = 'E-1majorG' - source = parse(xml_score(harmony + xml_note('E', alter=-1), '-3major')) - result = transpose_guitar_practice(source, 'D') - self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 62) - self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'DM/F#') - for mode, fifths in [('minor', -3), ('dorian', -2)]: - source = parse(xml_score(xml_note(), f'{fifths}{mode}')) - result = transpose_guitar_practice(source, 'D') - self.assertEqual(result['metadata']['key_signature'], f'D {mode}') - def test_local_keys_spelling_timing_and_ties_survive(self): - source = parse(xml_score(xml_note('B', 2, 'half', alter=1, extra='') + xml_note('B', 2, 'half', alter=1, extra=''))) - source['staves'][0]['measures'][0]['attributes'] = [{'onset': rational(2), 'key': key_from_fifths(-1)}] - result = transpose_guitar_practice(source, 'D') - validate_document(result) - self.assertEqual(result, transpose_document(source, 'D')) - self.assertEqual(notes(result)[0]['pitches'][0]['letter'], 'c') - self.assertEqual(fraction(notes(result)[0]['pitches'][0]['alter']), 2) - self.assertEqual(result['staves'][0]['measures'][0]['attributes'][0]['key'], key_from_fifths(1)) - def test_keyless_reference_required_for_practice(self): - source = parse(xml_score(xml_note(), '0none')) - with self.assertRaisesRegex(ValueError, 'vamp:tonic'): - transpose_guitar_practice(source, 'D') - source['metadata']['transpose_origin'] = {'letter': 'c', 'alter': rational(0)} - result = transpose_guitar_practice(source, 'D') - self.assertEqual(result['metadata']['key_signature'], 'No key') - self.assertEqual(result['metadata']['variant_tonic'], 'D') - self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 62) - def test_octave_adjustment_does_not_change_harmony_or_key(self): - source = parse(xml_score(xml_note())) - low = transpose_document(source, 'D') - high = transpose_document(source, 'D', octave_shift=1) - self.assertEqual(low['metadata'], high['metadata']) - self.assertEqual(pitch_number(notes(high)[0]['pitches'][0]) - pitch_number(notes(low)[0]['pitches'][0]), 12) - with self.assertRaisesRegex(ValueError, 'integer'): - transpose_document(source, 'D', octave_shift=0.5) - def test_instrument_transposition_clefs_and_tab_are_applied_once(self): - plain = parse(xml_score(xml_note('E', octave=3))) - explicit = parse(xml_score(xml_note('E', octave=3), GUITAR_TRANSPOSE)) - octave_clef = parse(xml_score(xml_note('E', octave=2), OCTAVE_CLEF)) - tab = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF)) - both = parse(xml_score(xml_note('E', octave=3), GUITAR_TRANSPOSE + OCTAVE_CLEF)) - for document in (plain, explicit, octave_clef, tab, both): - self.assertEqual(guitar_tones(document)[0].midi, 40) - validate_guitar_practice(document) - source = parse(xml_score(xml_note('E', 2, 'half', octave=3) + '' + GUITAR_TRANSPOSE + '' + xml_note('E', 2, 'half', octave=3))) - self.assertEqual([tone.midi for tone in guitar_tones(source)], [40, 40]) - def test_explicit_zero_transposition_overrides_implicit_guitar_octave(self): - zero = '0' - source = parse(xml_score(xml_note('E', octave=2), zero)) - self.assertTrue(source['staves'][0]['measures'][0]['transpose']['explicit']) - self.assertEqual(guitar_tones(source)[0].midi, 40) - validate_guitar_practice(source) - content = xml_note('E', 2, 'half', octave=3) + '' + zero + '' + xml_note('E', 2, 'half', octave=2) - self.assertEqual([tone.midi for tone in guitar_tones(parse(xml_score(content, GUITAR_TRANSPOSE)))], [40, 40]) - def test_real_quintuplets_and_standard_tab_tuning_survive_transposition(self): - content = '' - for index in range(5): - tuplet = '' if index == 0 else '' if index == 4 else '' - extra = '54' + tuplet + '' - content += xml_note(duration=4, kind='quarter', extra=extra) - source = parse(xml_score(content, divisions=5)) - result = transpose_guitar_practice(source, 'Db') - validate_document(result) - self.assertEqual([fraction(n['duration']) for n in notes(result)], [Fraction(4, 5)] * 5) - self.assertEqual(source['staves'][0]['tuplets'], result['staves'][0]['tuplets']) - tuning = '6' - for line, (step, octave) in enumerate((('E', 2), ('A', 2), ('D', 3), ('G', 3), ('B', 3), ('E', 4)), 1): - tuning += f'{step}{octave}' - tuning += '' - source = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF + tuning)) - result = transpose_guitar_practice(source, 'D') - self.assertEqual(notes(result)[0]['pitches'][0]['tablature'], {'string': 6, 'fret': 2}) - self.assertEqual(result['staves'][0]['measures'][0]['staff_details'], source['staves'][0]['measures'][0]['staff_details']) - def test_xml_roundtrip_preserves_implicit_and_explicit_transposition(self): - for attributes in ('', GUITAR_TRANSPOSE, '0'): - source = parse(xml_score(xml_note(), attributes)) - with tempfile.TemporaryDirectory() as temp: - path = Path(temp) / 'roundtrip.xml' - write_musicxml(path, source) - result = load_musicxml(path) - self.assertEqual(source['staves'][0]['measures'][0]['transpose'], result['staves'][0]['measures'][0]['transpose']) - self.assertEqual(guitar_tones(source)[0].midi, guitar_tones(result)[0].midi) - def test_tab_full_interval_is_applied_before_validating_fret(self): - source = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF)) - with self.assertRaisesRegex(ValueError, 'measure 1.*tablature'): - transpose_document(source, 'G') - result = transpose_guitar_practice(source, 'G') - self.assertEqual(notes(result)[0]['pitches'][0]['tablature'], {'string': 6, 'fret': 7}) - self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 47) - def test_held_strings_cannot_be_reassigned_when_another_voice_enters(self): - content = xml_note('E', extra=technical(1, 0)) + '41' - content += xml_note('F', 3, 'half', extra='2' + technical(1, 1)) - source = parse(xml_score(content, OCTAVE_CLEF)) - with self.assertRaisesRegex(ValueError, 'held/tied strings'): - validate_guitar_practice(source) - notes(source)[0]['pitches'][0].pop('tablature') - validate_guitar_practice(source) - def test_tied_strings_and_unequal_chord_durations(self): - start = xml_note('E', 1, 'quarter', extra='' + technical(1, 0)) - stop = xml_note('E', 3, 'half', extra='') - other = xml_note('F', 3, 'half', prefix='', extra='' + technical(1, 1)) - source = parse(xml_score(start + stop + other, OCTAVE_CLEF)) - with self.assertRaisesRegex(ValueError, 'held/tied strings'): - validate_guitar_practice(source) - notes(source)[0]['pitches'][0].pop('tie_to') - notes(source)[1]['pitches'][0].pop('tie_from') - validate_guitar_practice(source) - content = xml_note('E', 2, 'half', extra=technical(1, 0)) + xml_note('B', 1, 'quarter', prefix='', extra=technical(2, 0), octave=3) - content += '21' + xml_note('C', 3, 'half', extra='2' + technical(2, 1)) - validate_guitar_practice(parse(xml_score(content, OCTAVE_CLEF))) - def test_grace_notes_at_tie_boundary_do_not_drop_the_held_string(self): - start = xml_note('E', 1, 'quarter', extra='' + technical(1, 0)) - grace = 'B3eighth' - stop = xml_note('E', 3, 'half', extra='') - validate_guitar_practice(parse(xml_score(start + grace + stop, OCTAVE_CLEF))) - def test_unsupported_tuning_capo_and_microtones_fail_explicitly(self): - for attributes, error in [('1', 'capo'), ('D2', 'standard six-string tuning')]: - with self.assertRaisesRegex(ValueError, error): - transpose_guitar_practice(parse(xml_score(xml_note(), attributes)), 'C') - with self.assertRaisesRegex(ValueError, 'no whole-exercise guitar register'): - transpose_guitar_practice(parse(xml_score(xml_note(alter='0.5'))), 'C') - def test_no_register_and_failed_generation_preserve_previous_assets(self): - content = xml_note('E', octave=2) + xml_note('E', octave=7, prefix='') - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - (root / 'data/songs').mkdir(parents=True) - source = root / 'data/001.xml' - source.parent.mkdir(parents=True, exist_ok=True) - source.write_text(xml_score(content), encoding='utf-8') - output = root / 'output' - output.mkdir() - (output / 'last-good').write_text('preserved') - args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=Path('package/assets'), check=False) - with patch('generator.__main__.engrave_tree') as engrave: - with self.assertRaisesRegex(ValueError, r'001.xml, target key C:.*measure 1'): - generate_assets(args) - engrave.assert_not_called() - self.assertEqual((output / 'last-good').read_text(), 'preserved') - self.assertEqual(list(output.iterdir()), [output / 'last-good']) - def test_practice_requires_twelve_deterministic_variants(self): - with tempfile.TemporaryDirectory() as temp: - source, output = Path(temp) / 'source', Path(temp) / 'output' - source.mkdir() - (source / '001.xml').write_text(xml_score(xml_note()), encoding='utf-8') - self.assertEqual(generate_practice_scores(source, output), 12) - validate_practice_variants(output) - first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')} - generate_practice_scores(source, output) - self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')}) - (output / '001/g.json').unlink() - with self.assertRaisesRegex(ValueError, 'practice generator drift'): - validate_practice_variants(output) diff --git a/tests/test_harmony.py b/tests/test_harmony.py index 03bf140..951d01c 100644 --- a/tests/test_harmony.py +++ b/tests/test_harmony.py @@ -7,7 +7,7 @@ from generator.model import rational from generator.musicxml import MusicXMLError, load_musicxml from generator.musicxml_writer import write_musicxml from generator.practice.generate import practice_document -from generator.transposition import transpose_document +from generator.pitch import concert_document from tests.test_musicxml import parse, xml_note, xml_score def chord_xml(text, kind='other', root='C', extra=''): @@ -54,10 +54,11 @@ class HarmonyTests(unittest.TestCase): self.assertEqual(chord['symbol'], label) self.assertTrue(chord['chord_optional']) self.assertTrue(chord['parenthesized']) - practice = practice_document(source)['staves'][0]['measures'][0]['harmonies'][0] + practice = practice_document(source, 'vocal')['staves'][0]['measures'][0]['harmonies'][0] self.assertNotIn('chord_optional', practice) self.assertEqual(harmony.symbol(practice), label) - shifted = transpose_document(source, 'D') + source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0} + shifted = concert_document(source) result = shifted['staves'][0]['measures'][0]['harmonies'][0] self.assertEqual(result['symbol'], transposed) with tempfile.TemporaryDirectory() as temporary: @@ -87,7 +88,7 @@ class HarmonyTests(unittest.TestCase): source = parse(xml_score(chord_xml('7', 'dominant', extra='9-1add') + xml_note())) self.assertEqual(source['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'C7b9') def test_current_exercise_and_song_exports_import_without_edits(self): - exercise = load_musicxml(Path('data/001.xml')) + exercise = load_musicxml(Path('data/practice/001.xml')) measures = exercise['staves'][0]['measures'] self.assertEqual(len(measures), 20) self.assertEqual(sum(len(n['pitches']) for m in measures for v in m['voices'] for n in v['notes']), 70) diff --git a/tests/test_health.cpp b/tests/test_health.cpp index 0341a86..52fe676 100644 --- a/tests/test_health.cpp +++ b/tests/test_health.cpp @@ -165,11 +165,11 @@ auto checksum(std::string const &bytes) -> std::string { return result; } -auto write_score(std::filesystem::path const &path, std::string key = "C") -> void { +auto write_score(std::filesystem::path const &path, std::string part = "Lead") -> void { auto score = boost::json::parse(R"({"schema_version":3,"music":{"metadata":{"title":"Fixture","composer":"Composer", "key_signature":"C","time_signature":"4/4"},"staves":[{"name":"Lead","measures":[{"voices":[]}]}]}, "presentation":{"width":976,"height":484,"pages":[]}})"); - score.at("music").at("metadata").as_object()["key_signature"] = key; + score.at("music").at("staves").as_array().front().as_object()["name"] = part; auto &pages = score.at("presentation").at("pages").as_array(); for (auto index = std::uint32_t{1}; index <= 2U; ++index) { auto relative = path.stem().string() + ".pages/" + std::to_string(index) + ".png"; @@ -184,10 +184,10 @@ auto make_assets(vamp_zero::config const &config) -> void { auto root = std::filesystem::path{config.runtime.assets_path}; write_score(root / "songs" / "first.json"); write_score(root / "songs" / "second.json"); - for (auto key : vamp_zero::practice_key_names()) { - auto name = std::string{key}; + for (auto part_name : vamp_zero::practice_part_names()) { + auto name = std::string{part_name}; name.front() = static_cast(name.front() - 'A' + 'a'); - write_score(root / "practice" / "001" / (name + ".json"), std::string{key}); + write_score(root / "practice" / "001" / (name + ".json"), std::string{part_name}); } BOOST_REQUIRE(vamp_test::build_catalogue(root, config.database.path)); } @@ -363,7 +363,7 @@ BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalogue_without_hardware_or_hear BOOST_REQUIRE(exercises); BOOST_TEST(songs->parts.size() == 2U); BOOST_REQUIRE_EQUAL(exercises->exercises.size(), 1U); - BOOST_TEST(exercises->exercises.front().variants.size() == 12U); + BOOST_TEST(exercises->exercises.front().parts.size() == 3U); write_file(config.runtime.heartbeat_path, "existing heartbeat"); auto heartbeat_time = std::filesystem::last_write_time(config.runtime.heartbeat_path); config.runtime.exit_after_startup = true; @@ -374,8 +374,8 @@ BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalogue_without_hardware_or_hear require_no_probe(config, true); } -BOOST_AUTO_TEST_CASE(readiness_validates_later_pages_parts_and_practice_variants) { - for (std::char_t const *relative : {"songs/first.pages/2.png", "songs/second.pages/2.png", "practice/001/d.pages/2.png"}) { +BOOST_AUTO_TEST_CASE(readiness_validates_later_pages_and_parts) { + for (std::char_t const *relative : {"songs/first.pages/2.png", "songs/second.pages/2.png", "practice/001/electric.pages/2.png"}) { auto root = temporary_directory{}; auto config = fixture_config(root.path); make_assets(config); @@ -394,8 +394,8 @@ BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_ 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 / "practice/001/c.json"; - write_score(path); + auto path = assets / "practice/001/acoustic.json"; + write_score(path, "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)); diff --git a/tests/test_key_metadata.py b/tests/test_key_metadata.py index 222d2e9..7487fce 100644 --- a/tests/test_key_metadata.py +++ b/tests/test_key_metadata.py @@ -10,13 +10,18 @@ from generator.engraving import Fonts, Style, engrave_tree, layout_score from generator.engraving.attributes import staff_attributes from generator.engraving.notation import ScoreIndex from generator.model import key_from_fifths, parse_pitch_name, rational, validate_document -from generator.practice.generate import generate_practice_scores, key_file_name, practice_document from generator.score import load_json, write_json -from generator.transposition import TARGET_KEYS, concert_document, shift_key, transpose_document +from generator.pitch import concert_document, shift_key from tests.test_musicxml import parse, xml_note, xml_score FONT_ROOT = Path('package/assets/fonts/petaluma') +def instrument_score(document, steps, semitones): + source = copy.deepcopy(document) + for measure in source['staves'][0]['measures']: + measure['transpose'] = {'diatonic': steps, 'chromatic': semitones, 'octave_change': 0} + return concert_document(source) + def key_change(old, new, local=False, attributes=''): initial = f'{old}' + attributes change = f'{old}{new}' @@ -38,7 +43,7 @@ class KeyCancellationTests(unittest.TestCase): with self.subTest(old=old, new=new, target=target, local=local): source = key_change(old, new, local) before = copy.deepcopy(source) - result = transpose_document(source, target) + result = instrument_score(source, *({'A': (1, 2), 'Ab': (-1, -2), 'G': (1, 2), 'F': (-1, -2)}[target])) validate_document(result) measures = result['staves'][0]['measures'] self.assertEqual(measures[0]['key'], key_from_fifths(expected_old)) @@ -66,7 +71,7 @@ class KeyCancellationTests(unittest.TestCase): self.assertEqual(source, before) def test_metadata_and_initial_signature_transpose_cancellation(self): source = parse(xml_score(xml_note(), '10')) - result = transpose_document(source, 'D', octave_shift=1) + result = instrument_score(source, 1, 2) expected = key_from_fifths(2) | {'cancel': 3} self.assertEqual(result['metadata']['key'], expected) self.assertEqual(result['staves'][0]['measures'][0]['key'], expected) @@ -81,7 +86,7 @@ class KeyCancellationTests(unittest.TestCase): def test_unchanged_accidentals_do_not_receive_naturals(self): for old, new, target in [(1, 0, 'F'), (-1, 0, 'G'), (1, 1, 'A')]: with self.subTest(old=old, new=new, target=target): - result = transpose_document(key_change(old, new), target) + result = instrument_score(key_change(old, new), *({'F': (-1, -2), 'G': (1, 2), 'A': (1, 2)}[target])) index = ScoreIndex(result) glyphs = staff_attributes(index, index.state(Fraction(4)), Fraction(4), self.fonts, 12, 'change', ('key',)) self.assertNotIn('accidentalNatural', [p.value for p in glyphs]) @@ -99,7 +104,7 @@ class KeyCancellationTests(unittest.TestCase): change['key']['cancel'] = 14 before = copy.deepcopy(source) with self.subTest(local=local), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'): - transpose_document(source, 'D') + instrument_score(source, 1, 2) self.assertEqual(source, before) def test_concert_transposition_uses_the_same_interval_for_cancellation(self): attributes = '12' @@ -111,60 +116,3 @@ class KeyCancellationTests(unittest.TestCase): measures = result['staves'][0]['measures'] change = measures[0]['attributes'][0] if local else measures[1] self.assertEqual(change['key'], key_from_fifths(2) | {'cancel': 3}) - -class AuthoredKeyMetadataTests(unittest.TestCase): - def test_eb_authored_key_survives_every_generated_practice_variant(self): - with tempfile.TemporaryDirectory() as temp: - source, output = Path(temp) / 'source', Path(temp) / 'output' - text = xml_score(xml_note('E', alter=-1), '-3') - source.mkdir() - path = source / '001.xml' - path.write_text(text, encoding='utf-8') - self.assertEqual(generate_practice_scores(source, output), 12) - for key in TARGET_KEYS: - with self.subTest(key=key): - result = load_json(output / '001' / key_file_name(key)) - self.assertEqual(result['schema_version'], 3) - self.assertEqual(result['metadata']['authored_key_signature'], 'Eb') - self.assertEqual(result['metadata']['key_signature'], key) - self.assertEqual(result['metadata']['variant_tonic'], key) - self.assertEqual(path.read_text(encoding='utf-8'), text) - def test_modes_and_keyless_authored_labels_survive_variants(self): - for mode, fifths, label in [('minor', -3, 'C minor'), ('dorian', -2, 'C dorian'), ('mixolydian', -1, 'C mixolydian'), ('none', 0, 'No key')]: - source = parse(xml_score(xml_note(), f'{fifths}{mode}')) - if mode == 'none': - source['metadata']['transpose_origin'] = parse_pitch_name('C') - before = copy.deepcopy(source) - base = practice_document(source) - self.assertEqual(base['metadata']['authored_key_signature'], label) - for key in TARGET_KEYS: - with self.subTest(mode=mode, key=key): - result = transpose_document(base, key) - validate_document(result) - self.assertEqual(result['metadata']['authored_key_signature'], label) - self.assertEqual(result['metadata']['key_signature'], 'No key' if mode == 'none' else f'{key} {mode}') - self.assertEqual(result['metadata']['variant_tonic'], key) - self.assertEqual(source, before) - def test_existing_authored_key_is_retained_when_practice_is_rebuilt(self): - source = parse(xml_score(xml_note('E', alter=-1), '-3')) - before = copy.deepcopy(source) - variant = transpose_document(practice_document(source), 'D') - result = transpose_document(practice_document(variant), 'F') - self.assertEqual(result['metadata']['authored_key_signature'], 'Eb') - self.assertEqual(result['metadata']['key_signature'], 'F') - self.assertEqual(source, before) - def test_schema3_bundles_accept_optional_authored_key(self): - source = parse(xml_score(xml_note('E', alter=-1), '-3')) - self.assertNotIn('authored_key_signature', source['metadata']) - for authored in (False, True): - with self.subTest(authored=authored), tempfile.TemporaryDirectory() as temp: - result = transpose_document(practice_document(source) if authored else source, 'D') - root = Path(temp) - path = root / 'variant.json' - write_json(path, result) - engrave_tree(root, FONT_ROOT) - bundle = json.loads(path.read_text(encoding='utf-8')) - self.assertEqual(bundle['schema_version'], 3) - self.assertEqual(bundle['music']['metadata']['key_signature'], 'D') - self.assertEqual(bundle['music']['metadata'].get('authored_key_signature'), 'Eb' if authored else None) - self.assertEqual(load_json(path), result) diff --git a/tests/test_music_model.py b/tests/test_music_model.py index 9eb8f5d..fa37c5f 100644 --- a/tests/test_music_model.py +++ b/tests/test_music_model.py @@ -8,12 +8,13 @@ from unittest.mock import patch from generator import harmony from generator.__main__ import generate_assets -from generator.arrange.generate import active_chord, arrange_document, generated_voice, propagated_chords -from generator.arrange.fretboard import pitches_for_voicing +from generator.arrange.generate import arrange_document +from generator.parts import active_chord, generated_voice, propagated_chords +from generator.guitar import chord_pitches, chord_positions, chord_requirements from generator.model import beat_lengths, fraction, key_from_fifths, pitch_number, rational, validate_document from generator.musicxml import load_musicxml from generator.styles import BUILT_IN_STYLES, canonical_style -from generator.transposition import concert_document, shift_pitch, transpose_document +from generator.pitch import concert_document, shift_pitch from tests.test_musicxml import parse, notes, xml_note, xml_score class MusicModelTests(unittest.TestCase): @@ -30,7 +31,8 @@ class MusicModelTests(unittest.TestCase): for label, expected in cases.items(): self.assertEqual(set(harmony.pitch_classes(harmony.parse_symbol(label))), expected, label) for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]: - pitches = pitches_for_voicing(label) + definition = harmony.parse_symbol(label) + pitches = chord_pitches(definition, chord_positions(*chord_requirements(definition))[0]) self.assertTrue(essentials.issubset({pitch_number(p) % 12 for p in pitches}), label) with self.assertRaises(ValueError): harmony.parse_symbol('Cunrecognised') @@ -39,8 +41,8 @@ class MusicModelTests(unittest.TestCase): p['alter'] = rational(p['alter']) self.assertEqual(shift_pitch(p, 0, 0), p) self.assertEqual(pitch_number(shift_pitch(p, 1, 2)), pitch_number(p) + 2) - source = parse(xml_score(xml_note(), attributes='-3minor')) - result = transpose_document(source, 'D') + source = parse(xml_score(xml_note(), attributes='-3minor12')) + result = concert_document(source) self.assertEqual(result['metadata']['key_signature'], 'D minor') self.assertEqual(result['metadata']['key']['alterations'], key_from_fifths(-1, 'minor')['alterations']) def test_additive_and_compound_grouping(self): @@ -105,6 +107,7 @@ class MusicModelTests(unittest.TestCase): root = Path(temp) data = root / 'data' (data / 'songs').mkdir(parents=True) + (data / 'practice').mkdir() (data / 'songs' / 'bad.xml').write_text(xml_score(xml_note(duration=5)), encoding='utf-8') output = root / 'output' output.mkdir() @@ -129,6 +132,7 @@ class MusicModelTests(unittest.TestCase): with tempfile.TemporaryDirectory() as temp: data, output = Path(temp) / 'data', Path(temp) / 'output' (data / 'songs').mkdir(parents=True) + (data / 'practice').mkdir() source = data / 'songs' / 'fixture.xml' source.write_text(xml_score(xml_note()), encoding='utf-8') args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False) diff --git a/tests/test_playback.py b/tests/test_playback.py index bbeeded..fdbf7a5 100644 --- a/tests/test_playback.py +++ b/tests/test_playback.py @@ -40,17 +40,18 @@ class PlaybackTests(unittest.TestCase): unpitched = 'C44whole' with self.assertRaises(PlaybackUnavailable): compile_timeline(parse(xml_score(unpitched))) - def test_guitar_octave_depends_on_score_metadata_not_path(self): + def test_sounding_pitch_depends_on_instrument_transposition_not_part_or_path(self): from generator.playback import compile_tree from generator.score import write_json with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = parse(xml_score(xml_note())) - for part in ('practice', 'acoustic', 'electric', 'vocal'): + 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 write_json(root / f'{part}.json', source) compile_tree(root) - for part, pitch in [('practice', 48), ('acoustic', 48), ('electric', 48), ('vocal', 60)]: + 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'], generate_tone(root, Fraction(pitch))['id']) def test_audio_assets_validate_and_reject_corruption(self): diff --git a/tests/test_scat.py b/tests/test_scat.py new file mode 100644 index 0000000..8952595 --- /dev/null +++ b/tests/test_scat.py @@ -0,0 +1,77 @@ +import copy +from fractions import Fraction +from pathlib import Path +import unittest + +from generator.engraving import Fonts, Style, layout_score +from generator.engraving.layout import make_system +from generator.engraving.notation import ScoreIndex +from generator.model import rational +from generator.playback import compile_timeline +from generator.scat import add_scat, pulse, syllable +from tests.test_musicxml import notes, parse, xml_note, xml_score + +class ScatTests(unittest.TestCase): + def test_syllables_use_duration_pulse_phrase_and_previous_consonant(self): + self.assertEqual(syllable(Fraction(2), Fraction(1), False, True, ''), 'doo') + self.assertEqual(syllable(Fraction(1, 2), Fraction(1), True, False, ''), 'ba') + self.assertEqual(syllable(Fraction(1, 2), Fraction(1), False, False, ''), 'dit') + self.assertEqual(syllable(Fraction(1, 2), Fraction(1), False, False, 'dit'), 'da') + self.assertEqual(syllable(Fraction(1, 2), Fraction(1), True, True, ''), 'dat') + self.assertEqual(syllable(Fraction(1, 2), None, False, False, ''), 'ba') + def test_compound_additive_meter_and_pickups(self): + measure = {'meter': {'groups': [{'beats': [6], 'unit': 8}]}, 'duration': rational(3)} + self.assertEqual(pulse(measure, Fraction(3, 2)), (Fraction(3, 2), True)) + measure['meter']['groups'][0]['beats'] = [3, 2] + self.assertEqual(pulse(measure, Fraction(3, 2)), (Fraction(1), True)) + measure.update(implicit=True, duration=rational(Fraction(1, 2))) + self.assertEqual(pulse(measure, Fraction()), (Fraction(1), False)) + def test_ties_sustain_one_syllable_and_stop_before_rests(self): + content = xml_note(duration=1, kind='quarter', extra='') + xml_note(duration=1, kind='quarter', extra='') + content += '2half' + document = parse(xml_score(content)) + original = copy.deepcopy(document) + before = compile_timeline(document) + add_scat(document) + self.assertEqual(compile_timeline(document), before) + self.assertEqual(notes(document)[0]['lyric'], 'doo') + self.assertEqual(notes(document)[0]['lyrics'][0]['extend_to'], rational(2)) + self.assertNotIn('lyric', notes(document)[1]) + self.assertNotIn('lyrics', notes(document)[2]) + again = copy.deepcopy(original) + add_scat(again) + self.assertEqual(document, again) + fonts = Fonts(Path('package/assets/fonts/petaluma')) + layout = layout_score(document, Style(), fonts) + system = layout.pages[0].systems[0] + rest = next(a.x for a in system.anchors if a.fragment.kind == 'rest') + self.assertTrue(all(p.box.right < rest for p in system.primitives if p.role == 'lyric-extension')) + def test_cross_bar_tie_has_one_lyric_and_page_continuations(self): + text = xml_score(xml_note(extra='')) + text = text.replace('', '' + xml_note(extra='') + '') + document = parse(text) + add_scat(document) + self.assertEqual(sum(bool(n.get('lyric')) for n in notes(document)), 1) + fonts = Fonts(Path('package/assets/fonts/petaluma')) + for start, end in ((0, 4), (4, 8)): + system = make_system(ScoreIndex(document), start, end, fonts, Style()) + self.assertTrue(any(p.role == 'lyric-extension' for p in system.primitives)) + def test_long_tie_chain_draws_one_extender_per_system(self): + text = xml_score(xml_note(extra='')) + text = text.replace('', '' + xml_note(extra='') + '' + xml_note(extra='') + '') + document = parse(text) + add_scat(document) + fonts = Fonts(Path('package/assets/fonts/petaluma')) + index = ScoreIndex(document) + for start, end in ((0, 4), (4, 8), (8, 12), (0, 12)): + system = make_system(index, start, end, fonts, Style()) + self.assertEqual(sum(p.role == 'lyric-extension' for p in system.primitives), 1) + def test_straight_offbeat_attacks_and_cues(self): + document = parse(xml_score(''.join(xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2)) + before = compile_timeline(document) + add_scat(document) + self.assertEqual([n['lyric'] for n in notes(document)], ['ba', 'dit', 'ba', 'dit', 'ba', 'dit', 'ba', 'dat']) + self.assertEqual(compile_timeline(document), before) + document = parse(xml_score(xml_note(prefix=''))) + add_scat(document) + self.assertNotIn('lyrics', notes(document)[0]) diff --git a/tests/test_score_bundle.py b/tests/test_score_bundle.py index a10bc64..574a40a 100644 --- a/tests/test_score_bundle.py +++ b/tests/test_score_bundle.py @@ -18,7 +18,7 @@ from generator.musicxml import load_musicxml from generator.musicxml_writer import write_musicxml from generator.practice.generate import practice_document from generator.score import load_json -from generator.transposition import transpose_document +from generator.pitch import concert_document from tests.test_musicxml import notes, parse, xml_note, xml_score class ScoreBundleTests(unittest.TestCase): @@ -27,7 +27,8 @@ class ScoreBundleTests(unittest.TestCase): root = Path(temporary) data, output = root / 'data', root / 'output' (data / 'songs').mkdir(parents=True) - source = data / '001.xml' + (data / 'practice').mkdir() + source = data / 'practice/001.xml' source.write_text(xml_score(xml_note())) args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False) generate_assets(args) @@ -48,12 +49,12 @@ class ScoreBundleTests(unittest.TestCase): generate_assets(args) self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}) source.write_text(xml_score(xml_note())) - renamed = data / '127.xml' + renamed = data / 'practice/127.xml' source.rename(renamed) source = renamed generate_assets(args) self.assertFalse((output / 'practice/001').exists()) - self.assertEqual(len(list((output / 'practice/127').glob('*.json'))), 12) + self.assertEqual(len(list((output / 'practice/127').glob('*.json'))), 3) source.unlink() generate_assets(args) self.assertFalse(list(output.rglob('*.png'))) @@ -72,7 +73,7 @@ class ScoreBundleTests(unittest.TestCase): measure = source['staves'][0]['measures'][0] self.assertEqual([v['id'] for v in measure['voices']], ['1', '2']) self.assertNotIn('tie_from', measure['voices'][1]['notes'][0]['pitches'][0]) - for document in (source, practice_document(source), transpose_document(source, 'D'), arrange_document(source, 'vocal')): + for document in (source, concert_document(source), arrange_document(source, 'vocal')): validate_document(document) restored = self.round_trip(document) validate_document(restored) @@ -95,7 +96,8 @@ class ScoreBundleTests(unittest.TestCase): self.assertEqual(restored['staves'][0]['measures'][1]['barlines'], {'right': 'light-heavy'}) def test_microtones_remain_exact_after_transposition_and_export(self): source = parse(xml_score(xml_note(alter='0.5', extra='quarter-sharp'))) - transposed = transpose_document(source, 'D') + source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0} + transposed = concert_document(source) self.assertEqual(pitch_number(notes(transposed)[0]['pitches'][0]), Fraction(125, 2)) restored = self.round_trip(transposed) self.assertEqual(notes(restored)[0]['pitches'], notes(transposed)[0]['pitches']) @@ -131,13 +133,13 @@ class ScoreBundleTests(unittest.TestCase): source = parse(xml_score(percussion, attributes='percussion')) restored = self.round_trip(source) self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches']) - tab = parse(xml_score(xml_note(extra='21'), attributes='TAB56')) - self.assertEqual(notes(self.round_trip(tab))[0]['pitches'], notes(tab)[0]['pitches']) - for document in (source, tab): - validate_document(practice_document(document)) - for part in ('vocal', 'acoustic', 'electric'): - with self.assertRaisesRegex(ValueError, 'conversion is not qualified'): - arrange_document(document, part) + with self.assertRaisesRegex(ValueError, 'authored TAB'): + parse(xml_score(xml_note(), attributes='TAB')) + for part in ('vocal', 'acoustic', 'electric'): + with self.assertRaisesRegex(ValueError, 'pitched'): + practice_document(source, part) + with self.assertRaisesRegex(ValueError, 'pitched'): + arrange_document(source, part) def test_percussion_instrument_definitions_round_trip_and_are_audited(self): tree = ElementTree.fromstring(xml_score('C54whole', attributes='percussion')) definition = tree.find('part-list/score-part') diff --git a/tests/test_score_loader.cpp b/tests/test_score_loader.cpp index 5061293..2ad082b 100644 --- a/tests/test_score_loader.cpp +++ b/tests/test_score_loader.cpp @@ -316,10 +316,10 @@ BOOST_AUTO_TEST_CASE(score_loader_completion_can_overlap_stop_and_consumer_destr BOOST_CHECK(entered_status == std::future_status::ready); } -BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_variant) { +BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_part) { auto catalog = std::make_shared(); catalog->performance.parts = {{.id = "part-a"}, {.id = "part-b"}}; - catalog->practice.exercises = {{.id = "scale", .variants = {{.id = "scale-c"}, {.id = "scale-d"}}}}; + catalog->practice.exercises = {{.id = "scale", .parts = {{.id = "scale-c"}, {.id = "scale-d"}}}}; auto state = vamp_zero::app_state{.catalog = catalog}; BOOST_CHECK(!vamp_zero::selected_score_reference(state)); state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE; @@ -330,12 +330,12 @@ BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_vari BOOST_CHECK(reference->category == vamp_zero::score_category::PERFORMANCE); state.mode = vamp_zero::app_mode::PRACTICE_SCORE; state.active_practice_id = "scale"; - state.selected_practice_key_index = 2; + state.selected_practice_part_index = 2; reference = vamp_zero::selected_score_reference(state); BOOST_REQUIRE(reference); BOOST_CHECK_EQUAL(reference->id, "scale-d"); BOOST_CHECK(reference->category == vamp_zero::score_category::PRACTICE); - state.selected_practice_key_index = 0; + state.selected_practice_part_index = 0; BOOST_CHECK(!vamp_zero::selected_score_reference(state)); state.catalog.reset(); BOOST_CHECK(!vamp_zero::selected_score_reference(state));