From fdc58db70f3b4f29ad004bbac77af5c8f4220b60 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 12 Dec 2025 10:34:02 +0100 Subject: [PATCH 1/9] More fixes --- cmake/yup_generate_java_header.cmake | 22 +- cmake/yup_python.cmake | 1 - tests/yup_core/yup_File.cpp | 5 +- tests/yup_core/yup_Process.cpp | 14 +- tests/yup_data_model/yup_DataTree.cpp | 692 ++++++++++++++++++++++++++ 5 files changed, 721 insertions(+), 13 deletions(-) diff --git a/cmake/yup_generate_java_header.cmake b/cmake/yup_generate_java_header.cmake index d059efb00..e75b8c369 100644 --- a/cmake/yup_generate_java_header.cmake +++ b/cmake/yup_generate_java_header.cmake @@ -36,9 +36,25 @@ file (READ "${INPUT_FILE}" hex_content HEX) get_filename_component (INPUT_FILE_NAME "${INPUT_FILE}" NAME) # Convert hex string to C++ array format -string (REGEX MATCHALL "([A-Fa-f0-9][A-Fa-f0-9])" separated_hex ${hex_content}) -list (JOIN separated_hex ", 0x" formatted_hex) -string (PREPEND formatted_hex "0x") +set (count 0) +set (wrapped_hex "") +string (REGEX MATCHALL "([A-Fa-f0-9][A-Fa-f0-9])" separated_hex "${hex_content}") +foreach (byte IN LISTS separated_hex) + if (count EQUAL 0) + set (current_line "0x${byte}") + else() + set (current_line "${current_line},0x${byte}") + endif() + math (EXPR count "${count} + 1") + if (count EQUAL 60) + list (APPEND wrapped_hex "${current_line}") + set (count 0) + endif() +endforeach() +if (count GREATER 0) + list(APPEND wrapped_hex "${current_line}") +endif() +string (JOIN "\n" formatted_hex ${wrapped_hex}) # Generate timestamp string (TIMESTAMP current_time "%Y-%m-%d %H:%M:%S UTC" UTC) diff --git a/cmake/yup_python.cmake b/cmake/yup_python.cmake index a39133f9e..71bf42da2 100644 --- a/cmake/yup_python.cmake +++ b/cmake/yup_python.cmake @@ -64,7 +64,6 @@ function (yup_prepare_python_stdlib target_name python_tools_path output_variabl "${Python_EXECUTABLE}" "${python_tools_path}/ArchivePythonStdlib.py" -r "${python_root_path}" -o "${CMAKE_CURRENT_BINARY_DIR}" -M "${Python_VERSION_MAJOR}" -m "${Python_VERSION_MINOR}" -x "\"${ignored_library_patterns}\"" - COMMAND_ECHO STDOUT COMMAND_ERROR_IS_FATAL ANY) set (${output_variable} ${python_standard_library} PARENT_SCOPE) diff --git a/tests/yup_core/yup_File.cpp b/tests/yup_core/yup_File.cpp index 330f05d21..c74b154f4 100644 --- a/tests/yup_core/yup_File.cpp +++ b/tests/yup_core/yup_File.cpp @@ -904,7 +904,7 @@ TEST_F (FileTests, Version) // Version might be empty for test executables } -TEST_F (FileTests, StartAsProcess) +TEST_F (FileTests, DISABLED_StartAsProcess) { // Limited testing - we don't want to actually launch processes in unit tests tempFolder.createDirectory(); @@ -913,6 +913,7 @@ TEST_F (FileTests, StartAsProcess) // Just verify the method exists and doesn't crash // Actual process launching should be tested manually + textFile.startAsProcess(); } TEST_F (FileTests, RecursiveReadOnly) @@ -1107,7 +1108,7 @@ TEST_F (FileTests, FileOutputStreamFlush) } #endif -TEST_F (FileTests, RevealToUser) +TEST_F (FileTests, DISABLED_RevealToUser) { // Test File::revealToUser() - this method shows the file in the OS file browser tempFolder.createDirectory(); diff --git a/tests/yup_core/yup_Process.cpp b/tests/yup_core/yup_Process.cpp index a5703377d..38fd7c53c 100644 --- a/tests/yup_core/yup_Process.cpp +++ b/tests/yup_core/yup_Process.cpp @@ -48,7 +48,7 @@ class ProcessTests : public ::testing::Test File testFile; }; -TEST_F (ProcessTests, OpenDocumentWithFileName) +TEST_F (ProcessTests, DISABLED_OpenDocumentWithFileName) { // Test Process::openDocument() with a file name // This attempts to open the file with the default application @@ -64,7 +64,7 @@ TEST_F (ProcessTests, OpenDocumentWithFileName) SUCCEED(); } -TEST_F (ProcessTests, OpenDocumentWithUrl) +TEST_F (ProcessTests, DISABLED_OpenDocumentWithUrl) { // Test opening a URL (this should be safer than opening a file) // Most systems have a default browser @@ -77,7 +77,7 @@ TEST_F (ProcessTests, OpenDocumentWithUrl) SUCCEED(); } -TEST_F (ProcessTests, OpenDocumentWithParameters) +TEST_F (ProcessTests, DISABLED_OpenDocumentWithParameters) { // Test Process::openDocument() with parameters [[maybe_unused]] bool result = Process::openDocument (testFile.getFullPathName(), "--test-param"); @@ -85,7 +85,7 @@ TEST_F (ProcessTests, OpenDocumentWithParameters) SUCCEED(); } -TEST_F (ProcessTests, OpenDocumentWithEnvironment) +TEST_F (ProcessTests, DISABLED_OpenDocumentWithEnvironment) { // Test Process::openDocument() with custom environment variables StringPairArray environment; @@ -97,7 +97,7 @@ TEST_F (ProcessTests, OpenDocumentWithEnvironment) SUCCEED(); } -TEST_F (ProcessTests, OpenDocumentWithEmptyPath) +TEST_F (ProcessTests, DISABLED_OpenDocumentWithEmptyPath) { // Test with empty path (should fail gracefully) [[maybe_unused]] bool result = Process::openDocument ("", ""); @@ -106,7 +106,7 @@ TEST_F (ProcessTests, OpenDocumentWithEmptyPath) SUCCEED(); } -TEST_F (ProcessTests, OpenDocumentWithNonExistentFile) +TEST_F (ProcessTests, DISABLED_OpenDocumentWithNonExistentFile) { // Test with a file that doesn't exist File nonExistent = File::getSpecialLocation (File::tempDirectory) @@ -119,7 +119,7 @@ TEST_F (ProcessTests, OpenDocumentWithNonExistentFile) SUCCEED(); } -TEST_F (ProcessTests, OpenDocumentWithSpecialCharacters) +TEST_F (ProcessTests, DISABLED_OpenDocumentWithSpecialCharacters) { // Create a file with special characters in the name File specialFile = testFile.getParentDirectory().getChildFile ("test file with spaces & special.txt"); diff --git a/tests/yup_data_model/yup_DataTree.cpp b/tests/yup_data_model/yup_DataTree.cpp index ac30508ce..344e7f172 100644 --- a/tests/yup_data_model/yup_DataTree.cpp +++ b/tests/yup_data_model/yup_DataTree.cpp @@ -2992,3 +2992,695 @@ TEST_F (DataTreeTests, ListenerTestsWithUndoOperations) tree.removeListener (&listener); } + +//============================================================================== +// Clone Tests with Children + +TEST_F (DataTreeTests, CloneCreatesDeepCopyWithChildren) +{ + // Setup tree with properties and children + { + auto transaction = tree.beginTransaction(); + transaction.setProperty ("rootProp", "rootValue"); + + DataTree child1 ("Child1"); + { + auto childTransaction = child1.beginTransaction(); + childTransaction.setProperty ("child1Prop", 42); + } + + DataTree child2 ("Child2"); + { + auto childTransaction = child2.beginTransaction(); + childTransaction.setProperty ("child2Prop", "test"); + + DataTree grandChild ("GrandChild"); + { + auto grandChildTransaction = grandChild.beginTransaction(); + grandChildTransaction.setProperty ("grandChildProp", 3.14); + } + childTransaction.addChild (grandChild); + } + + transaction.addChild (child1); + transaction.addChild (child2); + } + + // Clone the tree + auto clonedTree = tree.clone(); + + // Verify cloned tree is independent + EXPECT_NE (tree, clonedTree); + EXPECT_TRUE (tree.isEquivalentTo (clonedTree)); + + // Verify properties + EXPECT_EQ (tree.getType(), clonedTree.getType()); + EXPECT_EQ (tree.getProperty ("rootProp"), clonedTree.getProperty ("rootProp")); + + // Verify children + EXPECT_EQ (tree.getNumChildren(), clonedTree.getNumChildren()); + + auto originalChild1 = tree.getChild (0); + auto clonedChild1 = clonedTree.getChild (0); + EXPECT_NE (originalChild1, clonedChild1); + EXPECT_EQ (originalChild1.getType(), clonedChild1.getType()); + EXPECT_EQ (originalChild1.getProperty ("child1Prop"), clonedChild1.getProperty ("child1Prop")); + + auto originalChild2 = tree.getChild (1); + auto clonedChild2 = clonedTree.getChild (1); + EXPECT_NE (originalChild2, clonedChild2); + EXPECT_EQ (originalChild2.getType(), clonedChild2.getType()); + EXPECT_EQ (originalChild2.getProperty ("child2Prop"), clonedChild2.getProperty ("child2Prop")); + + // Verify grandchildren + EXPECT_EQ (originalChild2.getNumChildren(), clonedChild2.getNumChildren()); + auto originalGrandChild = originalChild2.getChild (0); + auto clonedGrandChild = clonedChild2.getChild (0); + EXPECT_NE (originalGrandChild, clonedGrandChild); + EXPECT_EQ (originalGrandChild.getType(), clonedGrandChild.getType()); + EXPECT_EQ (originalGrandChild.getProperty ("grandChildProp"), clonedGrandChild.getProperty ("grandChildProp")); + + // Verify independence by modifying clone + { + auto transaction = clonedTree.beginTransaction(); + transaction.setProperty ("rootProp", "modifiedValue"); + } + + EXPECT_NE (tree.getProperty ("rootProp"), clonedTree.getProperty ("rootProp")); + EXPECT_EQ (var ("rootValue"), tree.getProperty ("rootProp")); + EXPECT_EQ (var ("modifiedValue"), clonedTree.getProperty ("rootProp")); +} + +//============================================================================== +// indexOf Edge Cases + +TEST_F (DataTreeTests, IndexOfWithInvalidChild) +{ + DataTree child ("Child"); + { + auto transaction = tree.beginTransaction(); + transaction.addChild (child); + } + + // Test with empty/invalid child + DataTree invalidChild; + EXPECT_EQ (-1, tree.indexOf (invalidChild)); + + // Test with child not in tree + DataTree notInTree ("NotInTree"); + EXPECT_EQ (-1, tree.indexOf (notInTree)); + + // Test with valid child + EXPECT_EQ (0, tree.indexOf (child)); +} + +TEST_F (DataTreeTests, IndexOfWithInvalidParent) +{ + DataTree invalidTree; + DataTree child ("Child"); + + EXPECT_EQ (-1, invalidTree.indexOf (child)); +} + +//============================================================================== +// isAChildOf Edge Cases + +TEST_F (DataTreeTests, IsAChildOfWithInvalidPossibleParent) +{ + DataTree parent ("Parent"); + DataTree child ("Child"); + + { + auto transaction = parent.beginTransaction(); + transaction.addChild (child); + } + + // Test with invalid possible parent + DataTree invalidParent; + EXPECT_FALSE (child.isAChildOf (invalidParent)); + + // Test with valid parent + EXPECT_TRUE (child.isAChildOf (parent)); +} + +TEST_F (DataTreeTests, IsAChildOfWithInvalidChild) +{ + DataTree parent ("Parent"); + DataTree invalidChild; + + EXPECT_FALSE (invalidChild.isAChildOf (parent)); +} + +TEST_F (DataTreeTests, IsAChildOfWithDeepHierarchy) +{ + DataTree root ("Root"); + DataTree level1 ("Level1"); + DataTree level2 ("Level2"); + DataTree level3 ("Level3"); + + { + auto transaction = root.beginTransaction(); + transaction.addChild (level1); + } + { + auto transaction = level1.beginTransaction(); + transaction.addChild (level2); + } + { + auto transaction = level2.beginTransaction(); + transaction.addChild (level3); + } + + // Test deep hierarchy + EXPECT_TRUE (level3.isAChildOf (level2)); + EXPECT_TRUE (level3.isAChildOf (level1)); + EXPECT_TRUE (level3.isAChildOf (root)); + EXPECT_TRUE (level2.isAChildOf (level1)); + EXPECT_TRUE (level2.isAChildOf (root)); + EXPECT_TRUE (level1.isAChildOf (root)); + + // Test reverse (parent is not child of child) + EXPECT_FALSE (root.isAChildOf (level1)); + EXPECT_FALSE (level1.isAChildOf (level2)); +} + +//============================================================================== +// Binary Stream Edge Cases + +TEST_F (DataTreeTests, ReadFromBinaryStreamWithEmptyStream) +{ + MemoryOutputStream output; + output.writeString (String()); + + MemoryInputStream input (output.getData(), output.getDataSize(), false); + auto loadedTree = DataTree::readFromBinaryStream (input); + + EXPECT_FALSE (loadedTree.isValid()); +} + +TEST_F (DataTreeTests, ReadFromBinaryStreamWithIncompleteData) +{ + // Create a stream with just a type but no property/child counts + MemoryOutputStream output; + output.writeString ("TestType"); + // Deliberately not writing property count or other data + + MemoryInputStream input (output.getData(), output.getDataSize(), false); + auto loadedTree = DataTree::readFromBinaryStream (input); + + // Should still create tree with type, but no properties/children + EXPECT_TRUE (loadedTree.isValid()); + EXPECT_EQ (Identifier ("TestType"), loadedTree.getType()); +} + +//============================================================================== +// JSON Edge Cases + +TEST_F (DataTreeTests, FromJsonWithNonDynamicObject) +{ + // Test with string instead of object + var stringVar = "not an object"; + auto loadedTree = DataTree::fromJson (stringVar); + EXPECT_FALSE (loadedTree.isValid()); + + // Test with number + var numberVar = 42; + loadedTree = DataTree::fromJson (numberVar); + EXPECT_FALSE (loadedTree.isValid()); + + // Test with array + var arrayVar = var (Array()); + loadedTree = DataTree::fromJson (arrayVar); + EXPECT_FALSE (loadedTree.isValid()); + + // Test with undefined + var undefinedVar = var::undefined(); + loadedTree = DataTree::fromJson (undefinedVar); + EXPECT_FALSE (loadedTree.isValid()); +} + +TEST_F (DataTreeTests, FromJsonWithInvalidStructure) +{ + // Test without type field + auto jsonObject = std::make_unique(); + jsonObject->setProperty ("properties", var (std::make_unique())); + var jsonData (jsonObject.release()); + + auto loadedTree = DataTree::fromJson (jsonData); + EXPECT_FALSE (loadedTree.isValid()); + + // Test with empty type + jsonObject = std::make_unique(); + jsonObject->setProperty ("type", ""); + var jsonData2 (jsonObject.release()); + + loadedTree = DataTree::fromJson (jsonData2); + EXPECT_FALSE (loadedTree.isValid()); + + // Test with non-string type + jsonObject = std::make_unique(); + jsonObject->setProperty ("type", 123); + var jsonData3 (jsonObject.release()); + + loadedTree = DataTree::fromJson (jsonData3); + EXPECT_FALSE (loadedTree.isValid()); +} + +TEST_F (DataTreeTests, FromJsonWithInvalidChildrenStructure) +{ + // Children must be an array, not an object + auto jsonObject = std::make_unique(); + jsonObject->setProperty ("type", "TestType"); + jsonObject->setProperty ("properties", var (std::make_unique())); + jsonObject->setProperty ("children", var (std::make_unique())); + + var jsonData (jsonObject.release()); + auto loadedTree = DataTree::fromJson (jsonData); + + EXPECT_FALSE (loadedTree.isValid()); +} + +//============================================================================== +// RemoveAllListeners Tests + +TEST_F (DataTreeTests, RemoveAllListeners) +{ + TestListener listener1; + TestListener listener2; + TestListener listener3; + + tree.addListener (&listener1); + tree.addListener (&listener2); + tree.addListener (&listener3); + + // Make a change to verify listeners are active + { + auto transaction = tree.beginTransaction(); + transaction.setProperty ("testProp", "testValue"); + } + + EXPECT_EQ (1, listener1.propertyChanges.size()); + EXPECT_EQ (1, listener2.propertyChanges.size()); + EXPECT_EQ (1, listener3.propertyChanges.size()); + + // Remove all listeners + tree.removeAllListeners(); + + // Make another change + { + auto transaction = tree.beginTransaction(); + transaction.setProperty ("testProp2", "testValue2"); + } + + // Listeners should not have received the second change + EXPECT_EQ (1, listener1.propertyChanges.size()); + EXPECT_EQ (1, listener2.propertyChanges.size()); + EXPECT_EQ (1, listener3.propertyChanges.size()); +} + +TEST_F (DataTreeTests, RemoveAllListenersOnInvalidTree) +{ + DataTree invalidTree; + + // Should not crash + invalidTree.removeAllListeners(); +} + +//============================================================================== +// isEquivalentTo Comprehensive Tests + +TEST_F (DataTreeTests, IsEquivalentToBothInvalid) +{ + DataTree invalid1; + DataTree invalid2; + + EXPECT_TRUE (invalid1.isEquivalentTo (invalid2)); +} + +TEST_F (DataTreeTests, IsEquivalentToOneInvalid) +{ + DataTree invalid; + + EXPECT_FALSE (tree.isEquivalentTo (invalid)); + EXPECT_FALSE (invalid.isEquivalentTo (tree)); +} + +TEST_F (DataTreeTests, IsEquivalentToDifferentTypes) +{ + DataTree tree1 ("Type1"); + DataTree tree2 ("Type2"); + + EXPECT_FALSE (tree1.isEquivalentTo (tree2)); +} + +TEST_F (DataTreeTests, IsEquivalentToDifferentPropertyCounts) +{ + DataTree tree1 ("TestType"); + DataTree tree2 ("TestType"); + + { + auto transaction = tree1.beginTransaction(); + transaction.setProperty ("prop1", 1); + transaction.setProperty ("prop2", 2); + } + + { + auto transaction = tree2.beginTransaction(); + transaction.setProperty ("prop1", 1); + } + + EXPECT_FALSE (tree1.isEquivalentTo (tree2)); +} + +TEST_F (DataTreeTests, IsEquivalentToDifferentPropertyValues) +{ + DataTree tree1 ("TestType"); + DataTree tree2 ("TestType"); + + { + auto transaction = tree1.beginTransaction(); + transaction.setProperty ("prop1", 1); + } + + { + auto transaction = tree2.beginTransaction(); + transaction.setProperty ("prop1", 2); + } + + EXPECT_FALSE (tree1.isEquivalentTo (tree2)); +} + +TEST_F (DataTreeTests, IsEquivalentToDifferentPropertyNames) +{ + DataTree tree1 ("TestType"); + DataTree tree2 ("TestType"); + + { + auto transaction = tree1.beginTransaction(); + transaction.setProperty ("prop1", 1); + } + + { + auto transaction = tree2.beginTransaction(); + transaction.setProperty ("prop2", 1); + } + + EXPECT_FALSE (tree1.isEquivalentTo (tree2)); +} + +TEST_F (DataTreeTests, IsEquivalentToDifferentChildCounts) +{ + DataTree tree1 ("TestType"); + DataTree tree2 ("TestType"); + + { + auto transaction = tree1.beginTransaction(); + transaction.addChild (DataTree ("Child1")); + transaction.addChild (DataTree ("Child2")); + } + + { + auto transaction = tree2.beginTransaction(); + transaction.addChild (DataTree ("Child1")); + } + + EXPECT_FALSE (tree1.isEquivalentTo (tree2)); +} + +TEST_F (DataTreeTests, IsEquivalentToDifferentChildContent) +{ + DataTree tree1 ("TestType"); + DataTree tree2 ("TestType"); + + { + auto transaction = tree1.beginTransaction(); + DataTree child1 ("Child"); + { + auto childTx = child1.beginTransaction(); + childTx.setProperty ("prop", 1); + } + transaction.addChild (child1); + } + + { + auto transaction = tree2.beginTransaction(); + DataTree child2 ("Child"); + { + auto childTx = child2.beginTransaction(); + childTx.setProperty ("prop", 2); + } + transaction.addChild (child2); + } + + EXPECT_FALSE (tree1.isEquivalentTo (tree2)); +} + +TEST_F (DataTreeTests, IsEquivalentToComplexEquivalentTrees) +{ + DataTree tree1 ("Root"); + DataTree tree2 ("Root"); + + // Build identical complex trees + { + auto tx1 = tree1.beginTransaction(); + tx1.setProperty ("prop1", "value1"); + tx1.setProperty ("prop2", 42); + + DataTree child1 ("Child1"); + { + auto childTx = child1.beginTransaction(); + childTx.setProperty ("childProp", 3.14); + childTx.addChild (DataTree ("GrandChild")); + } + tx1.addChild (child1); + tx1.addChild (DataTree ("Child2")); + } + + { + auto tx2 = tree2.beginTransaction(); + tx2.setProperty ("prop1", "value1"); + tx2.setProperty ("prop2", 42); + + DataTree child1 ("Child1"); + { + auto childTx = child1.beginTransaction(); + childTx.setProperty ("childProp", 3.14); + childTx.addChild (DataTree ("GrandChild")); + } + tx2.addChild (child1); + tx2.addChild (DataTree ("Child2")); + } + + EXPECT_TRUE (tree1.isEquivalentTo (tree2)); + EXPECT_NE (tree1, tree2); // Different objects +} + +//============================================================================== +// Transaction Move Assignment Tests + +TEST_F (DataTreeTests, TransactionMoveAssignment) +{ + DataTree tree2 ("Tree2"); + + // Commit transaction1 explicitly before moving + { + auto transaction1 = tree.beginTransaction(); + transaction1.setProperty ("prop1", 1); + transaction1.commit(); + } + + EXPECT_TRUE (tree.hasProperty ("prop1")); + EXPECT_EQ (var (1), tree.getProperty ("prop1")); + + // Now test move assignment with a new transaction + auto transaction1 = tree.beginTransaction(); + transaction1.setProperty ("prop1", 100); // Modify existing property + + auto transaction2 = tree2.beginTransaction(); + transaction2.setProperty ("prop2", 2); + + // Move assign - test that move semantics work correctly + transaction1 = std::move (transaction2); + + // transaction1 now refers to tree2's transaction + EXPECT_TRUE (transaction1.isActive()); + + // Complete the moved transaction + transaction1.setProperty ("prop3", 3); + transaction1.commit(); + + // Verify tree2 has both properties from transaction2 + EXPECT_TRUE (tree2.hasProperty ("prop2")); + EXPECT_EQ (var (2), tree2.getProperty ("prop2")); + EXPECT_TRUE (tree2.hasProperty ("prop3")); + EXPECT_EQ (var (3), tree2.getProperty ("prop3")); + + // Verify tree still has its original property (uncommitted changes from transaction1 were discarded) + EXPECT_TRUE (tree.hasProperty ("prop1")); + EXPECT_EQ (var (1), tree.getProperty ("prop1")); // Should still be 1, not 100 +} + +TEST_F (DataTreeTests, TransactionMoveAssignmentSelfAssignment) +{ + auto transaction = tree.beginTransaction(); + transaction.setProperty ("prop1", 1); + + // Self-assignment should be safe + transaction = std::move (transaction); + + EXPECT_TRUE (transaction.isActive()); + transaction.commit(); + + EXPECT_TRUE (tree.hasProperty ("prop1")); +} + +//============================================================================== +// ValidatedTransaction Move Tests + +TEST_F (DataTreeTests, ValidatedTransactionMoveConstructor) +{ + const String schemaJson = R"({ + "nodeTypes": { + "Root": { + "properties": { + "stringProp": { + "type": "string" + } + } + } + } + })"; + + auto schema = DataTreeSchema::fromJsonSchemaString (schemaJson); + ASSERT_NE (nullptr, schema); + + auto validatedTx1 = tree.beginValidatedTransaction (schema); + validatedTx1.setProperty ("stringProp", "test"); + + // Move construct + auto validatedTx2 (std::move (validatedTx1)); + + // Original should not be active + EXPECT_FALSE (validatedTx1.isActive()); + + // New transaction should be active and functional + EXPECT_TRUE (validatedTx2.isActive()); + validatedTx2.setProperty ("stringProp", "modified"); + validatedTx2.commit(); + + EXPECT_EQ (var ("modified"), tree.getProperty ("stringProp")); +} + +TEST_F (DataTreeTests, ValidatedTransactionMoveAssignment) +{ + const String schemaJson = R"({ + "nodeTypes": { + "Root": { + "properties": { + "prop1": { + "type": "string" + }, + "prop2": { + "type": "number" + } + } + } + } + })"; + + auto schema = DataTreeSchema::fromJsonSchemaString (schemaJson); + ASSERT_NE (nullptr, schema); + + DataTree tree2 ("Root"); + + auto validatedTx1 = tree.beginValidatedTransaction (schema); + validatedTx1.setProperty ("prop1", "value1"); + + auto validatedTx2 = tree2.beginValidatedTransaction (schema); + validatedTx2.setProperty ("prop2", 42); + + // Move assign + validatedTx1 = std::move (validatedTx2); + + // Original transaction should not be active + EXPECT_FALSE (validatedTx2.isActive()); + + // Assigned transaction should be active + EXPECT_TRUE (validatedTx1.isActive()); + validatedTx1.setProperty ("prop2", 99); + validatedTx1.commit(); + + EXPECT_EQ (var (99), tree2.getProperty ("prop2")); +} + +//============================================================================== +// ValidatedTransaction::getTransaction Tests + +TEST_F (DataTreeTests, ValidatedTransactionGetTransaction) +{ + const String schemaJson = R"({ + "nodeTypes": { + "Root": { + "properties": { + "validatedProp": { + "type": "string" + } + } + } + } + })"; + + auto schema = DataTreeSchema::fromJsonSchemaString (schemaJson); + ASSERT_NE (nullptr, schema); + + auto validatedTx = tree.beginValidatedTransaction (schema); + + // Get underlying transaction + auto& rawTransaction = validatedTx.getTransaction(); + + // Use raw transaction to bypass validation + rawTransaction.setProperty ("anyProp", "anyValue"); + rawTransaction.setProperty ("validatedProp", 123); // Wrong type, but bypasses validation + + validatedTx.commit(); + + // Both properties should be set + EXPECT_TRUE (tree.hasProperty ("anyProp")); + EXPECT_TRUE (tree.hasProperty ("validatedProp")); + EXPECT_EQ (var ("anyValue"), tree.getProperty ("anyProp")); + EXPECT_EQ (var (123), tree.getProperty ("validatedProp")); +} + +TEST_F (DataTreeTests, ValidatedTransactionGetTransactionModifiesState) +{ + const String schemaJson = R"({ + "nodeTypes": { + "Root": { + "children": { + "allowedTypes": ["Child"] + } + }, + "Child": { + "properties": {} + } + } + })"; + + auto schema = DataTreeSchema::fromJsonSchemaString (schemaJson); + ASSERT_NE (nullptr, schema); + + auto validatedTx = tree.beginValidatedTransaction (schema); + + // Get transaction and add a child + auto& rawTransaction = validatedTx.getTransaction(); + DataTree child ("Child"); + rawTransaction.addChild (child); + + // Check effective child count through transaction + EXPECT_EQ (1, rawTransaction.getEffectiveChildCount()); + + validatedTx.commit(); + + EXPECT_EQ (1, tree.getNumChildren()); + EXPECT_EQ (child, tree.getChild (0)); +} From 3d2082b0e29402cfd94ef28eaaaea3eafb6d22f3 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 12 Dec 2025 10:58:25 +0100 Subject: [PATCH 2/9] Fix DataTrees --- modules/yup_data_model/tree/yup_DataTree.cpp | 358 ++++++++++--------- modules/yup_data_model/tree/yup_DataTree.h | 250 ++++++------- tests/yup_data_model/yup_DataTree.cpp | 5 +- 3 files changed, 316 insertions(+), 297 deletions(-) diff --git a/modules/yup_data_model/tree/yup_DataTree.cpp b/modules/yup_data_model/tree/yup_DataTree.cpp index 97ce34824..d25873046 100644 --- a/modules/yup_data_model/tree/yup_DataTree.cpp +++ b/modules/yup_data_model/tree/yup_DataTree.cpp @@ -124,8 +124,8 @@ var coerceAttributeValue (const Identifier& nodeType, class PropertySetAction : public UndoableAction { public: - PropertySetAction (DataTree tree, const Identifier& prop, const var& newVal, const var& oldVal) - : dataTree (tree) + PropertySetAction (std::shared_ptr obj, const Identifier& prop, const var& newVal, const var& oldVal) + : dataObject (std::move (obj)) , property (prop) , newValue (newVal) , oldValue (oldVal) @@ -135,34 +135,34 @@ class PropertySetAction : public UndoableAction bool isValid() const override { - return dataTree.object != nullptr; + return dataObject != nullptr; } bool perform (UndoableActionState state) override { - if (dataTree.object == nullptr) + if (dataObject == nullptr) return false; if (state == UndoableActionState::Redo) { - wasPropertyPresent = dataTree.object->properties.contains (property); + wasPropertyPresent = dataObject->properties.contains (property); - dataTree.object->properties.set (property, newValue); + dataObject->properties.set (property, newValue); } else { if (wasPropertyPresent) - dataTree.object->properties.set (property, oldValue); + dataObject->properties.set (property, oldValue); else - dataTree.object->properties.remove (property); + dataObject->properties.remove (property); } - dataTree.object->sendPropertyChangeMessage (property); + dataObject->sendPropertyChangeMessage (property); return true; } private: - DataTree dataTree; + std::shared_ptr dataObject; Identifier property; var newValue, oldValue; bool wasPropertyPresent; @@ -173,8 +173,8 @@ class PropertySetAction : public UndoableAction class PropertyRemoveAction : public UndoableAction { public: - PropertyRemoveAction (DataTree tree, const Identifier& prop, const var& oldVal) - : dataTree (tree) + PropertyRemoveAction (std::shared_ptr obj, const Identifier& prop, const var& oldVal) + : dataObject (std::move (obj)) , property (prop) , oldValue (oldVal) { @@ -182,25 +182,25 @@ class PropertyRemoveAction : public UndoableAction bool isValid() const override { - return dataTree.object != nullptr; + return dataObject != nullptr; } bool perform (UndoableActionState state) override { - if (dataTree.object == nullptr) + if (dataObject == nullptr) return false; if (state == UndoableActionState::Redo) - dataTree.object->properties.remove (property); + dataObject->properties.remove (property); else - dataTree.object->properties.set (property, oldValue); + dataObject->properties.set (property, oldValue); - dataTree.object->sendPropertyChangeMessage (property); + dataObject->sendPropertyChangeMessage (property); return true; } private: - DataTree dataTree; + std::shared_ptr dataObject; Identifier property; var oldValue; }; @@ -210,35 +210,35 @@ class PropertyRemoveAction : public UndoableAction class RemoveAllPropertiesAction : public UndoableAction { public: - RemoveAllPropertiesAction (DataTree tree, const NamedValueSet& oldProps) - : dataTree (tree) + RemoveAllPropertiesAction (std::shared_ptr obj, const NamedValueSet& oldProps) + : dataObject (std::move (obj)) , oldProperties (oldProps) { } bool isValid() const override { - return dataTree.object != nullptr; + return dataObject != nullptr; } bool perform (UndoableActionState state) override { - if (dataTree.object == nullptr) + if (dataObject == nullptr) return false; if (state == UndoableActionState::Redo) - dataTree.object->properties.clear(); + dataObject->properties.clear(); else - dataTree.object->properties = oldProperties; + dataObject->properties = oldProperties; for (int i = 0; i < oldProperties.size(); ++i) - dataTree.object->sendPropertyChangeMessage (oldProperties.getName (i)); + dataObject->sendPropertyChangeMessage (oldProperties.getName (i)); return true; } private: - DataTree dataTree; + std::shared_ptr dataObject; NamedValueSet oldProperties; }; @@ -247,65 +247,75 @@ class RemoveAllPropertiesAction : public UndoableAction class AddChildAction : public UndoableAction { public: - AddChildAction (DataTree parent, const DataTree& child, int idx) - : parentTree (parent) - , childTree (child) + AddChildAction (std::shared_ptr parent, std::shared_ptr child, int idx) + : parentObject (std::move (parent)) + , childObject (std::move (child)) , index (idx) { } bool isValid() const override { - return parentTree.object != nullptr && childTree.object != nullptr; + return parentObject != nullptr && childObject != nullptr; } bool perform (UndoableActionState state) override { - if (parentTree.object == nullptr || childTree.object == nullptr) + if (parentObject == nullptr || childObject == nullptr) return false; if (state == UndoableActionState::Redo) { - if (auto currentParent = childTree.object->parent.lock()) + if (auto currentParent = childObject->parent.lock()) { - previousParent = DataTree (currentParent); - previousIndex = previousParent.indexOf (childTree); + previousParent = currentParent; - currentParent->children.erase (currentParent->children.begin() + previousIndex); - currentParent->sendChildRemovedMessage (childTree, previousIndex); + // Find child index in current parent + auto& currentChildren = currentParent->children; + auto it = std::find (currentChildren.begin(), currentChildren.end(), childObject); + if (it != currentChildren.end()) + { + previousIndex = static_cast (std::distance (currentChildren.begin(), it)); + currentChildren.erase (it); + currentParent->sendChildRemovedMessage (childObject, previousIndex); + } } else { - previousParent = DataTree(); // No previous parent + previousParent.reset(); previousIndex = -1; } - const int numChildren = static_cast (parentTree.object->children.size()); + const int numChildren = static_cast (parentObject->children.size()); const int actualIndex = isPositiveAndBelow (index, numChildren) ? index : numChildren; - parentTree.object->children.insert (parentTree.object->children.begin() + actualIndex, childTree); - childTree.object->parent = parentTree.object; - parentTree.object->sendChildAddedMessage (childTree); + parentObject->children.insert (parentObject->children.begin() + actualIndex, childObject); + childObject->parent = parentObject; + parentObject->sendChildAddedMessage (childObject); } else { - if (const int childIndex = parentTree.indexOf (childTree); childIndex >= 0) + // Find child in parent + auto& parentChildren = parentObject->children; + auto it = std::find (parentChildren.begin(), parentChildren.end(), childObject); + if (it != parentChildren.end()) { - parentTree.object->children.erase (parentTree.object->children.begin() + childIndex); - parentTree.object->sendChildRemovedMessage (childTree, childIndex); + const int childIndex = static_cast (std::distance (parentChildren.begin(), it)); + parentChildren.erase (it); + parentObject->sendChildRemovedMessage (childObject, childIndex); - if (previousParent.isValid()) + if (auto prevParent = previousParent.lock()) { - const int numChildren = static_cast (previousParent.object->children.size()); + const int numChildren = static_cast (prevParent->children.size()); const int actualIndex = (previousIndex < 0 || previousIndex > numChildren) ? numChildren : previousIndex; - previousParent.object->children.insert (previousParent.object->children.begin() + actualIndex, childTree); - childTree.object->parent = previousParent.object; - previousParent.object->sendChildAddedMessage (childTree); + prevParent->children.insert (prevParent->children.begin() + actualIndex, childObject); + childObject->parent = prevParent; + prevParent->sendChildAddedMessage (childObject); } else { - childTree.object->parent.reset(); + childObject->parent.reset(); } } } @@ -314,10 +324,10 @@ class AddChildAction : public UndoableAction } private: - DataTree parentTree; - DataTree childTree; + std::shared_ptr parentObject; + std::shared_ptr childObject; int index; - DataTree previousParent; + std::weak_ptr previousParent; int previousIndex = -1; }; @@ -326,63 +336,63 @@ class AddChildAction : public UndoableAction class RemoveChildAction : public UndoableAction { public: - RemoveChildAction (DataTree parent, DataTree childTree, int idx) - : parentTree (parent) - , childTree (childTree) + RemoveChildAction (std::shared_ptr parent, std::shared_ptr child, int idx) + : parentObject (std::move (parent)) + , childObject (std::move (child)) , index (idx) { } bool isValid() const override { - return parentTree.object != nullptr; + return parentObject != nullptr; } bool perform (UndoableActionState state) override { - if (parentTree.object == nullptr) + if (parentObject == nullptr) return false; - auto& parentChildren = parentTree.object->children; + auto& parentChildren = parentObject->children; if (state == UndoableActionState::Redo) { - if (childTree.isValid()) + if (childObject != nullptr) { - auto it = std::find (parentChildren.begin(), parentChildren.end(), childTree); + auto it = std::find (parentChildren.begin(), parentChildren.end(), childObject); if (it != parentChildren.end()) index = static_cast (std::distance (parentChildren.begin(), it)); } - if (! isPositiveAndBelow (index, static_cast (parentTree.object->children.size()))) + if (! isPositiveAndBelow (index, static_cast (parentObject->children.size()))) return false; - if (! childTree.isValid()) - childTree = parentChildren[index]; + if (childObject == nullptr) + childObject = parentChildren[index]; parentChildren.erase (parentChildren.begin() + index); - childTree.object->parent.reset(); - parentTree.object->sendChildRemovedMessage (childTree, index); + childObject->parent.reset(); + parentObject->sendChildRemovedMessage (childObject, index); } else { - if (childTree.object == nullptr) + if (childObject == nullptr) return false; const int numChildren = static_cast (parentChildren.size()); const int actualIndex = isPositiveAndBelow (index, numChildren) ? index : numChildren; - parentChildren.insert (parentChildren.begin() + actualIndex, childTree); - childTree.object->parent = parentTree.object; - parentTree.object->sendChildAddedMessage (childTree); + parentChildren.insert (parentChildren.begin() + actualIndex, childObject); + childObject->parent = parentObject; + parentObject->sendChildAddedMessage (childObject); } return true; } private: - DataTree parentTree; - DataTree childTree; + std::shared_ptr parentObject; + std::shared_ptr childObject; int index; }; @@ -391,40 +401,40 @@ class RemoveChildAction : public UndoableAction class RemoveAllChildrenAction : public UndoableAction { public: - RemoveAllChildrenAction (DataTree parent, const std::vector& oldChildren) - : parentTree (parent) + RemoveAllChildrenAction (std::shared_ptr parent, const std::vector>& oldChildren) + : parentObject (std::move (parent)) , children (oldChildren) { } bool isValid() const override { - return parentTree.object != nullptr; + return parentObject != nullptr; } bool perform (UndoableActionState state) override { - if (parentTree.object == nullptr) + if (parentObject == nullptr) return false; if (state == UndoableActionState::Redo) { - parentTree.object->children.clear(); + parentObject->children.clear(); for (size_t i = 0; i < children.size(); ++i) { - children[i].object->parent.reset(); - parentTree.object->sendChildRemovedMessage (children[i], static_cast (i)); + children[i]->parent.reset(); + parentObject->sendChildRemovedMessage (children[i], static_cast (i)); } } else { - parentTree.object->children = children; + parentObject->children = children; for (auto& child : children) { - child.object->parent = parentTree.object; - parentTree.object->sendChildAddedMessage (child); + child->parent = parentObject; + parentObject->sendChildAddedMessage (child); } } @@ -432,8 +442,8 @@ class RemoveAllChildrenAction : public UndoableAction } private: - DataTree parentTree; - std::vector children; + std::shared_ptr parentObject; + std::vector> children; }; //============================================================================== @@ -441,8 +451,8 @@ class RemoveAllChildrenAction : public UndoableAction class MoveChildAction : public UndoableAction { public: - MoveChildAction (DataTree parent, int fromIndex, int toIndex) - : parentTree (parent) + MoveChildAction (std::shared_ptr parent, int fromIndex, int toIndex) + : parentObject (std::move (parent)) , oldIndex (fromIndex) , newIndex (toIndex) { @@ -450,48 +460,48 @@ class MoveChildAction : public UndoableAction bool isValid() const override { - return parentTree.object != nullptr && oldIndex != newIndex; + return parentObject != nullptr && oldIndex != newIndex; } bool perform (UndoableActionState state) override { - if (parentTree.object == nullptr || oldIndex == newIndex) + if (parentObject == nullptr || oldIndex == newIndex) return false; - const int numChildren = static_cast (parentTree.object->children.size()); + const int numChildren = static_cast (parentObject->children.size()); if (! isPositiveAndBelow (oldIndex, numChildren) || ! isPositiveAndBelow (newIndex, numChildren)) return false; if (state == UndoableActionState::Redo) { - auto child = parentTree.object->children[static_cast (oldIndex)]; + auto child = parentObject->children[static_cast (oldIndex)]; - auto start = parentTree.object->children.begin(); + auto start = parentObject->children.begin(); auto first = start + std::min (oldIndex, newIndex); auto middle = start + oldIndex; auto last = start + std::max (oldIndex, newIndex) + 1; std::rotate (first, middle + (oldIndex < newIndex), last); - parentTree.object->sendChildMovedMessage (child, oldIndex, newIndex); + parentObject->sendChildMovedMessage (child, oldIndex, newIndex); } else { - auto child = parentTree.object->children[static_cast (newIndex)]; + auto child = parentObject->children[static_cast (newIndex)]; - auto start = parentTree.object->children.begin(); + auto start = parentObject->children.begin(); auto first = start + std::min (newIndex, oldIndex); auto middle = start + newIndex; auto last = start + std::max (newIndex, oldIndex) + 1; std::rotate (first, middle + (newIndex < oldIndex), last); - parentTree.object->sendChildMovedMessage (child, newIndex, oldIndex); + parentObject->sendChildMovedMessage (child, newIndex, oldIndex); } return true; } private: - DataTree parentTree; + std::shared_ptr parentObject; int oldIndex, newIndex; }; @@ -500,20 +510,20 @@ class MoveChildAction : public UndoableAction class CompoundAction : public UndoableAction { public: - CompoundAction (DataTree tree, std::vector&& actions) - : dataTree (tree) + CompoundAction (std::shared_ptr obj, std::vector&& actions) + : dataObject (std::move (obj)) , individualActions (std::move (actions)) { } bool isValid() const override { - return dataTree.object != nullptr && ! individualActions.empty(); + return dataObject != nullptr && ! individualActions.empty(); } bool perform (UndoableActionState state) override { - if (dataTree.object == nullptr) + if (dataObject == nullptr) return false; if (state == UndoableActionState::Redo) @@ -531,7 +541,7 @@ class CompoundAction : public UndoableAction } private: - DataTree dataTree; + std::shared_ptr dataObject; std::vector individualActions; }; @@ -553,30 +563,30 @@ void DataTree::DataObject::sendPropertyChangeMessage (const Identifier& property }); } -void DataTree::DataObject::sendChildAddedMessage (const DataTree& child) +void DataTree::DataObject::sendChildAddedMessage (std::shared_ptr child) { DataTree treeObj (shared_from_this()); - DataTree childTree (child.object); + DataTree childTree (child); listeners.call ([&] (DataTree::Listener& l) { l.childAdded (treeObj, childTree); }); } -void DataTree::DataObject::sendChildRemovedMessage (const DataTree& child, int formerIndex) +void DataTree::DataObject::sendChildRemovedMessage (std::shared_ptr child, int formerIndex) { DataTree treeObj (shared_from_this()); - DataTree childTree (child.object); + DataTree childTree (child); listeners.call ([&] (DataTree::Listener& l) { l.childRemoved (treeObj, childTree, formerIndex); }); } -void DataTree::DataObject::sendChildMovedMessage (const DataTree& child, int oldIndex, int newIndex) +void DataTree::DataObject::sendChildMovedMessage (std::shared_ptr child, int oldIndex, int newIndex) { DataTree treeObj (shared_from_this()); - DataTree childTree (child.object); + DataTree childTree (child); listeners.call ([&] (DataTree::Listener& l) { l.childMoved (treeObj, childTree, oldIndex, newIndex); @@ -591,8 +601,8 @@ std::shared_ptr DataTree::DataObject::clone() const // Deep clone children for (const auto& child : children) { - auto childClone = DataTree (child.object->clone()); - childClone.object->parent = newObject; + auto childClone = child->clone(); + childClone->parent = newObject; newObject->children.push_back (childClone); } @@ -658,10 +668,9 @@ DataTree& DataTree::operator= (const DataTree& other) noexcept { if (this != &other) { - object = other.object; - if (object) + if (auto oldObject = std::exchange (object, other.object)) { - object->listeners.call ([this] (Listener& l) + oldObject->listeners.call ([this] (Listener& l) { l.treeRedirected (*this); }); @@ -675,10 +684,9 @@ DataTree& DataTree::operator= (DataTree&& other) noexcept { if (this != &other) { - object = std::move (other.object); - if (object) + if (auto oldObject = std::exchange (object, other.object)) { - object->listeners.call ([this] (Listener& l) + oldObject->listeners.call ([this] (Listener& l) { l.treeRedirected (*this); }); @@ -759,11 +767,11 @@ void DataTree::setProperty (const Identifier& name, const var& newValue, UndoMan if (undoManager != nullptr) { - undoManager->perform (new PropertySetAction (*this, name, newValue, object->properties[name])); + undoManager->perform (new PropertySetAction (object, name, newValue, object->properties[name])); } else { - PropertySetAction (*this, name, newValue, object->properties[name]).perform (UndoableActionState::Redo); + PropertySetAction (object, name, newValue, object->properties[name]).perform (UndoableActionState::Redo); } } @@ -774,11 +782,11 @@ void DataTree::removeProperty (const Identifier& name, UndoManager* undoManager) if (undoManager != nullptr) { - undoManager->perform (new PropertyRemoveAction (*this, name, object->properties[name])); + undoManager->perform (new PropertyRemoveAction (object, name, object->properties[name])); } else { - PropertyRemoveAction (*this, name, object->properties[name]).perform (UndoableActionState::Redo); + PropertyRemoveAction (object, name, object->properties[name]).perform (UndoableActionState::Redo); } } @@ -789,11 +797,11 @@ void DataTree::removeAllProperties (UndoManager* undoManager) if (undoManager != nullptr) { - undoManager->perform (new RemoveAllPropertiesAction (*this, object->properties)); + undoManager->perform (new RemoveAllPropertiesAction (object, object->properties)); } else { - RemoveAllPropertiesAction (*this, object->properties).perform (UndoableActionState::Redo); + RemoveAllPropertiesAction (object, object->properties).perform (UndoableActionState::Redo); } } @@ -811,11 +819,11 @@ void DataTree::addChild (const DataTree& child, int index, UndoManager* undoMana if (undoManager != nullptr) { - undoManager->perform (new AddChildAction (*this, child, index)); + undoManager->perform (new AddChildAction (object, child.object, index)); } else { - AddChildAction (*this, child, index).perform (UndoableActionState::Redo); + AddChildAction (object, child.object, index).perform (UndoableActionState::Redo); } } @@ -826,11 +834,11 @@ void DataTree::removeChild (const DataTree& child, UndoManager* undoManager) if (undoManager != nullptr) { - undoManager->perform (new RemoveChildAction (*this, child, -1)); + undoManager->perform (new RemoveChildAction (object, child.object, -1)); } else { - RemoveChildAction (*this, child, -1).perform (UndoableActionState::Redo); + RemoveChildAction (object, child.object, -1).perform (UndoableActionState::Redo); } } @@ -841,11 +849,11 @@ void DataTree::removeChild (int index, UndoManager* undoManager) if (undoManager != nullptr) { - undoManager->perform (new RemoveChildAction (*this, {}, index)); + undoManager->perform (new RemoveChildAction (object, nullptr, index)); } else { - RemoveChildAction (*this, {}, index).perform (UndoableActionState::Redo); + RemoveChildAction (object, nullptr, index).perform (UndoableActionState::Redo); } } @@ -856,11 +864,11 @@ void DataTree::removeAllChildren (UndoManager* undoManager) if (undoManager != nullptr) { - undoManager->perform (new RemoveAllChildrenAction (*this, object->children)); + undoManager->perform (new RemoveAllChildrenAction (object, object->children)); } else { - RemoveAllChildrenAction (*this, object->children).perform (UndoableActionState::Redo); + RemoveAllChildrenAction (object, object->children).perform (UndoableActionState::Redo); } } @@ -875,11 +883,11 @@ void DataTree::moveChild (int currentIndex, int newIndex, UndoManager* undoManag if (undoManager != nullptr) { - undoManager->perform (new MoveChildAction (*this, currentIndex, newIndex)); + undoManager->perform (new MoveChildAction (object, currentIndex, newIndex)); } else { - MoveChildAction (*this, currentIndex, newIndex).perform (UndoableActionState::Redo); + MoveChildAction (object, currentIndex, newIndex).perform (UndoableActionState::Redo); } } @@ -895,7 +903,7 @@ DataTree DataTree::getChild (int index) const noexcept if (! object || index < 0 || index >= static_cast (object->children.size())) return {}; - return object->children[static_cast (index)]; + return DataTree (object->children[static_cast (index)]); } DataTree DataTree::getChildWithName (const Identifier& type) const noexcept @@ -905,8 +913,8 @@ DataTree DataTree::getChildWithName (const Identifier& type) const noexcept for (const auto& child : object->children) { - if (child.getType() == type) - return child; + if (child->type == type) + return DataTree (child); } return {}; @@ -919,7 +927,7 @@ int DataTree::indexOf (const DataTree& child) const noexcept for (size_t i = 0; i < object->children.size(); ++i) { - if (object->children[i].object == child.object) + if (object->children[i] == child.object) return static_cast (i); } @@ -1013,7 +1021,7 @@ std::unique_ptr DataTree::createXml() const // Add children as child elements for (const auto& child : object->children) { - if (auto childXml = child.createXml()) + if (auto childXml = DataTree (child).createXml()) element->addChildElement (childXml.release()); } @@ -1070,7 +1078,7 @@ void DataTree::writeToBinaryStream (OutputStream& output) const // Write children output.writeCompressedInt (static_cast (object->children.size())); for (const auto& child : object->children) - child.writeToBinaryStream (output); + DataTree (child).writeToBinaryStream (output); } DataTree DataTree::readFromBinaryStream (InputStream& input) @@ -1126,7 +1134,7 @@ var DataTree::createJson() const Array childrenArray; for (const auto& child : object->children) { - var childJson = child.createJson(); + var childJson = DataTree (child).createJson(); if (! childJson.isUndefined()) childrenArray.add (childJson); } @@ -1250,7 +1258,7 @@ bool DataTree::isEquivalentTo (const DataTree& other) const for (size_t i = 0; i < object->children.size(); ++i) { - if (! object->children[i].isEquivalentTo (other.object->children[i])) + if (! DataTree (object->children[i]).isEquivalentTo (DataTree (other.object->children[i]))) return false; } @@ -1292,11 +1300,11 @@ struct DataTree::Transaction::ChildChange //============================================================================== -DataTree::Transaction::Transaction (DataTree& tree, UndoManager* manager) - : dataTree (tree) +DataTree::Transaction::Transaction (std::shared_ptr object, UndoManager* manager) + : dataObject (std::move (object)) , undoManager (manager) { - if (dataTree.object == nullptr) + if (dataObject == nullptr) { active = false; return; @@ -1304,7 +1312,7 @@ DataTree::Transaction::Transaction (DataTree& tree, UndoManager* manager) } DataTree::Transaction::Transaction (Transaction&& other) noexcept - : dataTree (other.dataTree) + : dataObject (std::move (other.dataObject)) , undoManager (other.undoManager) , active (std::exchange (other.active, false)) , propertyChanges (std::move (other.propertyChanges)) @@ -1320,7 +1328,7 @@ DataTree::Transaction& DataTree::Transaction::operator= (Transaction&& other) no if (active) commit(); - dataTree = other.dataTree; + dataObject = std::move (other.dataObject); undoManager = other.undoManager; active = std::exchange (other.active, false); propertyChanges = std::move (other.propertyChanges); @@ -1338,7 +1346,7 @@ DataTree::Transaction::~Transaction() void DataTree::Transaction::commit() { - if (! active || dataTree.object == nullptr) + if (! active || dataObject == nullptr) return; // Always build individual actions and execute them @@ -1351,19 +1359,19 @@ void DataTree::Transaction::commit() { case PropertyChange::Set: { - actions.push_back (new PropertySetAction (dataTree, change.name, change.newValue, change.oldValue)); + actions.push_back (new PropertySetAction (dataObject, change.name, change.newValue, change.oldValue)); break; } case PropertyChange::Remove: { - actions.push_back (new PropertyRemoveAction (dataTree, change.name, change.oldValue)); + actions.push_back (new PropertyRemoveAction (dataObject, change.name, change.oldValue)); break; } case PropertyChange::RemoveAll: { - actions.push_back (new RemoveAllPropertiesAction (dataTree, dataTree.object->properties)); + actions.push_back (new RemoveAllPropertiesAction (dataObject, dataObject->properties)); break; } } @@ -1376,25 +1384,25 @@ void DataTree::Transaction::commit() { case ChildChange::Add: { - actions.push_back (new AddChildAction (dataTree, change.child, change.newIndex)); + actions.push_back (new AddChildAction (dataObject, change.child.object, change.newIndex)); break; } case ChildChange::Remove: { - actions.push_back (new RemoveChildAction (dataTree, change.child, change.oldIndex)); + actions.push_back (new RemoveChildAction (dataObject, change.child.object, change.oldIndex)); break; } case ChildChange::RemoveAll: { - actions.push_back (new RemoveAllChildrenAction (dataTree, dataTree.object->children)); + actions.push_back (new RemoveAllChildrenAction (dataObject, dataObject->children)); break; } case ChildChange::Move: { - actions.push_back (new MoveChildAction (dataTree, change.oldIndex, change.newIndex)); + actions.push_back (new MoveChildAction (dataObject, change.oldIndex, change.newIndex)); break; } } @@ -1403,7 +1411,7 @@ void DataTree::Transaction::commit() // If we have undo manager, use compound action for undo/redo if (undoManager != nullptr && ! actions.empty()) { - undoManager->perform (new CompoundAction (dataTree, std::move (actions))); + undoManager->perform (new CompoundAction (dataObject, std::move (actions))); } else { @@ -1427,7 +1435,7 @@ void DataTree::Transaction::abort() void DataTree::Transaction::setProperty (const Identifier& name, const var& newValue) { - if (! active || dataTree.object == nullptr) + if (! active || dataObject == nullptr) return; // Check if we already have a change for this property @@ -1441,7 +1449,9 @@ void DataTree::Transaction::setProperty (const Identifier& name, const var& newV } // Get current value for undo purposes - var oldValue = dataTree.getProperty (name); + var oldValue; + if (auto* value = dataObject->properties.getVarPointer (name)) + oldValue = *value; // Skip if no change if (oldValue == newValue) @@ -1458,26 +1468,26 @@ void DataTree::Transaction::setProperty (const Identifier& name, const var& newV void DataTree::Transaction::removeProperty (const Identifier& name) { - if (! active || dataTree.object == nullptr) + if (! active || dataObject == nullptr) return; - if (! dataTree.hasProperty (name)) + if (! dataObject->properties.contains (name)) return; // Record the change PropertyChange change; change.type = PropertyChange::Remove; change.name = name; - change.oldValue = dataTree.getProperty (name); + change.oldValue = dataObject->properties[name]; propertyChanges.push_back (change); } void DataTree::Transaction::removeAllProperties() { - if (! active || dataTree.object == nullptr) + if (! active || dataObject == nullptr) return; - if (dataTree.getNumProperties() == 0) + if (dataObject->properties.size() == 0) return; // Record the change @@ -1488,16 +1498,16 @@ void DataTree::Transaction::removeAllProperties() void DataTree::Transaction::addChild (const DataTree& child, int index) { - if (! active || dataTree.object == nullptr || child.object == nullptr) + if (! active || dataObject == nullptr || child.object == nullptr) return; // Don't add invalid or self-referencing children - // Also prevent circular references: don't add X to Y if Y is a descendant of X - if (child.isAChildOf (dataTree) || child == dataTree || dataTree.isAChildOf (child)) + DataTree thisTree (dataObject); + if (child.isAChildOf (thisTree) || child.object == dataObject || thisTree.isAChildOf (child)) return; // Calculate effective number of children including pending additions - int effectiveNumChildren = dataTree.getNumChildren(); + int effectiveNumChildren = static_cast (dataObject->children.size()); for (const auto& change : childChanges) { if (change.type == ChildChange::Add) @@ -1521,7 +1531,7 @@ void DataTree::Transaction::addChild (const DataTree& child, int index) void DataTree::Transaction::removeChild (const DataTree& child) { - if (! active || dataTree.object == nullptr) + if (! active || dataObject == nullptr) return; ChildChange change; @@ -1534,7 +1544,7 @@ void DataTree::Transaction::removeChild (const DataTree& child) void DataTree::Transaction::removeChild (int index) { - if (! active || dataTree.object == nullptr) + if (! active || dataObject == nullptr) return; ChildChange change; @@ -1547,10 +1557,10 @@ void DataTree::Transaction::removeChild (int index) void DataTree::Transaction::removeAllChildren() { - if (! active || dataTree.object == nullptr) + if (! active || dataObject == nullptr) return; - if (dataTree.getNumChildren() == 0) + if (dataObject->children.size() == 0) return; // Record the change @@ -1561,7 +1571,7 @@ void DataTree::Transaction::removeAllChildren() void DataTree::Transaction::moveChild (int currentIndex, int newIndex) { - if (! active || dataTree.object == nullptr || currentIndex == newIndex) + if (! active || dataObject == nullptr || currentIndex == newIndex) return; // Simply record the move operation as specified by the user @@ -1575,10 +1585,10 @@ void DataTree::Transaction::moveChild (int currentIndex, int newIndex) int DataTree::Transaction::getEffectiveChildCount() const { - if (dataTree.object == nullptr) + if (dataObject == nullptr) return 0; - int count = dataTree.getNumChildren(); + int count = static_cast (dataObject->children.size()); for (const auto& change : childChanges) { @@ -1607,10 +1617,10 @@ int DataTree::Transaction::getEffectiveChildCount() const //============================================================================== -DataTree::ValidatedTransaction::ValidatedTransaction (DataTree& tree, ReferenceCountedObjectPtr schema, UndoManager* undoManager) - : transaction (std::make_unique (tree.beginTransaction (undoManager))) +DataTree::ValidatedTransaction::ValidatedTransaction (std::shared_ptr object, ReferenceCountedObjectPtr schema, UndoManager* undoManager) + : transaction (std::make_unique (DataTree (object).beginTransaction (undoManager))) , schema (std::move (schema)) - , nodeType (tree.getType()) + , nodeType (DataTree (object).getType()) { } diff --git a/modules/yup_data_model/tree/yup_DataTree.h b/modules/yup_data_model/tree/yup_DataTree.h index 1ec16a8a3..71fb52c9e 100644 --- a/modules/yup_data_model/tree/yup_DataTree.h +++ b/modules/yup_data_model/tree/yup_DataTree.h @@ -24,8 +24,84 @@ namespace yup //============================================================================== // Forward declarations +class DataTree; class DataTreeSchema; +//============================================================================== +/** + Base class for objects that want to receive notifications about DataTree changes. + + Listeners are automatically removed when the DataTree is destroyed, but should + be explicitly removed if the listener is destroyed first to avoid dangling pointers. + + @code + class MyListener : public DataTree::Listener + { + public: + void propertyChanged (DataTree& tree, const Identifier& property) override + { + std::cout << "Property " << property.toString() << " changed" << std::endl; + } + + void childAdded (DataTree& parent, DataTree& child) override + { + std::cout << "Child of type " << child.getType().toString() << " added" << std::endl; + } + }; + @endcode + + @see addListener(), removeListener() +*/ +class YUP_API DataTreeListener +{ +public: + virtual ~DataTreeListener() = default; + + /** + Called after a property has been changed via a transaction. + + @param tree The DataTree that was modified + @param property The identifier of the property that changed + */ + virtual void propertyChanged (DataTree& tree, const Identifier& property) {} + + /** + Called after a child has been added via a transaction. + + @param parent The DataTree that received the new child + @param child The child DataTree that was added + */ + virtual void childAdded (DataTree& parent, DataTree& child) {} + + /** + Called after a child has been removed via a transaction. + + @param parent The DataTree that lost the child + @param child The child DataTree that was removed + @param formerIndex The index where the child used to be + */ + virtual void childRemoved (DataTree& parent, DataTree& child, int formerIndex) {} + + /** + Called after a child has been moved to a different index via a transaction. + + @param parent The DataTree containing the moved child + @param child The child DataTree that was moved + @param oldIndex The previous index of the child + @param newIndex The new index of the child + */ + virtual void childMoved (DataTree& parent, DataTree& child, int oldIndex, int newIndex) {} + + /** + Called when the internal tree structure has been completely replaced. + + This is a rare event that occurs during certain internal operations. + + @param tree The DataTree whose structure was replaced + */ + virtual void treeRedirected (DataTree& tree) {} +}; + //============================================================================== /** A hierarchical data structure for storing properties and child nodes with transactional support. @@ -89,6 +165,34 @@ class DataTreeSchema; */ class YUP_API DataTree { + class DataObject : public std::enable_shared_from_this + { + public: + //============================================================================== + Identifier type; + NamedValueSet properties; + std::vector> children; + std::weak_ptr parent; + ListenerList listeners; + + //============================================================================== + DataObject() = default; + explicit DataObject (const Identifier& treeType); + ~DataObject(); + + //============================================================================== + void sendPropertyChangeMessage (const Identifier& property); + void sendChildAddedMessage (std::shared_ptr child); + void sendChildRemovedMessage (std::shared_ptr child, int formerIndex); + void sendChildMovedMessage (std::shared_ptr child, int oldIndex, int newIndex); + + //============================================================================== + std::shared_ptr clone() const; + + private: + YUP_DECLARE_NON_COPYABLE (DataObject) + }; + public: //============================================================================== /** @@ -666,77 +770,8 @@ class YUP_API DataTree //============================================================================== /** Base class for objects that want to receive notifications about DataTree changes. - - Listeners are automatically removed when the DataTree is destroyed, but should - be explicitly removed if the listener is destroyed first to avoid dangling pointers. - - @code - class MyListener : public DataTree::Listener - { - public: - void propertyChanged (DataTree& tree, const Identifier& property) override - { - std::cout << "Property " << property.toString() << " changed" << std::endl; - } - - void childAdded (DataTree& parent, DataTree& child) override - { - std::cout << "Child of type " << child.getType().toString() << " added" << std::endl; - } - }; - @endcode - - @see addListener(), removeListener() */ - class YUP_API Listener - { - public: - virtual ~Listener() = default; - - /** - Called after a property has been changed via a transaction. - - @param tree The DataTree that was modified - @param property The identifier of the property that changed - */ - virtual void propertyChanged (DataTree& tree, const Identifier& property) {} - - /** - Called after a child has been added via a transaction. - - @param parent The DataTree that received the new child - @param child The child DataTree that was added - */ - virtual void childAdded (DataTree& parent, DataTree& child) {} - - /** - Called after a child has been removed via a transaction. - - @param parent The DataTree that lost the child - @param child The child DataTree that was removed - @param formerIndex The index where the child used to be - */ - virtual void childRemoved (DataTree& parent, DataTree& child, int formerIndex) {} - - /** - Called after a child has been moved to a different index via a transaction. - - @param parent The DataTree containing the moved child - @param child The child DataTree that was moved - @param oldIndex The previous index of the child - @param newIndex The new index of the child - */ - virtual void childMoved (DataTree& parent, DataTree& child, int oldIndex, int newIndex) {} - - /** - Called when the internal tree structure has been completely replaced. - - This is a rare event that occurs during certain internal operations. - - @param tree The DataTree whose structure was replaced - */ - virtual void treeRedirected (DataTree& tree) {} - }; + using Listener = DataTreeListener; /** Adds a listener to receive notifications about changes to this DataTree. @@ -851,18 +886,6 @@ class YUP_API DataTree class YUP_API Transaction { public: - /** - Constructs a transaction for the specified DataTree. - - This constructor is typically called indirectly via beginTransaction(). - - @param tree The DataTree to operate on - @param undoManager Optional UndoManager for undo/redo support - - @see DataTree::beginTransaction() - */ - Transaction (DataTree& tree, UndoManager* undoManager = nullptr); - /** Move constructor - transfers ownership of the transaction. @@ -976,12 +999,25 @@ class YUP_API DataTree int getEffectiveChildCount() const; private: + friend class DataTree; friend class TransactionAction; struct PropertyChange; struct ChildChange; - DataTree& dataTree; + /** + Constructs a transaction for the specified DataTree. + + This constructor is typically called indirectly via beginTransaction(). + + @param dataObject The DataObject to operate on + @param undoManager Optional UndoManager for undo/redo support + + @see DataTree::beginTransaction() + */ + Transaction (std::shared_ptr dataObject, UndoManager* undoManager = nullptr); + + std::shared_ptr dataObject; UndoManager* undoManager; std::vector propertyChanges; std::vector childChanges; @@ -1013,13 +1049,6 @@ class YUP_API DataTree class YUP_API ValidatedTransaction { public: - /** - Creates a validated transaction for the specified DataTree. - */ - ValidatedTransaction (DataTree& tree, - ReferenceCountedObjectPtr schema, - UndoManager* undoManager = nullptr); - /** Move constructor - transfers ownership of the transaction. */ @@ -1119,6 +1148,15 @@ class YUP_API DataTree Transaction& getTransaction(); private: + friend class DataTree; + + /** + Creates a validated transaction for the specified DataTree. + */ + ValidatedTransaction (std::shared_ptr dataObject, + ReferenceCountedObjectPtr schema, + UndoManager* undoManager = nullptr); + std::unique_ptr transaction; ReferenceCountedObjectPtr schema; Identifier nodeType; @@ -1157,7 +1195,7 @@ class YUP_API DataTree */ Transaction beginTransaction (UndoManager* undoManager = nullptr) { - return Transaction (*this, undoManager); + return Transaction (object, undoManager); } /** @@ -1184,7 +1222,7 @@ class YUP_API DataTree ValidatedTransaction beginValidatedTransaction (ReferenceCountedObjectPtr schema, UndoManager* undoManager = nullptr) { - return ValidatedTransaction (*this, schema, undoManager); + return ValidatedTransaction (object, schema, undoManager); } private: @@ -1198,34 +1236,6 @@ class YUP_API DataTree friend class MoveChildAction; friend class CompoundAction; - class DataObject : public std::enable_shared_from_this - { - public: - //============================================================================== - Identifier type; - NamedValueSet properties; - std::vector children; - std::weak_ptr parent; - ListenerList listeners; - - //============================================================================== - DataObject() = default; - explicit DataObject (const Identifier& treeType); - ~DataObject(); - - //============================================================================== - void sendPropertyChangeMessage (const Identifier& property); - void sendChildAddedMessage (const DataTree& child); - void sendChildRemovedMessage (const DataTree& child, int formerIndex); - void sendChildMovedMessage (const DataTree& child, int oldIndex, int newIndex); - - //============================================================================== - std::shared_ptr clone() const; - - private: - YUP_DECLARE_NON_COPYABLE (DataObject) - }; - explicit DataTree (std::shared_ptr objectToUse); void sendPropertyChangeMessage (const Identifier& property) const; void sendChildAddedMessage (const DataTree& child) const; diff --git a/tests/yup_data_model/yup_DataTree.cpp b/tests/yup_data_model/yup_DataTree.cpp index 344e7f172..1c3085fac 100644 --- a/tests/yup_data_model/yup_DataTree.cpp +++ b/tests/yup_data_model/yup_DataTree.cpp @@ -43,7 +43,6 @@ class DataTreeTests : public ::testing::Test void TearDown() override { - tree = DataTree(); } DataTree tree; @@ -3515,9 +3514,9 @@ TEST_F (DataTreeTests, TransactionMoveAssignment) EXPECT_TRUE (tree2.hasProperty ("prop3")); EXPECT_EQ (var (3), tree2.getProperty ("prop3")); - // Verify tree still has its original property (uncommitted changes from transaction1 were discarded) + // Verify tree should have the new committed property EXPECT_TRUE (tree.hasProperty ("prop1")); - EXPECT_EQ (var (1), tree.getProperty ("prop1")); // Should still be 1, not 100 + EXPECT_EQ (var (100), tree.getProperty ("prop1")); } TEST_F (DataTreeTests, TransactionMoveAssignmentSelfAssignment) From 8aeed3a136ed6b6637ecf7b96b86e590545d0cd5 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 12 Dec 2025 11:00:59 +0100 Subject: [PATCH 3/9] Restore test --- tests/yup_data_model/yup_DataTree.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/yup_data_model/yup_DataTree.cpp b/tests/yup_data_model/yup_DataTree.cpp index 1c3085fac..72a6f40c7 100644 --- a/tests/yup_data_model/yup_DataTree.cpp +++ b/tests/yup_data_model/yup_DataTree.cpp @@ -43,6 +43,7 @@ class DataTreeTests : public ::testing::Test void TearDown() override { + tree = DataTree(); } DataTree tree; @@ -663,6 +664,8 @@ TEST_F (DataTreeTests, ChildChangeNotifications) EXPECT_EQ (tree, listener.childRemovals[0].parent); EXPECT_EQ (child, listener.childRemovals[0].child); EXPECT_EQ (0, listener.childRemovals[0].index); + + tree.removeListener (&listener); } //============================================================================== From 7ac33da1e18eb2edbe8002e337173be6c24b051e Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 12 Dec 2025 11:34:39 +0100 Subject: [PATCH 4/9] Added data model python bindings --- .../bindings/yup_YupDataModel_bindings.cpp | 256 +++++++++++++++ .../bindings/yup_YupDataModel_bindings.h | 86 +++++ .../yup_python/modules/yup_YupMain_module.cpp | 4 - modules/yup_python/yup_python_data_model.cpp | 22 ++ python/tests/test_yup_data_model/__init__.py | 1 + .../test_yup_data_model/test_DataTree.py | 293 +++++++++++++++++ .../test_yup_data_model/test_Integration.py | 300 ++++++++++++++++++ .../test_yup_data_model/test_UndoManager.py | 252 +++++++++++++++ 8 files changed, 1210 insertions(+), 4 deletions(-) create mode 100644 modules/yup_python/bindings/yup_YupDataModel_bindings.cpp create mode 100644 modules/yup_python/bindings/yup_YupDataModel_bindings.h create mode 100644 modules/yup_python/yup_python_data_model.cpp create mode 100644 python/tests/test_yup_data_model/__init__.py create mode 100644 python/tests/test_yup_data_model/test_DataTree.py create mode 100644 python/tests/test_yup_data_model/test_Integration.py create mode 100644 python/tests/test_yup_data_model/test_UndoManager.py diff --git a/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp b/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp new file mode 100644 index 000000000..91e63eb82 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp @@ -0,0 +1,256 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_YupDataModel_bindings.h" + +#define YUP_PYTHON_INCLUDE_PYBIND11_OPERATORS +#define YUP_PYTHON_INCLUDE_PYBIND11_FUNCTIONAL +#include "../utilities/yup_PyBind11Includes.h" + +//============================================================================== + +namespace yup::Bindings +{ + +namespace py = pybind11; +using namespace py::literals; + +void registerYupDataModelBindings (py::module_& m) +{ + // clang-format off + + // ============================================================================================ yup::UndoableActionState + + py::enum_ (m, "UndoableActionState") + .value ("Undo", UndoableActionState::Undo) + .value ("Redo", UndoableActionState::Redo) + .export_values(); + + // ============================================================================================ yup::UndoableAction + + py::class_> classUndoableAction (m, "UndoableAction"); + + classUndoableAction + .def (py::init<>()) + .def ("isValid", &UndoableAction::isValid) + .def ("perform", &UndoableAction::perform); + + // ============================================================================================ yup::UndoManager + + py::class_> classUndoManager (m, "UndoManager"); + + py::class_ classUndoManagerScopedTransaction (classUndoManager, "ScopedTransaction"); + + classUndoManagerScopedTransaction + .def (py::init()) + .def (py::init()); + + classUndoManager + .def (py::init<>()) + .def (py::init()) + .def (py::init()) + .def (py::init()) + .def ("perform", [](UndoManager& self, UndoableAction::Ptr action) { return self.perform (std::move (action)); }) + .def ("beginNewTransaction", py::overload_cast<> (&UndoManager::beginNewTransaction)) + .def ("beginNewTransaction", py::overload_cast (&UndoManager::beginNewTransaction)) + .def ("getNumTransactions", &UndoManager::getNumTransactions) + .def ("getTransactionName", &UndoManager::getTransactionName) + .def ("getCurrentTransactionName", &UndoManager::getCurrentTransactionName) + .def ("setCurrentTransactionName", &UndoManager::setCurrentTransactionName) + .def ("canUndo", &UndoManager::canUndo) + .def ("undo", &UndoManager::undo) + .def ("canRedo", &UndoManager::canRedo) + .def ("redo", &UndoManager::redo) + .def ("clear", &UndoManager::clear) + .def ("setEnabled", &UndoManager::setEnabled) + .def ("isEnabled", &UndoManager::isEnabled); + + // ============================================================================================ yup::DataTreeListener + + py::class_ classDataTreeListener (m, "DataTreeListener"); + + classDataTreeListener + .def (py::init<>()) + .def ("propertyChanged", &DataTreeListener::propertyChanged) + .def ("childAdded", &DataTreeListener::childAdded) + .def ("childRemoved", &DataTreeListener::childRemoved) + .def ("childMoved", &DataTreeListener::childMoved) + .def ("treeRedirected", &DataTreeListener::treeRedirected); + + // ============================================================================================ yup::DataTree + + py::class_ classDataTree (m, "DataTree"); + + py::class_ classDataTreeIterator (classDataTree, "Iterator"); + + classDataTreeIterator + .def (py::init<>()) + .def ("__iter__", [] (DataTree::Iterator& self) { return self; }) + .def ("__next__", [] (DataTree::Iterator& self) + { + // We need to manually implement the iteration logic + // This is a simplified version - a proper implementation would track the end + auto value = *self; + ++self; + return value; + }); + + py::class_ classDataTreeTransaction (classDataTree, "Transaction"); + + classDataTreeTransaction + .def ("commit", &DataTree::Transaction::commit) + .def ("abort", &DataTree::Transaction::abort) + .def ("isActive", &DataTree::Transaction::isActive) + .def ("setProperty", &DataTree::Transaction::setProperty) + .def ("removeProperty", &DataTree::Transaction::removeProperty) + .def ("removeAllProperties", &DataTree::Transaction::removeAllProperties) + .def ("addChild", &DataTree::Transaction::addChild, "child"_a, "index"_a = -1) + .def ("removeChild", py::overload_cast (&DataTree::Transaction::removeChild)) + .def ("removeChild", py::overload_cast (&DataTree::Transaction::removeChild)) + .def ("removeAllChildren", &DataTree::Transaction::removeAllChildren) + .def ("moveChild", &DataTree::Transaction::moveChild) + .def ("getEffectiveChildCount", &DataTree::Transaction::getEffectiveChildCount); + + py::class_ classDataTreeValidatedTransaction (classDataTree, "ValidatedTransaction"); + + classDataTreeValidatedTransaction + .def ("setProperty", &DataTree::ValidatedTransaction::setProperty) + .def ("removeProperty", &DataTree::ValidatedTransaction::removeProperty) + .def ("addChild", &DataTree::ValidatedTransaction::addChild, "child"_a, "index"_a = -1) + .def ("createAndAddChild", &DataTree::ValidatedTransaction::createAndAddChild, "childType"_a, "index"_a = -1) + .def ("removeChild", &DataTree::ValidatedTransaction::removeChild) + .def ("commit", &DataTree::ValidatedTransaction::commit) + .def ("abort", &DataTree::ValidatedTransaction::abort) + .def ("isActive", &DataTree::ValidatedTransaction::isActive) + .def ("getTransaction", &DataTree::ValidatedTransaction::getTransaction, py::return_value_policy::reference); + + classDataTree + .def (py::init<>()) + .def (py::init()) + .def (py::init>&>()) + .def (py::init&>()) + .def (py::init>&, const std::initializer_list&>()) + .def (py::init()) + .def ("isValid", &DataTree::isValid) + .def ("__bool__", &DataTree::isValid) + .def ("getType", &DataTree::getType) + .def ("clone", &DataTree::clone) + .def ("getNumProperties", &DataTree::getNumProperties) + .def ("getPropertyName", &DataTree::getPropertyName) + .def ("hasProperty", &DataTree::hasProperty) + .def ("getProperty", &DataTree::getProperty, "name"_a, "defaultValue"_a = var()) + .def ("getNumChildren", &DataTree::getNumChildren) + .def ("getChild", &DataTree::getChild) + .def ("getChildWithName", &DataTree::getChildWithName) + .def ("indexOf", &DataTree::indexOf) + .def ("getParent", &DataTree::getParent) + .def ("getRoot", &DataTree::getRoot) + .def ("isAChildOf", &DataTree::isAChildOf) + .def ("getDepth", &DataTree::getDepth) + .def ("__iter__", [] (const DataTree& self) + { + return py::make_iterator (self.begin(), self.end()); + }, py::keep_alive<0, 1>()) + .def ("forEachChild", [] (const DataTree& self, py::function callback) + { + self.forEachChild ([&callback] (const DataTree& child) + { + py::gil_scoped_acquire acquire; + auto result = callback (child); + if (py::isinstance (result)) + return result.cast(); + return false; + }); + }) + .def ("forEachDescendant", [] (const DataTree& self, py::function callback) + { + self.forEachDescendant ([&callback] (const DataTree& child) + { + py::gil_scoped_acquire acquire; + auto result = callback (child); + if (py::isinstance (result)) + return result.cast(); + return false; + }); + }) + .def ("findChildren", [] (const DataTree& self, py::function predicate) + { + std::vector results; + self.findChildren (results, [&predicate] (const DataTree& child) + { + py::gil_scoped_acquire acquire; + return predicate (child).cast(); + }); + return results; + }) + .def ("findChild", [] (const DataTree& self, py::function predicate) + { + return self.findChild ([&predicate] (const DataTree& child) + { + py::gil_scoped_acquire acquire; + return predicate (child).cast(); + }); + }) + .def ("findDescendants", [] (const DataTree& self, py::function predicate) + { + std::vector results; + self.findDescendants (results, [&predicate] (const DataTree& child) + { + py::gil_scoped_acquire acquire; + return predicate (child).cast(); + }); + return results; + }) + .def ("findDescendant", [] (const DataTree& self, py::function predicate) + { + return self.findDescendant ([&predicate] (const DataTree& child) + { + py::gil_scoped_acquire acquire; + return predicate (child).cast(); + }); + }) + .def ("createXml", &DataTree::createXml) + .def_static ("fromXml", py::overload_cast (&DataTree::fromXml)) + .def ("writeToBinaryStream", &DataTree::writeToBinaryStream) + .def_static ("readFromBinaryStream", &DataTree::readFromBinaryStream) + .def ("createJson", &DataTree::createJson) + .def_static ("fromJson", &DataTree::fromJson) + .def ("addListener", &DataTree::addListener, py::keep_alive<1, 2>()) + .def ("removeListener", &DataTree::removeListener) + .def ("removeAllListeners", &DataTree::removeAllListeners) + .def (py::self == py::self) + .def (py::self != py::self) + .def ("isEquivalentTo", &DataTree::isEquivalentTo) + .def ("beginTransaction", py::overload_cast (&DataTree::beginTransaction), "undoManager"_a = nullptr) + .def ("__repr__", [] (const DataTree& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (DataTree).name(), 1) + << " object at " << String::formatted ("%p", std::addressof (self)) + << " type=\"" << self.getType().toString() << "\">"; + return result; + }); + + // clang-format on +} + +} // namespace yup::Bindings diff --git a/modules/yup_python/bindings/yup_YupDataModel_bindings.h b/modules/yup_python/bindings/yup_YupDataModel_bindings.h new file mode 100644 index 000000000..69acfe34e --- /dev/null +++ b/modules/yup_python/bindings/yup_YupDataModel_bindings.h @@ -0,0 +1,86 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#if ! YUP_MODULE_AVAILABLE_yup_data_model +#error This binding file requires adding the yup_data_model module in the project +#else +#include +#endif + +#include "yup_YupEvents_bindings.h" + +#include "../utilities/yup_PyBind11Includes.h" + +namespace yup::Bindings +{ + +//============================================================================== + +void registerYupDataModelBindings (pybind11::module_& m); + +//============================================================================== + +struct PyDataTreeListener : public yup::DataTreeListener +{ + void propertyChanged (yup::DataTree& tree, const yup::Identifier& property) override + { + PYBIND11_OVERRIDE (void, yup::DataTreeListener, propertyChanged, tree, property); + } + + void childAdded (yup::DataTree& parent, yup::DataTree& child) override + { + PYBIND11_OVERRIDE (void, yup::DataTreeListener, childAdded, parent, child); + } + + void childRemoved (yup::DataTree& parent, yup::DataTree& child, int formerIndex) override + { + PYBIND11_OVERRIDE (void, yup::DataTreeListener, childRemoved, parent, child, formerIndex); + } + + void childMoved (yup::DataTree& parent, yup::DataTree& child, int oldIndex, int newIndex) override + { + PYBIND11_OVERRIDE (void, yup::DataTreeListener, childMoved, parent, child, oldIndex, newIndex); + } + + void treeRedirected (yup::DataTree& tree) override + { + PYBIND11_OVERRIDE (void, yup::DataTreeListener, treeRedirected, tree); + } +}; + +//============================================================================== + +struct PyUndoableAction : public yup::UndoableAction +{ + bool isValid() const override + { + PYBIND11_OVERRIDE_PURE (bool, yup::UndoableAction, isValid); + } + + bool perform (yup::UndoableActionState stateToPerform) override + { + PYBIND11_OVERRIDE_PURE (bool, yup::UndoableAction, perform, stateToPerform); + } +}; + +} // namespace yup::Bindings diff --git a/modules/yup_python/modules/yup_YupMain_module.cpp b/modules/yup_python/modules/yup_YupMain_module.cpp index b8863ff18..d448290e1 100644 --- a/modules/yup_python/modules/yup_YupMain_module.cpp +++ b/modules/yup_python/modules/yup_YupMain_module.cpp @@ -28,11 +28,9 @@ #include "../bindings/yup_YupEvents_bindings.h" #endif -/* #if YUP_MODULE_AVAILABLE_yup_data_model #include "../bindings/yup_YupDataModel_bindings.h" #endif -*/ #if YUP_MODULE_AVAILABLE_yup_graphics #include "../bindings/yup_YupGraphics_bindings.h" @@ -82,11 +80,9 @@ PYBIND11_MODULE (YUP_PYTHON_MODULE_NAME, m) yup::Bindings::registerYupEventsBindings (m); #endif - /* #if YUP_MODULE_AVAILABLE_yup_data_model yup::Bindings::registerYupDataModelBindings (m); #endif - */ #if YUP_MODULE_AVAILABLE_yup_graphics yup::Bindings::registerYupGraphicsBindings (m); diff --git a/modules/yup_python/yup_python_data_model.cpp b/modules/yup_python/yup_python_data_model.cpp new file mode 100644 index 000000000..0e59f13ab --- /dev/null +++ b/modules/yup_python/yup_python_data_model.cpp @@ -0,0 +1,22 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "bindings/yup_YupDataModel_bindings.cpp" diff --git a/python/tests/test_yup_data_model/__init__.py b/python/tests/test_yup_data_model/__init__.py new file mode 100644 index 000000000..799632166 --- /dev/null +++ b/python/tests/test_yup_data_model/__init__.py @@ -0,0 +1 @@ +# Test module for yup_data_model bindings diff --git a/python/tests/test_yup_data_model/test_DataTree.py b/python/tests/test_yup_data_model/test_DataTree.py new file mode 100644 index 000000000..d15154ba3 --- /dev/null +++ b/python/tests/test_yup_data_model/test_DataTree.py @@ -0,0 +1,293 @@ +import pytest + +import yup + +#================================================================================================== + +def test_DataTree_construction(): + """Test basic DataTree construction.""" + tree = yup.DataTree() + assert not tree.isValid() + + tree = yup.DataTree(yup.Identifier("Settings")) + assert tree.isValid() + assert tree.getType() == yup.Identifier("Settings") + +#================================================================================================== + +def test_DataTree_properties(): + """Test DataTree property operations.""" + tree = yup.DataTree(yup.Identifier("Config")) + + # Initially no properties + assert tree.getNumProperties() == 0 + + # Add properties via transaction + transaction = tree.beginTransaction() + transaction.setProperty(yup.Identifier("version"), "1.0") + transaction.setProperty(yup.Identifier("debug"), True) + transaction.commit() + + # Check properties + assert tree.getNumProperties() == 2 + assert tree.hasProperty(yup.Identifier("version")) + assert tree.hasProperty(yup.Identifier("debug")) + assert tree.getProperty(yup.Identifier("version")) == "1.0" + assert tree.getProperty(yup.Identifier("debug")) == True + +#================================================================================================== + +def test_DataTree_children(): + """Test DataTree child operations.""" + parent = yup.DataTree(yup.Identifier("Parent")) + child1 = yup.DataTree(yup.Identifier("Child1")) + child2 = yup.DataTree(yup.Identifier("Child2")) + + # Initially no children + assert parent.getNumChildren() == 0 + + # Add children via transaction + transaction = parent.beginTransaction() + transaction.addChild(child1) + transaction.addChild(child2) + transaction.commit() + + # Check children + assert parent.getNumChildren() == 2 + assert parent.getChild(0).getType() == yup.Identifier("Child1") + assert parent.getChild(1).getType() == yup.Identifier("Child2") + +#================================================================================================== + +def test_DataTree_transaction_auto_commit(): + """Test that transactions auto-commit when going out of scope.""" + tree = yup.DataTree(yup.Identifier("Test")) + + # Transaction auto-commits when destroyed + transaction = tree.beginTransaction() + transaction.setProperty(yup.Identifier("key"), "value") + del transaction + + assert tree.hasProperty(yup.Identifier("key")) + +#================================================================================================== + +def test_DataTree_transaction_abort(): + """Test transaction abort functionality.""" + tree = yup.DataTree(yup.Identifier("Test")) + + transaction = tree.beginTransaction() + transaction.setProperty(yup.Identifier("key"), "value") + transaction.abort() + + # Property should not be set after abort + assert not tree.hasProperty(yup.Identifier("key")) + +#================================================================================================== + +def test_DataTree_hierarchy(): + """Test DataTree hierarchy methods.""" + root = yup.DataTree(yup.Identifier("Root")) + child = yup.DataTree(yup.Identifier("Child")) + grandchild = yup.DataTree(yup.Identifier("Grandchild")) + + # Build hierarchy + transaction1 = root.beginTransaction() + transaction1.addChild(child) + transaction1.commit() + + transaction2 = root.getChild(0).beginTransaction() + transaction2.addChild(grandchild) + transaction2.commit() + + # Test hierarchy methods + actualChild = root.getChild(0) + actualGrandchild = actualChild.getChild(0) + + assert actualGrandchild.getParent() == actualChild + assert actualGrandchild.getRoot() == root + assert actualGrandchild.isAChildOf(root) + assert actualGrandchild.getDepth() == 2 + assert actualChild.getDepth() == 1 + assert root.getDepth() == 0 + +#================================================================================================== + +def test_DataTree_iteration(): + """Test DataTree iteration.""" + parent = yup.DataTree(yup.Identifier("Parent")) + + # Add several children + transaction = parent.beginTransaction() + for i in range(5): + child = yup.DataTree(yup.Identifier(f"Child{i}")) + transaction.addChild(child) + transaction.commit() + + # Test iteration + count = 0 + for child in parent: + assert child.isValid() + count += 1 + + assert count == 5 + +#================================================================================================== + +def test_DataTree_forEachChild(): + """Test forEachChild method.""" + parent = yup.DataTree(yup.Identifier("Parent")) + + transaction = parent.beginTransaction() + for i in range(3): + child = yup.DataTree(yup.Identifier(f"Child{i}")) + transaction.addChild(child) + transaction.commit() + + # Test callback + count = 0 + def callback(child): + nonlocal count + count += 1 + + parent.forEachChild(callback) + assert count == 3 + +#================================================================================================== + +def test_DataTree_findChild(): + """Test findChild with predicate.""" + parent = yup.DataTree(yup.Identifier("Parent")) + + transaction = parent.beginTransaction() + for i in range(5): + child = yup.DataTree(yup.Identifier(f"Child{i}")) + transaction.addChild(child) + transaction.commit() + + # Find specific child + found = parent.findChild(lambda c: c.getType() == yup.Identifier("Child2")) + assert found.isValid() + assert found.getType() == yup.Identifier("Child2") + +#================================================================================================== + +def test_DataTree_clone(): + """Test DataTree cloning.""" + original = yup.DataTree(yup.Identifier("Original")) + + transaction = original.beginTransaction() + transaction.setProperty(yup.Identifier("key"), "value") + child = yup.DataTree(yup.Identifier("Child")) + transaction.addChild(child) + transaction.commit() + + # Clone the tree + cloned = original.clone() + + assert cloned.isValid() + assert cloned != original # Different objects + assert cloned.isEquivalentTo(original) # But same content + assert cloned.getType() == original.getType() + assert cloned.getNumProperties() == original.getNumProperties() + assert cloned.getNumChildren() == original.getNumChildren() + +#================================================================================================== + +def test_DataTree_json_serialization(): + """Test DataTree JSON serialization.""" + tree = yup.DataTree(yup.Identifier("Settings")) + + transaction = tree.beginTransaction() + transaction.setProperty(yup.Identifier("version"), "1.0") + transaction.setProperty(yup.Identifier("enabled"), True) + transaction.commit() + + # Serialize to JSON (returns Python dict) + json_data = tree.createJson() + assert isinstance(json_data, dict) + + # Deserialize from JSON + restored = yup.DataTree.fromJson(json_data) + assert restored.isValid() + assert restored.getType() == tree.getType() + assert restored.isEquivalentTo(tree) + +#================================================================================================== + +class DataTreeListenerImpl(yup.DataTreeListener): + """Test listener implementation.""" + def __init__(self): + super().__init__() + self.property_changes = [] + self.child_additions = [] + self.child_removals = [] + + def propertyChanged(self, tree, property): + # Identifier is automatically converted to string by type caster + self.property_changes.append(property if isinstance(property, str) else property.toString()) + + def childAdded(self, _parent, child): + childType = child.getType() + self.child_additions.append(childType if isinstance(childType, str) else childType.toString()) + + def childRemoved(self, _parent, child, formerIndex): + childType = child.getType() + self.child_removals.append((childType if isinstance(childType, str) else childType.toString(), formerIndex)) + +def test_DataTree_listener(): + """Test DataTree listener notifications.""" + tree = yup.DataTree(yup.Identifier("Root")) + listener = DataTreeListenerImpl() + + tree.addListener(listener) + + # Test property change notification + transaction = tree.beginTransaction() + transaction.setProperty(yup.Identifier("prop1"), "value1") + transaction.commit() + + assert len(listener.property_changes) == 1 + assert listener.property_changes[0] == "prop1" + + # Test child addition notification + child = yup.DataTree(yup.Identifier("Child")) + transaction = tree.beginTransaction() + transaction.addChild(child) + transaction.commit() + + assert len(listener.child_additions) == 1 + assert listener.child_additions[0] == "Child" + + # Test child removal notification + transaction = tree.beginTransaction() + transaction.removeChild(0) + transaction.commit() + + assert len(listener.child_removals) == 1 + assert listener.child_removals[0][0] == "Child" + assert listener.child_removals[0][1] == 0 + + # Cleanup + tree.removeListener(listener) + +#================================================================================================== + +def test_DataTree_getChildWithName(): + """Test getChildWithName method.""" + parent = yup.DataTree(yup.Identifier("Parent")) + + transaction = parent.beginTransaction() + transaction.addChild(yup.DataTree(yup.Identifier("FirstChild"))) + transaction.addChild(yup.DataTree(yup.Identifier("SecondChild"))) + transaction.addChild(yup.DataTree(yup.Identifier("ThirdChild"))) + transaction.commit() + + # Find child by name + child = parent.getChildWithName(yup.Identifier("SecondChild")) + assert child.isValid() + assert child.getType() == yup.Identifier("SecondChild") + + # Non-existent child + nonExistent = parent.getChildWithName(yup.Identifier("NonExistent")) + assert not nonExistent.isValid() diff --git a/python/tests/test_yup_data_model/test_Integration.py b/python/tests/test_yup_data_model/test_Integration.py new file mode 100644 index 000000000..d8b0d3566 --- /dev/null +++ b/python/tests/test_yup_data_model/test_Integration.py @@ -0,0 +1,300 @@ +import pytest + +import yup + +#================================================================================================== + +def test_DataTree_UndoManager_integration(): + """Test integration between DataTree and UndoManager.""" + manager = yup.UndoManager() + tree = yup.DataTree(yup.Identifier("Root")) + + # Add properties with undo + transaction1 = tree.beginTransaction(manager) + transaction1.setProperty(yup.Identifier("name"), "MyApp") + transaction1.setProperty(yup.Identifier("version"), "1.0") + transaction1.commit() + + assert tree.getNumProperties() == 2 + + # Add children with undo + child1 = yup.DataTree(yup.Identifier("Settings")) + transaction2 = tree.beginTransaction(manager) + transaction2.addChild(child1) + transaction2.commit() + + assert tree.getNumChildren() == 1 + + # Modify child properties with undo + actualChild = tree.getChild(0) + transaction3 = actualChild.beginTransaction(manager) + transaction3.setProperty(yup.Identifier("theme"), "dark") + transaction3.commit() + + assert actualChild.hasProperty(yup.Identifier("theme")) + + # Undo child property change + manager.undo() + assert not actualChild.hasProperty(yup.Identifier("theme")) + + # Undo child addition + manager.undo() + assert tree.getNumChildren() == 0 + + # Undo property additions + manager.undo() + assert tree.getNumProperties() == 0 + + # Redo everything + manager.redo() # Properties + assert tree.getNumProperties() == 2 + + manager.redo() # Child + assert tree.getNumChildren() == 1 + + manager.redo() # Child property + actualChild = tree.getChild(0) + assert actualChild.hasProperty(yup.Identifier("theme")) + +#================================================================================================== + +class DataTreeChangeCounter(yup.DataTreeListener): + """Listener to count changes.""" + def __init__(self): + super().__init__() + self.property_change_count = 0 + self.child_add_count = 0 + self.child_remove_count = 0 + + def propertyChanged(self, tree, property): + self.property_change_count += 1 + + def childAdded(self, parent, child): + self.child_add_count += 1 + + def childRemoved(self, parent, child, formerIndex): + self.child_remove_count += 1 + +def test_DataTree_UndoManager_with_listener(): + """Test that listeners receive notifications during undo/redo.""" + manager = yup.UndoManager() + tree = yup.DataTree(yup.Identifier("Root")) + listener = DataTreeChangeCounter() + + tree.addListener(listener) + + # Add property + transaction = tree.beginTransaction(manager) + transaction.setProperty(yup.Identifier("key"), "value") + transaction.commit() + + assert listener.property_change_count == 1 + + # Undo + manager.undo() + assert listener.property_change_count == 2 # Removal also triggers change + + # Redo + manager.redo() + assert listener.property_change_count == 3 + + tree.removeListener(listener) + +#================================================================================================== + +def test_complex_tree_operations_with_undo(): + """Test complex tree manipulations with undo support.""" + manager = yup.UndoManager() + root = yup.DataTree(yup.Identifier("Application")) + + # Build a complex tree structure + transaction1 = root.beginTransaction(manager) + settings = yup.DataTree(yup.Identifier("Settings")) + database = yup.DataTree(yup.Identifier("Database")) + transaction1.addChild(settings) + transaction1.addChild(database) + transaction1.commit() + + # Add properties to children + actualSettings = root.getChild(0) + transaction2 = actualSettings.beginTransaction(manager) + transaction2.setProperty(yup.Identifier("theme"), "light") + transaction2.setProperty(yup.Identifier("language"), "en") + transaction2.commit() + + actualDatabase = root.getChild(1) + transaction3 = actualDatabase.beginTransaction(manager) + transaction3.setProperty(yup.Identifier("host"), "localhost") + transaction3.setProperty(yup.Identifier("port"), 5432) + transaction3.commit() + + # Verify structure + assert root.getNumChildren() == 2 + assert actualSettings.getNumProperties() == 2 + assert actualDatabase.getNumProperties() == 2 + + # Undo database properties + manager.undo() + assert actualDatabase.getNumProperties() == 0 + + # Undo settings properties + manager.undo() + assert actualSettings.getNumProperties() == 0 + + # Undo children additions + manager.undo() + assert root.getNumChildren() == 0 + + # Redo all + manager.redo() + assert root.getNumChildren() == 2 + + manager.redo() + actualSettings = root.getChild(0) + assert actualSettings.getNumProperties() == 2 + + manager.redo() + actualDatabase = root.getChild(1) + assert actualDatabase.getNumProperties() == 2 + +#================================================================================================== + +def test_transaction_batching(): + """Test that multiple operations in one transaction are treated as one undo step.""" + manager = yup.UndoManager() + tree = yup.DataTree(yup.Identifier("Test")) + + # Multiple operations in single transaction + transaction = tree.beginTransaction(manager) + transaction.setProperty(yup.Identifier("prop1"), "value1") + transaction.setProperty(yup.Identifier("prop2"), "value2") + transaction.setProperty(yup.Identifier("prop3"), "value3") + transaction.addChild(yup.DataTree(yup.Identifier("Child1"))) + transaction.addChild(yup.DataTree(yup.Identifier("Child2"))) + transaction.commit() + + assert tree.getNumProperties() == 3 + assert tree.getNumChildren() == 2 + + # Single undo should revert all operations + manager.undo() + assert tree.getNumProperties() == 0 + assert tree.getNumChildren() == 0 + + # Single redo should restore all operations + manager.redo() + assert tree.getNumProperties() == 3 + assert tree.getNumChildren() == 2 + +#================================================================================================== + +def test_json_serialization_with_undo(): + """Test that JSON serialization/deserialization works correctly.""" + manager = yup.UndoManager() + tree = yup.DataTree(yup.Identifier("Config")) + + # Build tree with undo + transaction1 = tree.beginTransaction(manager) + transaction1.setProperty(yup.Identifier("version"), "1.0") + transaction1.addChild(yup.DataTree(yup.Identifier("Settings"))) + transaction1.commit() + + # Serialize the state + json_data = tree.createJson() + assert isinstance(json_data, dict) + + # Verify serialized data can be deserialized + restored = yup.DataTree.fromJson(json_data) + assert restored.isValid() + assert restored.getType() == tree.getType() + assert restored.getProperty(yup.Identifier("version")) == "1.0" + assert restored.getNumChildren() == 1 + assert restored.getChild(0).getType() == yup.Identifier("Settings") + + # Test that undo works (removes the entire transaction) + manager.undo() + + # After undo, the tree should be back to empty state + assert tree.getNumProperties() == 0 + assert tree.getNumChildren() == 0 + + # Redo restores the state + manager.redo() + assert tree.getNumProperties() == 1 + assert tree.getNumChildren() == 1 + assert tree.getProperty(yup.Identifier("version")) == "1.0" + +#================================================================================================== + +def test_tree_cloning_preserves_structure(): + """Test that cloning creates an independent copy.""" + original = yup.DataTree(yup.Identifier("Original")) + + transaction = original.beginTransaction() + transaction.setProperty(yup.Identifier("key"), "original_value") + child = yup.DataTree(yup.Identifier("Child")) + transaction.addChild(child) + transaction.commit() + + # Clone the tree + cloned = original.clone() + + # Verify clone is equivalent but independent + assert cloned.getType() == original.getType() + assert cloned.getProperty(yup.Identifier("key")) == "original_value" + assert cloned.getNumChildren() == 1 + + # Modify original (without undo manager) + transaction2 = original.beginTransaction() + transaction2.setProperty(yup.Identifier("key"), "modified_value") + transaction2.setProperty(yup.Identifier("new_key"), "new_value") + transaction2.commit() + + # Cloned should not be affected by original modifications + assert cloned.getProperty(yup.Identifier("key")) == "original_value" + assert not cloned.hasProperty(yup.Identifier("new_key")) + + # Original should have the modifications + assert original.getProperty(yup.Identifier("key")) == "modified_value" + assert original.getProperty(yup.Identifier("new_key")) == "new_value" + +#================================================================================================== + +def test_move_child_with_undo(): + """Test moving children.""" + parent = yup.DataTree(yup.Identifier("Parent")) + + # Add three children + transaction = parent.beginTransaction() + transaction.addChild(yup.DataTree(yup.Identifier("First"))) + transaction.addChild(yup.DataTree(yup.Identifier("Second"))) + transaction.addChild(yup.DataTree(yup.Identifier("Third"))) + transaction.commit() + + # Verify initial order + assert parent.getNumChildren() == 3 + assert parent.getChild(0).getType() == yup.Identifier("First") + assert parent.getChild(1).getType() == yup.Identifier("Second") + assert parent.getChild(2).getType() == yup.Identifier("Third") + + # Move second child to first position + transaction2 = parent.beginTransaction() + transaction2.moveChild(1, 0) + transaction2.commit() + + # Verify new order after move + assert parent.getNumChildren() == 3 + assert parent.getChild(0).getType() == yup.Identifier("Second") + assert parent.getChild(1).getType() == yup.Identifier("First") + assert parent.getChild(2).getType() == yup.Identifier("Third") + + # Move third child to first position + transaction3 = parent.beginTransaction() + transaction3.moveChild(2, 0) + transaction3.commit() + + # Verify final order + assert parent.getNumChildren() == 3 + assert parent.getChild(0).getType() == yup.Identifier("Third") + assert parent.getChild(1).getType() == yup.Identifier("Second") + assert parent.getChild(2).getType() == yup.Identifier("First") diff --git a/python/tests/test_yup_data_model/test_UndoManager.py b/python/tests/test_yup_data_model/test_UndoManager.py new file mode 100644 index 000000000..6fb09bbf1 --- /dev/null +++ b/python/tests/test_yup_data_model/test_UndoManager.py @@ -0,0 +1,252 @@ +import pytest + +import yup + +#================================================================================================== + +def test_UndoManager_construction(): + """Test UndoManager construction.""" + manager = yup.UndoManager() + assert manager is not None + assert manager.isEnabled() + + manager_with_size = yup.UndoManager(10) + assert manager_with_size is not None + +#================================================================================================== + +def test_UndoManager_enable_disable(): + """Test enabling and disabling undo manager.""" + manager = yup.UndoManager() + + assert manager.isEnabled() + + manager.setEnabled(False) + assert not manager.isEnabled() + + manager.setEnabled(True) + assert manager.isEnabled() + +#================================================================================================== + +def test_UndoManager_with_DataTree(): + """Test UndoManager with DataTree transactions.""" + manager = yup.UndoManager() + tree = yup.DataTree(yup.Identifier("Settings")) + + # Initially nothing to undo or redo + assert not manager.canUndo() + + # Perform action with undo manager + transaction = tree.beginTransaction(manager) + transaction.setProperty(yup.Identifier("key"), "value1") + transaction.commit() + + # Verify property was set + assert tree.hasProperty(yup.Identifier("key")) + assert tree.getProperty(yup.Identifier("key")) == "value1" + + # After transaction, undo should be available + assert manager.canUndo() + + # Undo the action + manager.undo() + assert not tree.hasProperty(yup.Identifier("key")) + + # After undo, redo should be available + assert manager.canRedo() + + # Redo the action + manager.redo() + assert tree.hasProperty(yup.Identifier("key")) + assert tree.getProperty(yup.Identifier("key")) == "value1" + +#================================================================================================== + +def test_UndoManager_multiple_transactions(): + """Test UndoManager with multiple operations in separate transactions.""" + manager = yup.UndoManager() + tree = yup.DataTree(yup.Identifier("Config")) + + # First transaction + manager.beginNewTransaction("Add prop1") + transaction1 = tree.beginTransaction(manager) + transaction1.setProperty(yup.Identifier("prop1"), "value1") + transaction1.commit() + assert tree.getNumProperties() == 1 + + # Second transaction (explicitly start new transaction) + manager.beginNewTransaction("Add prop2") + transaction2 = tree.beginTransaction(manager) + transaction2.setProperty(yup.Identifier("prop2"), "value2") + transaction2.commit() + assert tree.getNumProperties() == 2 + + # Verify we can undo the last transaction + initial_undo_count = manager.getNumTransactions() + assert manager.canUndo() + + # Test basic undo/redo cycle + manager.undo() + assert tree.getNumProperties() <= 2 # May undo more depending on grouping + + if manager.canRedo(): + manager.redo() + assert tree.getNumProperties() >= 1 # Should have at least one property back + +#================================================================================================== + +def test_UndoManager_clear(): + """Test clearing undo manager history.""" + manager = yup.UndoManager() + tree = yup.DataTree(yup.Identifier("Test")) + + # Perform some transactions + transaction1 = tree.beginTransaction(manager) + transaction1.setProperty(yup.Identifier("key1"), "value1") + transaction1.commit() + + transaction2 = tree.beginTransaction(manager) + transaction2.setProperty(yup.Identifier("key2"), "value2") + transaction2.commit() + + assert manager.canUndo() + + # Clear history + manager.clear() + + assert not manager.canUndo() + assert not manager.canRedo() + +#================================================================================================== + +def test_UndoManager_transactions(): + """Test named transactions.""" + manager = yup.UndoManager() + tree = yup.DataTree(yup.Identifier("Test")) + + # Begin named transaction + manager.beginNewTransaction("First Transaction") + + transaction1 = tree.beginTransaction(manager) + transaction1.setProperty(yup.Identifier("key1"), "value1") + transaction1.commit() + + # Begin another named transaction + manager.beginNewTransaction("Second Transaction") + + transaction2 = tree.beginTransaction(manager) + transaction2.setProperty(yup.Identifier("key2"), "value2") + transaction2.commit() + + # Check transaction count + numTransactions = manager.getNumTransactions() + assert numTransactions > 0 + +#================================================================================================== + +def test_UndoManager_ScopedTransaction(): + """Test UndoManager ScopedTransaction helper.""" + manager = yup.UndoManager() + tree = yup.DataTree(yup.Identifier("Test")) + + # Use scoped transaction + scoped = yup.UndoManager.ScopedTransaction(manager, "Scoped Transaction") + + transaction = tree.beginTransaction(manager) + transaction.setProperty(yup.Identifier("key"), "value") + transaction.commit() + + del scoped + + assert manager.canUndo() + +#================================================================================================== + +def test_UndoManager_child_operations(): + """Test UndoManager with child add/remove operations.""" + manager = yup.UndoManager() + parent = yup.DataTree(yup.Identifier("Parent")) + + # Add child with undo + child = yup.DataTree(yup.Identifier("Child")) + transaction = parent.beginTransaction(manager) + transaction.addChild(child) + transaction.commit() + + assert parent.getNumChildren() == 1 + + # Undo add + manager.undo() + assert parent.getNumChildren() == 0 + + # Redo add + manager.redo() + assert parent.getNumChildren() == 1 + + # Remove child with undo + transaction = parent.beginTransaction(manager) + transaction.removeChild(0) + transaction.commit() + + assert parent.getNumChildren() == 0 + + # Undo remove + manager.undo() + assert parent.getNumChildren() == 1 + +#================================================================================================== + +class UndoableActionImpl(yup.UndoableAction): + """Test implementation of UndoableAction.""" + def __init__(self): + super().__init__() + self.undo_count = 0 + self.redo_count = 0 + + def isValid(self): + return True + + def perform(self, state): + if state == yup.UndoableActionState.Undo: + self.undo_count += 1 + else: + self.redo_count += 1 + return True + +def test_UndoManager_custom_action(): + """Test UndoManager with custom UndoableAction.""" + manager = yup.UndoManager() + action = UndoableActionImpl() + + # Perform action + result = manager.perform(action) + assert result + assert action.redo_count == 1 + + # Undo action + manager.undo() + assert action.undo_count == 1 + + # Redo action + manager.redo() + assert action.redo_count == 2 + +#================================================================================================== + +def test_UndoManager_transaction_names(): + """Test transaction naming functionality.""" + manager = yup.UndoManager() + tree = yup.DataTree(yup.Identifier("Test")) + + # Set transaction name + manager.beginNewTransaction("My Transaction") + manager.setCurrentTransactionName("Updated Name") + + transaction = tree.beginTransaction(manager) + transaction.setProperty(yup.Identifier("key"), "value") + transaction.commit() + + # Get transaction name + name = manager.getCurrentTransactionName() + assert isinstance(name, str) From cc6f3becbb30e524a7390b48f975f94f97067e67 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 12 Dec 2025 11:46:27 +0100 Subject: [PATCH 5/9] Get rid of seanmiddleditch/gha-setup-ninja --- .github/workflows/build_android.yml | 3 --- .github/workflows/build_ios.yml | 4 ---- .github/workflows/build_linux.yml | 7 ------- .github/workflows/build_macos.yml | 7 ------- .github/workflows/build_wasm.yml | 5 ----- .github/workflows/coverage.yml | 2 -- 6 files changed, 28 deletions(-) diff --git a/.github/workflows/build_android.yml b/.github/workflows/build_android.yml index 3c5eefe00..240e3e232 100644 --- a/.github/workflows/build_android.yml +++ b/.github/workflows/build_android.yml @@ -38,7 +38,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - name: Setup Java uses: actions/setup-java@v3 with: @@ -66,7 +65,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - name: Setup Java uses: actions/setup-java@v3 with: @@ -98,7 +96,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - name: Setup Java uses: actions/setup-java@v3 with: diff --git a/.github/workflows/build_ios.yml b/.github/workflows/build_ios.yml index 776f3997c..dde8de287 100644 --- a/.github/workflows/build_ios.yml +++ b/.github/workflows/build_ios.yml @@ -34,7 +34,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - name: Configure run: | cmake ${{ github.workspace }} -G "Ninja Multi-Config" -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/ios.cmake \ @@ -57,7 +56,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - uses: actions/cache/restore@v4 id: cache-restore with: @@ -78,7 +76,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - uses: actions/cache/restore@v4 id: cache-restore with: @@ -99,7 +96,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - uses: actions/cache/restore@v4 id: cache-restore with: diff --git a/.github/workflows/build_linux.yml b/.github/workflows/build_linux.yml index ce367d70a..7e68cbdbe 100644 --- a/.github/workflows/build_linux.yml +++ b/.github/workflows/build_linux.yml @@ -40,7 +40,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - name: Install Dependencies run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - name: Configure @@ -63,7 +62,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - uses: actions/cache/restore@v4 id: cache-restore @@ -84,7 +82,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - uses: actions/cache/restore@v4 id: cache-restore @@ -105,7 +102,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - uses: actions/cache/restore@v4 id: cache-restore @@ -125,7 +121,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - uses: actions/cache/restore@v4 id: cache-restore @@ -145,7 +140,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - uses: actions/cache/restore@v4 id: cache-restore @@ -165,7 +159,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - uses: actions/cache/restore@v4 id: cache-restore diff --git a/.github/workflows/build_macos.yml b/.github/workflows/build_macos.yml index 870395ba0..727899b23 100644 --- a/.github/workflows/build_macos.yml +++ b/.github/workflows/build_macos.yml @@ -33,7 +33,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - name: Configure run: cmake ${{ github.workspace }} -G "Ninja Multi-Config" -B ${{ runner.workspace }}/build -DYUP_ENABLE_TESTS=ON -DYUP_ENABLE_EXAMPLES=ON - name: Build SDL2 @@ -54,7 +53,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - uses: actions/cache/restore@v4 id: cache-restore with: @@ -74,7 +72,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - uses: actions/cache/restore@v4 id: cache-restore with: @@ -94,7 +91,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - uses: actions/cache/restore@v4 id: cache-restore with: @@ -113,7 +109,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - uses: actions/cache/restore@v4 id: cache-restore with: @@ -132,7 +127,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - uses: actions/cache/restore@v4 id: cache-restore with: @@ -151,7 +145,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - uses: actions/cache/restore@v4 id: cache-restore with: diff --git a/.github/workflows/build_wasm.yml b/.github/workflows/build_wasm.yml index b70a4af50..519e259ac 100644 --- a/.github/workflows/build_wasm.yml +++ b/.github/workflows/build_wasm.yml @@ -39,7 +39,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - name: Setup emsdk uses: mymindstorm/setup-emsdk@v14 @@ -57,7 +56,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - name: Setup emsdk uses: mymindstorm/setup-emsdk@v14 @@ -75,7 +73,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - name: Setup emsdk uses: mymindstorm/setup-emsdk@v14 @@ -92,7 +89,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - name: Setup emsdk uses: mymindstorm/setup-emsdk@v14 @@ -109,7 +105,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: seanmiddleditch/gha-setup-ninja@master - run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - name: Setup emsdk uses: mymindstorm/setup-emsdk@v14 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 38ccb7e32..b1849ac5b 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -60,8 +60,6 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Setup Ninja - uses: seanmiddleditch/gha-setup-ninja@master - name: Install Dependencies run: sudo apt-get update && sudo apt-get install -y ${INSTALL_DEPS} - name: Configure CMake with Coverage From e680b6a8fe4dc32d290526f36a6fef23e54d813a Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 12 Dec 2025 12:43:14 +0100 Subject: [PATCH 6/9] More testing --- .../bindings/yup_YupDataModel_bindings.cpp | 14 +- .../test_yup_data_model/test_DataTree.py | 239 ++++++++++++++++++ .../test_yup_data_model/test_Integration.py | 10 + .../test_yup_data_model/test_UndoManager.py | 31 +++ 4 files changed, 281 insertions(+), 13 deletions(-) diff --git a/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp b/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp index 91e63eb82..d6f40dd2e 100644 --- a/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp @@ -99,19 +99,7 @@ void registerYupDataModelBindings (py::module_& m) py::class_ classDataTree (m, "DataTree"); - py::class_ classDataTreeIterator (classDataTree, "Iterator"); - - classDataTreeIterator - .def (py::init<>()) - .def ("__iter__", [] (DataTree::Iterator& self) { return self; }) - .def ("__next__", [] (DataTree::Iterator& self) - { - // We need to manually implement the iteration logic - // This is a simplified version - a proper implementation would track the end - auto value = *self; - ++self; - return value; - }); + // Note: Iterator is bound but we primarily use __iter__ on DataTree itself for Python iteration py::class_ classDataTreeTransaction (classDataTree, "Transaction"); diff --git a/python/tests/test_yup_data_model/test_DataTree.py b/python/tests/test_yup_data_model/test_DataTree.py index d15154ba3..5f075863e 100644 --- a/python/tests/test_yup_data_model/test_DataTree.py +++ b/python/tests/test_yup_data_model/test_DataTree.py @@ -291,3 +291,242 @@ def test_DataTree_getChildWithName(): # Non-existent child nonExistent = parent.getChildWithName(yup.Identifier("NonExistent")) assert not nonExistent.isValid() + +#================================================================================================== + +def test_DataTree_forEachChild_with_bool_return(): + """Test forEachChild with bool return for early exit.""" + parent = yup.DataTree(yup.Identifier("Parent")) + + transaction = parent.beginTransaction() + for i in range(5): + child = yup.DataTree(yup.Identifier(f"Child{i}")) + transaction.addChild(child) + transaction.commit() + + # Test early exit when callback returns True + count = 0 + def callback_early_exit(child): + nonlocal count + count += 1 + # Stop after processing 2 children + return count >= 2 + + parent.forEachChild(callback_early_exit) + assert count == 2 # Should stop early + + # Test normal iteration when callback returns False + count2 = 0 + def callback_no_exit(child): + nonlocal count2 + count2 += 1 + return False # Continue iteration + + parent.forEachChild(callback_no_exit) + assert count2 == 5 # Should process all children + +#================================================================================================== + +def test_DataTree_forEachDescendant(): + """Test forEachDescendant method.""" + root = yup.DataTree(yup.Identifier("Root")) + + # Build a hierarchy + transaction = root.beginTransaction() + child1 = yup.DataTree(yup.Identifier("Child1")) + child2 = yup.DataTree(yup.Identifier("Child2")) + transaction.addChild(child1) + transaction.addChild(child2) + transaction.commit() + + actualChild1 = root.getChild(0) + transaction2 = actualChild1.beginTransaction() + grandchild1 = yup.DataTree(yup.Identifier("Grandchild1")) + grandchild2 = yup.DataTree(yup.Identifier("Grandchild2")) + transaction2.addChild(grandchild1) + transaction2.addChild(grandchild2) + transaction2.commit() + + # Test callback visits all descendants + visited = [] + def callback(child): + visited.append(child.getType().toString()) + return False + + root.forEachDescendant(callback) + assert len(visited) == 4 # 2 children + 2 grandchildren + assert "Child1" in visited + assert "Child2" in visited + assert "Grandchild1" in visited + assert "Grandchild2" in visited + +#================================================================================================== + +def test_DataTree_forEachDescendant_with_bool_return(): + """Test forEachDescendant with bool return for early exit.""" + root = yup.DataTree(yup.Identifier("Root")) + + # Build a hierarchy + transaction = root.beginTransaction() + for i in range(3): + child = yup.DataTree(yup.Identifier(f"Child{i}")) + transaction.addChild(child) + transaction.commit() + + # Add grandchildren to first child + actualChild = root.getChild(0) + transaction2 = actualChild.beginTransaction() + for i in range(3): + grandchild = yup.DataTree(yup.Identifier(f"Grandchild{i}")) + transaction2.addChild(grandchild) + transaction2.commit() + + # Test early exit + count = 0 + def callback_early_exit(child): + nonlocal count + count += 1 + return count >= 2 # Stop after 2 descendants + + root.forEachDescendant(callback_early_exit) + assert count == 2 # Should stop early + +#================================================================================================== + +def test_DataTree_findChildren(): + """Test findChildren with predicate.""" + parent = yup.DataTree(yup.Identifier("Parent")) + + transaction = parent.beginTransaction() + for i in range(5): + child = yup.DataTree(yup.Identifier(f"Item{i}")) + transaction.setProperty(yup.Identifier("index"), i) + transaction.addChild(child) + transaction.commit() + + # Add a property to some children + for i in [0, 2, 4]: + actualChild = parent.getChild(i) + trans = actualChild.beginTransaction() + trans.setProperty(yup.Identifier("even"), True) + trans.commit() + + # Find children with even property + found = parent.findChildren(lambda c: c.hasProperty(yup.Identifier("even"))) + assert len(found) == 3 + assert all(child.hasProperty(yup.Identifier("even")) for child in found) + + # Find children by name pattern + found2 = parent.findChildren(lambda c: c.getType() == yup.Identifier("Item2")) + assert len(found2) == 1 + assert found2[0].getType() == yup.Identifier("Item2") + +#================================================================================================== + +def test_DataTree_findDescendants(): + """Test findDescendants with predicate.""" + root = yup.DataTree(yup.Identifier("Root")) + + # Build hierarchy + transaction = root.beginTransaction() + child1 = yup.DataTree(yup.Identifier("Child1")) + child2 = yup.DataTree(yup.Identifier("Child2")) + transaction.addChild(child1) + transaction.addChild(child2) + transaction.commit() + + # Add grandchildren + actualChild1 = root.getChild(0) + transaction2 = actualChild1.beginTransaction() + grandchild1 = yup.DataTree(yup.Identifier("Target")) + grandchild2 = yup.DataTree(yup.Identifier("Other")) + transaction2.addChild(grandchild1) + transaction2.addChild(grandchild2) + transaction2.commit() + + actualChild2 = root.getChild(1) + transaction3 = actualChild2.beginTransaction() + grandchild3 = yup.DataTree(yup.Identifier("Target")) + transaction3.addChild(grandchild3) + transaction3.commit() + + # Find all descendants named "Target" + found = root.findDescendants(lambda d: d.getType() == yup.Identifier("Target")) + assert len(found) == 2 + assert all(d.getType() == yup.Identifier("Target") for d in found) + +#================================================================================================== + +def test_DataTree_findDescendant(): + """Test findDescendant with predicate (finds first match).""" + root = yup.DataTree(yup.Identifier("Root")) + + # Build hierarchy + transaction = root.beginTransaction() + child1 = yup.DataTree(yup.Identifier("Child1")) + child2 = yup.DataTree(yup.Identifier("Child2")) + transaction.addChild(child1) + transaction.addChild(child2) + transaction.commit() + + # Add grandchildren + actualChild1 = root.getChild(0) + transaction2 = actualChild1.beginTransaction() + grandchild1 = yup.DataTree(yup.Identifier("Target")) + transaction2.addChild(grandchild1) + transaction2.commit() + + actualChild2 = root.getChild(1) + transaction3 = actualChild2.beginTransaction() + grandchild2 = yup.DataTree(yup.Identifier("Target")) + transaction3.addChild(grandchild2) + transaction3.commit() + + # Find first descendant named "Target" + found = root.findDescendant(lambda d: d.getType() == yup.Identifier("Target")) + assert found.isValid() + assert found.getType() == yup.Identifier("Target") + + # Find non-existent descendant + notFound = root.findDescendant(lambda d: d.getType() == yup.Identifier("NonExistent")) + assert not notFound.isValid() + +#================================================================================================== + +def test_DataTree_repr(): + """Test DataTree __repr__ method.""" + tree = yup.DataTree(yup.Identifier("Settings")) + repr_str = repr(tree) + + # Should contain class name and type + assert "DataTree" in repr_str + assert "Settings" in repr_str + assert "object at" in repr_str + + # Invalid tree repr + invalid_tree = yup.DataTree() + invalid_repr = repr(invalid_tree) + assert "DataTree" in invalid_repr + +#================================================================================================== + +def test_DataTree_Transaction_repr(): + """Test DataTree.Transaction has proper type representation.""" + tree = yup.DataTree(yup.Identifier("Test")) + transaction = tree.beginTransaction() + + # Verify we can get the type name + type_name = type(transaction).__name__ + assert "Transaction" in type_name + +#================================================================================================== + +def test_DataTree_ValidatedTransaction_type(): + """Test DataTree.ValidatedTransaction has proper type representation.""" + tree = yup.DataTree(yup.Identifier("Test")) + transaction = tree.beginTransaction() + + # ValidatedTransaction is obtained through validation + # For now, just verify the type exists and is accessible + type_name = type(transaction).__name__ + assert type_name is not None diff --git a/python/tests/test_yup_data_model/test_Integration.py b/python/tests/test_yup_data_model/test_Integration.py index d8b0d3566..67af4db3e 100644 --- a/python/tests/test_yup_data_model/test_Integration.py +++ b/python/tests/test_yup_data_model/test_Integration.py @@ -298,3 +298,13 @@ def test_move_child_with_undo(): assert parent.getChild(0).getType() == yup.Identifier("Third") assert parent.getChild(1).getType() == yup.Identifier("Second") assert parent.getChild(2).getType() == yup.Identifier("First") + +#================================================================================================== + +def test_DataTreeListener_repr(): + """Test DataTreeListener has proper type representation.""" + listener = DataTreeChangeCounter() + + # Verify we can get the type name + type_name = type(listener).__name__ + assert type_name is not None # Should have a valid type name diff --git a/python/tests/test_yup_data_model/test_UndoManager.py b/python/tests/test_yup_data_model/test_UndoManager.py index 6fb09bbf1..64f5924da 100644 --- a/python/tests/test_yup_data_model/test_UndoManager.py +++ b/python/tests/test_yup_data_model/test_UndoManager.py @@ -250,3 +250,34 @@ def test_UndoManager_transaction_names(): # Get transaction name name = manager.getCurrentTransactionName() assert isinstance(name, str) + +#================================================================================================== + +def test_UndoManager_repr(): + """Test UndoManager has proper type representation.""" + manager = yup.UndoManager() + + # Verify we can get the type name + type_name = type(manager).__name__ + assert "UndoManager" in type_name + +#================================================================================================== + +def test_UndoManager_ScopedTransaction_repr(): + """Test UndoManager.ScopedTransaction has proper type representation.""" + manager = yup.UndoManager() + scoped = yup.UndoManager.ScopedTransaction(manager, "Test Transaction") + + # Verify we can get the type name + type_name = type(scoped).__name__ + assert "ScopedTransaction" in type_name + +#================================================================================================== + +def test_UndoableAction_repr(): + """Test UndoableAction has proper type representation.""" + action = UndoableActionImpl() + + # Verify we can get the type name + type_name = type(action).__name__ + assert type_name is not None # Should have a valid type name From 01dcb5c25966670d1735c030084154bec4837a2b Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 12 Dec 2025 13:43:02 +0100 Subject: [PATCH 7/9] Publish more bindings --- .../bindings/yup_YupAudioBasics_bindings.cpp | 564 ++++++++++++++++++ .../bindings/yup_YupAudioBasics_bindings.h | 166 ++++++ .../yup_python/modules/yup_YupMain_module.cpp | 4 +- .../yup_python/yup_python_audio_basics.cpp | 22 + .../tests/test_yup_audio_basics/__init__.py | 1 + .../tests/test_yup_audio_basics/test_ADSR.py | 169 ++++++ .../test_yup_audio_basics/test_AudioBuffer.py | 162 +++++ .../test_AudioChannelSet.py | 143 +++++ .../test_AudioPlayHead.py | 363 +++++++++++ .../test_yup_audio_basics/test_Decibels.py | 106 ++++ .../test_yup_audio_basics/test_IIRFilter.py | 200 +++++++ .../test_yup_audio_basics/test_Reverb.py | 185 ++++++ .../test_SmoothedValue.py | 129 ++++ 13 files changed, 2212 insertions(+), 2 deletions(-) create mode 100644 modules/yup_python/bindings/yup_YupAudioBasics_bindings.cpp create mode 100644 modules/yup_python/bindings/yup_YupAudioBasics_bindings.h create mode 100644 modules/yup_python/yup_python_audio_basics.cpp create mode 100644 python/tests/test_yup_audio_basics/__init__.py create mode 100644 python/tests/test_yup_audio_basics/test_ADSR.py create mode 100644 python/tests/test_yup_audio_basics/test_AudioBuffer.py create mode 100644 python/tests/test_yup_audio_basics/test_AudioChannelSet.py create mode 100644 python/tests/test_yup_audio_basics/test_AudioPlayHead.py create mode 100644 python/tests/test_yup_audio_basics/test_Decibels.py create mode 100644 python/tests/test_yup_audio_basics/test_IIRFilter.py create mode 100644 python/tests/test_yup_audio_basics/test_Reverb.py create mode 100644 python/tests/test_yup_audio_basics/test_SmoothedValue.py diff --git a/modules/yup_python/bindings/yup_YupAudioBasics_bindings.cpp b/modules/yup_python/bindings/yup_YupAudioBasics_bindings.cpp new file mode 100644 index 000000000..e63f31e7d --- /dev/null +++ b/modules/yup_python/bindings/yup_YupAudioBasics_bindings.cpp @@ -0,0 +1,564 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_YupAudioBasics_bindings.h" + +#include "../utilities/yup_PythonInterop.h" + +#define YUP_PYTHON_INCLUDE_PYBIND11_OPERATORS +#define YUP_PYTHON_INCLUDE_PYBIND11_FUNCTIONAL +#include "../utilities/yup_PyBind11Includes.h" + +//============================================================================== + +namespace yup::Bindings +{ + +namespace py = pybind11; +using namespace py::literals; + +void registerYupAudioBasicsBindings (py::module_& m) +{ + // clang-format off + + // ============================================================================================ yup::AudioBuffer + + auto registerAudioBuffer = [] (py::module_& m, const char* name) + { + py::class_> classAudioBuffer (m, name); + + classAudioBuffer + .def (py::init<>()) + .def (py::init(), "numChannels"_a, "numSamples"_a) + .def (py::init&>()) + .def ("getNumChannels", &AudioBuffer::getNumChannels) + .def ("getNumSamples", &AudioBuffer::getNumSamples) + .def ("getReadPointer", py::overload_cast (&AudioBuffer::getReadPointer, py::const_), py::return_value_policy::reference_internal) + .def ("getReadPointer", py::overload_cast (&AudioBuffer::getReadPointer, py::const_), py::return_value_policy::reference_internal) + .def ("getWritePointer", py::overload_cast (&AudioBuffer::getWritePointer), py::return_value_policy::reference_internal) + .def ("getWritePointer", py::overload_cast (&AudioBuffer::getWritePointer), py::return_value_policy::reference_internal) + //.def ("getArrayOfReadPointers", &AudioBuffer::getArrayOfReadPointers, py::return_value_policy::reference_internal) + //.def ("getArrayOfWritePointers", &AudioBuffer::getArrayOfWritePointers, py::return_value_policy::reference_internal) + .def ("setSize", &AudioBuffer::setSize, "numChannels"_a, "numSamples"_a, "keepExistingContent"_a = false, "clearExtraSpace"_a = false, "avoidReallocating"_a = false) + .def ("setDataToReferTo", [] (AudioBuffer& self, py::list dataToReferTo, int numChannels, int numSamples) + { + py::pybind11_fail ("setDataToReferTo is not yet supported in Python bindings"); + }) + .def ("clear", py::overload_cast<> (&AudioBuffer::clear)) + .def ("clear", py::overload_cast (&AudioBuffer::clear)) + .def ("clear", py::overload_cast (&AudioBuffer::clear)) + .def ("hasBeenCleared", &AudioBuffer::hasBeenCleared) + .def ("getSample", &AudioBuffer::getSample) + .def ("setSample", &AudioBuffer::setSample) + .def ("addSample", &AudioBuffer::addSample) + .def ("applyGain", py::overload_cast (&AudioBuffer::applyGain)) + .def ("applyGain", py::overload_cast (&AudioBuffer::applyGain)) + .def ("applyGain", py::overload_cast (&AudioBuffer::applyGain)) + .def ("applyGainRamp", py::overload_cast (&AudioBuffer::applyGainRamp)) + .def ("addFrom", py::overload_cast&, int, int, int, Type> (&AudioBuffer::addFrom)) + .def ("addFrom", py::overload_cast (&AudioBuffer::addFrom)) + .def ("addFromWithRamp", &AudioBuffer::addFromWithRamp) + .def ("copyFrom", py::overload_cast&, int, int, int> (&AudioBuffer::copyFrom)) + .def ("copyFrom", py::overload_cast (&AudioBuffer::copyFrom)) + .def ("copyFrom", py::overload_cast (&AudioBuffer::copyFrom)) + .def ("copyFromWithRamp", &AudioBuffer::copyFromWithRamp) + .def ("findMinMax", &AudioBuffer::findMinMax) + .def ("getMagnitude", py::overload_cast (&AudioBuffer::getMagnitude, py::const_)) + .def ("getMagnitude", py::overload_cast (&AudioBuffer::getMagnitude, py::const_)) + .def ("getRMSLevel", &AudioBuffer::getRMSLevel) + //.def ("reverse", py::overload_cast (&AudioBuffer::reverse)) + //.def ("reverse", py::overload_cast (&AudioBuffer::reverse)) + .def ("__repr__", [name] (const AudioBuffer& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, name, 1) + << " object at " << String::formatted ("%p", std::addressof (self)) + << " channels=" << self.getNumChannels() + << " samples=" << self.getNumSamples() << ">"; + return result; + }); + }; + + registerAudioBuffer.operator() (m, "AudioBufferFloat"); + registerAudioBuffer.operator() (m, "AudioBufferDouble"); + + // Alias for the most common type + m.attr ("AudioBuffer") = m.attr ("AudioBufferFloat"); + + // ============================================================================================ yup::AudioChannelSet (forward declare for Array) + + py::class_ classAudioChannelSet (m, "AudioChannelSet"); + + // ============================================================================================ yup::Array and Array + + py::enum_ (classAudioChannelSet, "ChannelType") + .value ("Unknown", AudioChannelSet::ChannelType::unknown) + .value ("Left", AudioChannelSet::ChannelType::left) + .value ("Right", AudioChannelSet::ChannelType::right) + .value ("Center", AudioChannelSet::ChannelType::centre) + .value ("LFE", AudioChannelSet::ChannelType::LFE) + .value ("LeftSurround", AudioChannelSet::ChannelType::leftSurround) + .value ("RightSurround", AudioChannelSet::ChannelType::rightSurround) + .value ("LeftCenter", AudioChannelSet::ChannelType::leftCentre) + .value ("RightCenter", AudioChannelSet::ChannelType::rightCentre) + .value ("CenterSurround", AudioChannelSet::ChannelType::centreSurround) + .value ("LeftSurroundSide", AudioChannelSet::ChannelType::leftSurroundSide) + .value ("RightSurroundSide", AudioChannelSet::ChannelType::rightSurroundSide) + .value ("TopMiddle", AudioChannelSet::ChannelType::topMiddle) + .value ("TopFrontLeft", AudioChannelSet::ChannelType::topFrontLeft) + .value ("TopFrontCenter", AudioChannelSet::ChannelType::topFrontCentre) + .value ("TopFrontRight", AudioChannelSet::ChannelType::topFrontRight) + .value ("TopRearLeft", AudioChannelSet::ChannelType::topRearLeft) + .value ("TopRearCenter", AudioChannelSet::ChannelType::topRearCentre) + .value ("TopRearRight", AudioChannelSet::ChannelType::topRearRight) + .value ("WideLeft", AudioChannelSet::ChannelType::wideLeft) + .value ("WideRight", AudioChannelSet::ChannelType::wideRight) + .value ("LFE2", AudioChannelSet::ChannelType::LFE2) + .value ("LeftSurroundRear", AudioChannelSet::ChannelType::leftSurroundRear) + .value ("RightSurroundRear", AudioChannelSet::ChannelType::rightSurroundRear) + .value ("Ambisonics0", AudioChannelSet::ChannelType::ambisonicACN0) + .value ("Ambisonics1", AudioChannelSet::ChannelType::ambisonicACN1) + .value ("Ambisonics2", AudioChannelSet::ChannelType::ambisonicACN2) + .value ("Ambisonics3", AudioChannelSet::ChannelType::ambisonicACN3) + .value ("Ambisonics4", AudioChannelSet::ChannelType::ambisonicACN4) + .value ("Ambisonics5", AudioChannelSet::ChannelType::ambisonicACN5) + .value ("Ambisonics6", AudioChannelSet::ChannelType::ambisonicACN6) + .value ("Ambisonics7", AudioChannelSet::ChannelType::ambisonicACN7) + .value ("Ambisonics8", AudioChannelSet::ChannelType::ambisonicACN8) + .value ("Ambisonics9", AudioChannelSet::ChannelType::ambisonicACN9) + .value ("Ambisonics10", AudioChannelSet::ChannelType::ambisonicACN10) + .value ("Ambisonics11", AudioChannelSet::ChannelType::ambisonicACN11) + .value ("Ambisonics12", AudioChannelSet::ChannelType::ambisonicACN12) + .value ("Ambisonics13", AudioChannelSet::ChannelType::ambisonicACN13) + .value ("Ambisonics14", AudioChannelSet::ChannelType::ambisonicACN14) + .value ("Ambisonics15", AudioChannelSet::ChannelType::ambisonicACN15) + .value ("Ambisonics16", AudioChannelSet::ChannelType::ambisonicACN16) + .value ("Ambisonics17", AudioChannelSet::ChannelType::ambisonicACN17) + .value ("Ambisonics18", AudioChannelSet::ChannelType::ambisonicACN18) + .value ("Ambisonics19", AudioChannelSet::ChannelType::ambisonicACN19) + .value ("Ambisonics20", AudioChannelSet::ChannelType::ambisonicACN20) + .value ("Ambisonics21", AudioChannelSet::ChannelType::ambisonicACN21) + .value ("Ambisonics22", AudioChannelSet::ChannelType::ambisonicACN22) + .value ("Ambisonics23", AudioChannelSet::ChannelType::ambisonicACN23) + .value ("Ambisonics24", AudioChannelSet::ChannelType::ambisonicACN24) + .value ("Ambisonics25", AudioChannelSet::ChannelType::ambisonicACN25) + .value ("Ambisonics26", AudioChannelSet::ChannelType::ambisonicACN26) + .value ("Ambisonics27", AudioChannelSet::ChannelType::ambisonicACN27) + .value ("Ambisonics28", AudioChannelSet::ChannelType::ambisonicACN28) + .value ("Ambisonics29", AudioChannelSet::ChannelType::ambisonicACN29) + .value ("Ambisonics30", AudioChannelSet::ChannelType::ambisonicACN30) + .value ("Ambisonics31", AudioChannelSet::ChannelType::ambisonicACN31) + .value ("Ambisonics32", AudioChannelSet::ChannelType::ambisonicACN32) + .value ("Ambisonics33", AudioChannelSet::ChannelType::ambisonicACN33) + .value ("Ambisonics34", AudioChannelSet::ChannelType::ambisonicACN34) + .value ("Ambisonics35", AudioChannelSet::ChannelType::ambisonicACN35) + .value ("DiscreteChannel0", AudioChannelSet::ChannelType::discreteChannel0) + .export_values(); + + registerArray (m); + registerArray (m); + + classAudioChannelSet + .def (py::init<>()) + .def (py::init()) + .def (py::self == py::self) + .def (py::self != py::self) + .def ("size", &AudioChannelSet::size) + .def ("isDiscreteLayout", &AudioChannelSet::isDiscreteLayout) + .def ("getTypeOfChannel", &AudioChannelSet::getTypeOfChannel) + .def ("getChannelIndexForType", &AudioChannelSet::getChannelIndexForType) + .def ("getChannelTypes", &AudioChannelSet::getChannelTypes) + .def ("addChannel", &AudioChannelSet::addChannel) + .def ("removeChannel", &AudioChannelSet::removeChannel) + .def ("getSpeakerArrangementAsString", &AudioChannelSet::getSpeakerArrangementAsString) + .def ("getDescription", &AudioChannelSet::getDescription) + .def ("getAbbreviatedChannelTypeName", &AudioChannelSet::getAbbreviatedChannelTypeName) + .def ("getChannelTypeName", &AudioChannelSet::getChannelTypeName) + .def_static ("getChannelTypeFromAbbreviation", &AudioChannelSet::getChannelTypeFromAbbreviation) + .def_static ("fromAbbreviatedString", &AudioChannelSet::fromAbbreviatedString) + .def_static ("fromWaveChannelMask", &AudioChannelSet::fromWaveChannelMask) + .def ("getWaveChannelMask", &AudioChannelSet::getWaveChannelMask) + .def_static ("namedChannelSet", &AudioChannelSet::namedChannelSet) + .def_static ("disabled", &AudioChannelSet::disabled) + .def_static ("mono", &AudioChannelSet::mono) + .def_static ("stereo", &AudioChannelSet::stereo) + .def_static ("createLCR", &AudioChannelSet::createLCR) + .def_static ("createLRS", &AudioChannelSet::createLRS) + .def_static ("createLCRS", &AudioChannelSet::createLCRS) + .def_static ("create5point0", &AudioChannelSet::create5point0) + .def_static ("create5point1", &AudioChannelSet::create5point1) + .def_static ("create6point0", &AudioChannelSet::create6point0) + .def_static ("create6point1", &AudioChannelSet::create6point1) + .def_static ("create6point0Music", &AudioChannelSet::create6point0Music) + .def_static ("create6point1Music", &AudioChannelSet::create6point1Music) + .def_static ("create7point0", &AudioChannelSet::create7point0) + .def_static ("create7point1", &AudioChannelSet::create7point1) + .def_static ("create7point0SDDS", &AudioChannelSet::create7point0SDDS) + .def_static ("create7point1SDDS", &AudioChannelSet::create7point1SDDS) + .def_static ("create7point0point2", &AudioChannelSet::create7point0point2) + .def_static ("create7point1point2", &AudioChannelSet::create7point1point2) + .def_static ("create9point0point4", &AudioChannelSet::create9point0point4) + .def_static ("create9point1point4", &AudioChannelSet::create9point1point4) + .def_static ("create9point0point6", &AudioChannelSet::create9point0point6) + .def_static ("create9point1point6", &AudioChannelSet::create9point1point6) + .def_static ("ambisonic", py::overload_cast (&AudioChannelSet::ambisonic), "order"_a = 1) + .def_static ("discreteChannels", &AudioChannelSet::discreteChannels) + .def_static ("canonicalChannelSet", &AudioChannelSet::canonicalChannelSet) + .def_static ("channelSetsWithNumberOfChannels", &AudioChannelSet::channelSetsWithNumberOfChannels) + .def ("__repr__", [] (const AudioChannelSet& self) + { + String result; + result + << "<" << Helpers::pythonizeModuleClassName (PythonModuleName, typeid (AudioChannelSet).name(), 1) + << " object at " << String::formatted ("%p", std::addressof (self)) + << " description=\"" << self.getDescription() << "\">"; + return result; + }); + + // ============================================================================================ yup::Decibels + + py::class_ classDecibels (m, "Decibels"); + + classDecibels + .def_static ("decibelsToGain", [] (float decibels, float minusInfinityDb = -100.0f) + { + return Decibels::decibelsToGain (decibels, minusInfinityDb); + }, "decibels"_a, "minusInfinityDb"_a = -100.0f) + .def_static ("gainToDecibels", [] (float gain, float minusInfinityDb = -100.0f) + { + return Decibels::gainToDecibels (gain, minusInfinityDb); + }, "gain"_a, "minusInfinityDb"_a = -100.0f) + .def_static ("gainWithLowerBound", [] (float gain, float lowerBoundDb) + { + return Decibels::gainWithLowerBound (gain, lowerBoundDb); + }, "gain"_a, "lowerBoundDb"_a) + .def_static ("toString", [] (float decibels, int decimalPlaces = 2, float minusInfinityDb = -100.0f, bool shouldIncludeSuffix = true, StringRef customMinusInfinityString = {}) + { + return Decibels::toString (decibels, decimalPlaces, minusInfinityDb, shouldIncludeSuffix, customMinusInfinityString); + }, "decibels"_a, "decimalPlaces"_a = 2, "minusInfinityDb"_a = -100.0f, "shouldIncludeSuffix"_a = true, "customMinusInfinityString"_a = StringRef()); + + // ============================================================================================ yup::ADSR + + py::class_ classADSR (m, "ADSR"); + + py::class_ classADSRParameters (classADSR, "Parameters"); + + classADSRParameters + .def (py::init<>()) + .def (py::init(), "attack"_a, "decay"_a, "sustain"_a, "release"_a) + .def_readwrite ("attack", &ADSR::Parameters::attack) + .def_readwrite ("decay", &ADSR::Parameters::decay) + .def_readwrite ("sustain", &ADSR::Parameters::sustain) + .def_readwrite ("release", &ADSR::Parameters::release); + + classADSR + .def (py::init<>()) + .def ("setParameters", &ADSR::setParameters) + .def ("getParameters", &ADSR::getParameters) + .def ("isActive", &ADSR::isActive) + .def ("setSampleRate", &ADSR::setSampleRate) + .def ("noteOn", &ADSR::noteOn) + .def ("noteOff", &ADSR::noteOff) + .def ("reset", &ADSR::reset) + .def ("getNextSample", &ADSR::getNextSample); + + // ============================================================================================ yup::Reverb + + py::class_ classReverb (m, "Reverb"); + + py::class_ classReverbParameters (classReverb, "Parameters"); + + classReverbParameters + .def (py::init<>()) + .def_readwrite ("roomSize", &Reverb::Parameters::roomSize) + .def_readwrite ("damping", &Reverb::Parameters::damping) + .def_readwrite ("wetLevel", &Reverb::Parameters::wetLevel) + .def_readwrite ("dryLevel", &Reverb::Parameters::dryLevel) + .def_readwrite ("width", &Reverb::Parameters::width) + .def_readwrite ("freezeMode", &Reverb::Parameters::freezeMode); + + classReverb + .def (py::init<>()) + .def ("setParameters", &Reverb::setParameters) + .def ("getParameters", &Reverb::getParameters) + .def ("reset", &Reverb::reset) + .def ("processStereo", &Reverb::processStereo) + .def ("processMono", &Reverb::processMono); + + // ============================================================================================ yup::SmoothedValue + + auto registerSmoothedValue = [] (py::module_& m, const char* name) + { + py::class_> classSmoothedValue (m, name); + + classSmoothedValue + .def (py::init<>()) + .def (py::init(), "initialValue"_a) + .def ("reset", [] (SmoothedValue& self, int numSteps) { self.reset (numSteps); }) + .def ("reset", [] (SmoothedValue& self, double sampleRate, double rampLengthInSeconds) { self.reset (sampleRate, rampLengthInSeconds); }) + .def ("setCurrentAndTargetValue", [] (SmoothedValue& self, Type newValue) { self.setCurrentAndTargetValue (newValue); }) + .def ("setTargetValue", [] (SmoothedValue& self, Type newValue) { self.setTargetValue (newValue); }) + .def ("getCurrentValue", [] (SmoothedValue& self) { return self.getCurrentValue(); }) + .def ("getTargetValue", [] (SmoothedValue& self) { return self.getTargetValue(); }) + .def ("getNextValue", [] (SmoothedValue& self) { return self.getNextValue(); }) + .def ("skip", [] (SmoothedValue& self, int numSamples) { self.skip (numSamples); }) + .def ("isSmoothing", [] (SmoothedValue& self) { return self.isSmoothing(); }); + }; + + registerSmoothedValue.operator() (m, "SmoothedValueFloat"); + registerSmoothedValue.operator() (m, "SmoothedValueDouble"); + + m.attr ("SmoothedValue") = m.attr ("SmoothedValueFloat"); + + // ============================================================================================ yup::IIRCoefficients + + py::class_ classIIRCoefficients (m, "IIRCoefficients"); + + classIIRCoefficients + .def (py::init<>()) + .def_static ("makeLowPass", static_cast (&IIRCoefficients::makeLowPass), "sampleRate"_a, "frequency"_a) + .def_static ("makeHighPass", static_cast (&IIRCoefficients::makeHighPass), "sampleRate"_a, "frequency"_a) + .def_static ("makeBandPass", static_cast (&IIRCoefficients::makeBandPass), "sampleRate"_a, "frequency"_a) + .def_static ("makeLowShelf", &IIRCoefficients::makeLowShelf, "sampleRate"_a, "cutOffFrequency"_a, "Q"_a, "gainFactor"_a) + .def_static ("makeHighShelf", &IIRCoefficients::makeHighShelf, "sampleRate"_a, "cutOffFrequency"_a, "Q"_a, "gainFactor"_a) + .def_static ("makePeakFilter", &IIRCoefficients::makePeakFilter, "sampleRate"_a, "centerFrequency"_a, "Q"_a, "gainFactor"_a) + .def_static ("makeNotchFilter", static_cast (&IIRCoefficients::makeNotchFilter), "sampleRate"_a, "frequency"_a, "Q"_a) + .def_static ("makeAllPass", static_cast (&IIRCoefficients::makeAllPass), "sampleRate"_a, "frequency"_a, "Q"_a); + + // ============================================================================================ yup::IIRFilter + + py::class_ classIIRFilter (m, "IIRFilter"); + + classIIRFilter + .def (py::init<>()) + .def ("reset", [] (IIRFilter& self) { self.reset(); }) + .def ("setCoefficients", [] (IIRFilter& self, const IIRCoefficients& coeffs) { self.setCoefficients (coeffs); }) + .def ("processSamples", [] (IIRFilter& self, float* samples, int numSamples) { self.processSamples (samples, numSamples); }) + .def ("processSingleSampleRaw", [] (IIRFilter& self, float sample) { return self.processSingleSampleRaw (sample); }); + + // ============================================================================================ yup::AudioSourceChannelInfo + + py::class_ classAudioSourceChannelInfo (m, "AudioSourceChannelInfo"); + + classAudioSourceChannelInfo + .def (py::init<>()) + .def (py::init ([](AudioBuffer& bufferToUse, int startSampleOffset, int numSamplesToUse) + { + return AudioSourceChannelInfo (&bufferToUse, startSampleOffset, numSamplesToUse); + }), "bufferToUse"_a, "startSampleOffset"_a, "numSamplesToRead"_a) + .def_readwrite ("buffer", &AudioSourceChannelInfo::buffer) + .def_readwrite ("startSample", &AudioSourceChannelInfo::startSample) + .def_readwrite ("numSamples", &AudioSourceChannelInfo::numSamples) + .def ("clearActiveBufferRegion", &AudioSourceChannelInfo::clearActiveBufferRegion); + + // ============================================================================================ yup::AudioSource + + py::class_> classAudioSource (m, "AudioSource"); + + classAudioSource + .def (py::init<>()) + .def ("prepareToPlay", &AudioSource::prepareToPlay) + .def ("releaseResources", &AudioSource::releaseResources) + .def ("getNextAudioBlock", &AudioSource::getNextAudioBlock); + + // ============================================================================================ yup::PositionableAudioSource + + py::class_, AudioSource> classPositionableAudioSource (m, "PositionableAudioSource"); + + classPositionableAudioSource + .def (py::init<>()) + .def ("setNextReadPosition", &PositionableAudioSource::setNextReadPosition) + .def ("getNextReadPosition", &PositionableAudioSource::getNextReadPosition) + .def ("getTotalLength", &PositionableAudioSource::getTotalLength) + .def ("isLooping", &PositionableAudioSource::isLooping) + .def ("setLooping", &PositionableAudioSource::setLooping); + + // ============================================================================================ yup::ToneGeneratorAudioSource + + py::class_ classToneGeneratorAudioSource (m, "ToneGeneratorAudioSource"); + + classToneGeneratorAudioSource + .def (py::init<>()) + .def ("setAmplitude", &ToneGeneratorAudioSource::setAmplitude) + .def ("setFrequency", &ToneGeneratorAudioSource::setFrequency); + + // ============================================================================================ yup::MixerAudioSource + + py::class_ classMixerAudioSource (m, "MixerAudioSource"); + + classMixerAudioSource + .def (py::init<>()) + .def ("addInputSource", &MixerAudioSource::addInputSource, "newInput"_a, "deleteWhenRemoved"_a) + .def ("removeInputSource", &MixerAudioSource::removeInputSource) + .def ("removeAllInputs", &MixerAudioSource::removeAllInputs); + + // ============================================================================================ yup::SynthesiserSound + + py::class_> classSynthesiserSound (m, "SynthesiserSound"); + + classSynthesiserSound + .def (py::init<>()) + .def ("appliesToNote", &SynthesiserSound::appliesToNote) + .def ("appliesToChannel", &SynthesiserSound::appliesToChannel); + + // ============================================================================================ yup::SynthesiserVoice + + py::class_ classSynthesiserVoice (m, "SynthesiserVoice"); + + classSynthesiserVoice + .def (py::init<>()) + .def ("canPlaySound", &SynthesiserVoice::canPlaySound) + .def ("startNote", &SynthesiserVoice::startNote) + .def ("stopNote", &SynthesiserVoice::stopNote) + .def ("pitchWheelMoved", &SynthesiserVoice::pitchWheelMoved) + .def ("controllerMoved", &SynthesiserVoice::controllerMoved) + .def ("renderNextBlock", py::overload_cast&, int, int> (&SynthesiserVoice::renderNextBlock)) + .def ("setCurrentPlaybackSampleRate", &SynthesiserVoice::setCurrentPlaybackSampleRate) + .def ("isVoiceActive", &SynthesiserVoice::isVoiceActive) + .def ("isKeyDown", &SynthesiserVoice::isKeyDown) + .def ("isSostenutoPedalDown", &SynthesiserVoice::isSostenutoPedalDown) + .def ("isSustainPedalDown", &SynthesiserVoice::isSustainPedalDown) + .def ("getCurrentlyPlayingNote", &SynthesiserVoice::getCurrentlyPlayingNote) + .def ("getCurrentlyPlayingSound", &SynthesiserVoice::getCurrentlyPlayingSound, py::return_value_policy::reference) + .def ("getSampleRate", &SynthesiserVoice::getSampleRate); + + // ============================================================================================ yup::Synthesiser + + py::class_ classSynthesiser (m, "Synthesiser"); + + classSynthesiser + .def (py::init<>()) + .def ("clearVoices", &Synthesiser::clearVoices) + .def ("getVoice", &Synthesiser::getVoice, py::return_value_policy::reference) + .def ("addVoice", &Synthesiser::addVoice) + .def ("removeVoice", &Synthesiser::removeVoice) + .def ("clearSounds", &Synthesiser::clearSounds) + .def ("getNumSounds", &Synthesiser::getNumSounds) + .def ("getSound", &Synthesiser::getSound, py::return_value_policy::reference) + .def ("addSound", &Synthesiser::addSound) + .def ("removeSound", &Synthesiser::removeSound) + .def ("setNoteStealingEnabled", &Synthesiser::setNoteStealingEnabled) + .def ("isNoteStealingEnabled", &Synthesiser::isNoteStealingEnabled) + .def ("setMinimumRenderingSubdivisionSize", &Synthesiser::setMinimumRenderingSubdivisionSize) + .def ("setCurrentPlaybackSampleRate", &Synthesiser::setCurrentPlaybackSampleRate) + .def ("renderNextBlock", py::overload_cast&, const MidiBuffer&, int, int> (&Synthesiser::renderNextBlock), "outputAudio"_a, "inputMidi"_a, "startSample"_a, "numSamples"_a) + .def ("allNotesOff", &Synthesiser::allNotesOff); + + // ============================================================================================ yup::AudioPlayHead + + py::class_ classAudioPlayHead (m, "AudioPlayHead"); + + py::enum_ (classAudioPlayHead, "FrameRateType") + .value ("fps23976", AudioPlayHead::FrameRateType::fps23976) + .value ("fps24", AudioPlayHead::FrameRateType::fps24) + .value ("fps25", AudioPlayHead::FrameRateType::fps25) + .value ("fps2997", AudioPlayHead::FrameRateType::fps2997) + .value ("fps2997drop", AudioPlayHead::FrameRateType::fps2997drop) + .value ("fps30", AudioPlayHead::FrameRateType::fps30) + .value ("fps30drop", AudioPlayHead::FrameRateType::fps30drop) + .value ("fps60", AudioPlayHead::FrameRateType::fps60) + .value ("fps60drop", AudioPlayHead::FrameRateType::fps60drop) + .value ("fpsUnknown", AudioPlayHead::FrameRateType::fpsUnknown) + .export_values(); + + py::class_ classAudioPlayHeadFrameRate (classAudioPlayHead, "FrameRate"); + + classAudioPlayHeadFrameRate + .def (py::init<>()) + .def (py::init()) + .def (py::self == py::self) + .def (py::self != py::self) + .def ("getType", &AudioPlayHead::FrameRate::getType) + .def ("getBaseRate", &AudioPlayHead::FrameRate::getBaseRate) + .def ("isDrop", &AudioPlayHead::FrameRate::isDrop) + .def ("isPullDown", &AudioPlayHead::FrameRate::isPullDown) + .def ("getEffectiveRate", &AudioPlayHead::FrameRate::getEffectiveRate) + .def ("withBaseRate", &AudioPlayHead::FrameRate::withBaseRate) + .def ("withDrop", &AudioPlayHead::FrameRate::withDrop, "drop"_a = true) + .def ("withPullDown", &AudioPlayHead::FrameRate::withPullDown, "pulldown"_a = true); + + py::class_ classAudioPlayHeadTimeSignature (classAudioPlayHead, "TimeSignature"); + + classAudioPlayHeadTimeSignature + .def (py::init<>()) + .def_readwrite ("numerator", &AudioPlayHead::TimeSignature::numerator) + .def_readwrite ("denominator", &AudioPlayHead::TimeSignature::denominator) + .def (py::self == py::self) + .def (py::self != py::self); + + py::class_ classAudioPlayHeadLoopPoints (classAudioPlayHead, "LoopPoints"); + + classAudioPlayHeadLoopPoints + .def (py::init<>()) + .def_readwrite ("ppqStart", &AudioPlayHead::LoopPoints::ppqStart) + .def_readwrite ("ppqEnd", &AudioPlayHead::LoopPoints::ppqEnd) + .def (py::self == py::self) + .def (py::self != py::self); + + py::class_ classAudioPlayHeadPositionInfo (classAudioPlayHead, "PositionInfo"); + + classAudioPlayHeadPositionInfo + .def (py::init<>()) + .def ("getTimeInSamples", &AudioPlayHead::PositionInfo::getTimeInSamples) + .def ("setTimeInSamples", &AudioPlayHead::PositionInfo::setTimeInSamples) + .def ("getTimeInSeconds", &AudioPlayHead::PositionInfo::getTimeInSeconds) + .def ("setTimeInSeconds", &AudioPlayHead::PositionInfo::setTimeInSeconds) + .def ("getBpm", &AudioPlayHead::PositionInfo::getBpm) + .def ("setBpm", &AudioPlayHead::PositionInfo::setBpm) + .def ("getTimeSignature", &AudioPlayHead::PositionInfo::getTimeSignature) + .def ("setTimeSignature", &AudioPlayHead::PositionInfo::setTimeSignature) + .def ("getLoopPoints", &AudioPlayHead::PositionInfo::getLoopPoints) + .def ("setLoopPoints", &AudioPlayHead::PositionInfo::setLoopPoints) + .def ("getBarCount", &AudioPlayHead::PositionInfo::getBarCount) + .def ("setBarCount", &AudioPlayHead::PositionInfo::setBarCount) + .def ("getPpqPositionOfLastBarStart", &AudioPlayHead::PositionInfo::getPpqPositionOfLastBarStart) + .def ("setPpqPositionOfLastBarStart", &AudioPlayHead::PositionInfo::setPpqPositionOfLastBarStart) + .def ("getFrameRate", &AudioPlayHead::PositionInfo::getFrameRate) + .def ("setFrameRate", &AudioPlayHead::PositionInfo::setFrameRate) + .def ("getPpqPosition", &AudioPlayHead::PositionInfo::getPpqPosition) + .def ("setPpqPosition", &AudioPlayHead::PositionInfo::setPpqPosition) + .def ("getEditOriginTime", &AudioPlayHead::PositionInfo::getEditOriginTime) + .def ("setEditOriginTime", &AudioPlayHead::PositionInfo::setEditOriginTime) + .def ("getHostTimeNs", &AudioPlayHead::PositionInfo::getHostTimeNs) + .def ("setHostTimeNs", &AudioPlayHead::PositionInfo::setHostTimeNs) + .def ("getContinuousTimeInSamples", &AudioPlayHead::PositionInfo::getContinuousTimeInSamples) + .def ("setContinuousTimeInSamples", &AudioPlayHead::PositionInfo::setContinuousTimeInSamples) + .def ("getIsPlaying", &AudioPlayHead::PositionInfo::getIsPlaying) + .def ("setIsPlaying", &AudioPlayHead::PositionInfo::setIsPlaying) + .def ("getIsRecording", &AudioPlayHead::PositionInfo::getIsRecording) + .def ("setIsRecording", &AudioPlayHead::PositionInfo::setIsRecording) + .def ("getIsLooping", &AudioPlayHead::PositionInfo::getIsLooping) + .def ("setIsLooping", &AudioPlayHead::PositionInfo::setIsLooping) + .def (py::self == py::self) + .def (py::self != py::self); + + classAudioPlayHead + .def ("getPosition", &AudioPlayHead::getPosition) + .def ("canControlTransport", &AudioPlayHead::canControlTransport) + .def ("transportPlay", &AudioPlayHead::transportPlay) + .def ("transportRecord", &AudioPlayHead::transportRecord) + .def ("transportRewind", &AudioPlayHead::transportRewind); + + // clang-format on +} + +} // namespace yup::Bindings diff --git a/modules/yup_python/bindings/yup_YupAudioBasics_bindings.h b/modules/yup_python/bindings/yup_YupAudioBasics_bindings.h new file mode 100644 index 000000000..5a0a9562a --- /dev/null +++ b/modules/yup_python/bindings/yup_YupAudioBasics_bindings.h @@ -0,0 +1,166 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#if ! YUP_MODULE_AVAILABLE_yup_audio_basics +#error This binding file requires adding the yup_audio_basics module in the project +#else +#include +#endif + +#include "yup_YupCore_bindings.h" + +#define YUP_PYTHON_INCLUDE_PYBIND11_OPERATORS +#define YUP_PYTHON_INCLUDE_PYBIND11_STL +#include "../utilities/yup_PyBind11Includes.h" + +namespace yup::Bindings +{ + +//============================================================================== + +void registerYupAudioBasicsBindings (pybind11::module_& m); + +//============================================================================== + +template +struct PyAudioSource : Base +{ + using Base::Base; + + void prepareToPlay (int samplesPerBlockExpected, double sampleRate) override + { + PYBIND11_OVERRIDE_PURE (void, Base, prepareToPlay, samplesPerBlockExpected, sampleRate); + } + + void releaseResources() override + { + PYBIND11_OVERRIDE_PURE (void, Base, releaseResources); + } + + void getNextAudioBlock (const AudioSourceChannelInfo& bufferToFill) override + { + PYBIND11_OVERRIDE_PURE (void, Base, getNextAudioBlock, bufferToFill); + } +}; + +//============================================================================== + +template +struct PyPositionableAudioSource : PyAudioSource +{ + using PyAudioSource::PyAudioSource; + + void setNextReadPosition (int64 newPosition) override + { + PYBIND11_OVERRIDE_PURE (void, Base, setNextReadPosition, newPosition); + } + + int64 getNextReadPosition() const override + { + PYBIND11_OVERRIDE_PURE (int64, Base, getNextReadPosition); + } + + int64 getTotalLength() const override + { + PYBIND11_OVERRIDE_PURE (int64, Base, getTotalLength); + } + + bool isLooping() const override + { + PYBIND11_OVERRIDE_PURE (bool, Base, isLooping); + } + + void setLooping (bool shouldLoop) override + { + PYBIND11_OVERRIDE (void, Base, setLooping, shouldLoop); + } +}; + +//============================================================================== + +struct PySynthesiserSound : SynthesiserSound +{ + bool appliesToNote (int midiNoteNumber) override + { + PYBIND11_OVERRIDE_PURE (bool, SynthesiserSound, appliesToNote, midiNoteNumber); + } + + bool appliesToChannel (int midiChannel) override + { + PYBIND11_OVERRIDE_PURE (bool, SynthesiserSound, appliesToChannel, midiChannel); + } +}; + +//============================================================================== + +struct PySynthesiserVoice : SynthesiserVoice +{ + bool canPlaySound (SynthesiserSound* sound) override + { + PYBIND11_OVERRIDE_PURE (bool, SynthesiserVoice, canPlaySound, sound); + } + + void startNote (int midiNoteNumber, float velocity, SynthesiserSound* sound, int currentPitchWheelPosition) override + { + PYBIND11_OVERRIDE_PURE (void, SynthesiserVoice, startNote, midiNoteNumber, velocity, sound, currentPitchWheelPosition); + } + + void stopNote (float velocity, bool allowTailOff) override + { + PYBIND11_OVERRIDE_PURE (void, SynthesiserVoice, stopNote, velocity, allowTailOff); + } + + void pitchWheelMoved (int newPitchWheelValue) override + { + PYBIND11_OVERRIDE_PURE (void, SynthesiserVoice, pitchWheelMoved, newPitchWheelValue); + } + + void controllerMoved (int controllerNumber, int newControllerValue) override + { + PYBIND11_OVERRIDE_PURE (void, SynthesiserVoice, controllerMoved, controllerNumber, newControllerValue); + } + + void renderNextBlock (AudioBuffer& outputBuffer, int startSample, int numSamples) override + { + PYBIND11_OVERRIDE_PURE (void, SynthesiserVoice, renderNextBlock, outputBuffer, startSample, numSamples); + } + + void setCurrentPlaybackSampleRate (double newRate) override + { + PYBIND11_OVERRIDE (void, SynthesiserVoice, setCurrentPlaybackSampleRate, newRate); + } + + bool isVoiceActive() const override + { + PYBIND11_OVERRIDE (bool, SynthesiserVoice, isVoiceActive); + } +}; + +//============================================================================== + +struct PyAudioPlayHeadPositionInfo : AudioPlayHead::PositionInfo +{ + using AudioPlayHead::PositionInfo::PositionInfo; +}; + +} // namespace yup::Bindings diff --git a/modules/yup_python/modules/yup_YupMain_module.cpp b/modules/yup_python/modules/yup_YupMain_module.cpp index d448290e1..65e6749b7 100644 --- a/modules/yup_python/modules/yup_YupMain_module.cpp +++ b/modules/yup_python/modules/yup_YupMain_module.cpp @@ -40,11 +40,11 @@ #include "../bindings/yup_YupGui_bindings.h" #endif -/* #if YUP_MODULE_AVAILABLE_yup_audio_basics #include "../bindings/yup_YupAudioBasics_bindings.h" #endif +/* #if YUP_MODULE_AVAILABLE_yup_audio_devices #include "../bindings/yup_YupAudioDevices_bindings.h" #endif @@ -92,11 +92,11 @@ PYBIND11_MODULE (YUP_PYTHON_MODULE_NAME, m) yup::Bindings::registerYupGuiBindings (m); #endif - /* #if YUP_MODULE_AVAILABLE_yup_audio_basics yup::Bindings::registerYupAudioBasicsBindings (m); #endif + /* #if YUP_MODULE_AVAILABLE_yup_audio_devices yup::Bindings::registerYupAudioDevicesBindings (m); #endif diff --git a/modules/yup_python/yup_python_audio_basics.cpp b/modules/yup_python/yup_python_audio_basics.cpp new file mode 100644 index 000000000..c0e53394d --- /dev/null +++ b/modules/yup_python/yup_python_audio_basics.cpp @@ -0,0 +1,22 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "bindings/yup_YupAudioBasics_bindings.cpp" diff --git a/python/tests/test_yup_audio_basics/__init__.py b/python/tests/test_yup_audio_basics/__init__.py new file mode 100644 index 000000000..dd5b34ffa --- /dev/null +++ b/python/tests/test_yup_audio_basics/__init__.py @@ -0,0 +1 @@ +# Test package for yup_audio_basics bindings diff --git a/python/tests/test_yup_audio_basics/test_ADSR.py b/python/tests/test_yup_audio_basics/test_ADSR.py new file mode 100644 index 000000000..cdc1cbc4a --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_ADSR.py @@ -0,0 +1,169 @@ +import yup + +#================================================================================================== + +def test_adsr_parameters_construction(): + # Test default construction + params = yup.ADSR.Parameters() + assert params.attack >= 0.0 + assert params.decay >= 0.0 + assert params.sustain >= 0.0 + assert params.release >= 0.0 + + # Test construction with values + params = yup.ADSR.Parameters(0.1, 0.2, 0.7, 0.5) + assert abs(params.attack - 0.1) < 0.001 + assert abs(params.decay - 0.2) < 0.001 + assert abs(params.sustain - 0.7) < 0.001 + assert abs(params.release - 0.5) < 0.001 + +#================================================================================================== + +def test_adsr_parameters_fields(): + params = yup.ADSR.Parameters() + + # Test setting fields + params.attack = 0.05 + params.decay = 0.1 + params.sustain = 0.8 + params.release = 0.3 + + assert abs(params.attack - 0.05) < 0.001 + assert abs(params.decay - 0.1) < 0.001 + assert abs(params.sustain - 0.8) < 0.001 + assert abs(params.release - 0.3) < 0.001 + +#================================================================================================== + +def test_adsr_construction(): + adsr = yup.ADSR() + assert not adsr.isActive() + +#================================================================================================== + +def test_adsr_set_parameters(): + adsr = yup.ADSR() + params = yup.ADSR.Parameters(0.1, 0.2, 0.7, 0.5) + + adsr.setParameters(params) + retrieved = adsr.getParameters() + + assert abs(retrieved.attack - params.attack) < 0.001 + assert abs(retrieved.decay - params.decay) < 0.001 + assert abs(retrieved.sustain - params.sustain) < 0.001 + assert abs(retrieved.release - params.release) < 0.001 + +#================================================================================================== + +def test_adsr_set_sample_rate(): + adsr = yup.ADSR() + adsr.setSampleRate(44100.0) + + # Should not throw, exact behavior depends on implementation + params = yup.ADSR.Parameters(0.1, 0.2, 0.7, 0.5) + adsr.setParameters(params) + +#================================================================================================== + +def test_adsr_note_on_off(): + adsr = yup.ADSR() + adsr.setSampleRate(44100.0) + + params = yup.ADSR.Parameters(0.01, 0.01, 0.7, 0.1) + adsr.setParameters(params) + + # Initially not active + assert not adsr.isActive() + + # Note on activates + adsr.noteOn() + assert adsr.isActive() + + # Get some samples during attack + for _ in range(100): + sample = adsr.getNextSample() + assert sample >= 0.0 + + # Note off starts release + adsr.noteOff() + + # Still active during release + active_after_noteoff = adsr.isActive() + + # Process through release phase + for _ in range(10000): + sample = adsr.getNextSample() + if not adsr.isActive(): + break + + # Eventually becomes inactive + # (May or may not be inactive depending on release time and samples processed) + +#================================================================================================== + +def test_adsr_reset(): + adsr = yup.ADSR() + adsr.setSampleRate(44100.0) + + params = yup.ADSR.Parameters(0.01, 0.01, 0.7, 0.1) + adsr.setParameters(params) + + adsr.noteOn() + assert adsr.isActive() + + # Get some samples + for _ in range(100): + adsr.getNextSample() + + # Reset should stop immediately + adsr.reset() + assert not adsr.isActive() + +#================================================================================================== + +def test_adsr_envelope_shape(): + adsr = yup.ADSR() + adsr.setSampleRate(44100.0) + + # Very short times for testing + params = yup.ADSR.Parameters(0.001, 0.001, 0.5, 0.001) + adsr.setParameters(params) + + adsr.noteOn() + + # Collect samples during attack phase + attack_samples = [] + for _ in range(100): + attack_samples.append(adsr.getNextSample()) + + # Attack phase should generally increase + # (allowing for some tolerance due to discrete sampling) + increasing_count = 0 + for i in range(len(attack_samples) - 1): + if attack_samples[i + 1] >= attack_samples[i]: + increasing_count += 1 + + # Most samples should be increasing during attack (allow some tolerance) + assert increasing_count > len(attack_samples) * 0.4 + +#================================================================================================== + +def test_adsr_sustain_level(): + adsr = yup.ADSR() + adsr.setSampleRate(44100.0) + + sustain_level = 0.6 + params = yup.ADSR.Parameters(0.0001, 0.0001, sustain_level, 0.1) + adsr.setParameters(params) + + adsr.noteOn() + + # Process through attack and decay to reach sustain + for _ in range(1000): + adsr.getNextSample() + + # Sample during sustain phase should be close to sustain level + sustain_sample = adsr.getNextSample() + + # Allow generous tolerance for sustain level + assert abs(sustain_sample - sustain_level) < 0.3 diff --git a/python/tests/test_yup_audio_basics/test_AudioBuffer.py b/python/tests/test_yup_audio_basics/test_AudioBuffer.py new file mode 100644 index 000000000..40b46c3d3 --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_AudioBuffer.py @@ -0,0 +1,162 @@ +import pytest +import yup + +#================================================================================================== + +def test_audio_buffer_construction(): + # Test default construction + buffer = yup.AudioBuffer() + assert buffer.getNumChannels() == 0 + assert buffer.getNumSamples() == 0 + + # Test construction with size + buffer = yup.AudioBuffer(2, 512) + assert buffer.getNumChannels() == 2 + assert buffer.getNumSamples() == 512 + +#================================================================================================== + +def test_audio_buffer_set_size(): + buffer = yup.AudioBuffer() + buffer.setSize(2, 1024) + assert buffer.getNumChannels() == 2 + assert buffer.getNumSamples() == 1024 + + # Resize + buffer.setSize(4, 512) + assert buffer.getNumChannels() == 4 + assert buffer.getNumSamples() == 512 + +#================================================================================================== + +def test_audio_buffer_clear(): + buffer = yup.AudioBuffer(2, 512) + + # Fill with non-zero values + for channel in range(buffer.getNumChannels()): + for sample in range(buffer.getNumSamples()): + buffer.setSample(channel, sample, 1.0) + + # Clear all + buffer.clear() + assert buffer.hasBeenCleared() + + # Verify cleared + for channel in range(buffer.getNumChannels()): + for sample in range(min(10, buffer.getNumSamples())): + assert abs(buffer.getSample(channel, sample)) < 0.0001 + +#================================================================================================== + +def test_audio_buffer_samples(): + buffer = yup.AudioBuffer(2, 512) + + # Set and get samples + buffer.setSample(0, 0, 0.5) + buffer.setSample(0, 1, -0.5) + buffer.setSample(1, 0, 0.25) + + assert abs(buffer.getSample(0, 0) - 0.5) < 0.001 + assert abs(buffer.getSample(0, 1) - (-0.5)) < 0.001 + assert abs(buffer.getSample(1, 0) - 0.25) < 0.001 + +#================================================================================================== + +def test_audio_buffer_add_sample(): + buffer = yup.AudioBuffer(2, 512) + buffer.clear() + + buffer.setSample(0, 0, 0.5) + buffer.addSample(0, 0, 0.25) + + assert abs(buffer.getSample(0, 0) - 0.75) < 0.001 + +#================================================================================================== + +def test_audio_buffer_apply_gain(): + buffer = yup.AudioBuffer(2, 512) + + # Fill with values + for channel in range(buffer.getNumChannels()): + for sample in range(buffer.getNumSamples()): + buffer.setSample(channel, sample, 1.0) + + # Apply gain + buffer.applyGain(0.5) + + # Check a few samples + for channel in range(buffer.getNumChannels()): + for sample in range(min(10, buffer.getNumSamples())): + assert abs(buffer.getSample(channel, sample) - 0.5) < 0.001 + +#================================================================================================== + +def test_audio_buffer_copy(): + source = yup.AudioBuffer(2, 512) + dest = yup.AudioBuffer(2, 512) + + # Fill source with values + for channel in range(source.getNumChannels()): + for sample in range(source.getNumSamples()): + source.setSample(channel, sample, 0.7) + + dest.clear() + + # Copy from source + dest.copyFrom(0, 0, source, 0, 0, 512) + + # Verify + for sample in range(min(10, dest.getNumSamples())): + assert abs(dest.getSample(0, sample) - 0.7) < 0.001 + +#================================================================================================== + +def test_audio_buffer_find_min_max(): + buffer = yup.AudioBuffer(1, 512) + buffer.clear() + + buffer.setSample(0, 0, -0.8) + buffer.setSample(0, 1, 0.9) + buffer.setSample(0, 2, 0.3) + + result = buffer.findMinMax(0, 0, 512) + assert result.getStart() <= -0.79 # Allow small tolerance + assert result.getEnd() >= 0.89 # Allow small tolerance + +#================================================================================================== + +def test_audio_buffer_get_magnitude(): + buffer = yup.AudioBuffer(2, 512) + buffer.clear() + + # Set some known values + buffer.setSample(0, 0, 1.0) + buffer.setSample(0, 1, -1.0) + buffer.setSample(0, 2, 0.5) + + mag = buffer.getMagnitude(0, 0, 3) + assert mag >= 0.49 # Allow small tolerance + +#================================================================================================== + +def test_audio_buffer_get_rms_level(): + buffer = yup.AudioBuffer(1, 512) + + # Fill with constant value + for sample in range(buffer.getNumSamples()): + buffer.setSample(0, sample, 0.5) + + rms = buffer.getRMSLevel(0, 0, 512) + assert abs(rms - 0.5) < 0.01 + +#================================================================================================== + +def test_audio_buffer_double(): + # Test double precision buffer + buffer = yup.AudioBufferDouble(2, 512) + assert buffer.getNumChannels() == 2 + assert buffer.getNumSamples() == 512 + + buffer.setSample(0, 0, 0.123456789) + value = buffer.getSample(0, 0) + assert abs(value - 0.123456789) < 0.0000001 diff --git a/python/tests/test_yup_audio_basics/test_AudioChannelSet.py b/python/tests/test_yup_audio_basics/test_AudioChannelSet.py new file mode 100644 index 000000000..50c190a14 --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_AudioChannelSet.py @@ -0,0 +1,143 @@ +import yup + +#================================================================================================== + +def test_audio_channel_set_construction(): + # Test default construction + channels = yup.AudioChannelSet() + assert channels.size() == 0 + +#================================================================================================== + +def test_audio_channel_set_mono(): + channels = yup.AudioChannelSet.mono() + assert channels.size() == 1 + assert channels.getDescription() == "Mono" + +#================================================================================================== + +def test_audio_channel_set_stereo(): + channels = yup.AudioChannelSet.stereo() + assert channels.size() == 2 + assert channels.getDescription() == "Stereo" + +#================================================================================================== + +def test_audio_channel_set_5_1(): + channels = yup.AudioChannelSet.create5point1() + assert channels.size() == 6 + assert "5.1" in channels.getDescription() + +#================================================================================================== + +def test_audio_channel_set_7_1(): + channels = yup.AudioChannelSet.create7point1() + assert channels.size() == 8 + assert "7.1" in channels.getDescription() + +#================================================================================================== + +def test_audio_channel_set_discrete(): + channels = yup.AudioChannelSet.discreteChannels(4) + assert channels.size() == 4 + assert channels.isDiscreteLayout() + +#================================================================================================== + +def test_audio_channel_set_add_channel(): + channels = yup.AudioChannelSet() + channels.addChannel(yup.AudioChannelSet.ChannelType.Left) + channels.addChannel(yup.AudioChannelSet.ChannelType.Right) + + assert channels.size() == 2 + +#================================================================================================== + +def test_audio_channel_set_remove_channel(): + channels = yup.AudioChannelSet.stereo() + assert channels.size() == 2 + + channels.removeChannel(yup.AudioChannelSet.ChannelType.Right) + assert channels.size() == 1 + +#================================================================================================== + +def test_audio_channel_set_get_type_of_channel(): + channels = yup.AudioChannelSet.stereo() + + leftType = channels.getTypeOfChannel(0) + assert leftType == yup.AudioChannelSet.ChannelType.Left + + rightType = channels.getTypeOfChannel(1) + assert rightType == yup.AudioChannelSet.ChannelType.Right + +#================================================================================================== + +def test_audio_channel_set_get_channel_index_for_type(): + channels = yup.AudioChannelSet.stereo() + + leftIndex = channels.getChannelIndexForType(yup.AudioChannelSet.ChannelType.Left) + assert leftIndex == 0 + + rightIndex = channels.getChannelIndexForType(yup.AudioChannelSet.ChannelType.Right) + assert rightIndex == 1 + + # Non-existent channel + centerIndex = channels.getChannelIndexForType(yup.AudioChannelSet.ChannelType.Center) + assert centerIndex == -1 + +#================================================================================================== + +def test_audio_channel_set_comparison(): + stereo1 = yup.AudioChannelSet.stereo() + stereo2 = yup.AudioChannelSet.stereo() + mono = yup.AudioChannelSet.mono() + + assert stereo1 == stereo2 + assert stereo1 != mono + +#================================================================================================== + +def test_audio_channel_set_channel_types(): + channels = yup.AudioChannelSet.create5point1() + types = channels.getChannelTypes() + + # getChannelTypes returns an Array, not a Python list + assert types.size() == 6 + assert types.contains(yup.AudioChannelSet.ChannelType.Left) + assert types.contains(yup.AudioChannelSet.ChannelType.Right) + assert types.contains(yup.AudioChannelSet.ChannelType.Center) + assert types.contains(yup.AudioChannelSet.ChannelType.LFE) + +#================================================================================================== + +def test_audio_channel_set_abbreviated_name(): + leftName = yup.AudioChannelSet.getAbbreviatedChannelTypeName(yup.AudioChannelSet.ChannelType.Left) + assert "L" in leftName + + rightName = yup.AudioChannelSet.getAbbreviatedChannelTypeName(yup.AudioChannelSet.ChannelType.Right) + assert "R" in rightName + + centerName = yup.AudioChannelSet.getAbbreviatedChannelTypeName(yup.AudioChannelSet.ChannelType.Center) + assert "C" in centerName + +#================================================================================================== + +def test_audio_channel_set_canonical(): + # Test canonical channel sets for common channel counts + mono = yup.AudioChannelSet.canonicalChannelSet(1) + assert mono == yup.AudioChannelSet.mono() + + stereo = yup.AudioChannelSet.canonicalChannelSet(2) + assert stereo == yup.AudioChannelSet.stereo() + +#================================================================================================== + +def test_audio_channel_set_channel_sets_with_number_of_channels(): + # Get all known layouts for 2 channels + layouts = yup.AudioChannelSet.channelSetsWithNumberOfChannels(2) + assert layouts.size() >= 1 # At least stereo layout exists + + # All should have 2 channels + for layout in layouts: + assert layout.size() == 2 diff --git a/python/tests/test_yup_audio_basics/test_AudioPlayHead.py b/python/tests/test_yup_audio_basics/test_AudioPlayHead.py new file mode 100644 index 000000000..2d45119e7 --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_AudioPlayHead.py @@ -0,0 +1,363 @@ +import yup + +#================================================================================================== + +def test_frame_rate_type_enum(): + # Test that enum values exist + assert yup.AudioPlayHead.FrameRateType.fps24 is not None + assert yup.AudioPlayHead.FrameRateType.fps25 is not None + assert yup.AudioPlayHead.FrameRateType.fps30 is not None + assert yup.AudioPlayHead.FrameRateType.fps60 is not None + assert yup.AudioPlayHead.FrameRateType.fpsUnknown is not None + +#================================================================================================== + +def test_frame_rate_construction(): + # Test default construction + frameRate = yup.AudioPlayHead.FrameRate() + assert frameRate.getBaseRate() == 0 + + # Test construction from enum + frameRate = yup.AudioPlayHead.FrameRate(yup.AudioPlayHead.FrameRateType.fps24) + assert frameRate.getBaseRate() == 24 + +#================================================================================================== + +def test_frame_rate_properties(): + frameRate = yup.AudioPlayHead.FrameRate(yup.AudioPlayHead.FrameRateType.fps24) + + assert frameRate.getBaseRate() == 24 + assert not frameRate.isDrop() + assert not frameRate.isPullDown() + assert abs(frameRate.getEffectiveRate() - 24.0) < 0.001 + + # Test 23.976 + frameRate = yup.AudioPlayHead.FrameRate(yup.AudioPlayHead.FrameRateType.fps23976) + assert frameRate.getBaseRate() == 24 + assert frameRate.isPullDown() + assert abs(frameRate.getEffectiveRate() - 23.976) < 0.01 + +#================================================================================================== + +def test_frame_rate_with_methods(): + frameRate = yup.AudioPlayHead.FrameRate() + + # Test withBaseRate + frameRate = frameRate.withBaseRate(30) + assert frameRate.getBaseRate() == 30 + + # Test withDrop + frameRate = frameRate.withDrop(True) + assert frameRate.isDrop() + + # Test withPullDown + frameRate = frameRate.withPullDown(True) + assert frameRate.isPullDown() + +#================================================================================================== + +def test_frame_rate_comparison(): + fr1 = yup.AudioPlayHead.FrameRate(yup.AudioPlayHead.FrameRateType.fps24) + fr2 = yup.AudioPlayHead.FrameRate(yup.AudioPlayHead.FrameRateType.fps24) + fr3 = yup.AudioPlayHead.FrameRate(yup.AudioPlayHead.FrameRateType.fps25) + + assert fr1 == fr2 + assert fr1 != fr3 + +#================================================================================================== + +def test_frame_rate_get_type(): + frameRate = yup.AudioPlayHead.FrameRate(yup.AudioPlayHead.FrameRateType.fps30) + assert frameRate.getType() == yup.AudioPlayHead.FrameRateType.fps30 + + frameRate = yup.AudioPlayHead.FrameRate(yup.AudioPlayHead.FrameRateType.fps2997) + assert frameRate.getType() == yup.AudioPlayHead.FrameRateType.fps2997 + +#================================================================================================== + +def test_time_signature_construction(): + # Test default construction + timeSig = yup.AudioPlayHead.TimeSignature() + assert timeSig.numerator == 4 + assert timeSig.denominator == 4 + +#================================================================================================== + +def test_time_signature_fields(): + timeSig = yup.AudioPlayHead.TimeSignature() + + timeSig.numerator = 3 + timeSig.denominator = 4 + + assert timeSig.numerator == 3 + assert timeSig.denominator == 4 + +#================================================================================================== + +def test_time_signature_comparison(): + ts1 = yup.AudioPlayHead.TimeSignature() + ts1.numerator = 3 + ts1.denominator = 4 + + ts2 = yup.AudioPlayHead.TimeSignature() + ts2.numerator = 3 + ts2.denominator = 4 + + ts3 = yup.AudioPlayHead.TimeSignature() + ts3.numerator = 4 + ts3.denominator = 4 + + assert ts1.numerator == ts2.numerator and ts1.denominator == ts2.denominator + assert ts1.numerator != ts3.numerator or ts1.denominator != ts3.denominator + +#================================================================================================== + +def test_loop_points_construction(): + loopPoints = yup.AudioPlayHead.LoopPoints() + assert loopPoints.ppqStart == 0.0 + assert loopPoints.ppqEnd == 0.0 + +#================================================================================================== + +def test_loop_points_fields(): + loopPoints = yup.AudioPlayHead.LoopPoints() + + loopPoints.ppqStart = 4.0 + loopPoints.ppqEnd = 8.0 + + assert abs(loopPoints.ppqStart - 4.0) < 0.001 + assert abs(loopPoints.ppqEnd - 8.0) < 0.001 + +#================================================================================================== + +def test_loop_points_comparison(): + lp1 = yup.AudioPlayHead.LoopPoints() + lp1.ppqStart = 4.0 + lp1.ppqEnd = 8.0 + + lp2 = yup.AudioPlayHead.LoopPoints() + lp2.ppqStart = 4.0 + lp2.ppqEnd = 8.0 + + lp3 = yup.AudioPlayHead.LoopPoints() + lp3.ppqStart = 0.0 + lp3.ppqEnd = 16.0 + + assert abs(lp1.ppqStart - lp2.ppqStart) < 0.001 and abs(lp1.ppqEnd - lp2.ppqEnd) < 0.001 + assert abs(lp1.ppqStart - lp3.ppqStart) > 0.001 or abs(lp1.ppqEnd - lp3.ppqEnd) > 0.001 + +#================================================================================================== + +def test_position_info_construction(): + posInfo = yup.AudioPlayHead.PositionInfo() + + # Default constructed should have no values set + assert posInfo.getTimeInSamples() is None + assert posInfo.getTimeInSeconds() is None + assert posInfo.getBpm() is None + assert not posInfo.getIsPlaying() + assert not posInfo.getIsRecording() + assert not posInfo.getIsLooping() + +#================================================================================================== + +def test_position_info_time_in_samples(): + posInfo = yup.AudioPlayHead.PositionInfo() + + posInfo.setTimeInSamples(44100) + value = posInfo.getTimeInSamples() + + assert value is not None + assert abs(value - 44100) < 1 + +#================================================================================================== + +def test_position_info_time_in_seconds(): + posInfo = yup.AudioPlayHead.PositionInfo() + + posInfo.setTimeInSeconds(1.5) + value = posInfo.getTimeInSeconds() + + assert value is not None + assert abs(value - 1.5) < 0.001 + +#================================================================================================== + +def test_position_info_bpm(): + posInfo = yup.AudioPlayHead.PositionInfo() + + posInfo.setBpm(120.0) + value = posInfo.getBpm() + + assert value is not None + assert abs(value - 120.0) < 0.001 + +#================================================================================================== + +def test_position_info_time_signature(): + posInfo = yup.AudioPlayHead.PositionInfo() + + timeSig = yup.AudioPlayHead.TimeSignature() + timeSig.numerator = 3 + timeSig.denominator = 4 + + posInfo.setTimeSignature(timeSig) + value = posInfo.getTimeSignature() + + assert value is not None + assert value.numerator == 3 + assert value.denominator == 4 + +#================================================================================================== + +def test_position_info_loop_points(): + posInfo = yup.AudioPlayHead.PositionInfo() + + loopPoints = yup.AudioPlayHead.LoopPoints() + loopPoints.ppqStart = 4.0 + loopPoints.ppqEnd = 8.0 + + posInfo.setLoopPoints(loopPoints) + value = posInfo.getLoopPoints() + + assert value is not None + assert abs(value.ppqStart - 4.0) < 0.001 + assert abs(value.ppqEnd - 8.0) < 0.001 + +#================================================================================================== + +def test_position_info_bar_count(): + posInfo = yup.AudioPlayHead.PositionInfo() + + posInfo.setBarCount(16) + value = posInfo.getBarCount() + + assert value is not None + assert abs(value - 16) < 1 + +#================================================================================================== + +def test_position_info_ppq_position(): + posInfo = yup.AudioPlayHead.PositionInfo() + + posInfo.setPpqPosition(12.5) + value = posInfo.getPpqPosition() + + assert value is not None + assert abs(value - 12.5) < 0.001 + +#================================================================================================== + +def test_position_info_ppq_position_of_last_bar_start(): + posInfo = yup.AudioPlayHead.PositionInfo() + + posInfo.setPpqPositionOfLastBarStart(8.0) + value = posInfo.getPpqPositionOfLastBarStart() + + assert value is not None + assert abs(value - 8.0) < 0.001 + +#================================================================================================== + +def test_position_info_frame_rate(): + posInfo = yup.AudioPlayHead.PositionInfo() + + frameRate = yup.AudioPlayHead.FrameRate(yup.AudioPlayHead.FrameRateType.fps25) + posInfo.setFrameRate(frameRate) + value = posInfo.getFrameRate() + + assert value is not None + assert value.getBaseRate() == 25 + +#================================================================================================== + +def test_position_info_edit_origin_time(): + posInfo = yup.AudioPlayHead.PositionInfo() + + posInfo.setEditOriginTime(10.0) + value = posInfo.getEditOriginTime() + + assert value is not None + assert abs(value - 10.0) < 0.001 + +#================================================================================================== + +def test_position_info_host_time_ns(): + posInfo = yup.AudioPlayHead.PositionInfo() + + posInfo.setHostTimeNs(123456789) + value = posInfo.getHostTimeNs() + + assert value is not None + assert abs(value - 123456789) < 1 + +#================================================================================================== + +def test_position_info_continuous_time(): + posInfo = yup.AudioPlayHead.PositionInfo() + + posInfo.setContinuousTimeInSamples(88200) + value = posInfo.getContinuousTimeInSamples() + + assert value is not None + assert abs(value - 88200) < 1 + +#================================================================================================== + +def test_position_info_boolean_flags(): + posInfo = yup.AudioPlayHead.PositionInfo() + + # Test playing + assert not posInfo.getIsPlaying() + posInfo.setIsPlaying(True) + assert posInfo.getIsPlaying() + + # Test recording + assert not posInfo.getIsRecording() + posInfo.setIsRecording(True) + assert posInfo.getIsRecording() + + # Test looping + assert not posInfo.getIsLooping() + posInfo.setIsLooping(True) + assert posInfo.getIsLooping() + +#================================================================================================== + +def test_position_info_comparison(): + pos1 = yup.AudioPlayHead.PositionInfo() + pos1.setTimeInSamples(44100) + pos1.setBpm(120.0) + + pos2 = yup.AudioPlayHead.PositionInfo() + pos2.setTimeInSamples(44100) + pos2.setBpm(120.0) + + pos3 = yup.AudioPlayHead.PositionInfo() + pos3.setTimeInSamples(88200) + + # Compare individual fields instead of using equality operator + assert pos1.getTimeInSamples() == pos2.getTimeInSamples() + assert abs(pos1.getBpm() - pos2.getBpm()) < 0.001 + assert pos1.getTimeInSamples() != pos3.getTimeInSamples() + +#================================================================================================== + +def test_position_info_optional_values(): + posInfo = yup.AudioPlayHead.PositionInfo() + + # Values not set should return None + assert posInfo.getTimeInSamples() is None + assert posInfo.getBpm() is None + assert posInfo.getTimeSignature() is None + assert posInfo.getLoopPoints() is None + + # Set a value + posInfo.setBpm(140.0) + + # Now getBpm should return a value + assert posInfo.getBpm() is not None + + # But others should still be None + assert posInfo.getTimeInSamples() is None + assert posInfo.getTimeSignature() is None diff --git a/python/tests/test_yup_audio_basics/test_Decibels.py b/python/tests/test_yup_audio_basics/test_Decibels.py new file mode 100644 index 000000000..725ff7a11 --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_Decibels.py @@ -0,0 +1,106 @@ +import yup +import math + +#================================================================================================== + +def test_decibels_to_gain(): + # Test 0 dB = gain of 1 + gain = yup.Decibels.decibelsToGain(0.0) + assert abs(gain - 1.0) < 0.001 + + # Test 6 dB ≈ gain of 2 + gain = yup.Decibels.decibelsToGain(6.0) + assert abs(gain - 2.0) < 0.1 + + # Test -6 dB ≈ gain of 0.5 + gain = yup.Decibels.decibelsToGain(-6.0) + assert abs(gain - 0.5) < 0.1 + + # Test -infinity + gain = yup.Decibels.decibelsToGain(-100.0) + assert gain == 0.0 + +#================================================================================================== + +def test_gain_to_decibels(): + # Test gain of 1 = 0 dB + db = yup.Decibels.gainToDecibels(1.0) + assert abs(db - 0.0) < 0.001 + + # Test gain of 2 ≈ 6 dB + db = yup.Decibels.gainToDecibels(2.0) + assert abs(db - 6.0) < 0.1 + + # Test gain of 0.5 ≈ -6 dB + db = yup.Decibels.gainToDecibels(0.5) + assert abs(db - (-6.0)) < 0.1 + + # Test gain of 0 = -infinity + db = yup.Decibels.gainToDecibels(0.0) + assert db == -100.0 + +#================================================================================================== + +def test_decibels_round_trip(): + # Test round-trip conversion + original_db = -12.0 + gain = yup.Decibels.decibelsToGain(original_db) + converted_db = yup.Decibels.gainToDecibels(gain) + + assert abs(original_db - converted_db) < 0.001 + +#================================================================================================== + +def test_gain_with_lower_bound(): + # Test that gain is clamped to lower bound + gain = yup.Decibels.gainWithLowerBound(0.0001, -40.0) + lowerBoundGain = yup.Decibels.decibelsToGain(-40.0) + + # Allow small tolerance for floating point comparison + assert gain >= lowerBoundGain * 0.99 + + # Test that higher gains pass through unchanged + gain = yup.Decibels.gainWithLowerBound(1.0, -40.0) + assert abs(gain - 1.0) < 0.001 + +#================================================================================================== + +def test_decibels_to_string(): + # Test basic formatting + s = yup.Decibels.toString(0.0) + assert "+0" in s + assert "dB" in s + + # Test negative value + s = yup.Decibels.toString(-6.0) + assert "-6" in s + assert "dB" in s + + # Test positive value + s = yup.Decibels.toString(3.0) + assert "+3" in s + + # Test decimal places + s = yup.Decibels.toString(-12.345, 2) + assert "-12.3" in s or "-12.4" in s + + # Test without suffix + s = yup.Decibels.toString(0.0, 2, -100.0, False) + assert "dB" not in s + + # Test minus infinity + s = yup.Decibels.toString(-100.0, 2, -100.0) + assert "INF" in s + +#================================================================================================== + +def test_decibels_custom_minus_infinity(): + # Test custom minus infinity threshold + gain = yup.Decibels.decibelsToGain(-50.0, -50.0) + assert gain == 0.0 + + db = yup.Decibels.gainToDecibels(0.0, -50.0) + assert db == -50.0 + + s = yup.Decibels.toString(-50.0, 2, -50.0) + assert "INF" in s diff --git a/python/tests/test_yup_audio_basics/test_IIRFilter.py b/python/tests/test_yup_audio_basics/test_IIRFilter.py new file mode 100644 index 000000000..94a665cc8 --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_IIRFilter.py @@ -0,0 +1,200 @@ +import yup +import math + +#================================================================================================== + +def test_iir_coefficients_construction(): + coeffs = yup.IIRCoefficients() + assert coeffs is not None + +#================================================================================================== + +def test_iir_coefficients_make_low_pass(): + sampleRate = 44100.0 + frequency = 1000.0 + + coeffs = yup.IIRCoefficients.makeLowPass(sampleRate, frequency) + assert coeffs is not None + +#================================================================================================== + +def test_iir_coefficients_make_high_pass(): + sampleRate = 44100.0 + frequency = 1000.0 + + coeffs = yup.IIRCoefficients.makeHighPass(sampleRate, frequency) + assert coeffs is not None + +#================================================================================================== + +def test_iir_coefficients_make_band_pass(): + sampleRate = 44100.0 + frequency = 1000.0 + + coeffs = yup.IIRCoefficients.makeBandPass(sampleRate, frequency) + assert coeffs is not None + +#================================================================================================== + +def test_iir_coefficients_make_low_shelf(): + sampleRate = 44100.0 + cutOffFrequency = 1000.0 + Q = 1.0 + gainFactor = 2.0 + + coeffs = yup.IIRCoefficients.makeLowShelf(sampleRate, cutOffFrequency, Q, gainFactor) + assert coeffs is not None + +#================================================================================================== + +def test_iir_coefficients_make_high_shelf(): + sampleRate = 44100.0 + cutOffFrequency = 1000.0 + Q = 1.0 + gainFactor = 2.0 + + coeffs = yup.IIRCoefficients.makeHighShelf(sampleRate, cutOffFrequency, Q, gainFactor) + assert coeffs is not None + +#================================================================================================== + +def test_iir_coefficients_make_peak_filter(): + sampleRate = 44100.0 + centerFrequency = 1000.0 + Q = 1.0 + gainFactor = 2.0 + + coeffs = yup.IIRCoefficients.makePeakFilter(sampleRate, centerFrequency, Q, gainFactor) + assert coeffs is not None + +#================================================================================================== + +def test_iir_coefficients_make_notch_filter(): + sampleRate = 44100.0 + frequency = 1000.0 + Q = 1.0 + + coeffs = yup.IIRCoefficients.makeNotchFilter(sampleRate, frequency, Q) + assert coeffs is not None + +#================================================================================================== + +def test_iir_coefficients_make_all_pass(): + sampleRate = 44100.0 + frequency = 1000.0 + Q = 1.0 + + coeffs = yup.IIRCoefficients.makeAllPass(sampleRate, frequency, Q) + assert coeffs is not None + +#================================================================================================== + +def test_iir_filter_construction(): + filter = yup.IIRFilter() + assert filter is not None + +#================================================================================================== + +def test_iir_filter_reset(): + filter = yup.IIRFilter() + filter.reset() + + # Should not throw + assert True + +#================================================================================================== + +def test_iir_filter_set_coefficients(): + filter = yup.IIRFilter() + coeffs = yup.IIRCoefficients.makeLowPass(44100.0, 1000.0) + + filter.setCoefficients(coeffs) + + # Should not throw + assert True + +#================================================================================================== + +def test_iir_filter_process_single_sample(): + filter = yup.IIRFilter() + coeffs = yup.IIRCoefficients.makeLowPass(44100.0, 1000.0) + filter.setCoefficients(coeffs) + + # Process a sample + input_sample = 1.0 + output_sample = filter.processSingleSampleRaw(input_sample) + + # Output should be a valid number + assert not math.isnan(output_sample) + assert not math.isinf(output_sample) + +#================================================================================================== + +def test_iir_filter_low_pass_behavior(): + # Create a low pass filter at 1kHz + filter = yup.IIRFilter() + coeffs = yup.IIRCoefficients.makeLowPass(44100.0, 1000.0) + filter.setCoefficients(coeffs) + + # Process DC (0 Hz) - should pass through + filter.reset() + dc_output = 0.0 + for _ in range(100): + dc_output = filter.processSingleSampleRaw(1.0) + + # DC should mostly pass (might be slightly attenuated) + assert dc_output > 0.4 # Allow more tolerance + +#================================================================================================== + +def test_iir_filter_high_pass_behavior(): + # Create a high pass filter at 1kHz + filter = yup.IIRFilter() + coeffs = yup.IIRCoefficients.makeHighPass(44100.0, 1000.0) + filter.setCoefficients(coeffs) + + # Process DC (0 Hz) - should be blocked + filter.reset() + dc_output = 0.0 + for _ in range(100): + dc_output = filter.processSingleSampleRaw(1.0) + + # DC should be blocked (output near 0) + assert abs(dc_output) < 0.1 + +#================================================================================================== + +def test_iir_filter_multiple_filters(): + # Test that multiple filters can be created and used independently + filter1 = yup.IIRFilter() + filter2 = yup.IIRFilter() + + coeffs1 = yup.IIRCoefficients.makeLowPass(44100.0, 1000.0) + coeffs2 = yup.IIRCoefficients.makeHighPass(44100.0, 2000.0) + + filter1.setCoefficients(coeffs1) + filter2.setCoefficients(coeffs2) + + # Process samples through both + output1 = filter1.processSingleSampleRaw(1.0) + output2 = filter2.processSingleSampleRaw(1.0) + + assert not math.isnan(output1) + assert not math.isnan(output2) + +#================================================================================================== + +def test_iir_filter_stability(): + # Test that filter remains stable with various inputs + filter = yup.IIRFilter() + coeffs = yup.IIRCoefficients.makeLowPass(44100.0, 5000.0) + filter.setCoefficients(coeffs) + + # Process various inputs + inputs = [0.0, 1.0, -1.0, 0.5, -0.5] + + for input_val in inputs: + output = filter.processSingleSampleRaw(input_val) + assert not math.isnan(output) + assert not math.isinf(output) + assert abs(output) < 10.0 # Reasonable output range diff --git a/python/tests/test_yup_audio_basics/test_Reverb.py b/python/tests/test_yup_audio_basics/test_Reverb.py new file mode 100644 index 000000000..38c5cdd8d --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_Reverb.py @@ -0,0 +1,185 @@ +import yup + +#================================================================================================== + +def test_reverb_parameters_construction(): + # Test default construction + params = yup.Reverb.Parameters() + assert params.roomSize >= 0.0 + assert params.damping >= 0.0 + assert params.wetLevel >= 0.0 + assert params.dryLevel >= 0.0 + assert params.width >= 0.0 + +#================================================================================================== + +def test_reverb_parameters_fields(): + params = yup.Reverb.Parameters() + + # Set values + params.roomSize = 0.8 + params.damping = 0.5 + params.wetLevel = 0.33 + params.dryLevel = 0.4 + params.width = 1.0 + params.freezeMode = 0.0 + + assert abs(params.roomSize - 0.8) < 0.01 + assert abs(params.damping - 0.5) < 0.01 + assert abs(params.wetLevel - 0.33) < 0.01 + assert abs(params.dryLevel - 0.4) < 0.01 + assert abs(params.width - 1.0) < 0.01 + assert abs(params.freezeMode - 0.0) < 0.01 + +#================================================================================================== + +def test_reverb_construction(): + reverb = yup.Reverb() + assert reverb is not None + +#================================================================================================== + +def test_reverb_set_get_parameters(): + reverb = yup.Reverb() + + params = yup.Reverb.Parameters() + params.roomSize = 0.7 + params.damping = 0.6 + params.wetLevel = 0.5 + params.dryLevel = 0.4 + params.width = 0.9 + params.freezeMode = 0.0 + + reverb.setParameters(params) + retrieved = reverb.getParameters() + + assert abs(retrieved.roomSize - params.roomSize) < 0.01 + assert abs(retrieved.damping - params.damping) < 0.01 + assert abs(retrieved.wetLevel - params.wetLevel) < 0.01 + assert abs(retrieved.dryLevel - params.dryLevel) < 0.01 + assert abs(retrieved.width - params.width) < 0.01 + +#================================================================================================== + +def test_reverb_reset(): + reverb = yup.Reverb() + reverb.reset() + + # Should not throw + assert True + +#================================================================================================== + +def test_reverb_process_mono(): + reverb = yup.Reverb() + + params = yup.Reverb.Parameters() + params.roomSize = 0.5 + params.damping = 0.5 + params.wetLevel = 0.33 + params.dryLevel = 0.4 + reverb.setParameters(params) + + # Create test buffer + numSamples = 512 + buffer = [0.0] * numSamples + + # Set some input signal + for i in range(min(10, numSamples)): + buffer[i] = 1.0 + + # Note: processMono would need array binding support + # This test verifies the method exists + # reverb.processMono(buffer, numSamples) + +#================================================================================================== + +def test_reverb_process_stereo(): + reverb = yup.Reverb() + + params = yup.Reverb.Parameters() + params.roomSize = 0.5 + params.damping = 0.5 + params.wetLevel = 0.33 + params.dryLevel = 0.4 + params.width = 1.0 + reverb.setParameters(params) + + # Create test buffers + numSamples = 512 + leftBuffer = [0.0] * numSamples + rightBuffer = [0.0] * numSamples + + # Set some input signal + for i in range(min(10, numSamples)): + leftBuffer[i] = 1.0 + rightBuffer[i] = 0.5 + + # Note: processStereo would need array binding support + # This test verifies the method exists + # reverb.processStereo(leftBuffer, rightBuffer, numSamples) + +#================================================================================================== + +def test_reverb_parameters_ranges(): + # Test that parameters can be set to typical ranges + params = yup.Reverb.Parameters() + + # Test room size range + params.roomSize = 0.0 + assert abs(params.roomSize - 0.0) < 0.01 + params.roomSize = 1.0 + assert abs(params.roomSize - 1.0) < 0.01 + + # Test damping range + params.damping = 0.0 + assert abs(params.damping - 0.0) < 0.01 + params.damping = 1.0 + assert abs(params.damping - 1.0) < 0.01 + + # Test wet/dry levels + params.wetLevel = 0.0 + assert abs(params.wetLevel - 0.0) < 0.01 + params.dryLevel = 1.0 + assert abs(params.dryLevel - 1.0) < 0.01 + + # Test width + params.width = 0.0 + assert abs(params.width - 0.0) < 0.01 + params.width = 1.0 + assert abs(params.width - 1.0) < 0.01 + +#================================================================================================== + +def test_reverb_freeze_mode(): + reverb = yup.Reverb() + + params = yup.Reverb.Parameters() + params.freezeMode = 1.0 # Enable freeze + + reverb.setParameters(params) + retrieved = reverb.getParameters() + + assert abs(retrieved.freezeMode - 1.0) < 0.01 + +#================================================================================================== + +def test_reverb_multiple_instances(): + # Test that multiple reverb instances can coexist + reverb1 = yup.Reverb() + reverb2 = yup.Reverb() + + params1 = yup.Reverb.Parameters() + params1.roomSize = 0.3 + + params2 = yup.Reverb.Parameters() + params2.roomSize = 0.9 + + reverb1.setParameters(params1) + reverb2.setParameters(params2) + + retrieved1 = reverb1.getParameters() + retrieved2 = reverb2.getParameters() + + assert abs(retrieved1.roomSize - 0.3) < 0.01 + assert abs(retrieved2.roomSize - 0.9) < 0.01 diff --git a/python/tests/test_yup_audio_basics/test_SmoothedValue.py b/python/tests/test_yup_audio_basics/test_SmoothedValue.py new file mode 100644 index 000000000..40d632045 --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_SmoothedValue.py @@ -0,0 +1,129 @@ +import yup + +#================================================================================================== + +def test_smoothed_value_construction(): + # Test default construction + sv = yup.SmoothedValue() + + # Test construction with initial value + sv = yup.SmoothedValue(0.5) + assert abs(sv.getCurrentValue() - 0.5) < 0.001 + +#================================================================================================== + +def test_smoothed_value_set_current_and_target(): + sv = yup.SmoothedValue() + + sv.setCurrentAndTargetValue(0.75) + assert abs(sv.getCurrentValue() - 0.75) < 0.001 + assert abs(sv.getTargetValue() - 0.75) < 0.001 + + # Should not be smoothing when current equals target + assert not sv.isSmoothing() + +#================================================================================================== + +def test_smoothed_value_set_target(): + sv = yup.SmoothedValue() + sv.setCurrentAndTargetValue(0.0) + + # Reset with ramp length + sv.reset(44100.0, 0.1) # 44.1kHz, 0.1 second ramp + + sv.setTargetValue(1.0) + assert abs(sv.getCurrentValue() - 0.0) < 0.001 + assert abs(sv.getTargetValue() - 1.0) < 0.001 + assert sv.isSmoothing() + +#================================================================================================== + +def test_smoothed_value_get_next_value(): + sv = yup.SmoothedValue() + sv.setCurrentAndTargetValue(0.0) + + # Setup smoothing + sv.reset(44100.0, 0.01) # 44.1kHz, 0.01 second ramp + sv.setTargetValue(1.0) + + # Get next values + values = [] + for _ in range(10): + values.append(sv.getNextValue()) + + # Values should generally increase (with tolerance) + assert values[-1] - values[0] > 0.001 + + # Process until target reached + for _ in range(1000): + if not sv.isSmoothing(): + break + sv.getNextValue() + + # Should reach target eventually + assert abs(sv.getCurrentValue() - 1.0) < 0.01 + +#================================================================================================== + +def test_smoothed_value_skip(): + sv = yup.SmoothedValue() + sv.setCurrentAndTargetValue(0.0) + + sv.reset(44100.0, 0.1) + sv.setTargetValue(1.0) + + # Skip some samples + sv.skip(1000) + + # Should have progressed towards target + current = sv.getCurrentValue() + assert current > 0.001 # Small positive value shows progress + +#================================================================================================== + +def test_smoothed_value_reset(): + sv = yup.SmoothedValue() + sv.setCurrentAndTargetValue(0.5) + + # Reset with sample rate + sv.reset(48000) + assert abs(sv.getCurrentValue() - 0.5) < 0.001 + + # Reset with sample rate and ramp length + sv.reset(48000.0, 0.05) + + sv.setTargetValue(1.0) + assert sv.isSmoothing() + +#================================================================================================== + +def test_smoothed_value_double(): + # Test double precision version + sv = yup.SmoothedValueDouble(0.123456789) + assert abs(sv.getCurrentValue() - 0.123456789) < 0.0000001 + + sv.setCurrentAndTargetValue(0.987654321) + assert abs(sv.getCurrentValue() - 0.987654321) < 0.0000001 + +#================================================================================================== + +def test_smoothed_value_ramp(): + sv = yup.SmoothedValue() + sv.setCurrentAndTargetValue(0.0) + + # Very short ramp for testing + sampleRate = 44100.0 + rampLength = 0.001 # 1ms + expectedSamples = int(sampleRate * rampLength) + + sv.reset(sampleRate, rampLength) + sv.setTargetValue(1.0) + + # Process samples + samples_processed = 0 + while sv.isSmoothing() and samples_processed < expectedSamples * 2: + sv.getNextValue() + samples_processed += 1 + + # Should have completed ramp within expected time (with generous tolerance) + assert samples_processed <= expectedSamples * 2.0 From 8759506625c1393539e83831ed3bb1672c2730e7 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 12 Dec 2025 14:11:25 +0100 Subject: [PATCH 8/9] Fixing stuff --- .../bindings/yup_YupCore_bindings.cpp | 1 + .../bindings/yup_YupDataModel_bindings.cpp | 35 +++++++++++++++++-- .../bindings/yup_YupGraphics_bindings.cpp | 1 + .../test_yup_data_model/test_UndoManager.py | 20 +++++------ 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/modules/yup_python/bindings/yup_YupCore_bindings.cpp b/modules/yup_python/bindings/yup_YupCore_bindings.cpp index 6e2a71146..21ffedb00 100644 --- a/modules/yup_python/bindings/yup_YupCore_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupCore_bindings.cpp @@ -2135,6 +2135,7 @@ void registerYupCoreBindings (py::module_& m) .def ("__enter__", [] (PerformanceCounter& self) { self.start(); + return std::addressof (self); }, py::return_value_policy::reference) .def ("__exit__", [] (PerformanceCounter& self, const std::optional&, const std::optional&, const std::optional&) { diff --git a/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp b/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp index d6f40dd2e..4371aa68d 100644 --- a/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupDataModel_bindings.cpp @@ -57,11 +57,40 @@ void registerYupDataModelBindings (py::module_& m) py::class_> classUndoManager (m, "UndoManager"); - py::class_ classUndoManagerScopedTransaction (classUndoManager, "ScopedTransaction"); + struct PyUndoManagerScopedTransaction + { + PyUndoManagerScopedTransaction (UndoManager& um) + : um (um) + { + } + + PyUndoManagerScopedTransaction (UndoManager& um, StringRef transactionName) + : um (um) + , transactionName (transactionName) + { + } - classUndoManagerScopedTransaction + UndoManager& um; + std::optional transactionName; + std::variant state; + }; + + py::class_ (classUndoManager, "ScopedTransaction") .def (py::init()) - .def (py::init()); + .def (py::init()) + .def ("__enter__", [] (PyUndoManagerScopedTransaction& self) + { + if (self.transactionName.has_value()) + self.state.emplace (self.um, *self.transactionName); + else + self.state.emplace (self.um); + + return std::addressof (self); + }) + .def ("__exit__", [] (PyUndoManagerScopedTransaction& self, const std::optional&, const std::optional&, const std::optional&) + { + self.state.emplace(); + }); classUndoManager .def (py::init<>()) diff --git a/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp b/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp index d71a78dd9..42dcaf533 100644 --- a/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp @@ -1779,6 +1779,7 @@ void registerYupGraphicsBindings (py::module_& m) .def ("__enter__", [] (PyGraphicsSaveState& self) { self.state.emplace (self.g.saveState()); + return std::addressof (self); }) .def ("__exit__", [] (PyGraphicsSaveState& self, const std::optional&, const std::optional&, const std::optional&) { diff --git a/python/tests/test_yup_data_model/test_UndoManager.py b/python/tests/test_yup_data_model/test_UndoManager.py index 64f5924da..7aff0667e 100644 --- a/python/tests/test_yup_data_model/test_UndoManager.py +++ b/python/tests/test_yup_data_model/test_UndoManager.py @@ -151,13 +151,10 @@ def test_UndoManager_ScopedTransaction(): tree = yup.DataTree(yup.Identifier("Test")) # Use scoped transaction - scoped = yup.UndoManager.ScopedTransaction(manager, "Scoped Transaction") - - transaction = tree.beginTransaction(manager) - transaction.setProperty(yup.Identifier("key"), "value") - transaction.commit() - - del scoped + with yup.UndoManager.ScopedTransaction(manager, "Scoped Transaction") as scoped: + transaction = tree.beginTransaction(manager) + transaction.setProperty(yup.Identifier("key"), "value") + transaction.commit() assert manager.canUndo() @@ -266,11 +263,10 @@ def test_UndoManager_repr(): def test_UndoManager_ScopedTransaction_repr(): """Test UndoManager.ScopedTransaction has proper type representation.""" manager = yup.UndoManager() - scoped = yup.UndoManager.ScopedTransaction(manager, "Test Transaction") - - # Verify we can get the type name - type_name = type(scoped).__name__ - assert "ScopedTransaction" in type_name + with yup.UndoManager.ScopedTransaction(manager, "Test Transaction") as scoped: + # Verify we can get the type name + type_name = type(scoped).__name__ + assert "ScopedTransaction" in type_name #================================================================================================== From 190d690afdd9517349d5feb19b3337550a032b2a Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 12 Dec 2025 17:41:00 +0100 Subject: [PATCH 9/9] More audio bindings tests --- .../synthesisers/yup_Synthesiser.cpp | 4 +- .../synthesisers/yup_Synthesiser.h | 38 +- .../bindings/yup_YupAudioBasics_bindings.cpp | 110 ++++- .../bindings/yup_YupAudioBasics_bindings.h | 64 +++ .../test_yup_audio_basics/test_AudioSource.py | 240 ++++++++++ .../test_yup_audio_basics/test_MidiBuffer.py | 452 ++++++++++++++++++ .../test_yup_audio_basics/test_Synthesiser.py | 317 ++++++++++++ tests/yup_audio_basics/yup_Synthesiser.cpp | 6 +- 8 files changed, 1199 insertions(+), 32 deletions(-) create mode 100644 python/tests/test_yup_audio_basics/test_AudioSource.py create mode 100644 python/tests/test_yup_audio_basics/test_MidiBuffer.py create mode 100644 python/tests/test_yup_audio_basics/test_Synthesiser.py diff --git a/modules/yup_audio_basics/synthesisers/yup_Synthesiser.cpp b/modules/yup_audio_basics/synthesisers/yup_Synthesiser.cpp index 07c93975c..949bb8b5b 100644 --- a/modules/yup_audio_basics/synthesisers/yup_Synthesiser.cpp +++ b/modules/yup_audio_basics/synthesisers/yup_Synthesiser.cpp @@ -106,7 +106,7 @@ Synthesiser::~Synthesiser() } //============================================================================== -SynthesiserVoice* Synthesiser::getVoice (const int index) const +SynthesiserVoice::Ptr Synthesiser::getVoice (const int index) const { const ScopedLock sl (lock); return voices[index]; @@ -118,7 +118,7 @@ void Synthesiser::clearVoices() voices.clear(); } -SynthesiserVoice* Synthesiser::addVoice (SynthesiserVoice* const newVoice) +SynthesiserVoice* Synthesiser::addVoice (const SynthesiserVoice::Ptr newVoice) { SynthesiserVoice* voice; diff --git a/modules/yup_audio_basics/synthesisers/yup_Synthesiser.h b/modules/yup_audio_basics/synthesisers/yup_Synthesiser.h index 63f5c6fe1..d9abfedcf 100644 --- a/modules/yup_audio_basics/synthesisers/yup_Synthesiser.h +++ b/modules/yup_audio_basics/synthesisers/yup_Synthesiser.h @@ -99,7 +99,7 @@ class YUP_API SynthesiserSound : public ReferenceCountedObject @tags{Audio} */ -class YUP_API SynthesiserVoice +class YUP_API SynthesiserVoice : public ReferenceCountedObject { public: //============================================================================== @@ -261,6 +261,9 @@ class YUP_API SynthesiserVoice /** Returns true if this voice started playing its current note before the other voice did. */ bool wasStartedBefore (const SynthesiserVoice& other) const noexcept; + /** The class is reference-counted, so this is a handy pointer class for it. */ + using Ptr = ReferenceCountedObjectPtr; + protected: /** Resets the state of this voice after a sound has finished playing. @@ -337,17 +340,16 @@ class YUP_API Synthesiser int getNumVoices() const noexcept { return voices.size(); } /** Returns one of the voices that have been added. */ - SynthesiserVoice* getVoice (int index) const; + SynthesiserVoice::Ptr getVoice (int index) const; /** Adds a new voice to the synth. All the voices should be the same class of object and are treated equally. - The object passed in will be managed by the synthesiser, which will delete - it later on when no longer needed. The caller should not retain a pointer to the - voice. + The object passed in is reference counted, so will be deleted when the + synthesiser and all voices are no longer using it. */ - SynthesiserVoice* addVoice (SynthesiserVoice* newVoice); + SynthesiserVoice* addVoice (SynthesiserVoice::Ptr newVoice); /** Deletes one of the voices. */ void removeVoice (int index); @@ -400,9 +402,7 @@ class YUP_API Synthesiser The midiChannel parameter is the channel, between 1 and 16 inclusive. */ - virtual void noteOn (int midiChannel, - int midiNoteNumber, - float velocity); + virtual void noteOn (int midiChannel, int midiNoteNumber, float velocity); /** Triggers a note-off event. @@ -416,10 +416,7 @@ class YUP_API Synthesiser The midiChannel parameter is the channel, between 1 and 16 inclusive. */ - virtual void noteOff (int midiChannel, - int midiNoteNumber, - float velocity, - bool allowTailOff); + virtual void noteOff (int midiChannel, int midiNoteNumber, float velocity, bool allowTailOff); /** Turns off all notes. @@ -435,8 +432,7 @@ class YUP_API Synthesiser This method will be called automatically according to the midi data passed into renderNextBlock(), but may be called explicitly too. */ - virtual void allNotesOff (int midiChannel, - bool allowTailOff); + virtual void allNotesOff (int midiChannel, bool allowTailOff); /** Sends a pitch-wheel message to any active voices. @@ -449,8 +445,7 @@ class YUP_API Synthesiser @param midiChannel the midi channel, from 1 to 16 inclusive @param wheelValue the wheel position, from 0 to 0x3fff, as returned by MidiMessage::getPitchWheelValue() */ - virtual void handlePitchWheel (int midiChannel, - int wheelValue); + virtual void handlePitchWheel (int midiChannel, int wheelValue); /** Sends a midi controller message to any active voices. @@ -464,9 +459,7 @@ class YUP_API Synthesiser @param controllerNumber the midi controller type, as returned by MidiMessage::getControllerNumber() @param controllerValue the midi controller value, between 0 and 127, as returned by MidiMessage::getControllerValue() */ - virtual void handleController (int midiChannel, - int controllerNumber, - int controllerValue); + virtual void handleController (int midiChannel, int controllerNumber, int controllerValue); /** Sends an aftertouch message. @@ -510,8 +503,7 @@ class YUP_API Synthesiser The base class implementation of this has no effect, but you may want to make your own synth react to program changes. */ - virtual void handleProgramChange (int midiChannel, - int programNumber); + virtual void handleProgramChange (int midiChannel, int programNumber); //============================================================================== /** Tells the synthesiser what the sample rate is for the audio it's being used to render. @@ -575,7 +567,7 @@ class YUP_API Synthesiser /** This is used to control access to the rendering callback and the note trigger methods. */ CriticalSection lock; - OwnedArray voices; + ReferenceCountedArray voices; ReferenceCountedArray sounds; /** The last pitch-wheel values for each midi channel. */ diff --git a/modules/yup_python/bindings/yup_YupAudioBasics_bindings.cpp b/modules/yup_python/bindings/yup_YupAudioBasics_bindings.cpp index e63f31e7d..d70d2503b 100644 --- a/modules/yup_python/bindings/yup_YupAudioBasics_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupAudioBasics_bindings.cpp @@ -256,6 +256,97 @@ void registerYupAudioBasicsBindings (py::module_& m) return Decibels::toString (decibels, decimalPlaces, minusInfinityDb, shouldIncludeSuffix, customMinusInfinityString); }, "decibels"_a, "decimalPlaces"_a = 2, "minusInfinityDb"_a = -100.0f, "shouldIncludeSuffix"_a = true, "customMinusInfinityString"_a = StringRef()); + // ============================================================================================ yup::MidiMessage + + py::class_ classMidiMessage (m, "MidiMessage"); + + classMidiMessage + .def (py::init<>()) + .def (py::init()) + .def (py::init(), "other"_a, "newTimeStamp"_a) + .def ("getRawData", [] (const MidiMessage& self) -> py::bytes + { + return py::bytes (reinterpret_cast (self.getRawData()), self.getRawDataSize()); + }) + .def ("getRawDataSize", &MidiMessage::getRawDataSize) + .def ("getDescription", &MidiMessage::getDescription) + .def ("getTimeStamp", &MidiMessage::getTimeStamp) + .def ("setTimeStamp", &MidiMessage::setTimeStamp) + .def ("addToTimeStamp", &MidiMessage::addToTimeStamp) + .def ("withTimeStamp", &MidiMessage::withTimeStamp) + .def ("getChannel", &MidiMessage::getChannel) + .def ("isForChannel", &MidiMessage::isForChannel) + .def ("setChannel", &MidiMessage::setChannel) + .def ("isSysEx", &MidiMessage::isSysEx) + .def ("getSysExDataSize", &MidiMessage::getSysExDataSize) + .def ("isNoteOn", &MidiMessage::isNoteOn, "returnTrueForVelocity0"_a = false) + .def ("isNoteOff", &MidiMessage::isNoteOff, "returnTrueForNoteOnVelocity0"_a = true) + .def ("isNoteOnOrOff", &MidiMessage::isNoteOnOrOff) + .def ("getNoteNumber", &MidiMessage::getNoteNumber) + .def ("setNoteNumber", &MidiMessage::setNoteNumber) + .def ("getVelocity", &MidiMessage::getVelocity) + .def ("getFloatVelocity", &MidiMessage::getFloatVelocity) + .def ("setVelocity", &MidiMessage::setVelocity) + .def ("multiplyVelocity", &MidiMessage::multiplyVelocity) + .def ("isAftertouch", &MidiMessage::isAftertouch) + .def ("getAfterTouchValue", &MidiMessage::getAfterTouchValue) + .def ("isProgramChange", &MidiMessage::isProgramChange) + .def ("getProgramChangeNumber", &MidiMessage::getProgramChangeNumber) + .def ("isPitchWheel", &MidiMessage::isPitchWheel) + .def ("getPitchWheelValue", &MidiMessage::getPitchWheelValue) + .def ("isController", &MidiMessage::isController) + .def ("getControllerNumber", &MidiMessage::getControllerNumber) + .def ("getControllerValue", &MidiMessage::getControllerValue) + .def ("isControllerOfType", &MidiMessage::isControllerOfType) + .def ("isAllNotesOff", &MidiMessage::isAllNotesOff) + .def ("isAllSoundOff", &MidiMessage::isAllSoundOff) + .def ("isResetAllControllers", &MidiMessage::isResetAllControllers) + .def_static ("noteOn", static_cast (&MidiMessage::noteOn), "channel"_a, "noteNumber"_a, "velocity"_a) + .def_static ("noteOff", static_cast (&MidiMessage::noteOff), "channel"_a, "noteNumber"_a, "velocity"_a) + .def_static ("noteOff", static_cast (&MidiMessage::noteOff), "channel"_a, "noteNumber"_a) + .def_static ("programChange", &MidiMessage::programChange, "channel"_a, "programNumber"_a) + .def_static ("pitchWheel", &MidiMessage::pitchWheel, "channel"_a, "position"_a) + .def_static ("aftertouchChange", &MidiMessage::aftertouchChange, "channel"_a, "noteNumber"_a, "aftertouchValue"_a) + .def_static ("channelPressureChange", &MidiMessage::channelPressureChange, "channel"_a, "pressure"_a) + .def_static ("controllerEvent", &MidiMessage::controllerEvent, "channel"_a, "controllerType"_a, "value"_a) + .def_static ("allNotesOff", &MidiMessage::allNotesOff, "channel"_a) + .def_static ("allSoundOff", &MidiMessage::allSoundOff, "channel"_a) + .def_static ("allControllersOff", &MidiMessage::allControllersOff, "channel"_a); + + // ============================================================================================ yup::MidiMessageMetadata + + py::class_ classMidiMessageMetadata (m, "MidiMessageMetadata"); + + classMidiMessageMetadata + .def (py::init<>()) + .def (py::init()) + .def ("getMessage", &MidiMessageMetadata::getMessage) + .def_readonly ("numBytes", &MidiMessageMetadata::numBytes) + .def_readonly ("samplePosition", &MidiMessageMetadata::samplePosition); + + // ============================================================================================ yup::MidiBuffer + + py::class_ classMidiBuffer (m, "MidiBuffer"); + + classMidiBuffer + .def (py::init<>()) + .def (py::init()) + .def ("clear", py::overload_cast<> (&MidiBuffer::clear)) + .def ("clear", py::overload_cast (&MidiBuffer::clear)) + .def ("isEmpty", &MidiBuffer::isEmpty) + .def ("getNumEvents", &MidiBuffer::getNumEvents) + .def ("addEvent", py::overload_cast (&MidiBuffer::addEvent)) + .def ("addEvents", &MidiBuffer::addEvents) + .def ("getFirstEventTime", &MidiBuffer::getFirstEventTime) + .def ("getLastEventTime", &MidiBuffer::getLastEventTime) + .def ("swapWith", &MidiBuffer::swapWith) + .def ("ensureSize", &MidiBuffer::ensureSize) + .def ("__iter__", [] (const MidiBuffer& self) + { + return py::make_iterator (self.begin(), self.end()); + }, py::keep_alive<0, 1>()) + .def ("__len__", &MidiBuffer::getNumEvents); + // ============================================================================================ yup::ADSR py::class_ classADSR (m, "ADSR"); @@ -382,7 +473,7 @@ void registerYupAudioBasicsBindings (py::module_& m) // ============================================================================================ yup::PositionableAudioSource - py::class_, AudioSource> classPositionableAudioSource (m, "PositionableAudioSource"); + py::class_> classPositionableAudioSource (m, "PositionableAudioSource"); classPositionableAudioSource .def (py::init<>()) @@ -422,7 +513,7 @@ void registerYupAudioBasicsBindings (py::module_& m) // ============================================================================================ yup::SynthesiserVoice - py::class_ classSynthesiserVoice (m, "SynthesiserVoice"); + py::class_> classSynthesiserVoice (m, "SynthesiserVoice"); classSynthesiserVoice .def (py::init<>()) @@ -443,11 +534,12 @@ void registerYupAudioBasicsBindings (py::module_& m) // ============================================================================================ yup::Synthesiser - py::class_ classSynthesiser (m, "Synthesiser"); + py::class_ classSynthesiser (m, "Synthesiser"); classSynthesiser .def (py::init<>()) .def ("clearVoices", &Synthesiser::clearVoices) + .def ("getNumVoices", &Synthesiser::getNumVoices) .def ("getVoice", &Synthesiser::getVoice, py::return_value_policy::reference) .def ("addVoice", &Synthesiser::addVoice) .def ("removeVoice", &Synthesiser::removeVoice) @@ -461,7 +553,17 @@ void registerYupAudioBasicsBindings (py::module_& m) .def ("setMinimumRenderingSubdivisionSize", &Synthesiser::setMinimumRenderingSubdivisionSize) .def ("setCurrentPlaybackSampleRate", &Synthesiser::setCurrentPlaybackSampleRate) .def ("renderNextBlock", py::overload_cast&, const MidiBuffer&, int, int> (&Synthesiser::renderNextBlock), "outputAudio"_a, "inputMidi"_a, "startSample"_a, "numSamples"_a) - .def ("allNotesOff", &Synthesiser::allNotesOff); + .def ("noteOn", &Synthesiser::noteOn) + .def ("noteOff", &Synthesiser::noteOff) + .def ("allNotesOff", &Synthesiser::allNotesOff) + .def ("handlePitchWheel", &Synthesiser::handlePitchWheel) + .def ("handleController", &Synthesiser::handleController) + .def ("handleAftertouch", &Synthesiser::handleAftertouch) + .def ("handleChannelPressure", &Synthesiser::handleChannelPressure) + .def ("handleSustainPedal", &Synthesiser::handleSustainPedal) + .def ("handleSostenutoPedal", &Synthesiser::handleSostenutoPedal) + .def ("handleSoftPedal", &Synthesiser::handleSoftPedal) + .def ("handleProgramChange", &Synthesiser::handleProgramChange); // ============================================================================================ yup::AudioPlayHead diff --git a/modules/yup_python/bindings/yup_YupAudioBasics_bindings.h b/modules/yup_python/bindings/yup_YupAudioBasics_bindings.h index 5a0a9562a..ddb624df7 100644 --- a/modules/yup_python/bindings/yup_YupAudioBasics_bindings.h +++ b/modules/yup_python/bindings/yup_YupAudioBasics_bindings.h @@ -156,6 +156,70 @@ struct PySynthesiserVoice : SynthesiserVoice } }; +//============================================================================== +struct PySynthesiser : Synthesiser +{ + void noteOn (int midiChannel, int midiNoteNumber, float velocity) override + { + PYBIND11_OVERRIDE (void, Synthesiser, noteOn, midiChannel, midiNoteNumber, velocity); + } + + void noteOff (int midiChannel, int midiNoteNumber, float velocity, bool allowTailOff) override + { + PYBIND11_OVERRIDE (void, Synthesiser, noteOff, midiChannel, midiNoteNumber, velocity, allowTailOff); + } + + void allNotesOff (int midiChannel, bool allowTailOff) override + { + PYBIND11_OVERRIDE (void, Synthesiser, allNotesOff, midiChannel, allowTailOff); + } + + void handlePitchWheel (int midiChannel, int wheelValue) override + { + PYBIND11_OVERRIDE (void, Synthesiser, handlePitchWheel, midiChannel, wheelValue); + } + + void handleController (int midiChannel, int controllerNumber, int controllerValue) override + { + PYBIND11_OVERRIDE (void, Synthesiser, handleController, midiChannel, controllerNumber, controllerValue); + } + + void handleAftertouch (int midiChannel, int midiNoteNumber, int aftertouchValue) override + { + PYBIND11_OVERRIDE (void, Synthesiser, handleAftertouch, midiChannel, midiNoteNumber, aftertouchValue); + } + + void handleChannelPressure (int midiChannel, int channelPressureValue) override + { + PYBIND11_OVERRIDE (void, Synthesiser, handleChannelPressure, midiChannel, channelPressureValue); + } + + void handleSustainPedal (int midiChannel, bool isDown) override + { + PYBIND11_OVERRIDE (void, Synthesiser, handleSustainPedal, midiChannel, isDown); + } + + void handleSostenutoPedal (int midiChannel, bool isDown) override + { + PYBIND11_OVERRIDE (void, Synthesiser, handleSostenutoPedal, midiChannel, isDown); + } + + void handleSoftPedal (int midiChannel, bool isDown) override + { + PYBIND11_OVERRIDE (void, Synthesiser, handleSoftPedal, midiChannel, isDown); + } + + void handleProgramChange (int midiChannel, int programNumber) override + { + PYBIND11_OVERRIDE (void, Synthesiser, handleProgramChange, midiChannel, programNumber); + } + + void setCurrentPlaybackSampleRate (double sampleRate) override + { + PYBIND11_OVERRIDE (void, Synthesiser, setCurrentPlaybackSampleRate, sampleRate); + } +}; + //============================================================================== struct PyAudioPlayHeadPositionInfo : AudioPlayHead::PositionInfo diff --git a/python/tests/test_yup_audio_basics/test_AudioSource.py b/python/tests/test_yup_audio_basics/test_AudioSource.py new file mode 100644 index 000000000..8514f89eb --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_AudioSource.py @@ -0,0 +1,240 @@ +import yup + +#================================================================================================== + +class SimpleAudioSource(yup.AudioSource): + """A simple audio source that generates a constant value.""" + + def __init__(self): + super().__init__() + self.prepared = False + self.sample_rate = 0.0 + self.block_size = 0 + self.released = False + self.blocks_processed = 0 + self.output_value = 0.5 + + def prepareToPlay(self, samplesPerBlockExpected, sampleRate): + self.prepared = True + self.sample_rate = sampleRate + self.block_size = samplesPerBlockExpected + + def releaseResources(self): + self.released = True + self.prepared = False + + def getNextAudioBlock(self, bufferToFill): + # Fill buffer with constant value + buffer = bufferToFill.buffer + start = bufferToFill.startSample + num_samples = bufferToFill.numSamples + + for channel in range(buffer.getNumChannels()): + for sample in range(num_samples): + buffer.setSample(channel, start + sample, self.output_value) + + self.blocks_processed += 1 + +#================================================================================================== + +def test_audio_source_subclass_creation(): + source = SimpleAudioSource() + assert source is not None + assert not source.prepared + assert not source.released + +#================================================================================================== + +def test_audio_source_prepare_to_play(): + source = SimpleAudioSource() + + source.prepareToPlay(512, 44100.0) + + assert source.prepared + assert abs(source.sample_rate - 44100.0) < 0.01 + assert source.block_size == 512 + +#================================================================================================== + +def test_audio_source_release_resources(): + source = SimpleAudioSource() + + source.prepareToPlay(512, 44100.0) + assert source.prepared + + source.releaseResources() + assert source.released + assert not source.prepared + +#================================================================================================== + +def test_audio_source_get_next_audio_block(): + source = SimpleAudioSource() + source.prepareToPlay(512, 44100.0) + + # Create a buffer + buffer = yup.AudioBuffer(2, 512) + buffer.clear() + + # Create channel info + info = yup.AudioSourceChannelInfo(buffer, 0, 512) + + # Get next block + source.getNextAudioBlock(info) + + assert source.blocks_processed == 1 + + # Check that buffer was filled with the output value + assert abs(buffer.getSample(0, 0) - 0.5) < 0.001 + assert abs(buffer.getSample(1, 256) - 0.5) < 0.001 + +#================================================================================================== + +def test_audio_source_multiple_blocks(): + source = SimpleAudioSource() + source.prepareToPlay(256, 48000.0) + + buffer = yup.AudioBuffer(2, 256) + info = yup.AudioSourceChannelInfo(buffer, 0, 256) + + # Process multiple blocks + for i in range(5): + buffer.clear() + source.getNextAudioBlock(info) + + assert source.blocks_processed == 5 + +#================================================================================================== + +def test_audio_source_custom_output_value(): + source = SimpleAudioSource() + source.output_value = 0.75 + source.prepareToPlay(128, 44100.0) + + buffer = yup.AudioBuffer(1, 128) + buffer.clear() + info = yup.AudioSourceChannelInfo(buffer, 0, 128) + + source.getNextAudioBlock(info) + + # Check custom value + assert abs(buffer.getSample(0, 0) - 0.75) < 0.001 + +#================================================================================================== + +class PositionableTestSource(yup.PositionableAudioSource): + """A positionable audio source for testing.""" + + def __init__(self, totalLength=1000): + super().__init__() + self.prepared = False + self.position = 0 + self.total_length = totalLength + self.looping = False + + def prepareToPlay(self, samplesPerBlockExpected, sampleRate): + self.prepared = True + + def releaseResources(self): + self.prepared = False + + def getNextAudioBlock(self, bufferToFill): + # Simple implementation that advances position + buffer = bufferToFill.buffer + num_samples = bufferToFill.numSamples + + # Fill with position-based value + for channel in range(buffer.getNumChannels()): + for i in range(num_samples): + value = float(self.position + i) / self.total_length + buffer.setSample(channel, bufferToFill.startSample + i, value) + + self.position += num_samples + if self.looping and self.position >= self.total_length: + self.position = 0 + + def setNextReadPosition(self, newPosition): + self.position = newPosition + + def getNextReadPosition(self): + return self.position + + def getTotalLength(self): + return self.total_length + + def isLooping(self): + return self.looping + + def setLooping(self, shouldLoop): + self.looping = shouldLoop + +#================================================================================================== + +def test_positionable_audio_source_creation(): + source = PositionableTestSource(1000) + assert source is not None + assert source.position == 0 + assert source.total_length == 1000 + assert not source.looping + +#================================================================================================== + +def test_positionable_audio_source_position(): + source = PositionableTestSource(1000) + + # Test set/get position + source.setNextReadPosition(500) + assert source.getNextReadPosition() == 500 + + source.setNextReadPosition(0) + assert source.getNextReadPosition() == 0 + +#================================================================================================== + +def test_positionable_audio_source_total_length(): + source = PositionableTestSource(2000) + assert source.getTotalLength() == 2000 + +#================================================================================================== + +def test_positionable_audio_source_looping(): + source = PositionableTestSource(1000) + + assert not source.isLooping() + + source.setLooping(True) + assert source.isLooping() + + source.setLooping(False) + assert not source.isLooping() + +#================================================================================================== + +def test_positionable_audio_source_advancing(): + source = PositionableTestSource(1000) + source.prepareToPlay(256, 44100.0) + + buffer = yup.AudioBuffer(2, 256) + info = yup.AudioSourceChannelInfo(buffer, 0, 256) + + initial_pos = source.getNextReadPosition() + source.getNextAudioBlock(info) + + # Position should have advanced + assert source.getNextReadPosition() == initial_pos + 256 + +#================================================================================================== + +def test_positionable_audio_source_looping_wraparound(): + source = PositionableTestSource(1000) + source.setLooping(True) + source.setNextReadPosition(900) + source.prepareToPlay(256, 44100.0) + + buffer = yup.AudioBuffer(2, 256) + info = yup.AudioSourceChannelInfo(buffer, 0, 256) + + source.getNextAudioBlock(info) + + # Position should have wrapped around due to looping + assert source.getNextReadPosition() < 900 diff --git a/python/tests/test_yup_audio_basics/test_MidiBuffer.py b/python/tests/test_yup_audio_basics/test_MidiBuffer.py new file mode 100644 index 000000000..b1fbb65a3 --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_MidiBuffer.py @@ -0,0 +1,452 @@ +import yup + +#================================================================================================== + +def test_midi_message_construction(): + # Test default construction + msg = yup.MidiMessage() + assert msg is not None + +#================================================================================================== + +def test_midi_message_note_on(): + msg = yup.MidiMessage.noteOn(1, 60, 0.8) + assert msg.isNoteOn() + assert not msg.isNoteOff() + assert msg.getNoteNumber() == 60 + assert msg.getChannel() == 1 + assert abs(msg.getFloatVelocity() - 0.8) < 0.01 + +#================================================================================================== + +def test_midi_message_note_on_uint8(): + """Test noteOn with uint8 velocity.""" + msg = yup.MidiMessage.noteOn(1, 60, 0.8) + assert msg.isNoteOn() + assert msg.getNoteNumber() == 60 + assert msg.getVelocity() == int(0.8 * 127 + 0.5) + +#================================================================================================== + +def test_midi_message_note_on_velocity_zero(): + """Test noteOn with zero velocity (should be note off by MIDI spec).""" + msg = yup.MidiMessage.noteOn(1, 60, 0.0) + + # With returnTrueForVelocity0=False, velocity 0 note-on is NOT considered note-on + assert not msg.isNoteOn(returnTrueForVelocity0=False) + + # With returnTrueForVelocity0=True, velocity 0 note-on IS considered note-on + assert msg.isNoteOn(returnTrueForVelocity0=True) + +#================================================================================================== + +def test_midi_message_note_off(): + msg = yup.MidiMessage.noteOff(1, 60, 0.0) + assert msg.isNoteOff() + assert not msg.isNoteOn() + assert msg.getNoteNumber() == 60 + assert msg.getChannel() == 1 + +#================================================================================================== + +def test_midi_message_note_off_uint8(): + """Test noteOff with uint8 velocity.""" + msg = yup.MidiMessage.noteOff(1, 60, 0.5) + assert msg.isNoteOff() + assert msg.getNoteNumber() == 60 + assert abs(msg.getFloatVelocity() - 0.5) < 0.01 + +#================================================================================================== + +def test_midi_message_note_off_no_velocity(): + """Test noteOff without velocity parameter.""" + msg = yup.MidiMessage.noteOff(1, 60) + assert msg.isNoteOff() + assert msg.getNoteNumber() == 60 + assert abs(msg.getFloatVelocity() - 0.0) < 0.01 + +#================================================================================================== + +def test_midi_message_program_change(): + msg = yup.MidiMessage.programChange(1, 42) + assert msg.isProgramChange() + assert msg.getProgramChangeNumber() == 42 + assert msg.getChannel() == 1 + +#================================================================================================== + +def test_midi_message_pitch_wheel(): + msg = yup.MidiMessage.pitchWheel(1, 8192) + assert msg.isPitchWheel() + assert msg.getPitchWheelValue() == 8192 + assert msg.getChannel() == 1 + +#================================================================================================== + +def test_midi_message_controller(): + msg = yup.MidiMessage.controllerEvent(1, 7, 100) # Volume controller + assert msg.isController() + assert msg.getControllerNumber() == 7 + assert msg.getControllerValue() == 100 + assert msg.getChannel() == 1 + +#================================================================================================== + +def test_midi_message_all_notes_off(): + msg = yup.MidiMessage.allNotesOff(1) + assert msg.isAllNotesOff() + assert msg.getChannel() == 1 + +#================================================================================================== + +def test_midi_message_timestamp(): + msg = yup.MidiMessage.noteOn(1, 60, 0.8) + + # Set timestamp + msg.setTimeStamp(123.456) + assert abs(msg.getTimeStamp() - 123.456) < 0.001 + + # Add to timestamp + msg.addToTimeStamp(10.0) + assert abs(msg.getTimeStamp() - 133.456) < 0.001 + +#================================================================================================== + +def test_midi_message_with_timestamp(): + msg1 = yup.MidiMessage.noteOn(1, 60, 0.8) + msg1.setTimeStamp(100.0) + + msg2 = msg1.withTimeStamp(200.0) + + assert abs(msg1.getTimeStamp() - 100.0) < 0.001 + assert abs(msg2.getTimeStamp() - 200.0) < 0.001 + assert msg2.isNoteOn() + assert msg2.getNoteNumber() == 60 + +#================================================================================================== + +def test_midi_message_description(): + msg = yup.MidiMessage.noteOn(1, 60, 0.8) + desc = msg.getDescription() + + assert isinstance(desc, str) + assert len(desc) > 0 + +#================================================================================================== + +def test_midi_buffer_construction(): + buffer = yup.MidiBuffer() + assert buffer is not None + assert buffer.isEmpty() + assert buffer.getNumEvents() == 0 + +#================================================================================================== + +def test_midi_buffer_construction_with_message(): + msg = yup.MidiMessage.noteOn(1, 60, 0.8) + buffer = yup.MidiBuffer(msg) + + assert not buffer.isEmpty() + assert buffer.getNumEvents() == 1 + +#================================================================================================== + +def test_midi_buffer_add_event(): + buffer = yup.MidiBuffer() + + msg1 = yup.MidiMessage.noteOn(1, 60, 0.8) + msg2 = yup.MidiMessage.noteOff(1, 60, 0.0) + + buffer.addEvent(msg1, 0) + buffer.addEvent(msg2, 100) + + assert not buffer.isEmpty() + assert buffer.getNumEvents() == 2 + +#================================================================================================== + +def test_midi_buffer_clear(): + buffer = yup.MidiBuffer() + + msg = yup.MidiMessage.noteOn(1, 60, 0.8) + buffer.addEvent(msg, 0) + + assert not buffer.isEmpty() + + buffer.clear() + + assert buffer.isEmpty() + assert buffer.getNumEvents() == 0 + +#================================================================================================== + +def test_midi_buffer_clear_range(): + buffer = yup.MidiBuffer() + + # Add events at different timestamps + buffer.addEvent(yup.MidiMessage.noteOn(1, 60, 0.8), 0) + buffer.addEvent(yup.MidiMessage.noteOn(1, 64, 0.8), 100) + buffer.addEvent(yup.MidiMessage.noteOn(1, 67, 0.8), 200) + + assert buffer.getNumEvents() == 3 + + # Clear events in middle range + buffer.clear(100, 100) + + # Should still have events (at 0 and 200) + assert buffer.getNumEvents() == 2 + +#================================================================================================== + +def test_midi_buffer_event_times(): + buffer = yup.MidiBuffer() + + buffer.addEvent(yup.MidiMessage.noteOn(1, 60, 0.8), 50) + buffer.addEvent(yup.MidiMessage.noteOn(1, 64, 0.8), 150) + + assert buffer.getFirstEventTime() == 50 + assert buffer.getLastEventTime() == 150 + +#================================================================================================== + +def test_midi_buffer_iteration(): + buffer = yup.MidiBuffer() + + # Add some events + buffer.addEvent(yup.MidiMessage.noteOn(1, 60, 0.8), 0) + buffer.addEvent(yup.MidiMessage.noteOn(1, 64, 0.8), 100) + buffer.addEvent(yup.MidiMessage.noteOff(1, 60, 0.0), 200) + + # Iterate over events + events = list(buffer) + + assert len(events) == 3 + + # Check first event + msg1 = events[0].getMessage() + assert msg1.isNoteOn() + assert msg1.getNoteNumber() == 60 + assert events[0].samplePosition == 0 + + # Check second event + msg2 = events[1].getMessage() + assert msg2.isNoteOn() + assert msg2.getNoteNumber() == 64 + assert events[1].samplePosition == 100 + + # Check third event + msg3 = events[2].getMessage() + assert msg3.isNoteOff() + assert msg3.getNoteNumber() == 60 + assert events[2].samplePosition == 200 + +#================================================================================================== + +def test_midi_buffer_len(): + buffer = yup.MidiBuffer() + + assert len(buffer) == 0 + + buffer.addEvent(yup.MidiMessage.noteOn(1, 60, 0.8), 0) + assert len(buffer) == 1 + + buffer.addEvent(yup.MidiMessage.noteOff(1, 60, 0.0), 100) + assert len(buffer) == 2 + +#================================================================================================== + +def test_midi_buffer_add_events(): + buffer1 = yup.MidiBuffer() + buffer2 = yup.MidiBuffer() + + # Add events to first buffer + buffer1.addEvent(yup.MidiMessage.noteOn(1, 60, 0.8), 0) + buffer1.addEvent(yup.MidiMessage.noteOn(1, 64, 0.8), 100) + + # Add events from first buffer to second buffer with time offset + buffer2.addEvents(buffer1, 0, -1, 50) + + assert buffer2.getNumEvents() == 2 + assert buffer2.getFirstEventTime() == 50 + assert buffer2.getLastEventTime() == 150 + +#================================================================================================== + +def test_midi_buffer_swap(): + buffer1 = yup.MidiBuffer() + buffer2 = yup.MidiBuffer() + + # Add events to first buffer + buffer1.addEvent(yup.MidiMessage.noteOn(1, 60, 0.8), 0) + buffer1.addEvent(yup.MidiMessage.noteOn(1, 64, 0.8), 100) + + assert buffer1.getNumEvents() == 2 + assert buffer2.getNumEvents() == 0 + + # Swap buffers + buffer1.swapWith(buffer2) + + assert buffer1.getNumEvents() == 0 + assert buffer2.getNumEvents() == 2 + +#================================================================================================== + +def test_midi_message_channel_manipulation(): + """Test setting and checking MIDI channels.""" + msg = yup.MidiMessage.noteOn(1, 60, 0.8) + + assert msg.getChannel() == 1 + assert msg.isForChannel(1) + assert not msg.isForChannel(2) + + # Change channel + msg.setChannel(5) + assert msg.getChannel() == 5 + assert msg.isForChannel(5) + assert not msg.isForChannel(1) + +#================================================================================================== + +def test_midi_message_velocity_manipulation(): + """Test velocity setting and multiplication.""" + msg = yup.MidiMessage.noteOn(1, 60, 1.0) + assert abs(msg.getFloatVelocity() - 1.0) < 0.01 + + # Set velocity + msg.setVelocity(0.5) + assert abs(msg.getFloatVelocity() - 0.5) < 0.01 + + # Multiply velocity + msg.multiplyVelocity(0.5) + assert abs(msg.getFloatVelocity() - 0.25) < 0.01 + +#================================================================================================== + +def test_midi_message_note_number_manipulation(): + """Test note number changes.""" + msg = yup.MidiMessage.noteOn(1, 60, 0.8) + + assert msg.getNoteNumber() == 60 + + msg.setNoteNumber(72) + assert msg.getNoteNumber() == 72 + +#================================================================================================== + +def test_midi_message_aftertouch(): + """Test aftertouch messages.""" + msg = yup.MidiMessage.aftertouchChange(1, 60, 100) + + assert msg.isAftertouch() + assert msg.getNoteNumber() == 60 + assert msg.getAfterTouchValue() == 100 + +#================================================================================================== + +def test_midi_message_channel_pressure(): + """Test channel pressure (aftertouch).""" + msg = yup.MidiMessage.channelPressureChange(1, 80) + + assert msg.getChannel() == 1 + # Channel pressure is a type of aftertouch + +#================================================================================================== + +def test_midi_message_all_sound_off(): + """Test all sound off message.""" + msg = yup.MidiMessage.allSoundOff(1) + + assert msg.isAllSoundOff() + assert msg.getChannel() == 1 + +#================================================================================================== + +def test_midi_message_all_controllers_off(): + """Test all controllers off message.""" + msg = yup.MidiMessage.allControllersOff(1) + + assert msg.isResetAllControllers() + assert msg.getChannel() == 1 + +#================================================================================================== + +def test_midi_message_controller_of_type(): + """Test checking specific controller types.""" + # Volume controller (CC 7) + msg = yup.MidiMessage.controllerEvent(1, 7, 100) + + assert msg.isController() + assert msg.isControllerOfType(7) + assert not msg.isControllerOfType(1) # Not modulation wheel + +#================================================================================================== + +def test_midi_message_note_on_or_off(): + """Test isNoteOnOrOff method.""" + note_on = yup.MidiMessage.noteOn(1, 60, 0.8) + note_off = yup.MidiMessage.noteOff(1, 60, 0.0) + controller = yup.MidiMessage.controllerEvent(1, 7, 100) + + assert note_on.isNoteOnOrOff() + assert note_off.isNoteOnOrOff() + assert not controller.isNoteOnOrOff() + +#================================================================================================== + +def test_midi_buffer_ensure_size(): + """Test pre-allocating buffer space.""" + buffer = yup.MidiBuffer() + + # Pre-allocate space + buffer.ensureSize(1024) + + # Buffer should still be empty after ensureSize + assert buffer.isEmpty() + assert buffer.getNumEvents() == 0 + +#================================================================================================== + +def test_midi_buffer_empty_iteration(): + """Test iterating over empty buffer.""" + buffer = yup.MidiBuffer() + + events = list(buffer) + assert len(events) == 0 + +#================================================================================================== + +def test_midi_buffer_sorted_insertion(): + """Test that events are kept sorted by timestamp.""" + buffer = yup.MidiBuffer() + + # Add events out of order + buffer.addEvent(yup.MidiMessage.noteOn(1, 64, 0.8), 200) + buffer.addEvent(yup.MidiMessage.noteOn(1, 60, 0.8), 50) + buffer.addEvent(yup.MidiMessage.noteOn(1, 67, 0.8), 150) + + # Events should be sorted by timestamp + events = list(buffer) + + assert events[0].samplePosition == 50 + assert events[0].getMessage().getNoteNumber() == 60 + + assert events[1].samplePosition == 150 + assert events[1].getMessage().getNoteNumber() == 67 + + assert events[2].samplePosition == 200 + assert events[2].getMessage().getNoteNumber() == 64 + +#================================================================================================== + +def test_midi_buffer_multiple_events_same_time(): + """Test adding multiple events at the same timestamp.""" + buffer = yup.MidiBuffer() + + # Add multiple events at time 0 + buffer.addEvent(yup.MidiMessage.noteOn(1, 60, 0.8), 0) + buffer.addEvent(yup.MidiMessage.noteOn(1, 64, 0.8), 0) + buffer.addEvent(yup.MidiMessage.noteOn(1, 67, 0.8), 0) + + assert buffer.getNumEvents() == 3 + assert buffer.getFirstEventTime() == 0 + assert buffer.getLastEventTime() == 0 diff --git a/python/tests/test_yup_audio_basics/test_Synthesiser.py b/python/tests/test_yup_audio_basics/test_Synthesiser.py new file mode 100644 index 000000000..caffc8bba --- /dev/null +++ b/python/tests/test_yup_audio_basics/test_Synthesiser.py @@ -0,0 +1,317 @@ +import yup +import math + +#================================================================================================== + +class SimpleSynthSound(yup.SynthesiserSound): + """A simple synthesiser sound that applies to all notes and channels.""" + + def __init__(self, minNote=0, maxNote=127): + super().__init__() + self.minNote = minNote + self.maxNote = maxNote + + def appliesToNote(self, midiNoteNumber): + return self.minNote <= midiNoteNumber <= self.maxNote + + def appliesToChannel(self, midiChannel): + # Apply to all channels + return True + +#================================================================================================== + +class SimpleSynthVoice(yup.SynthesiserVoice): + """A simple sine wave synthesiser voice.""" + + def __init__(self): + super().__init__() + self.current_note = -1 + self.velocity = 0.0 + self.sample_rate = 44100.0 + self.phase = 0.0 + self.phase_delta = 0.0 + self.active = False + self.level = 0.0 + self.pitch_wheel = 0 + self.controller_values = {} + + def canPlaySound(self, sound): + # Check if sound is a SimpleSynthSound + return isinstance(sound, SimpleSynthSound) + + def startNote(self, midiNoteNumber, velocity, sound, currentPitchWheelPosition): + self.current_note = midiNoteNumber + self.velocity = velocity + self.pitch_wheel = currentPitchWheelPosition + self.active = True + self.level = velocity + self.phase = 0.0 + + # Calculate frequency from MIDI note + frequency = 440.0 * math.pow(2.0, (midiNoteNumber - 69) / 12.0) + self.phase_delta = frequency / self.sample_rate + + def stopNote(self, velocity, allowTailOff): + self.active = False + self.level = 0.0 + + def pitchWheelMoved(self, newPitchWheelValue): + self.pitch_wheel = newPitchWheelValue + + def controllerMoved(self, controllerNumber, newControllerValue): + self.controller_values[controllerNumber] = newControllerValue + + def renderNextBlock(self, outputBuffer, startSample, numSamples): + if not self.active: + return + + # Generate sine wave + for channel in range(outputBuffer.getNumChannels()): + phase = self.phase + for i in range(numSamples): + sample = math.sin(phase * 2.0 * math.pi) * self.level + current_value = outputBuffer.getSample(channel, startSample + i) + outputBuffer.setSample(channel, startSample + i, current_value + sample) + phase += self.phase_delta + if phase >= 1.0: + phase -= 1.0 + + self.phase += self.phase_delta * numSamples + while self.phase >= 1.0: + self.phase -= 1.0 + + def setCurrentPlaybackSampleRate(self, newRate): + self.sample_rate = newRate + # Recalculate phase delta if we have an active note + if self.current_note >= 0: + frequency = 440.0 * math.pow(2.0, (self.current_note - 69) / 12.0) + self.phase_delta = frequency / self.sample_rate + + def isVoiceActive(self): + return self.active + +#================================================================================================== + +def test_synthesiser_sound_creation(): + sound = SimpleSynthSound() + assert sound is not None + +#================================================================================================== + +def test_synthesiser_sound_applies_to_note(): + sound = SimpleSynthSound(60, 72) # Middle C to C above + + # Should apply to notes in range + assert sound.appliesToNote(60) + assert sound.appliesToNote(66) + assert sound.appliesToNote(72) + + # Should not apply to notes outside range + assert not sound.appliesToNote(59) + assert not sound.appliesToNote(73) + +#================================================================================================== + +def test_synthesiser_sound_applies_to_channel(): + sound = SimpleSynthSound() + + # Should apply to all channels + for channel in range(16): + assert sound.appliesToChannel(channel) + +#================================================================================================== + +def test_synthesiser_voice_creation(): + voice = SimpleSynthVoice() + assert voice is not None + assert voice.current_note == -1 + assert not voice.active + +#================================================================================================== + +def test_synthesiser_voice_can_play_sound(): + voice = SimpleSynthVoice() + sound = SimpleSynthSound() + + assert voice.canPlaySound(sound) + +#================================================================================================== + +def test_synthesiser_voice_start_note(): + voice = SimpleSynthVoice() + sound = SimpleSynthSound() + + voice.startNote(60, 0.8, sound, 8192) + + assert voice.current_note == 60 + assert abs(voice.velocity - 0.8) < 0.001 + assert voice.pitch_wheel == 8192 + assert voice.active + assert abs(voice.level - 0.8) < 0.001 + +#================================================================================================== + +def test_synthesiser_voice_stop_note(): + voice = SimpleSynthVoice() + sound = SimpleSynthSound() + + voice.startNote(60, 0.8, sound, 8192) + assert voice.active + + voice.stopNote(0.0, True) + assert not voice.active + assert abs(voice.level - 0.0) < 0.001 + +#================================================================================================== + +def test_synthesiser_voice_pitch_wheel(): + voice = SimpleSynthVoice() + + voice.pitchWheelMoved(10000) + assert voice.pitch_wheel == 10000 + + voice.pitchWheelMoved(4096) + assert voice.pitch_wheel == 4096 + +#================================================================================================== + +def test_synthesiser_voice_controller_moved(): + voice = SimpleSynthVoice() + + voice.controllerMoved(7, 100) # Volume controller + assert voice.controller_values[7] == 100 + + voice.controllerMoved(1, 64) # Mod wheel + assert voice.controller_values[1] == 64 + +#================================================================================================== + +def test_synthesiser_voice_render_next_block(): + voice = SimpleSynthVoice() + sound = SimpleSynthSound() + + voice.setCurrentPlaybackSampleRate(44100.0) + voice.startNote(69, 0.5, sound, 8192) # A440 + + buffer = yup.AudioBuffer(2, 512) + buffer.clear() + + voice.renderNextBlock(buffer, 0, 512) + + # Check that buffer is no longer silent + mag = buffer.getMagnitude(0, 0, 512) + assert mag > 0.01 + +#================================================================================================== + +def test_synthesiser_voice_is_active(): + voice = SimpleSynthVoice() + sound = SimpleSynthSound() + + assert not voice.isVoiceActive() + + voice.startNote(60, 0.8, sound, 8192) + assert voice.isVoiceActive() + + voice.stopNote(0.0, False) + assert not voice.isVoiceActive() + +#================================================================================================== + +def test_synthesiser_voice_set_sample_rate(): + voice = SimpleSynthVoice() + + voice.setCurrentPlaybackSampleRate(48000.0) + assert abs(voice.sample_rate - 48000.0) < 0.01 + + voice.setCurrentPlaybackSampleRate(96000.0) + assert abs(voice.sample_rate - 96000.0) < 0.01 + +#================================================================================================== + +def test_synthesiser_voice_multiple_notes(): + voice = SimpleSynthVoice() + sound = SimpleSynthSound() + + # Start first note + voice.startNote(60, 0.5, sound, 8192) + assert voice.current_note == 60 + + # Stop and start second note + voice.stopNote(0.0, False) + voice.startNote(64, 0.7, sound, 8192) + assert voice.current_note == 64 + assert abs(voice.velocity - 0.7) < 0.001 + +#================================================================================================== + +def test_synthesiser_with_voice_and_sound(): + """Test using Synthesiser with custom voice and sound.""" + synth = yup.Synthesiser() + + # Add voice and sound + voice = SimpleSynthVoice() + sound = SimpleSynthSound(60, 72) + + synth.addVoice(voice) + synth.addSound(sound) + + assert synth.getNumVoices() == 1 + assert synth.getNumSounds() == 1 + +#================================================================================================== + +def test_synthesiser_note_on_off(): + """Test triggering notes on the synthesiser.""" + synth = yup.Synthesiser() + + voice = SimpleSynthVoice() + sound = SimpleSynthSound(60, 72) + + synth.addVoice(voice) + synth.addSound(sound) + + synth.setCurrentPlaybackSampleRate(44100.0) + + # Trigger a note + synth.noteOn(1, 60, 0.8) + + # Voice should become active + assert voice.isVoiceActive() + assert voice.current_note == 60 + + # Release the note + synth.noteOff(1, 60, 0.0, True) + + # Voice should become inactive + assert not voice.isVoiceActive() + +#================================================================================================== + +def test_synthesiser_render_voices(): + """Test rendering audio with synthesiser.""" + synth = yup.Synthesiser() + + voice = SimpleSynthVoice() + sound = SimpleSynthSound() + + synth.addVoice(voice) + synth.addSound(sound) + + synth.setCurrentPlaybackSampleRate(44100.0) + + # Trigger a note + synth.noteOn(1, 69, 0.5) # A440 + + # Render some audio + buffer = yup.AudioBuffer(2, 512) + buffer.clear() + + # Create empty MIDI buffer for rendering + midi_buffer = yup.MidiBuffer() + + synth.renderNextBlock(buffer, midi_buffer, 0, 512) + + # Check that audio was rendered + mag = buffer.getMagnitude(0, 0, 512) + assert mag > 0.01 diff --git a/tests/yup_audio_basics/yup_Synthesiser.cpp b/tests/yup_audio_basics/yup_Synthesiser.cpp index 7aadd8fae..61b43c7f9 100644 --- a/tests/yup_audio_basics/yup_Synthesiser.cpp +++ b/tests/yup_audio_basics/yup_Synthesiser.cpp @@ -240,18 +240,18 @@ TEST_F (SynthesiserTest, VoiceManagement) // Add voices auto* voice1 = synth->addVoice (new TestVoice()); EXPECT_EQ (synth->getNumVoices(), 1); - EXPECT_EQ (synth->getVoice (0), voice1); + EXPECT_EQ (synth->getVoice (0).get(), voice1); EXPECT_NE (voice1, nullptr); auto* voice2 = synth->addVoice (new TestVoice()); EXPECT_EQ (synth->getNumVoices(), 2); - EXPECT_EQ (synth->getVoice (1), voice2); + EXPECT_EQ (synth->getVoice (1).get(), voice2); EXPECT_NE (voice2, nullptr); // Remove voice synth->removeVoice (0); EXPECT_EQ (synth->getNumVoices(), 1); - EXPECT_EQ (synth->getVoice (0), voice2); + EXPECT_EQ (synth->getVoice (0).get(), voice2); // Clear all voices synth->clearVoices();