Compare commits
102 Commits
7bd911d58b
...
92f0aa209d
| Author | SHA1 | Date | |
|---|---|---|---|
| 92f0aa209d | |||
| 19e28b02c3 | |||
| ca43eb76d4 | |||
| 4fca140ace | |||
| 573e878cbd | |||
| 194c2392cf | |||
| a4aeb4f6ab | |||
| 0f870b1337 | |||
| a3a9c91256 | |||
| a21a6176e8 | |||
| 2a9ab65944 | |||
| f69c4bf6bf | |||
| 6e6f4a6a15 | |||
| 91f3ffb09e | |||
| 8b191e3379 | |||
| 0feb32d59b | |||
| 4fa3c1dd15 | |||
| 3278b4eced | |||
| 4bdbbe0517 | |||
| 178cca3c25 | |||
| eb6093e085 | |||
| 09d64c9f46 | |||
| a0220e8c57 | |||
| 65f6070348 | |||
| 7dc80e7a4f | |||
| a0f9a23711 | |||
| 1044f5418f | |||
| 148d8a9e06 | |||
| 19aeb92775 | |||
| 9d10f151da | |||
| d589b99705 | |||
| f2cdf676f3 | |||
| fe3ac79ab5 | |||
| 72b870459c | |||
| 9c234c2e6b | |||
| 5e3ea6c851 | |||
| d3894dae6d | |||
| 8bf6841f7b | |||
| 0a7778b396 | |||
| 9f17df1420 | |||
| 7ecb3aa470 | |||
| 2fa55658c1 | |||
| a09e45fc1d | |||
| 39c2d1cdb4 | |||
| 256216d670 | |||
| eb777f55e1 | |||
| 60308a3655 | |||
| 6c982cd617 | |||
| 806a3037a3 | |||
| 704b7ef373 | |||
| 3ad30942a7 | |||
| 07628a2059 | |||
| 3fb77027c6 | |||
| 2e09776342 | |||
| ae9019465e | |||
| 7504eefb59 | |||
| d445cfdae3 | |||
| 47f2a063e7 | |||
| ca464397b2 | |||
| 7f74b11dce | |||
| 213ecfafe6 | |||
| 85cf34f858 | |||
| aedcc6976c | |||
| 757e1585d6 | |||
| c3d67bc3da | |||
| d8ffd860d1 | |||
| 1c774226d3 | |||
| e44bd42dd9 | |||
| f115904e4f | |||
| a32ab4b5b2 | |||
| 5e290119c5 | |||
| cfb53aade3 | |||
| 9d38f87a2d | |||
| 4fecb58c0a | |||
| 7261f0f6a8 | |||
| 3cb22e984d | |||
| 0fe4166d7d | |||
| a90ccd9a00 | |||
| 1307007b91 | |||
| a19d645a49 | |||
| 91f71f92bd | |||
| e1e668a521 | |||
| 87754477a7 | |||
| 04e4f875af | |||
| 03fb471c92 | |||
| 2fa1405b06 | |||
| d60ab1524a | |||
| 13e8c5c4d9 | |||
| 87d7ceb066 | |||
| 98594df878 | |||
| 8dc76f4a1f | |||
| 60aeda1a3e | |||
| efd1e41f46 | |||
| bbc7dc70bb | |||
| 1dade0bfcf | |||
| dddecc5734 | |||
| a80eb76c1f | |||
| 6287534454 | |||
| d923b352ae | |||
| 45b87dc2ff | |||
| 834a6ddcc7 | |||
| 7f70d94228 |
+3
-1
@@ -1,10 +1,12 @@
|
||||
/build/
|
||||
/build-beta/
|
||||
/.claude/worktrees/
|
||||
/.idea/ctestState.xml
|
||||
/.idea/
|
||||
*.dll
|
||||
*.dylib
|
||||
*.so
|
||||
*.o
|
||||
*.obj
|
||||
.DS_Store
|
||||
/AGENTS.md
|
||||
cmake-build-*/
|
||||
|
||||
Generated
-10
@@ -1,10 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
-1
@@ -1 +0,0 @@
|
||||
reaper_mpeview
|
||||
Generated
-350
@@ -1,350 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="BackendCodeEditorSettings">
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CDeclarationWithImplicitIntType/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CommentTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=ConstevalIfIsAlwaysConstant/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractClassWithoutSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractVirtualFunctionCallInCtor/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAccessSpecifierWithNoDeclarations/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAwaiterTypeIsNotClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBooleanIncrementExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatBadCode/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatLegacyCode/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatMixedArgs/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooFewArgs/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCompileTimeConstantCanBeReplacedWithBooleanConstant/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConceptNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConditionalExpressionCanBeSimplified/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstValueFunctionReturnType/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstexprIfDiscardedBranch/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCoroutineCallResolveError/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAArrayIndexOutOfBounds/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantConditions/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantFunctionResult/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantParameter/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFADeletedPointer/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAEndlessLoop/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInfiniteRecursion/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInvalidatedMemory/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesFunction/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesScope/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALoopConditionNotUpdated/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAMemoryLeak/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANotInitializedField/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANullDereference/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFATimeOver/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableCode/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableFunctionCall/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreadVariable/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnusedValue/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesLocal/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesUncapturedLocal/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationSpecifierWithoutDeclarators/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorDisambiguatedAsFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorUsedBeforeInitialization/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultCaseNotHandledInSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefinitionsOrder/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedEntity/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedOverridenMethod/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedRegisterStorageClassSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDereferenceOperatorLimitExceeded/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDiscardedPostfixOperatorResult/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenSyntaxError/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUndocumentedParameter/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUnresolvedReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEmptyDeclaration/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersOrder/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersPlacement/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceDoStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceForStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceFunctionDeclarationStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceIfStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceNestedNamespacesStyle/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingDestructorStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingFunctionStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceTypeAliasCodeStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceWhileStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityAssignedButNoRead/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityUsedOnlyInUnevaluatedContext/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnumeratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEqualOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEvaluationFailure/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExplicitSpecializationInNonNamespaceScope/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExpressionWithoutSideEffects/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalFunctionInFinalClass/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalNonOverridingVirtualFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForLoopCanBeReplacedWithWhile/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForwardEnumDeclarationWithoutUnderlyingType/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionDoesntReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionResultShouldBeUsed/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionalStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHeaderHasBeenAlreadyIncluded/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHiddenFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHidingFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIdenticalOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIfCanBeReplacedByConstexprIf/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppImplicitDefaultConstructorNotAvailable/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompatiblePointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompleteSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInconsistentNaming/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIntegralToPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInvalidLineContinuation/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppJoinDeclarationAndAssignment/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLambdaCaptureNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableWithNonTrivialDtorIsNeverUsed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLongFloat/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeConst/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeStatic/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberInitializersOrder/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMismatchedClassTags/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingIncludeGuard/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingKeywordThrow/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppModulePartitionWithSeveralPartitionUnits/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtAddressOfClassRValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtBindingRValueToLvalueReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtCopyElisionInCopyInitDeclarator/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtDoubleUserConversionInCopyInit/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtNotInitializedStaticConstLocalVar/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtReinterpretCastFromNullptr/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterLiteral/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterWideLiteral/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMustBePublicVirtualToImplementInterface/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMutableSpecifierOnReferenceMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNoDiscardExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNodiscardFunctionWithoutReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExceptionSafeResourceAcquisition/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConversionOperator/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConvertingConstructor/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineFunctionDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineVariableDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNotAllPathsReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppObjectMemberMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppOutParameterMustBeWritten/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppOverrideWithDifferentVisibility/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConstPtrOrRef/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNamesMismatch/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPassValueParameterByConstReference/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerConversionDropsQualifiers/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerToIntegralConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPolymorphicClassWithNonVirtualPublicDestructor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyErroneousEmptyStatements/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUnintendedObjectSlicing/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderIsNotIncluded/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderNotFound/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfBadFormat/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfExtraArg/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfMissedArg/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfRiskyFormat/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrivateSpecialMemberFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRangeBasedForIncompatibleReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedefinitionOfDefaultArgumentInOverrideFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBooleanExpressionArgument/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantCastExpression/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantComplexityInComparison/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConditionalExpression/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConstSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantControlFlowJump/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantDereferencingAndTakingAddress/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElaboratedTypeSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeyword/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeywordInsideCompoundStatement/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyDeclaration/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyStatement/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantExportKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantFwdClassOrEnumSpecifier/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantInlineSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantLambdaParameterList/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantMemberInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantNamespaceDefinition/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantParentheses/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifierADL/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnMemberAllocationFunction/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnThreadLocalLocalVariable/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateArguments/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantVoidArgumentList/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantZeroInitializerInAggregateInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReferenceToOverriddenVirtualFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReinterpretCastFromVoidPtr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRemoveRedundantBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceMemsetWithZeroInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceTieWithStructuredBinding/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReturnNoValueInNonVoidFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSmartPointerVsMakeFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSomeObjectMembersMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSpecialFunctionWithoutNoexceptSpecification/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticAssertFailure/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticDataMemberInUnnamedStruct/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticSpecifierOnAnonymousNamespaceMember/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStringLiteralToCharPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTabsAreDisallowed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateArgumentsCanBeDeduced/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterShadowing/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppThrowExpressionCanBeReplacedWithRethrow/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScope/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScopeInitStatement/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTypeAliasNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedDependentBaseClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedNonStaticDataMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnionMemberOfReferenceType/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaEndRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnamedNamespaceInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnecessaryWhitespace/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnsignedZeroComparison/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnusedIncludeDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAlgorithmWithCount/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAssociativeContains/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAuto/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAutoForNumeric/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseDesignatedInitializers/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseElementsView/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseEraseAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseFamiliarTemplateSyntaxForGenericLambdas/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseInternalLinkage/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseRangeAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStdSize/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStructuredBinding/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseTypeTraitAlias/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUserDefinedLiteralSuffixDoesNotStartWithUnderscore/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUsingResultOfAssignmentAsCondition/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVariableCanBeMadeConstexpr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionCallInsideCtor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionInFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVolatileParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWarningDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongIncludesOrder/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongSlashesInIncludeDirective/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroConstantCanBeReplacedWithNullptr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroValuedExpressionUsedAsNullPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IdentifierTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IfStdIsConstantEvaluatedCanBeReplaced/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StdIsConstantEvaluatedWillAlwaysEvaluateToConstant/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppClangFormat/EnableClangFormatSupport/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_ARGUMENT/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_BINARY_EXPRESSIONS_CHAIN/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_CALLS_CHAIN/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_EXPRESSION/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_EXTENDS_LIST/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_FOR_STMT/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_PARAMETER/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_TYPE_ARGUMENT/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_TYPE_PARAMETER/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTIPLE_DECLARATION/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_TERNARY/@EntryValue" value="ALIGN_ALL" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ANONYMOUS_METHOD_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_CLASS_DEFINITION/@EntryValue" value="1" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_DECLARATIONS/@EntryValue" value="0" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_FUNCTION_DECLARATION/@EntryValue" value="1" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_FUNCTION_DEFINITION/@EntryValue" value="1" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BREAK_TEMPLATE_DECLARATION/@EntryValue" value="LINE_BREAK" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/CASE_BLOCK_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/CONTINUOUS_LINE_INDENT/@EntryValue" value="Double" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_ACCESS_SPECIFIERS_FROM_CLASS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_CASE_FROM_SWITCH/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_CLASS_MEMBERS_FROM_ACCESS_SPECIFIERS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_COMMENT/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_SIZE/@EntryValue" value="4" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_STYLE/@EntryValue" value="Space" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INITIALIZER_BRACES/@EntryValue" value="END_OF_LINE_NO_SPACE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INT_ALIGN_EQ/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INVOCABLE_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/KEEP_BLANK_LINES_IN_CODE/@EntryValue" value="2" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/KEEP_BLANK_LINES_IN_DECLARATIONS/@EntryValue" value="2" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/KEEP_USER_LINEBREAKS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/LINE_BREAK_AFTER_COLON_IN_MEMBER_INITIALIZER_LISTS/@EntryValue" value="ON_SINGLE_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/MEMBER_INITIALIZER_LIST_STYLE/@EntryValue" value="DO_NOT_CHANGE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/NAMESPACE_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/NAMESPACE_INDENTATION/@EntryValue" value="All" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/OTHER_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_CATCH_ON_NEW_LINE/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_ELSE_ON_NEW_LINE/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_NAMESPACE_DEFINITIONS_ON_SAME_LINE/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_WHILE_ON_NEW_LINE/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SIMPLE_BLOCK_STYLE/@EntryValue" value="DO_NOT_CHANGE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_CAST_EXPRESSION_PARENTHESES/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_COLON_IN_BITFIELD_DECLARATOR/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_COMMA_IN_TEMPLATE_ARGS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_COMMA_IN_TEMPLATE_PARAMS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_EXTENDS_COLON/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_FOR_COLON/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_FOR_SEMICOLON/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_DATA_MEMBER/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_DATA_MEMBERS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_METHOD/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_NESTED_DECLARATOR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_REF_IN_DATA_MEMBER/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_REF_IN_DATA_MEMBERS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_REF_IN_METHOD/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_UNARY_OPERATOR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_COLON_IN_BITFIELD_DECLARATOR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_EXTENDS_COLON/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_FOR_COLON/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_FOR_SEMICOLON/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_ABSTRACT_DECL/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_DATA_MEMBER/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_DATA_MEMBERS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_METHOD/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_ABSTRACT_DECL/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_DATA_MEMBER/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_DATA_MEMBERS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_METHOD/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_TEMPLATE_ARGS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_TEMPLATE_PARAMS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BETWEEN_CLOSING_ANGLE_BRACKETS_IN_TEMPLATE_ARGS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_ARRAY_ACCESS_BRACKETS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_CAST_EXPRESSION_PARENTHESES/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_DECLARATION_PARENTHESES/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_BLOCKS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_INITIALIZER_BRACES/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_METHOD_PARENTHESES/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_TEMPLATE_PARAMS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_INITIALIZER_BRACES/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_TEMPLATE_ARGS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_TEMPLATE_PARAMS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPECIAL_ELSE_IF_TREATMENT/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/TAB_WIDTH/@EntryValue" value="4" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/TYPE_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_AFTER_BINARY_OPSIGN/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_AFTER_DECLARATION_LPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_AFTER_INVOCATION_LPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_ARGUMENTS_STYLE/@EntryValue" value="WRAP_IF_LONG" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_DECLARATION_LPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_DECLARATION_RPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_INVOCATION_LPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_INVOCATION_RPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_TERNARY_OPSIGNS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_PARAMETERS_STYLE/@EntryValue" value="WRAP_IF_LONG" type="string" />
|
||||
<option name="/Default/CodeStyle/EditorConfig/EnableClangFormatSupport/@EntryValue" value="false" type="bool" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-10
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="userId" value="-2857a757:19fa27c75be:-7fff" />
|
||||
</MTProjectMetadataState>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-7
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CMakePythonSetting">
|
||||
<option name="pythonIntegrationState" value="YES" />
|
||||
</component>
|
||||
<component name="CMakeWorkspace" PROJECT_DIR="$PROJECT_DIR$" />
|
||||
</project>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/reasampler.iml" filepath="$PROJECT_DIR$/.idea/reasampler.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-2
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module classpath="CIDR" type="CPP_MODULE" version="4" />
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/vendor/WDL" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/vendor/reaper-sdk" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
|
||||
|
||||
Per-module detail — what each file owns, its invariants — lives in the twenty per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
|
||||
Per-module detail — what each file owns, its invariants — lives in the twenty-three per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
|
||||
|
||||
## Settled decisions
|
||||
|
||||
@@ -19,8 +19,9 @@ Per-module detail — what each file owns, its invariants — lives in the twent
|
||||
paths anywhere in the index.
|
||||
- **Material:** must handle full-mix/stem bounces, chops/one-shots, and
|
||||
single-cycle/wavetable grabs equally. That means exact sample-accurate bounds,
|
||||
explicit tail control, channel-count preservation, and loop/zero-crossing
|
||||
handling all matter from day one.
|
||||
explicit tail control, correct channel handling (the exact-bounds channel rule
|
||||
under Precision invariants), and loop/zero-crossing handling all matter from day
|
||||
one.
|
||||
|
||||
## One-time submodule setup
|
||||
|
||||
@@ -46,6 +47,16 @@ On a multi-config generator (Visual Studio, Xcode) the bare `ctest` command abov
|
||||
reports every test as "Not Run" — add `-C Debug` (or whichever config was built) to
|
||||
resolve the test executables. Single-config generators (Ninja, Make) need no such flag.
|
||||
|
||||
On a multi-config generator, `cmake --build build` with no `--config` builds **Debug** —
|
||||
there is no `CMAKE_BUILD_TYPE`, no `CMAKE_CXX_FLAGS`, and no IPO/LTO setting anywhere in
|
||||
the build, so nothing is optimized or inlined at that default. The performance
|
||||
guardrails and structural heuristics below (header-inline hot paths, "no LTO
|
||||
configured") presume an **optimizing** build. Shipping, installing, or judging
|
||||
performance requires the Release config explicitly:
|
||||
|
||||
cmake --build build --config Release
|
||||
ctest --test-dir build -C Release
|
||||
|
||||
Every pure module has a corresponding `<module>_tests` executable target that runs without REAPER or a DAW. Targets are declared per directory: each `src/**/CMakeLists.txt` owns its own libraries and their test targets, added via `add_subdirectory` from the root, which keeps only repo-global settings (version, channel, vendor paths). `cmake/reasampler_targets.cmake` holds the two shared declaration helpers. The two loadable-module targets are `reaper_reasampler` (the REAPER extension `.dll`/`.dylib`/`.so`) and `reasampler_vst` (the VST3 instrument; Windows-only, omitted if the `vendor/vst3sdk` slice is absent). The `sample_usage_tests` executable target runs the pure unit tests for `sample_usage` (no REAPER, no DAW).
|
||||
|
||||
### Beta channel build
|
||||
@@ -69,22 +80,25 @@ Add the generated file to the appropriate `APPLE` / Linux `target_sources` block
|
||||
|
||||
### Install / reload
|
||||
|
||||
There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folder (Options → Show REAPER resource path) and restart REAPER. Extensions load at startup only.
|
||||
There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on a multi-config generator — not the default `Debug/` output) into REAPER's `UserPlugins/` folder (Options → Show REAPER resource path) and restart REAPER. Extensions load at startup only.
|
||||
|
||||
## Architecture: the load-bearing split
|
||||
|
||||
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
|
||||
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-three directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
|
||||
|
||||
| Directory | Scope |
|
||||
|---|---|
|
||||
| `src/app/` | REAPER extension entry point |
|
||||
| `src/core/audio/` | pure audio-data math |
|
||||
| `src/core/capture/` | pure logic behind the capture pillar |
|
||||
| `src/core/instrument/` | pure VST3-instrument core (engine / map / ui) |
|
||||
| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / ui) |
|
||||
| `src/core/instrument/bake/` | the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset |
|
||||
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
|
||||
| `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
|
||||
| `src/core/json/` | the hand-rolled JSON lexical layer |
|
||||
| `src/core/model/` | the pure bank/sample index and its multi-bank container |
|
||||
| `src/core/reclaim/` | pure prune orphan computation |
|
||||
| `src/core/tracking/` | the consolidated file-tracking system — birth/lineage records and the one authority answering prune's protected set and the resample's replace-vs-add |
|
||||
| `src/core/ui/` | pure UI geometry, palette, and interaction-decision modules |
|
||||
| `src/core/util/` | small shared pure utilities |
|
||||
| `src/core/version/` | version/channel identity |
|
||||
@@ -103,7 +117,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
|
||||
The top-level split is by the pure/shell discipline: `core/` never includes REAPER or VST3 SDK
|
||||
types; `shell/` is where those host types are actually touched — the discriminator is "may this
|
||||
file touch a host type, REAPER *or* VST3 SDK." Subsystem directories sit beneath `core/` (see the
|
||||
table above); `core/instrument/` further subdivides into `engine/` / `map/` / `ui/`. Namespaces
|
||||
table above); `core/instrument/` further subdivides into `engine/` / `map/` / `note/` / `ui/`. Namespaces
|
||||
mirror directories — `reasampler::<subsystem>` for `core/`, house style for `shell/`. `app/` holds
|
||||
`main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and dispatch.
|
||||
|
||||
@@ -168,6 +182,7 @@ Comments carry *why*, and context where non-obvious — never *what* the code al
|
||||
2. `rec->Register("gaccel", &accel)` — puts the action in the Actions list.
|
||||
3. `rec->Register("hookcommand", ...)` — receives every action fired; claim only your own id, return `false` otherwise.
|
||||
4. On unload (`rec == nullptr`), mirror-unregister everything with the same strings prefixed by `'-'`.
|
||||
- **Non-main sections use a different mechanism.** `gaccel_register_t` carries no section field — `command_id` + `gaccel` can only ever produce a Main-section action. To publish into another section (Media Explorer = 32063, MIDI editor = 32060, MIDI event list = 32061, MIDI inline = 32062), register a `custom_action_register_t{uniqueSectionId, idStr, name, extra}` under `"custom_action"`; it returns the command id, or **0 on failure** (e.g. a duplicate `idStr`) — which the caller must tolerate rather than half-register. `idStr` must be unique **across all sections**, so an action published into both Main and a non-main section needs a SECOND id string; the FOREVER-STABLE contract binds it identically from the moment it ships. `custom_action_register_t` has no `ACCEL`, so a non-main entry ships no default keybinding. Dispatch for these ids arrives through `"hookcommand2"` (`bool(KbdSectionInfo*, int command, int val, int val2, int relmode, HWND)`) — `"hookcommand"` runs for the main section only. The two hooks must partition the ids between them; what happens when a command is claimed by both is unspecified by the SDK (`hookcommand2`'s doc says a `true` return prevents further hooks/actions from running, which is in tension with a clean double-fire either way), so nothing may rely on either outcome. On unload, mirror with `"-custom_action"` `[verify — DAW]` (the header confirms the `-` prefix for "most" registration types and spells out only `-pcmsrc` by name; `custom_action` itself is unconfirmed) and `"-hookcommand2"`.
|
||||
|
||||
## Product design docs
|
||||
|
||||
@@ -192,10 +207,30 @@ Plan-style docs live under `docs/`:
|
||||
- **Null test:** a dry offline capture of a range, re-inserted at its source position, nulls to silence against the source — the tool's trust anchor. Ship as a verification action. (Verification action cut per `docs/product/provenance.md` — manual verification only.)
|
||||
- **Bit-identical repeats:** identical offline capture requests produce identical files.
|
||||
- **Non-destructive:** capture never mutates source items or tracks; the realtime backend's temp track is created and removed cleanly, and source routing is restored.
|
||||
- **Exact bounds:** no rounding of the requested range; no added silence unless a tail is explicitly requested; channel count preserved (no silent stereo fold).
|
||||
- **Exact bounds:** no rounding of the requested range; no added silence unless a tail is explicitly requested; **no lossy channel fold** — summing or averaging differing channels is forbidden. The one permitted collapse is lossless: a new capture whose channels are bit-identical per frame (float bit patterns, never an epsilon) lands as a 1-channel file, with `Sample::channelCount` and the file's `fmt` written together so the two can never disagree. Frame count, sample rate and bit depth are untouched by it. Never retroactive — existing entries and files are never rewritten — and ingest is excluded, because an imported file is the user's bytes, not our capture. The superseded wording ("channel count preserved") was already untrue in the other direction: a mono source renders at `RENDER_CHANNELS = 2`. `[verify — DAW]` "lossless" here is a file-bytes property; whether REAPER sums a 1-channel item on a stereo track at the same unity gain as a dual-mono 2-channel item (pan law, mono spread) — the null test's actual playback-chain property — is unconfirmed.
|
||||
- **Relative paths only** in the persisted `BankIndex`.
|
||||
- **Capture FX scope:** two scopes only — item = item/take FX only; track = item FX + the selected track's own track FX. There is no master scope (to capture the master, render a track instead). For both scopes, the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity — the master track is bypassed as out-of-scope chain, not captured as a scope. Range (time selection or razor) is orthogonal.
|
||||
|
||||
## The resample bake — the one crossing from instrument into bank
|
||||
|
||||
A click inside the ReaSampler 9000 editor bakes the dialed sound into a bank capture. The
|
||||
split is: **the instrument renders, the extension banks.** The instrument produces the audio
|
||||
on its own voice path in its own process (so the bake is the object code that made the sound
|
||||
the user approved, immune to engine-version skew between the two artifacts), stages it
|
||||
outside the bank folder, and invokes ONE extension action over the VST3 host bridge; the
|
||||
extension lands it and answers over the same per-instance key, synchronously, inside that
|
||||
call. Consequences that bind:
|
||||
|
||||
- **The extension's link graph does not gain the voice engine.** `sampler_core` /
|
||||
`pitch_shift` / the filter are NOT linked into `reaper_reasampler` — a link edge to any of
|
||||
them means the design drifted back to an extension-side render.
|
||||
- **No arrange mutation and no deletion.** The bake writes a file plus an index entry, like
|
||||
every other capture. "Replace" means the bank entry now denotes the recapture; the
|
||||
superseded file survives on disk until a prune reclaims it — the iterate loop's recovery
|
||||
floor.
|
||||
- **The bake adds nothing to `process()`.** It renders on the UI thread over a separate
|
||||
`VoiceEngine`, with the live-parameter block detached.
|
||||
|
||||
## Non-goals / guardrails
|
||||
|
||||
- No auto-insertion of captures into the arrange (see the load-bearing principle).
|
||||
|
||||
+2
-2
@@ -21,9 +21,9 @@ cmake_minimum_required(VERSION 3.19)
|
||||
# invariant (reconstruct-from-components inside app_version.cpp) via a permanent synthetic
|
||||
# "0.9.01" fixture there that must NEVER be bumped on release. It cannot see this line
|
||||
# becoming a CMake derivation — that is this comment's job.
|
||||
set(REASAMPLER_VERSION "1.0.0")
|
||||
set(REASAMPLER_VERSION "1.4.0")
|
||||
|
||||
project(reaper_reasampler VERSION 1.0.0 LANGUAGES CXX)
|
||||
project(reaper_reasampler VERSION 1.4.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -1,20 +1,38 @@
|
||||
# ReaSampler
|
||||
|
||||
A native C++ REAPER extension that captures any arbitrary audio source into a
|
||||
per-project **sample bank** (cached files + a docked grid), decoupled from the
|
||||
arrange view, with keyboard/MIDI-bindable capture and placement. Built as a
|
||||
precision tool: deterministic, non-destructive, no clutter.
|
||||
Version 1.4.0 · License: GNU AGPL v3 (see `LICENSE`)
|
||||
|
||||
A per-project audio sample-bank capture tool for REAPER, built as two artifacts: a
|
||||
native C++ REAPER extension (`reaper_reasampler`) and a Windows-only VST3 sampler
|
||||
instrument (`reasampler_9000.vst3`, ReaSampler 9000). It captures any arbitrary audio
|
||||
source into a per-project **sample bank** (cached files + a docked grid), decoupled
|
||||
from the arrange view, with keyboard/MIDI-bindable capture and placement. It is
|
||||
designed as a precision tool: deterministic captures, non-destructive by
|
||||
construction, no auto-inserted clutter.
|
||||
|
||||
## Status
|
||||
|
||||
Under active development, not yet formally released.
|
||||
|
||||
## The two artifacts
|
||||
|
||||
**`reaper_reasampler`** — the REAPER extension. Captures audio into a named,
|
||||
per-project sample bank, displays it in a docked panel, and provides bindable
|
||||
actions for capture and placement.
|
||||
per-project sample bank and provides bindable actions across the workflow: offline
|
||||
and realtime capture (with cancel), batch capture (per selected item or per razor
|
||||
area), recapture-from-source, capture-and-assign to a live instrument instance,
|
||||
insert with an opt-in tempo-conform variant, the resample-bake landing action,
|
||||
multi-bank management (pool, activate, evacuate, move, copy, delete), prune/reclaim,
|
||||
provenance and lineage tracking, Design View, and ingest via drag-out, arrange-drop,
|
||||
and instrument-drop. All of it surfaces in a docked bank panel.
|
||||
|
||||
**ReaSampler 9000** (`reasampler_9000.vst3`) — a Windows-only VST3 sampler
|
||||
instrument that plays bank captures back across a MIDI keyboard. The VST3 target
|
||||
(`reasampler_vst`) is gated on the vendored `vendor/vst3sdk` slice; configure
|
||||
quietly omits it if the slice is absent.
|
||||
instrument that plays bank captures back across a MIDI keyboard. Since
|
||||
`ComponentState` v10 it is self-contained: it decodes samples from its own
|
||||
persisted references and plays with the extension absent, treating the bank as a
|
||||
browsing source rather than a runtime dependency. The VST3 target
|
||||
(`reasampler_vst`) is gated on `WIN32 AND EXISTS .../pluginfactory.cpp` — omitted
|
||||
on macOS/Linux even when the vendored `vendor/vst3sdk` slice is present, and
|
||||
quietly omitted anywhere the slice itself is absent.
|
||||
|
||||
A **beta channel** build is available via `-DREASAMPLER_CHANNEL=beta` at configure
|
||||
time, producing `reaper_reasampler_beta` and `reasampler_9000_beta.vst3`. The two
|
||||
@@ -27,8 +45,35 @@ and adds an index entry. It **never** puts an item in the arrange view. Placemen
|
||||
is a distinct, on-demand action. Any code path that auto-inserts a capture into the
|
||||
timeline violates the purpose of the tool.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- CMake ≥ 3.19
|
||||
- A C++17 compiler
|
||||
- Windows + MSVC to build the VST3 target (`reasampler_vst`) — the REAPER extension
|
||||
itself is cross-platform
|
||||
- PHP, to run the SWELL resource-generation step on macOS/Linux (below)
|
||||
|
||||
## Platform support
|
||||
|
||||
The REAPER extension targets Windows, macOS, and Linux; ReaSampler 9000 (the VST3
|
||||
instrument) is Windows-only.
|
||||
|
||||
| Platform | Extension | VST3 instrument |
|
||||
|---|---|---|
|
||||
| Windows | Builds with no extra steps | Builds when `vendor/vst3sdk` is present |
|
||||
| macOS | Builds, but dialogs need the manual SWELL resgen step below plus hand-uncommenting the `APPLE` `target_sources` block in `src/app/CMakeLists.txt` | Not built (Windows-only gate) |
|
||||
| Linux | Same manual resgen + hand-uncomment requirement as macOS, against its own commented block | Not built (Windows-only gate) |
|
||||
|
||||
A macOS/Linux build that skips the resgen-and-uncomment step compiles cleanly
|
||||
without dialogs and without a warning — this is expected, not a bug.
|
||||
|
||||
## One-time setup
|
||||
|
||||
A plain `git clone --recursive` also works, but it pulls every nested submodule of
|
||||
`vendor/vst3sdk` (including `vstgui4`, `tutorials`, `doc`, `cmake` — none of which
|
||||
this project links against). The steps below pull only the three submodules
|
||||
actually needed:
|
||||
|
||||
git submodule update --init
|
||||
|
||||
Vendors three submodules:
|
||||
@@ -43,10 +88,25 @@ Vendors three submodules:
|
||||
|
||||
cmake -B build -S .
|
||||
cmake --build build
|
||||
ctest --test-dir build
|
||||
ctest --test-dir build -C Debug
|
||||
|
||||
Pure modules have `<module>_tests` targets that run without REAPER or a DAW.
|
||||
`CMakeLists.txt` is the authoritative list of all targets.
|
||||
On a multi-config generator (Visual Studio, Xcode), `cmake --build build` with no
|
||||
`--config` builds **Debug** — nothing in this build sets `CMAKE_BUILD_TYPE` or an
|
||||
optimization flag, so that's the default. The `-C Debug` above is required on a
|
||||
multi-config generator too: without it, `ctest` silently reports every test as "Not
|
||||
Run" instead of running them. Single-config generators (Ninja, Make) need neither
|
||||
flag.
|
||||
|
||||
Pure `core/` modules each have a corresponding `<module>_tests` executable target
|
||||
that runs without REAPER or a DAW. Targets are declared per-directory — each
|
||||
`src/**/CMakeLists.txt` owns its own libraries and test targets, pulled in via
|
||||
`add_subdirectory` from the root `CMakeLists.txt`, which itself declares no targets
|
||||
directly.
|
||||
|
||||
Installing or judging performance requires the Release config explicitly:
|
||||
|
||||
cmake --build build --config Release
|
||||
ctest --test-dir build -C Release
|
||||
|
||||
### Beta channel
|
||||
|
||||
@@ -60,33 +120,59 @@ Pure modules have `<module>_tests` targets that run without REAPER or a DAW.
|
||||
php vendor/WDL/WDL/swell/swell_resgen.php src/resource.rc # macOS; Linux reuses the output
|
||||
|
||||
Add the generated file to the appropriate `APPLE` / Linux `target_sources` block in
|
||||
CMakeLists.txt. The SWS extension build is the canonical reference for this step.
|
||||
`src/app/CMakeLists.txt` (both are commented out by default). The SWS extension
|
||||
build is the canonical reference for this step.
|
||||
|
||||
## Install
|
||||
|
||||
Copy the built binary into REAPER's `UserPlugins/` folder
|
||||
(Options → Show REAPER resource path), then **restart REAPER**. Extensions load at
|
||||
startup only; there is no hot reload.
|
||||
There is no hot reload; REAPER loads extensions at startup only.
|
||||
|
||||
**Extension** — copy the **Release** build's `reaper_reasampler` binary
|
||||
(`build/Release/` on a multi-config generator — not the default `Debug/` output)
|
||||
into REAPER's `UserPlugins/` folder (Options → Show REAPER resource path), then
|
||||
restart REAPER.
|
||||
|
||||
**VST3 instrument** — copy the **Release** build's `reasampler_9000.vst3` into the
|
||||
system VST3 folder (`C:\Program Files\Common Files\VST3` on Windows) — a different
|
||||
destination from the extension, not `UserPlugins/`. REAPER picks it up on its next
|
||||
plugin rescan.
|
||||
|
||||
## Repo layout
|
||||
|
||||
The codebase is organized around one discipline: **pure, REAPER-free testable core
|
||||
split from REAPER-facing shells**.
|
||||
The codebase is organized around one discipline: **pure, REAPER/VST3-SDK-free
|
||||
testable core, split from the REAPER- and VST3-facing shells that touch those host
|
||||
types.**
|
||||
|
||||
- `src/` — pure core modules (no REAPER types, unit-testable outside the DAW) and
|
||||
REAPER-facing shells (extension entry point, panel, actions, capture backends, etc.)
|
||||
- `src/vst/` — the VST3 instrument: pure voice engine + zone payload + editor UI
|
||||
pure modules, and the VST3 shells
|
||||
- `tests/` — unit tests for the pure core modules
|
||||
- `src/app/` — the REAPER extension's entry point (`main.cpp` only)
|
||||
- `src/core/` — pure modules, no REAPER or VST3 SDK types, each with a
|
||||
`<module>_tests` target: `audio/`, `capture/`, `instrument/` (further split into
|
||||
`bake/`, `engine/filter/`, `engine/loop/`, `map/`, `note/`, `ui/`), `json/`,
|
||||
`model/`, `reclaim/`, `tracking/`, `ui/`, `util/`, `version/`, `view/`, `wire/`
|
||||
- `src/shell/` — REAPER/VST3-facing shells: `actions/`, `bank_ops/`, `capture/`,
|
||||
`instrument/` (the ReaSampler 9000 VST3 shells), `panel/`, `persist/`, `view/`
|
||||
- `src/resource.rc`, `src/resource.h`, `src/ext_keys.h` — root-level build inputs
|
||||
not claimed by any one subdirectory
|
||||
- `tests/` — unit test sources for the pure `core/` modules
|
||||
- `cmake/` — shared CMake target-declaration helpers
|
||||
- `docs/` — plan-style docs and product-design docs (see Further reading, below)
|
||||
- `vendor/` — git submodules
|
||||
|
||||
See `CLAUDE.md` for the full module inventory, architectural contracts, and the
|
||||
precise boundary between pure core and REAPER-facing shells.
|
||||
precise boundary between pure core and REAPER/VST3-facing shells; each `src/**/`
|
||||
directory also carries its own `CLAUDE.md` with that area's own module list and
|
||||
invariants.
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3, copyright Daniel Harvey. See `LICENSE`.
|
||||
|
||||
## Further reading
|
||||
|
||||
- `CLAUDE.md` — architecture, module inventory, and build/API contracts
|
||||
- `CONTEXT.md` — the authoritative spec (large: ~186k — grep the relevant section rather than reading whole)
|
||||
- `CONTEXT-ARCHIVE.md` — build detail for landed work
|
||||
- `PLAN.md` — roadmap
|
||||
- `COMPLETED.md` — landed milestones
|
||||
- `docs/PLAN.md` — the active roadmap
|
||||
- `docs/COMPLETED.md` — landed milestones for the current (1.x) cycle
|
||||
- `docs/TODO.md` — deferred follow-ups, with the reason each was deferred
|
||||
- `docs/TODO-1.0.md` — the raw 1.x work list this cycle's plan was structured from
|
||||
- `docs/ARCHIVE.md` — pre-1.0 history
|
||||
- `docs/cmake-cheatsheet.md` — a standalone build-system reference
|
||||
- `docs/product/` — the product-design reasoning behind each phase
|
||||
|
||||
@@ -228,3 +228,661 @@ uniform key widths, note-name tooltips, root displayed and settable.
|
||||
carries a "confirm the fix survives DPI scaling" item and now genuinely inherits it.
|
||||
- A stale-hover latch was fixed across **all** drag kinds and both drag-termination
|
||||
paths, wider than the strip work that surfaced it.
|
||||
|
||||
### Θ-W3-T1 — live-parameter-delivery
|
||||
|
||||
Continuous playback controls are now delivered live to sounding voices instead of being
|
||||
latched at note-on. New `src/core/instrument/engine/live_params.{h,cpp}` holds a
|
||||
seqlock-published `LiveValues` block owned at **processor-instance scope**, above
|
||||
`LoadedInstrument`, so `live_` and `draining_` observe the same one (a drain-slot voice
|
||||
tracks the knob, which is the desired behavior). `foldLive(const PlayParams&)` is the
|
||||
single derivation from the value type; `PlayParams` stays a plain copyable value type.
|
||||
|
||||
**Daniel's two decisions, both implemented:**
|
||||
- **Reload tier = Grouping B.** Continuous knobs live (filter cutoff/Q/morph/drive/mod
|
||||
amount/key-track; every stage time and level on all three envelopes). Root note, loop
|
||||
span, and start frame still trigger a full reload.
|
||||
- **Mid-stage rule = candidate (iv), hold normalized stage position.** φ =
|
||||
elapsed/duration held fixed across a duration change, then advancing at
|
||||
1/newDuration — expressed over normalized position specifically so Θ-W3-T2's
|
||||
per-segment curve exponent composes with it.
|
||||
|
||||
**Deviations from spec:**
|
||||
- **Trigger's %-length and fades are NOT live** — they are baked into `SampleData` at
|
||||
build, so reload is the only tier that can deliver them. Consequence: a Trigger-mode
|
||||
instance gets zero live amp delivery until Θ-W3-T2 folds the fade pair into the AHD.
|
||||
The five non-live exclusions (`kKeyTrack`, `kFilterVel`, `kTrigLength`,
|
||||
`kTrigFadeIn`, `kTrigFadeOut`) are documented in `src/core/instrument/ui/deck_groups.h`,
|
||||
now their single home.
|
||||
- Open question 3 resolved as **F2 + seqlock**; open question 4 (sub-block resolution)
|
||||
was not built but not foreclosed — the writer interface assumes no UI thread; open
|
||||
question 5 verified — a live edit still persists, `commitLive` keeps the
|
||||
`setInstrumentParams` write.
|
||||
- A filter envelope only advances while its depth is non-zero (the exact-skip at
|
||||
`modAmount == 0`), which is what keeps the at-rest path byte-identical.
|
||||
|
||||
### Θ-W3-T2 — staged-envelope-curves
|
||||
|
||||
Grows the envelope-overlay editor from an amp-only fixture into the shared graphical
|
||||
surface for all three envelopes (amp, pitch, filter): a corner radio switch per deck
|
||||
selects which envelope is overlay-active (none by default, exclusive); every sloped
|
||||
stage on every envelope (Attack/Decay/Release — Hold and Sustain stay flat) gets an
|
||||
editable curve exponent (0.1–10, 1.0 the linear neutral) via a paired inner knob dial
|
||||
and a round mid-segment overlay knot, both resolving through the one curve law in
|
||||
`src/core/util/curve_law.h`; the pitch envelope becomes AHD (Attack → Hold → Decay,
|
||||
Hold a fraction of the time remaining after Attack and Decay, so A+H+D ≤ span holds by
|
||||
construction, no clamp); AHDSR envelopes get a right-anchored release, dragged from
|
||||
its top node with the bottom-right corner fixed; and the Trigger amp/filter
|
||||
fade-in/fade-out pair is retired in favor of a Trigger AHD, consolidating what were
|
||||
two staged-shape mechanisms into one — item 8's rule (pitch always AHD; amp and filter
|
||||
AHDSR in Gate, AHD in Trigger) governs all three. The Trigger × Preserve end-of-sample
|
||||
click is fixed at its root cause: `freezeTail()` stopping the pitch shifter's writer a
|
||||
full window before the read head arrives.
|
||||
|
||||
**Open question resolved — per-mode stage-value state.** Gate and Trigger keep
|
||||
SEPARATE stored stage values, on both the amp (`PlaySeconds::adsr` +
|
||||
`PlaySeconds::trigAhd`) and the filter (`FilterSeconds::env` + `FilterSeconds::trigEnv`).
|
||||
Migration forces it: an old instance carries both an AHDSR and a fade pair, and one
|
||||
shared set cannot preserve both modes' prior sound. Cost: ~160 bytes of persisted
|
||||
state per instance, 6 additional `DeckParam` ids.
|
||||
|
||||
**Deviations from spec:**
|
||||
- **The migration exponent is FITTED, not neutral — Daniel's explicit ruling,
|
||||
resolving a spec contradiction.** PLAN.md stated both "pre-existing instances load
|
||||
at exponent 1.0" and "exponents at whatever reproduces the prior fade shape";
|
||||
those conflict, and the fix resolves toward the second, since it carries the
|
||||
migration guarantee. Attack lifts at **p = 0.6133**, decay at **q = 1.7437**; max
|
||||
deviation from the retired equal-power (sin/cos) fade shape drops from 0.2105 to
|
||||
0.0875. Every non-migrated curve still lifts to the 1.0 neutral.
|
||||
- **Item 4's fix is deliberately WIDER than spec.** The spec scoped the end-of-sample
|
||||
click fix to Trigger × Preserve; the landed fix is not mode-scoped, so Gate ×
|
||||
Preserve × source-exhaustion also now rings out (~4 ms) where it previously
|
||||
hard-cut. A held Gate note whose source runs out with no loop is cut at sustain
|
||||
level, landing on the same recycled synthetic tail — scoping the fix to Trigger
|
||||
alone would have knowingly left that click.
|
||||
- **Migration is lossy under a sample-rate mismatch** — a documented bound, not a
|
||||
bug. The retired fades were source frames; the lift divides by the project rate
|
||||
while the AHD rebuilds at decode rate, so a rate mismatch shifts migrated stage
|
||||
lengths by that ratio. Documented in the v10 version ladder
|
||||
(`component_state_io.h`) with a test.
|
||||
- **Payload version is v10.** `component_state_io.cpp` was split on the format seam
|
||||
into `component_state_io.cpp` + a new `params_payload.{h,cpp}`.
|
||||
- **New pure module:** `src/core/util/curve_law.h` — the one per-segment curve law
|
||||
(exponent domain, normalized-position→level map, the mid-segment inverse an overlay
|
||||
knot drags through, and the knob's norm↔exponent travel with an exact centre
|
||||
detent). The neutral exponent is a bit-identity. Measured cost of a non-neutral
|
||||
exponent: ~4.7 ns per evaluation, +224 ns/output frame worst case at 16 voices —
|
||||
3.1% → 4.1% of one core at 44.1 kHz.
|
||||
- **`OverlayEnv` and the overlay-selection state machine live in
|
||||
`core/instrument/ui/deck_groups`**, not the shell.
|
||||
- **The knot-creation gesture differs from spec.** Spec said dragging a segment
|
||||
*adds* a knot; the landed behavior draws the knot unconditionally on every sloped
|
||||
non-zero segment and responds to a drag within the grab radius. Daniel confirmed
|
||||
this reading stands.
|
||||
- **Loop markers moved from `AccentTertiary` to `AccentSecondary`** — they collided
|
||||
exactly with the envelope trace (RGB delta 0) in the same overlay rect. Daniel
|
||||
ruled. The palette has since settled: `AccentSecondary` is `#38A8A0` (see the
|
||||
palette-rework entry below and `src/core/ui/CLAUDE.md`).
|
||||
|
||||
**Left open by this track, resolved later.** The envelope overlay's contrast against
|
||||
the waveform (tertiary purple, measured 1.37:1, below the 3:1 indicator floor) awaited
|
||||
Daniel's eye on a build; pinned as a flagged deviation in `tests/test_theme.cpp` at the
|
||||
time. Resolved by the ad-hoc palette rework below (`91f71f9`/`a19d645`): the trace moved
|
||||
off `AccentTertiary` onto a new `Role::OverlayTrace` (`#816AA6`), clearing the floor at
|
||||
3.07:1 — the mathematical ceiling for the pairing. See the palette-rework entry below and
|
||||
`src/core/ui/CLAUDE.md`.
|
||||
|
||||
### Ξ-W1-T1 — tracking-consolidation
|
||||
|
||||
Consolidates the provenance/usage territory into one system: the retired
|
||||
`owned_manifest` gives way to a new `src/core/tracking/` directory holding
|
||||
`origin_ledger` (the record family — `OriginRecord`/`OriginKind`, the insertion-ordered
|
||||
`OriginLedger`, its JSON codec, and the `Fresh`/`Loaded`/`Unreadable`/`FutureVersion`
|
||||
load classification) and `tracking_authority` (the one decision surface:
|
||||
`pruneProtection` and `tiedUsageExists`). Both prune's protected set and the resample's
|
||||
replace-vs-add decision are computed from one borrowed `TrackingState`, so the two
|
||||
safety-critical consumers cannot drift apart. `isAbsolutePath` was hoisted out to a new
|
||||
`src/core/util/relative_path.h`, shared with `bank_model`'s `Sample.relativePath`.
|
||||
|
||||
**Deviations from spec:**
|
||||
- The deferred persisted-instance-identity fix was **not** folded in — open question 5
|
||||
resolved as "restate the deferral." `docs/TODO.md` already carries the sharpened
|
||||
rationale (the session-epoch candidate and its sibling-drop flaw); not duplicated here.
|
||||
- `sample_usage` deliberately **stays in `core/wire`** — the consolidation is of the
|
||||
*decisions*, not the codecs.
|
||||
- A realtime record interrupted by a project switch strands an untracked WAV in the old
|
||||
project's bank folder. Resolved as document-don't-delete (prune is the exclusive
|
||||
deletion authority); `docs/TODO.md` carries the entry.
|
||||
- `PruneReport` fields were renamed; a malformed ledger is now reported as a distinct
|
||||
blocker with its own recovery instructions.
|
||||
|
||||
### Ξ-W1-T2 — note-program-model
|
||||
|
||||
Lands the programmed-capture-signal model as a new pure module directory,
|
||||
`src/core/instrument/note/` — a fourth peer of `engine/`/`map/`/`ui/` under
|
||||
`core/instrument/` — holding `musical_division` (the 1/64–64/1 ladder with
|
||||
dotted/triplet multipliers, the 39-entry picker order), `tempo` (validated BPM plus
|
||||
every beats↔seconds↔ms conversion), and `note_program` (`Velocity`, the denominated
|
||||
`OffsetAmount`, the anchored `StartOffset`/`EndOffset`, the `NoteProgram` record, and
|
||||
`resolveNote`).
|
||||
|
||||
**Deviations / resolutions from spec:**
|
||||
- Open question "negative offsets" resolved: both directions are legal and the sign is
|
||||
uniform (positive is later in time); only an *inverted* window is refused, reported
|
||||
via `ResolvedNote::windowCollapsed`.
|
||||
- Open question "denomination seam" confirmed: note length is musical-division-only;
|
||||
the ms/beats duality belongs to the offsets alone. An offset stores the denomination
|
||||
it was **entered in**, deriving the other view on demand, so a beats offset follows a
|
||||
tempo change and a ms offset holds still.
|
||||
- Module name/location resolved as `src/core/instrument/note/` — three modules, not
|
||||
one, with the layering enforced by the CMake link line.
|
||||
- **Beyond spec:** every value type closes its domain at construction behind a single
|
||||
normalizing door (`makeDivision`, `offsetOf`, `Tempo::fromBpm`, `Velocity::of`), with
|
||||
private value constructors. Consequence: `resolveNote` needs no failure path and
|
||||
`ResolvedNote` no validity flag, because every returned field is finite for every
|
||||
constructible program and tempo. Junk detection is relocated to the future codec,
|
||||
which sees both the bytes it read and the value construction produced. `NoteProgram`
|
||||
deliberately carries no MIDI note number — render pitch is deferred to Ξ-W2 as an
|
||||
additive field.
|
||||
|
||||
### Palette rework — accent/secondary darkening + overlay/trace role (ad-hoc, Daniel's request)
|
||||
|
||||
Two commits (`91f71f9`, `a19d645`) resolve the envelope-overlay contrast wart Θ-W3-T2 left
|
||||
open (see above). `accent/secondary` darkened `#84D6D0` → `#38A8A0`; the keyboard strip's
|
||||
spectral mid stop decoupled from `accent/secondary` into its own constant, since the
|
||||
darkening had inverted the ramp's lo→mid→hi luminance ordering. A new `Role::OverlayTrace`
|
||||
(`#816AA6`) was added and the envelope trace + handles repointed onto it: the trace now
|
||||
measures **3.07:1** against the waveform — the mathematical ceiling for any single color
|
||||
sitting between the primary accent and `bg/base` (9.41:1 apart; `sqrt(9.41) ≈ 3.068`),
|
||||
`#816AA6` landing at 99.94% of that optimum. Two below-floor pairs remain deliberately
|
||||
accepted — the trace inside the 20%-alpha loop-span fill (2.25:1) and against the
|
||||
waveform's `line/hairline` zero-line (1.92:1) — both asserted as pinned ranges in
|
||||
`tests/test_theme.cpp` so either direction of drift fails the build.
|
||||
|
||||
Separately: the bank panel's region title enlarged into WCAG large class via a new
|
||||
`Font::RegionTitle` (19px bold); `theme.h`'s large-text thresholds were corrected (a prior
|
||||
revision had them ~25% low, letting 15px semibold self-classify as Large); `compositeOver`
|
||||
was added to `theme` (the composited-fill arithmetic the loop-span-fill contrast pair
|
||||
depends on); and the grabbed envelope handle was repointed off hue onto a size + ring
|
||||
treatment, since no two values that clear the overlay-trace ceiling differ enough to carry
|
||||
a state by color alone.
|
||||
|
||||
Full detail — the two-neighbour contrast rule, the WCAG threshold correction, and the
|
||||
accepted below-floor pairs — lives in `src/core/ui/CLAUDE.md` and
|
||||
`docs/product/visual-design-language.md` §4 Direction B; not duplicated here.
|
||||
|
||||
### Θ-W4-T1 — gate-loop-sustain
|
||||
|
||||
Establishes loop points as a usable feature and makes a Gate-mode loop function as the
|
||||
sustain — indefinite playback until note-off, with a crossfaded seam. The regression
|
||||
half resolved as **present but unreachable, not removed**: nothing in any capture path
|
||||
ever wrote `Sample::loop`, so every capture opened with `hasLoop == false`; the ghost
|
||||
default parked `loopStart` at frame 0 directly under the start marker, where
|
||||
`markerAtPoint`'s first-in-draw-order tie-break made the handle ungrabbable; and no
|
||||
crossfade existed at all. Fixed by moving the ghost span to `defaultLoopBounds` (last
|
||||
quarter of the sample, both handles clear), making a collapsed span the explicit OFF
|
||||
gesture, and adding a parameterized crossfade.
|
||||
|
||||
New pure module `src/core/instrument/engine/loop/` (`loop_span`, its own CMake target,
|
||||
its own `CLAUDE.md`, `loop_span_tests`) holds `resolveLoop`, `defaultLoopBounds`,
|
||||
`maxCrossfade`, `crossfadeWeight`, `lerpSource`, `crossfadedSource`. Params payload
|
||||
bumped to **v11** (`kParamsLoopVersion`), appended at the tail; slot 12 is reserved for
|
||||
Θ-W4-T2.
|
||||
|
||||
**Open questions resolved:**
|
||||
- **Crossfade units and range.** Stored in source FRAMES, not ms — deliberately against
|
||||
the plan's ms lean, because `sample_map.h`'s rule keeps source-timeline quantities in
|
||||
source frames and the seconds path is documented lossy under a sample-rate mismatch.
|
||||
Default 0 frames (a hard seam, which is what makes the migration bar hold by
|
||||
construction); range is the derived `[0, min(loopStart, loopEnd − loopStart)]`.
|
||||
- **Editing surface.** The waveform markers, plus a new `markerHandleRect` top-strip
|
||||
grab tab (top 10px, hit-tested before the full-height marker columns) so markers
|
||||
sharing a frame stay independently grabbable — a general fix for the tie-break
|
||||
defect, not a crossfade special case.
|
||||
- **Crossfade shape.** Settled during implementation, not specified in the source doc:
|
||||
linear, not equal-power (correlated taps one loop length apart; no transcendental on
|
||||
the per-sample path), with a decorrelated full-mix/stem exception recorded in the
|
||||
module's own `CLAUDE.md`.
|
||||
|
||||
**Deviations from spec / code review:**
|
||||
- Code review found one Major: the crossfade normalizer left an avoidable residual
|
||||
seam discontinuity, and the module's own `CLAUDE.md` had enshrined that limitation as
|
||||
a mathematical impossibility. Remediated — `crossfadeWeight` now normalizes over
|
||||
`crossfade − 1` so the last rendered frame lands exactly on the incoming tap, the
|
||||
false invariant was corrected, and the seam test now asserts against the material's
|
||||
natural one-frame step rather than a proportionality band. Six review minors were
|
||||
also fixed.
|
||||
- `voice.h` sits at ~650 lines after `lerpSource`/`crossfadedSource` moved out to
|
||||
`loop_span.h` — still over the ~600-line ceiling under the standing documented
|
||||
hot-path exception.
|
||||
|
||||
**Left open by this track, deferred to Daniel (not defects):** whether the seam sounds
|
||||
smooth on real material, whether the top-strip tab is discoverable, and the LICE
|
||||
rendering of the tab and crossfade fill. Also open: whether the crossfade default
|
||||
should stay 0 (a smooth seam becomes opt-in).
|
||||
|
||||
### Θ-W4-T2 — velocity-deck-and-bipolar-curves
|
||||
|
||||
Gives the three velocity-curve popups (amp, pitch, filter) one home — a new deck group
|
||||
labelled VELOCITY — and makes the pitch and filter transfer curves bipolar. No
|
||||
velocity-curve button remains in MASTER, PITCH, or Filter. Pitch and filter curves now
|
||||
run y range [−1, 1], default flat at 0, so velocity modulation of pitch and filter is off
|
||||
until the user draws a curve; amp stays unipolar [0, 1] with its flat-unity default
|
||||
unchanged. The domain is modelled as a `CurveDomain { Unipolar, Bipolar }` field on
|
||||
`VelocityCurve`, with `curveYMin`/`curveNeutral` deriving from it; `VelocityPoint::amp`
|
||||
was renamed to `value`. A velocity→pitch transfer curve is new — it did not previously
|
||||
exist. Full scale is `kVelocityPitchRangeSemitones = 24.0`, now the single constant the
|
||||
shell's pitch-depth control also consumes; it folds into `baseRatio_` once at note-on, so
|
||||
`process()` gains no per-frame work. The preview button's text is replaced by a drawn
|
||||
play triangle — `previewGlyph()` returns three vertices from the pure layer, the shell
|
||||
passes them to `LICE_FillTriangle`, which was already in the build: no new dependency, no
|
||||
asset. Params payload is **v12** (`kParamsVelocityVersion = 12`), appending the
|
||||
velocity→pitch curve after Θ-W4-T1's loop block.
|
||||
|
||||
**Daniel's ruling — the depth knob stays.** The implementation initially *removed*
|
||||
`FilterParams::velAmount` and the `kFilterVel` depth knob, arguing a bipolar curve is
|
||||
both shape and amount. Daniel rejected that: the knob scalar AND the curve both apply.
|
||||
The depth control was restored, and the filter's velocity contribution is
|
||||
`velAmount × curve.eval(v)` with the curve bipolar. Consequence: with `velAmount`
|
||||
surviving, the pre-v12 migration became a **pure domain re-tag** — a pre-v12 unipolar
|
||||
curve's y values already sit inside [−1, +1], so `velAmount` and every knot carry
|
||||
forward bit-identically, with no scaling transform and no version branch in the reader.
|
||||
The earlier fold-and-rescale approach (and its degree-1-homogeneity argument, which was
|
||||
only exact to within double rounding) was removed entirely.
|
||||
|
||||
**`kFilterVel` also crossed from non-live to live** — a user-visible contract change
|
||||
beyond simple restoration. Rationale: it is a depth over a latched value, the same shape
|
||||
as `kFilterKeyTrack`, live since Θ-W3; the note latches `curve.eval(velocity)` and the
|
||||
depth multiply happens in `applyLive` at block boundaries, gliding through the existing
|
||||
cutoff ramp at zero per-sample cost.
|
||||
|
||||
**Deviations from spec / code review:** Code review ran on two surfaces
|
||||
(engine/persistence, UI/editor) and found one Critical plus two actionable Majors and ten
|
||||
Minors, all remediated. The Critical: `editedCurve()`'s `kNone` fallback let
|
||||
Esc-during-a-curve-node-drag write the pitch or filter curve — bipolar domain and all —
|
||||
over the amp gain curve and persist it. Fixed on both routes (the popup close now
|
||||
cancels the drag; the mutable accessor refuses `kNone`). It has **no automated
|
||||
regression pin** — `src/shell/instrument/` has no test target, and the bug is shell
|
||||
state-machine coupling with no pure-layer equivalent.
|
||||
|
||||
### Θ-W5-T1 — spline-egs
|
||||
|
||||
Ships a free-drawn alternative to every staged envelope: the pitch, filter, and amp EGs
|
||||
can each switch Staged → Spline and have their contour drawn directly on the waveform
|
||||
overlay. The one shared monotone-spline implementation
|
||||
(`core/instrument/engine/velocity_curve`) gained **hard points** as a per-segment rule —
|
||||
a hard point does no curve smoothing on either adjacent segment, so the natural sharp
|
||||
angle stands instead of a continuous derivative — and the enhancement flows to every
|
||||
consumer, including the existing velocity→amp transfer curve, with no fork.
|
||||
|
||||
- **Dual state, save-but-inactive.** Both the Staged and Spline state persist
|
||||
simultaneously; switching modes never converts or discards the inactive one, so
|
||||
Staged↔Spline round-trips losslessly. Params payload reached **v13**; v12 projects
|
||||
still load.
|
||||
- **Gate unavailable in Spline mode.** A Spline EG's contour always covers the full
|
||||
sample length as a pure time function (the Trigger/one-shot playback model), so Gate
|
||||
is not selectable while it's active.
|
||||
- **Point-editing grammar converged**: left-click adds a point, right-click deletes it,
|
||||
control-click toggles hard/smooth — one grammar shared by both spline consumers (the
|
||||
EG overlay and the velocity-curve popup), matching the popup's already-shipped
|
||||
right-click delete.
|
||||
- **Point-count ceiling: 128 — a musical bound, not a performance one.** Segment lookup
|
||||
is an indexed binary search (≤7 steps at 128 points); the cap exists so long rhythmic
|
||||
phrases (roughly two points per articulation event) aren't limited, not because the
|
||||
evaluator is expensive.
|
||||
- **Staged controls disabled while Spline is active** — that envelope's segment knobs
|
||||
and their inner curve dials render disabled and reject edits; the dormant staged state
|
||||
is edited only by switching back to Staged.
|
||||
- The overlay's contour is normalized to the full sample length and drawn 1:1 with the
|
||||
sample's time axis; a different-length capture rescales the stored contour
|
||||
proportionally.
|
||||
|
||||
A follow-on change in the same track reworked deck cell width: `-1` in `cellIds` changed
|
||||
meaning from "a blank cell holding geometry" to **one cell's width, reserved and
|
||||
redistributed** — a Trigger face that drops Sustain and Release now gets wider cells
|
||||
instead of 144 px of dead slots. Group widths, row packing, deck height, and Gate-mode
|
||||
cell widths are unchanged.
|
||||
|
||||
**Deviations from spec / code review:**
|
||||
- A pure `resolveWaveformClaim` predicate (`core/instrument/ui/spline_edit`) now resolves
|
||||
competing waveform-band clicks — contour node, crossfade tab, marker column, staged
|
||||
envelope node — by **smallest nominal target area among candidates that actually
|
||||
contain the click**, replacing resolution by check order.
|
||||
- The Gate-unavailable-while-drawn rule was consolidated into
|
||||
`enforceGateUnavailableWhileDrawn` (`core/instrument/engine/play_params.h`), now the
|
||||
single home of that rule, called by both `resolvePlay` and the editor's
|
||||
`applyControl`.
|
||||
|
||||
### Θ-W6-T1 — legibility-and-antialiasing
|
||||
|
||||
Made the editor legible, then audited every drawn surface for high-DPI clean rendering
|
||||
— sequenced sizing first, audit second, since the audit's disposition list needed a
|
||||
surface that had stopped moving.
|
||||
|
||||
- **Sizing.** Knobs grew 28→40 px (inner curve dial 14→20), the deck cell 48×58→60×74,
|
||||
and the label band 12→16 px, now drawn in `Font::Label` rather than `Font::Micro`.
|
||||
Group captions and toggle segments deliberately stay `Font::Micro` — bumping them
|
||||
would grow the per-group `captionWidth` reserves, and row 1 has only 14 px of
|
||||
headroom at the floor width.
|
||||
- **Editor default/minimum size 840×620 → 980×680**, because the deck cannot pack
|
||||
three rows at the old floor with the wider cells. An existing saved instance's
|
||||
window grows on open. The floor is validated by a derived test rather than
|
||||
literals.
|
||||
- **All 14 time-constant labels now read in ms**; internal representation untouched
|
||||
(`formatEnvTimeMs` is display-only). `holdFraction` knobs and `Len %` stay `%` —
|
||||
they are fractions, not times. The bank panel's clip-length readout is a duration,
|
||||
not a parameter time constant, and stayed out of scope.
|
||||
- **Double-click reset, per ring.** Outer ring resets the value, inner dial resets
|
||||
the exponent to 1.0, independently. The window class gained `CS_DBLCLKS`;
|
||||
`WM_RBUTTONDBLCLK` was added as its peer so the spline right-click delete survives,
|
||||
and both DBLCLK handlers fall through to the ordinary down handler. The chrome's
|
||||
preview-velocity knob answers reset too, resolving against the drawn circle via a
|
||||
shared `inKnobFace` rule now used by both the deck and the chrome.
|
||||
- **Antialiasing pass.** Fixed: knob track/value arcs (widened to 3 px stacked-radius
|
||||
AA arcs), knob needle (`LICE_ThickFLine`), inner dial arc and needle, staged
|
||||
envelope slopes, spline contour, velocity-popup trace, waveform outline, preview
|
||||
triangle. Already clean: node handles, curve knots, knob discs, buttons, piano
|
||||
keys, loop markers, borders, gradients, text. The full disposition table is a
|
||||
standing artifact in `docs/product/visual-design-language.md` §8.
|
||||
- Three LICE facts the audit established: `LICE_Line` takes integer endpoints so
|
||||
`aa=true` still quantizes; `LICE_FillTriangle` has no `aa` parameter at all; LICE
|
||||
has no thick-arc call, so a wider ring is stacked 1 px arcs.
|
||||
- **Measured cost:** the new AA waveform stroke adds ~0.41 ms per full-grid panel
|
||||
repaint (0.070 → 0.48 ms over a 24-card × 2-band × 136-column grid), ~2.5% of a
|
||||
60 Hz frame. Recorded in the §8 table and annotated as a one-off scratchpad
|
||||
measurement, not a standing regression guard.
|
||||
- **The piano-key open question is answered: not aliasing.** Every key is an
|
||||
axis-aligned integer-width `LICE_FillRect`, so there was no sloped edge for
|
||||
aliasing to act on; the defect was integer-division residue in the tiling, and
|
||||
W2-T3's fix (remainder moved into symmetric end margins) is arithmetic. Above
|
||||
client-pixel scaling it is unverified — nothing implements
|
||||
`IPlugViewContentScaleSupport`.
|
||||
|
||||
**Two structural changes forced by review.** The waveform column's vertical
|
||||
arithmetic moved into a pure, unit-tested `waveformColumnSpan` in
|
||||
`core/ui/component_geometry` — the first pass had silently broken symmetry about
|
||||
the midline in the shared `draw_kit` primitive that also feeds the docked bank
|
||||
panel and browse thumbnails. And `PlaySeconds` plus its `AdsrSeconds`/
|
||||
`AhdSeconds`/`PitchEnvSeconds`/`FilterSeconds` companions hoisted out of
|
||||
`sample_map.h` into a header-only `play_seconds` INTERFACE target, so the new
|
||||
`core/instrument/ui/deck_values` module stops transitively linking the bank model
|
||||
and WAV codec. `deck_values` itself is an extraction of `controlValue`/
|
||||
`applyControl`/`resetDeckParam`/the ms formatter out of the editor shell, making
|
||||
reset semantics unit-testable; `editor_controls.cpp` dropped 469→293 lines.
|
||||
|
||||
All visual outcomes remain **pending Daniel's by-eye sign-off on `dev`** — sizes,
|
||||
arc weight, and whether the waveform stroke improves or thickens the docked panel.
|
||||
Not recorded as accepted.
|
||||
|
||||
### Θ-W7-T1 — arc-and-spline-aa
|
||||
|
||||
Two defects Daniel found by eye once Θ-W6-T1's antialiasing pass shipped — diagnosing
|
||||
both corrected the initial reading of each.
|
||||
|
||||
- **Arcs never reached opacity.** `LICE_Arc` rasterizes a whole circle clipped per 90°
|
||||
chunk and splits ink across two pixels by the fractional part of the radius;
|
||||
`rOuter = radius - 0.5f` is half-integer, so no pixel in the ring was ever opaque —
|
||||
measured peak alpha 138/255. The three stacked radii also did not tile: spacing
|
||||
dilates from 1.0 px to 1.41 px at 45°, leaving partial-coverage holes. It read as
|
||||
fuzz, but it was a stroke that never fully inked.
|
||||
- **Splines were fully aliased, not gapped.** The apparent dotting was not missing
|
||||
ink: `LICE_ThickFLine` steps the major axis and structurally cannot gap. The paint
|
||||
loop passed **integer** `cx`/`cy`, so LICE had no sub-pixel position to interpolate
|
||||
— every pixel was full or empty with no AA fringe, and integer `cy` quantized the
|
||||
slope into an alternating 1/2 px staircase that reads as beading at 100%.
|
||||
- **The cheap fix was rejected.** Float endpoints plus `LICE_ThickFLine` fixes
|
||||
opacity and the staircase, but `ThickFLine` lays width along the *minor* axis, so
|
||||
perpendicular weight is `wid·cosθ` — a measured 42% ripple dipping at every 45°
|
||||
diagonal.
|
||||
- **What landed:** one pure analytic thick-stroke rasterizer. Coverage is
|
||||
distance-to-polyline, accumulated with `max()` into a scratch buffer and blended
|
||||
**once** — the single blend is what structurally prevents the compositing fringe
|
||||
build-up behind the first defect. An arc is just a polyline, so one code path
|
||||
replaces the stacked arcs, both spline traces, and the two needles. Pure coverage
|
||||
math in a new `core/ui/stroke_aa`; the blend loop in a new
|
||||
`shell/instrument/editor_stroke`. `shell/panel/draw_kit` was deliberately **not**
|
||||
touched, keeping the docked bank panel and browse cards entirely out of the blast
|
||||
radius.
|
||||
- **Measured, before → after:** arc peak alpha 138/255 → 255/255; arc perpendicular
|
||||
weight 1.62–3.24 px (67% ripple) → 2.95–3.11 px (5%); spline weight 1.41–2.00 px
|
||||
(29%) → 1.95–2.01 px (3%). Cost: **+0.09 ms per full editor repaint** (30 arcs
|
||||
0.113 → 0.169 ms; 500 px contour 0.013 → 0.047 ms), a knowing regression on an
|
||||
interaction-driven surface, measured in Release against real LICE in an
|
||||
uncommitted harness.
|
||||
- **`velocity_curve` gained `subpixelFromPoint`** — sub-pixel y was unavoidable since
|
||||
integer `cy` was the root cause. The existing integer map now *rounds* the new
|
||||
float map rather than forking a second formula, so hit-testing is unchanged.
|
||||
- **Daniel then ruled that every sub-2 px stroker width be enlarged**, because the
|
||||
stroker can only guarantee an opaque core at width >= 2 px (an opaque pixel needs
|
||||
`d <= halfWidth − 0.5`, and the worst-case pixel-centre-to-centreline distance is
|
||||
0.5). The knob track arc, the inner-dial needle, and the deck's mini velocity trace
|
||||
all moved 1.0 → 2.0 px. A test pinning the sub-opaque behaviour at 1 px was kept as
|
||||
a guard against reintroduction.
|
||||
- **The audit's method was the root failure, not its output.**
|
||||
`docs/product/visual-design-language.md` §8 had claimed stacked 1 px `LICE_Arc`
|
||||
calls "keep every ring antialiased" — false. The Θ-W6 audit verified *which
|
||||
primitive was called* rather than *what it rasterized*, which is how both surfaces
|
||||
were signed off clean while never producing an opaque pixel. That sentence is
|
||||
deleted, the rows are re-dispositioned with measurements, and the methodological
|
||||
lesson is recorded in §8 as a standing blockquote.
|
||||
|
||||
All visual outcomes remain **pending Daniel's by-eye sign-off on `dev`** — nothing was
|
||||
verified in a live REAPER window; all measurement was against an offscreen bitmap in a
|
||||
standalone harness. Not recorded as accepted.
|
||||
|
||||
### Ξ-W2-T1 — resample-bake-chain
|
||||
|
||||
The one-click in-sampler resample: dial → bake → dial-again, run without leaving the
|
||||
sampler. A single click renders the dialed sound through the instrument's own voice
|
||||
path, banks the result, re-points the instance at it, and hands the parameter set back
|
||||
neutral — with the recapture's superseded predecessor never deleted, only retired to
|
||||
prune's reclaim pool.
|
||||
|
||||
**The architecture decision was this track's first deliverable, and Daniel ratified
|
||||
both halves of it.** Decision 1 (how the click crosses to the extension) is **(1b)**:
|
||||
the editor invokes the extension's bake action directly over the VST-host bridge —
|
||||
`NamedCommandLookup` on `"_" + channelCommandId(...)`, then `Main_OnCommandEx` — so
|
||||
there is no request poller, no nonce, and no cross-process handshake. This dissolves
|
||||
the S13 DEGRADED verdict rather than re-litigating it. Decision 2 (what renders the
|
||||
audio) is **(2c)**: the instrument renders in-process and the extension banks the
|
||||
file — taken **over the plan's leaning toward (2a)** on an engine-version-skew
|
||||
argument: under (2a) the extension's own copy of the voice engine would render audio
|
||||
the user heard through the VST3's separately-installed copy, and the format ladders
|
||||
do not catch a behavioral divergence between the two. (2c) also leaves the
|
||||
extension's link graph untouched, preserving `component_state_io`'s split-out purpose
|
||||
of keeping engine object code out of the extension.
|
||||
|
||||
**What shipped:**
|
||||
- New pure modules: `src/core/instrument/bake/` — a fifth peer of `engine/`/`map/`/
|
||||
`note/`/`ui/` under `core/instrument/`, holding `bake_plan`, `bake_render`, and
|
||||
`bake_reset` — plus `core/model/resample_name` and `core/wire/bake_wire`.
|
||||
- New shells: `shell/instrument/instrument_bake` (the instrument's half: render,
|
||||
stage the WAV outside the bank, publish one `rsbake_<guid>` request, invoke the
|
||||
extension's action synchronously, read the outcome back, adopt + reset) and
|
||||
`shell/capture/bake_land` (the extension's half: scans every open project tab for
|
||||
pending requests, lands the ones belonging to the loaded project, refuses the rest).
|
||||
- One new `ActionTableRow`, `RESAMPLE_BAKE`, registered through `main.cpp`'s existing
|
||||
data-driven table.
|
||||
- The instrument's guarded ext-state write surface grew from one prefix (`rsusage_`)
|
||||
to two (`+ rsbake_`); the read-only-**bank** invariant holds because a bake
|
||||
request key is not bank state, and the structural prefix guard still refuses
|
||||
`banks`/`view`/`tail`/`assign`.
|
||||
|
||||
**Reset-scope classifications made at review, against Daniel's ratified rule** — these
|
||||
parameters were absent from both ratified lists, so the classification itself is this
|
||||
track's durable output:
|
||||
- **Play mode → RESET, to Trigger.** The bake's product is a finished one-shot
|
||||
carrying its own attack, span, and release; Gate would re-gate it and re-truncate
|
||||
the printed tail on every iteration, breaking "iteration composes indefinitely." The
|
||||
user-visible consequence: after a bake the instance is in Trigger, and a sustained
|
||||
instrument needs Gate re-dialed by hand.
|
||||
- **Start point → RESET.**
|
||||
- **Channel mode and preview velocity → SURVIVE.**
|
||||
- `resetAfterBake` defaults everything and copies back only the survivors, so a
|
||||
parameter added later resets by default.
|
||||
|
||||
**Two behaviors worth recording because they are user-visible:**
|
||||
- A bake fired from an instance in a **background project tab refuses** with
|
||||
`BakeStatus::WrongProject` rather than risking a write into the wrong project's
|
||||
bank — landing requires three-way agreement between the request's tab, the
|
||||
session's loaded project, and the focused tab.
|
||||
- A crash-stranded bake request is **cleared, not landed**, past a 30-second
|
||||
staleness window (`kMaxRequestAgeSeconds`).
|
||||
- Extension presence: the resample affordance reads **cleanly unavailable, not
|
||||
silently lossy**, when the extension is not loaded — `bakeAvailable` gates the
|
||||
editor's paint state and `runBake` refuses up front with "resample needs the
|
||||
ReaSampler extension loaded" if asked anyway.
|
||||
|
||||
**Not demonstrated.** Nothing was verified in a live REAPER session. The
|
||||
audible-and-faithful, iteration-composes, save/reload, and arrange-untouched
|
||||
acceptance criteria are structural in the code and untested in a DAW. Three facts
|
||||
remain DAW-unverifiable and are handled defensively rather than asserted:
|
||||
`NamedCommandLookup`'s return on an absent command, `Main_OnCommandEx`'s `flag`
|
||||
semantics, and whether a `WM_TIMER`-issued invoke is honoured. Daniel's manual
|
||||
verification is still owed.
|
||||
|
||||
**Naming and lineage — proposed, not ratified, and still open jointly with
|
||||
Ξ-W1-T1's lineage-record question.** This track's proposal: `Kick` → `Kick r2` →
|
||||
`Kick r3`, incrementing rather than stacking; a replace keeps the source's name;
|
||||
machine-readable lineage rides `OriginRecord::parentSampleId`, written at birth
|
||||
(landed by Ξ-W1-T1). The proposal is implemented (`core/model/resample_name`) but not
|
||||
itself a ratified decision.
|
||||
|
||||
**Deferred, not done — logged to `docs/TODO.md`:** `Sample::sourceMode` has no value
|
||||
meaning "produced by the instrument" (appending one is a forward-incompatible
|
||||
bank-format change under the current deserializer, which fails the whole bank blob on
|
||||
an out-of-range value — it wants its own decision); and `instrument_bake` copies the
|
||||
interleaved render buffer into a `std::vector<double>` for the WAV build, roughly
|
||||
doubling peak memory for a large bake.
|
||||
|
||||
### Ξ-W3-T1 — capture-signal-popup (spec abandoned by ruling; shipped as derived bake window)
|
||||
|
||||
Phase Ξ's final track, and Phase Ξ is now complete. What `docs/PLAN.md` specified was a
|
||||
popup menu letting the user hand-program the capture signal: note length as a
|
||||
musical-division picker (1/64 to 64/1, dotted and triplet), start/end offsets editable in
|
||||
both ms and beats, velocity, and a preview trigger auditioning the programmed note, under
|
||||
the acceptance criterion "preview and bake cannot diverge."
|
||||
|
||||
**What shipped instead, and why — the spec and the landed code diverge substantially and
|
||||
deliberately, by Daniel's ruling, not by shortfall.** The popup was built (~1500 lines)
|
||||
and then abandoned unmerged. Daniel, verbatim: *"I didn't realize you had already derived
|
||||
a usable window. The manual stuff for baking a specific midi length was just an idea, if
|
||||
we have a smarter, fewer-clicks way of doing it, that is ideal. I just don't want to lose
|
||||
anything when we bake. We can abandon the whole parameterized bake window if we can safely
|
||||
derive the window in gate and trigger modes."* An audit then established, with executable
|
||||
tests (`tests/test_bake_window.cpp`), that the window derives losslessly everywhere except
|
||||
one irreducible case. What actually shipped, in `src/core/instrument/bake/`:
|
||||
|
||||
- **The bake window derives itself.** Trigger derives from the play span; Gate *without*
|
||||
an active sustain loop derives from source exhaustion + release; Gate *with* an active
|
||||
loop takes one user value, because a loop sounds for as long as it is held and no
|
||||
derivation can supply a duration.
|
||||
- **One control: "Hold,"** a musical-division picker in the chrome row, visible and
|
||||
settable only when Gate + an active sustain loop. `bakeWindowNeedsHold` is the predicate
|
||||
and it reads the ENGINE's loop fold (`resolveLoop`) rather than the loop fields.
|
||||
- **Velocity comes from the instance's persisted preview velocity**, not a hard-coded 100
|
||||
— three velocity curves are live, so the velocity is a property of the sound being
|
||||
printed.
|
||||
- **No preview trigger, and no popup at all.** The chrome-row play button stays a pure
|
||||
MIDI trigger; bake parameters are their own thing. Daniel's ruling: *"play button is
|
||||
pure MIDI trigger, Bake parameters are their own thing."* So the plan's acceptance
|
||||
criterion 2 ("preview and bake cannot diverge") and its preview-trigger behavior bullet
|
||||
are **retired by ruling.**
|
||||
- **Three truncation bugs that pre-existed on `dev` were found and fixed:** a drawn EG
|
||||
plus a stale stored `%`-length lost up to the whole take; the Preserve pitch engine's
|
||||
window closed on the exact frame the terminal declick ramp began, ending files on a
|
||||
full-scale hard cut; and the Gate hold length quantized onto a musical ladder that
|
||||
**saturated at 384 beats**, cutting any source past it mid-sound (at 120 BPM, anything
|
||||
from 192 s up — a full-mix bounce).
|
||||
- **An invariant was deliberately amended:** `note/CLAUDE.md`'s "note length stays
|
||||
musical-division-only" is superseded — a note length now carries EITHER an exact
|
||||
duration (every derived path) or a musical division (the Hold picker only).
|
||||
Quantizing a derived length is what caused the saturation truncation.
|
||||
- Two undefined-behaviour paths closed as fallout: a NaN `keyTrack` from a corrupt payload
|
||||
reached a narrowing cast on the per-sample audio path, and a misaligned payload tail
|
||||
could fabricate a value rather than degrade to absent.
|
||||
- Payload rung **v14** consumed (the Hold division).
|
||||
|
||||
**Not verified in a live REAPER session** — worth carrying forward as owed: the "Bake
|
||||
Hold" label fitting its 56 px cell, the Hold knob's duration-ordered travel, and the
|
||||
control's appearance/disappearance on the 500 ms sync tick.
|
||||
|
||||
**Deferred, not done — logged to `docs/TODO.md`:** the loop intrinsic is folded twice
|
||||
(the editor's `pickedMarkers` resolves it from the live bank blob first, the processor's
|
||||
`reloadInstrument` resolves it from the instance ref via `resolveCapture`), so the two can
|
||||
disagree whenever a bank blob's loop for a capture differs from the copy in the instance's
|
||||
own refs table. Pre-existing — `bakeWindowNeedsHold` is only a new *consumer* of
|
||||
`pickedMarkers`, not the origin of the divergence.
|
||||
|
||||
### Phase Ψ — The extension trust pass: exact bounds, disjoint solo surfaces, reachable actions, honest drops, real names, true mono
|
||||
|
||||
Seven tracks across three waves, code-complete, reviewed, remediated, and integrated on
|
||||
this branch: 89/89 tests passing, a clean build. Phase Ψ came from a direct list of
|
||||
seven defects and refinements (Daniel, 2026-08-01) rather than a backing product doc —
|
||||
see `docs/PLAN.md`'s Phase Ψ section for the Ψ.1–Ψ.7 provenance list this phase traces
|
||||
back to.
|
||||
|
||||
**Ψ-W1-T1 — `capture-range-exactness`.** A ranged item capture now renders the
|
||||
requested window instead of the whole item, by re-sourcing through the selected-tracks
|
||||
render when the item extent does not already print the window. **Deviation worth
|
||||
recording:** the spec named two candidate architectures; the engineer shipped a
|
||||
*conditional* form of candidate (a) — the full-extent case runs literally unchanged
|
||||
code, which makes the byte-identity regression floor structural rather than hoped-for,
|
||||
and makes the fix cheap to revert if the underlying inference proves wrong. Also added:
|
||||
a transient isolation guard cutting `B_MAINSEND` on direct folder children and muting
|
||||
receives so an item capture stays true to item scope, and a post-render frame-count
|
||||
gate (±1 tolerance, tail-None only) that refuses and self-cleans a widened render. New
|
||||
modules `core/capture/render_window`, `core/capture/track_topology`,
|
||||
`shell/capture/render_selection`, `shell/capture/render_isolation`.
|
||||
|
||||
**Ψ-W1-T2 — `mode-switch-discipline`.** Per-mode SOLO surfaces: solo cached, cleared,
|
||||
and replayed across a Design/Arrange switch, with the switch itself visibly refused
|
||||
while the transport runs. New `core/view/solo_cache`, `shell/view/view_solo`. Also
|
||||
closed a pre-existing bug where a footer mode-segment click never persisted view state.
|
||||
Required amending a thrice-stated never-touch-solo invariant (`src/shell/view/CLAUDE.md`,
|
||||
`src/core/view/CLAUDE.md`, `docs/product/design-view.md`) to the snapshot sense of
|
||||
non-destructive: solo is cached per mode on a real switch and restored verbatim, not
|
||||
left untouched absolutely the way `B_MUTE` and the master track are.
|
||||
|
||||
**Ψ-W1-T3 — `media-explorer-section`.** The Media Explorer import action is published
|
||||
into REAPER's Media Explorer action section (32063) via `custom_action` +
|
||||
`hookcommand2`, while remaining in Main so existing keybindings survive. A second
|
||||
FOREVER-STABLE id was minted — `INGEST_IMPORT_MEDIA_EXPLORER_MX` — permanent, per
|
||||
channel. The root `CLAUDE.md` REAPER extension contract gained the second,
|
||||
non-main registration mechanism alongside the original four-step main-section pattern.
|
||||
|
||||
**Ψ-W1-T4 — `drop-target-resolution`.** The drag-out gesture became a per-move,
|
||||
stateless law: target class resolves from what is under the cursor on every move,
|
||||
transitions reversible, OS hand-off reserved for leaving REAPER. The whole TCP/MCP is
|
||||
now the instrument-drop hotspot; a single-card arrange drop lands a timeline item at
|
||||
the pointer's track and time; every surface has a defined outcome and cue, no silent
|
||||
no-op release anywhere. New `shell/actions/arrange_drop_win`.
|
||||
|
||||
**Ψ-W2-T1 — `capture-naming`.** Captures are named after their source track plus a
|
||||
discriminator (`<Track> [+N] [#ordinal] MM-DD HHMM`) at every interactive mint site,
|
||||
with the name shown on the panel card over a scrim clearing the 4.5:1 contrast floor.
|
||||
New `core/capture/capture_name`. Recapture, ingest, and the bake deliberately keep
|
||||
their own naming.
|
||||
|
||||
**Ψ-W2-T2 — `mono-collapse`.** A capture whose channels are bit-identical collapses to
|
||||
one lossless mono channel, written via temp file plus atomic rename, with the index's
|
||||
channel count now *measured* off the landed file rather than echoed from the request.
|
||||
Required amending root `CLAUDE.md`'s channel-count-preserved precision invariant — the
|
||||
current wording ("no lossy channel fold... one permitted collapse is lossless") is the
|
||||
landed form.
|
||||
|
||||
**Ψ-W3-T1 — `track-scope-range` — a wave that did not exist when the phase was
|
||||
scoped, and consolidates none of the original seven.** Opened after Ψ-W2's review
|
||||
surfaced that the track scope carried the same multi-track stem-collapse hole
|
||||
Ψ-W1-T1 had just closed for item scope. Now any multi-track selected-tracks render
|
||||
refuses, both scopes, keyed on the render *source* rather than the capture scope.
|
||||
Realtime deliberately diverges — it sums correctly and was left untouched.
|
||||
|
||||
**None of the seven is DAW-verified.** All are code-complete and unit-tested; none
|
||||
has been confirmed in a running REAPER. Several rest on a **shared unverified
|
||||
inference** about how REAPER's selected-tracks render source interacts with custom
|
||||
time bounds — and Ψ-W3's refusal now rests on it too, meaning if the inference is
|
||||
wrong that refusal costs a working capture. Each track's DAW-verification obligation
|
||||
is recorded in `docs/PLAN.md`'s Phase Ψ section; `docs/verify-track-scope-multitrack.md`
|
||||
is a new standalone verification script on this branch, for Ψ-W3-T1's multi-track
|
||||
refusal specifically. No human has observed any of these seven behaviors in a DAW.
|
||||
|
||||
+2076
-1136
File diff suppressed because it is too large
Load Diff
+543
-1
@@ -46,7 +46,9 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
|
||||
|
||||
**Priority / risk.** Low / deferred. Current behavior is safe; the only cost is unbounded bank-folder growth after reopens. Decided 2026-07-28 to ship the safe version and defer this.
|
||||
|
||||
**Done looks like.** Save → reopen → de-reference a capture from an instance → prune reclaims it. And: in-place-duplicate + diverge + delete-from-bank never deletes a capture a live instance holds.
|
||||
**Re-examined 2026-07-30 by the tracking consolidation, and DELIBERATELY NOT absorbed.** The consolidation's mandate is a *safety* claim (no destructive act follows from ambiguity); this wart is a *completeness* one (nothing is lost, the folder grows). They do not conflict, and folding a fix in would have widened a safety-critical review surface with a mechanism that can under-protect. The strongest candidate examined was a **session epoch**: the extension mints a fresh epoch value at each project load and an instance stamps it into its record, so a record carrying a previous epoch is known-stale and may be clean-replaced regardless of nonce. It fixes exactly the reopen case — but a divergent same-key clone pair reopening together gives the first publisher a clean replace that drops the second's holds until the second republishes, i.e. a narrow revival of the sibling-drop bug. Any future attempt must close that window (e.g. by making the epoch rollover a union that clears the sticky poison only once both siblings have republished) before it is worth taking.
|
||||
|
||||
**Done looks like.** Save → reopen → de-reference a capture from an instance → prune reclaims it. And: in-place-duplicate + diverge + delete-from-bank never deletes a capture a live instance holds, with no window between the two publishes in which a hold is unprotected.
|
||||
|
||||
## Isolate capture from out-of-scope aux/parallel sends, not just FX/gain/pan
|
||||
|
||||
@@ -150,6 +152,34 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
|
||||
|
||||
**Done looks like.** Not stated in the source beyond "confirm no surprising fight."
|
||||
|
||||
## Spline overlay's drag-off delete margin may be too generous for its box
|
||||
|
||||
**Context (what shipped — Θ-W5-T1, spline-egs).** `kCurveDragOffMargin = 24` (`editor_internal.h`) was sized for the velocity-curve popup, whose editing box floats with slack on all sides — the popup's own sheet border sits well outside the box, so 24px of overshoot before a drag-off delete arms is comfortably inside the sheet. The Spline EG overlay reuses the same constant and the same drag-off-delete logic verbatim (`editor_paint_waveform.cpp`), but its box abuts the deck directly with no equivalent slack.
|
||||
|
||||
**The wart.** Dragging an overlay contour node toward the bottom of the waveform band and overshooting roughly 24px past the box floor carries the drag into the deck below and arms a delete — a gesture that reads as "drag toward the deck" rather than "delete this point." Mitigations already in place: a WARN paint cue while the drag is armed-to-delete, and `VelocityCurve::deletePoint` unconditionally refuses the two endpoints regardless of margin.
|
||||
|
||||
**Intended fix.** Not yet proposed — likely a smaller, overlay-specific margin (or a margin derived from the actual gap between the overlay box and the deck) rather than sharing the popup's constant.
|
||||
|
||||
**The constraint the fix MUST handle.** Whatever margin the overlay uses must still comfortably permit an intentional delete-by-drag-off gesture (the design's stated point-removal path) without shrinking it into a hair-trigger; the popup's own margin and delete behavior must be left untouched.
|
||||
|
||||
**Priority / risk.** Low, pending Daniel's hands-on assessment. Flagged by code review as an unmeasured UX judgment, not a confirmed defect — whether the overshoot is a real hazard in practice is Daniel's call.
|
||||
|
||||
**Done looks like.** Daniel has used the Spline EG overlay hands-on and either confirms the current margin is fine as shared, or a separate overlay margin is chosen and the WARN cue's trigger point is verified to match it.
|
||||
|
||||
## Pre-existing staged-envelope-node shadow at zero-attack (AttackEnd on Origin)
|
||||
|
||||
**Context (what shipped — Θ-W5-T1, spline-egs).** The staged envelope-node hit-test (`nodeAtPoint`, `envelope_edit.cpp`) and the drawn contour's node hit-test now feed the SAME `WaveformClaim` arbitration slot in `resolveWaveformClaim` (`spline_edit.h`), which resolves competing waveform-band claims — node, crossfade tab, marker column — by smallest nominal target area among the candidates that actually hit. This is the same defect class as the contour-node/marker collision W5 fixed by replacing check-order resolution with that arbitration.
|
||||
|
||||
**The wart.** A zero-attack `AttackEnd` vertex is drawn at the same pixel as `Origin` (the envelope's non-draggable start anchor), which for an AHD envelope sits at the start marker's frame. Because a node's nominal pick-box area is smaller than the marker's full-height grab-column area, and `resolveWaveformClaim`'s rule is "smallest area among hit candidates wins," the draggable `AttackEnd` node still claims the click over the start marker when the two coincide — and, at a loop starting there, over the crossfade tab. Folding the staged pass into the shared arbitration slot did not change this specific outcome, since the rule that decides node-vs-marker priority is unchanged from what the contour-node fix established. `Origin` itself is excluded from `nodeAtPoint`'s candidate set entirely (never draggable, never a hit), so the common case — attack > 0, no coincidence — is unaffected.
|
||||
|
||||
**Intended fix.** Not yet proposed. Bringing the staged pass into the shared arbitration slot was the natural first step and has landed; closing the remaining collision needs either a per-affordance priority rule for genuinely coincident precision targets, or accepting the current smallest-area outcome as intended and documenting it as such rather than as an open wart.
|
||||
|
||||
**The constraint the fix MUST handle.** Whatever rule changes must not regress the contour-node/marker and tab/marker arbitration W5 already fixed, and must not make `Origin` draggable or otherwise touch `isDraggable`'s AHD/AHDSR shape rules.
|
||||
|
||||
**Priority / risk.** Low. Pre-existing, not introduced by W5; the common case (nonzero attack) is unaffected, and the collision requires both a zero-attack stage and a coincident marker/tab to be reachable at all.
|
||||
|
||||
**Done looks like.** A zero-attack `AttackEnd` node coincident with the start marker (or, on a loop starting there, the crossfade tab) no longer silently claims the click ahead of the marker/tab — either by an explicit priority rule or by a recorded decision that the current behavior is intended.
|
||||
|
||||
## Active-bank indicator placement (B4 polish)
|
||||
|
||||
**Context.** CONTEXT-ARCHIVE.md's "Open questions to resolve during build" (B4 panel section): forks 1–5 are all settled; one panel-polish detail remains open. Fork 4 already settled that the active-bank indicator must be "visually unmistakable" — only its placement is undecided.
|
||||
@@ -163,3 +193,515 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
|
||||
**Priority / risk.** Not stated as a priority level; the source characterizes this as a "panel-polish detail."
|
||||
|
||||
**Done looks like.** Not stated in the source beyond choosing one of the three placement options.
|
||||
|
||||
## Confirm the card name strip reads legibly at the shipping cell size (Ψ-W2-T1 DAW verification)
|
||||
|
||||
**Context.** Ψ-W2-T1 (`capture-naming`) put the capture's label on the docked panel card,
|
||||
across the top of the cell, drawn OVER the waveform thumbnail. Review found the strip's
|
||||
text/primary was measured at ~1:1 contrast against the accent-lime waveform fill at the
|
||||
shipping 140×84 cell size — a loud capture's peak reaches into the strip on 12 of its 13
|
||||
rows — and remediated it with a bg/base scrim behind the name (`kCardNameScrimAlpha`,
|
||||
`core/ui/theme.h`) sized so the composite clears the WCAG 4.5:1 body floor against both the
|
||||
bare fill and bare bg/cell (pinned in `test_theme.cpp`).
|
||||
|
||||
**The wart.** The floor math is verified; the actual on-screen read is not. No `[verify —
|
||||
DAW]` deferral was filed for this track's acceptance criterion ("the panel card shows the
|
||||
name") when it landed, unlike the sibling Ψ tracks.
|
||||
|
||||
**Intended fix.** N/A — no code change. Daniel views the docked panel with real captures
|
||||
(quiet and loud material, long and short names) and confirms the name reads over the
|
||||
waveform at the shipping cell size.
|
||||
|
||||
**The constraint the fix MUST handle.** N/A — verification only.
|
||||
|
||||
**Priority / risk.** Not stated. The math clears its floor with real margin (see
|
||||
`testCardNameScrimClearsBodyFloorOnItsWorstBackground`), so this is a confirmation step,
|
||||
not a suspected defect.
|
||||
|
||||
**Done looks like.** Daniel confirms the card name reads legibly over both quiet and
|
||||
loud waveform material at the shipping 140×84 cell size, or a follow-up adjusts the scrim
|
||||
alpha and this entry is re-filed against the new value.
|
||||
|
||||
## A realtime capture interrupted by a project switch leaves an untracked file behind
|
||||
|
||||
**Context (found by the tracking-consolidation review, 2026-07-30).** `DriveRealtimeCapture` detects that the active project is no longer the one the in-flight capture belongs to, aborts the backend, and drops the handle. On a `Done` abort the backend has *already* moved the recorded WAV into the **original** project's bank folder (`capture_realtime_finalize`), so a file the tool created exists with no bank entry and no ledger record.
|
||||
|
||||
**The wart.** This is the one hole in "no silent gaps": a system-created file that is never recorded. It is in the safe direction — an untracked file is foreign, so prune will never reclaim it — but it is permanent, and the bank folder grows by one orphan per interrupted record.
|
||||
|
||||
**Intended fix.** Record the birth against the project the capture belongs to. Neither half is available at the switch point: `session`'s ledger and `saveToActiveProject` both target the *active* project, which is by definition the wrong one here.
|
||||
|
||||
**The constraint the fix MUST handle.** Writing the record into the now-active project would attribute another project's file to it — a worse error than the gap, since prune would then consider deleting a file it does not own the folder for. Deleting the stranded file instead was considered and rejected: it is the user's just-recorded audio, and prune is the system's only deletion authority over bank-folder bytes (`shell/persist/CLAUDE.md`) — a shell self-cleanup exemption covers transient scratch, not a finished recording. The fix therefore needs a deferred write against a *named* project (or a re-entry into the original project on the next poll), not a change at the abort site.
|
||||
|
||||
**Priority / risk.** Low / deferred. Mitigated in the meantime: the console message names the stranded file's project-relative path, so the operator can recover or remove it rather than discovering it later as an unexplained orphan.
|
||||
|
||||
**Done looks like.** Switching projects mid-record leaves the recorded file with a ledger record in the project it belongs to, so a later prune of that project can reclaim it normally.
|
||||
|
||||
## ~~Raise the stage-time ceiling above 2 s for long-decay sound design~~ — SCHEDULED, no longer deferred
|
||||
|
||||
**This entry is discharged into `docs/PLAN.md` at Γ-W1-T1 and is retained only as a pointer.** Daniel reversed Γ-F3 the same day he ruled it (2026-08-01): *"extend the stage lengths to 10s."* `kEnvTimeMaxSeconds` / `kGateStageMaxSeconds` move **2.0 → 10.0 in Γ-W1-T1**, beside the taper work rather than after it.
|
||||
|
||||
**Why the reversal, since the deferral's reasoning was sound.** The deferral said the right time to judge a 5× range change is with the new taper in the DAW under the hand. What changed is not that judgement but the **cost of waiting**: Ruling 1 schedules VST3 parameters inside the same phase (Γ-W4-T1), and a range endpoint is part of the host-facing normalization exactly as much as the curve between the endpoints is. Raising the ceiling is free this wave and permanently expensive four waves later — the same one-way door `docs/product/parameter-automation.md` §4 states for the taper itself, and §8 sweeps for exhaustively.
|
||||
|
||||
**What this entry contributed, and where it now lives.** Its two prerequisites (the log taper; `resetDeckParam` bypassing the taper, since 2.0 is a power of two and 10.0 is not) were already in Γ-W1-T1 and are now load-bearing rather than incidental. Its named hard part — *"the constant change is trivial; keeping the drawing legible is not"* — is now in-scope design work, specified at `docs/product/instrument-control-surface.md` §4.3.1: at 10 s a 30 ms attack is 0.3 % of the AHDSR schematic's stage domain, and the answer is to make the schematic axis **be** the taper, so a node's position within its stage slot is its knob's needle position.
|
||||
|
||||
**Nothing here is actionable as a TODO.** Delete this entry when Γ-W1-T1 lands.
|
||||
|
||||
## Decouple the instrument reload from VST3 activation
|
||||
|
||||
**Context (Daniel, 2026-08-01 — Phase Γ fork Γ-F6, ruled closed).** Γ-W1-T2 ships the plugin's
|
||||
first latency reporting: `getLatencySamples()` returns 0 with the limiter off and the lookahead
|
||||
with it on, and the toggle calls `IComponentHandler::restartComponent(kLatencyChanged)`. The
|
||||
vendored SDK defines that flag as a host **deactivate/reactivate**
|
||||
(`pluginterfaces/vst/ivsteditcontroller.h:105-108`). **Dynamic latency reporting is routine for
|
||||
VST3 instruments and REAPER handles it as a matter of course** — the deactivate/reactivate is
|
||||
the normal contract, and for a typical plugin `setActive` only allocates and frees buffers.
|
||||
Γ-F6 was originally posed as "is this SDK cost acceptable?"; Daniel's answer relocated it:
|
||||
*"you have to have missed something, I used plenty of VST3s inside of REAPER that report PDC
|
||||
dynamically... Toggling the limiter killing the voices isn't a deal breaker though, the limiter
|
||||
will either be on or off on its instance, toggling during playback is not a use case."*
|
||||
|
||||
**The wart — and it is ours, not the SDK's.** `ReaSamplerProcessor::setActive(true)` calls
|
||||
`reloadInstrument()` (`src/shell/instrument/reasampler_processor.cpp:89-97`) — a bridge read
|
||||
plus a **full WAV re-decode** plus a fresh engine. `setActive(false)` frees `live_`,
|
||||
`draining_` and the graveyard (`:98-107`). So every host-driven activation cycle — a
|
||||
latency-change restart, an offline-render bracket, any host that deactivates around transport
|
||||
state — pays a disk read and a decode that nothing about activation requires. **Activation
|
||||
currently means two things at once**: "the audio thread may run" and "the decoded `SampleData`
|
||||
is (re)built." Dynamic latency is simply the first feature that makes the cycle
|
||||
user-triggerable.
|
||||
|
||||
**Intended fix.** Separate the two lifetimes: keep the decoded `SampleData` alive across a
|
||||
deactivate and rebuild only the voice state on reactivate. The mechanism already exists in this
|
||||
file — `rebuildVoiceEngine` performs exactly that shape (drain-slot swap around the
|
||||
already-decoded `SampleData`, no bank re-read, no WAV re-decode) for voice-count and voice-mode
|
||||
edits. This is a lifetime split, not a new mechanism.
|
||||
|
||||
**The constraint the fix MUST handle.** The deactivate's destruction is deliberate and its
|
||||
reason is documented at the call site: a surviving `live_` would be displaced into the drain
|
||||
slot on reactivate and *"resurrect stale sustained voices as ghosts."* **Voice state must still
|
||||
die across the cycle** — only the decoded PCM survives, and those are two different lifetimes
|
||||
currently collapsed into one. Second constraint: `setActive(true)` is also the non-editor
|
||||
legacy-lift trigger for a pre-v10 blob (its opportunistic `refreshRefsFromBank` copies refs in
|
||||
once the bank blob is readable), so a path that skips the bridge read must keep that lift
|
||||
reachable — the comment at `:90-96` records the residual load-order race it exists to cover.
|
||||
|
||||
**Priority / risk.** Low; deferred by ruling. Nothing is incorrect today, only wasteful, and
|
||||
Daniel has explicitly accepted the user-visible consequence (held notes cut on a limiter
|
||||
toggle). **Trigger conditions — revisit when any one of these holds:** (a) a second
|
||||
latency-changing control appears, so the cycle stops being a once-per-patch event; (b) the
|
||||
limiter enable is ever wanted automatable, which `docs/product/parameter-automation.md` §3.8
|
||||
currently forbids *because* of this cost; or (c) the re-decode is observed to be perceptible in
|
||||
REAPER — Γ-W1-T2's review records that observation for exactly this purpose.
|
||||
|
||||
**Done looks like.** A host-driven deactivate/reactivate cycle costs no disk I/O and no WAV
|
||||
decode; sounding voices are still destroyed across it, with no ghost-resurrection regression;
|
||||
a pre-v10 blob still lifts; and `getLatencySamples()` still derives from persisted state rather
|
||||
than from a transient the deactivate cleared.
|
||||
|
||||
## `Sample::sourceMode` has no value meaning "produced by the instrument"
|
||||
|
||||
**Context (what shipped — Ξ-W2-T1, resample-bake-chain).** A resample bake's landed
|
||||
`Sample` entry (`bake_land.cpp`) never sets `sourceMode`; it is left at the struct
|
||||
default (`SourceMode::MasterMix`) rather than recording that the entry's audio came
|
||||
from the instrument's own offline render, not from a capture backend.
|
||||
|
||||
**The wart.** A baked capture is indistinguishable, by `sourceMode`, from a master-mix
|
||||
render — the bank has no way to tell "this file was produced by ReaSampler 9000" from
|
||||
"this file was rendered off the master bus."
|
||||
|
||||
**Intended fix.** Add a `SourceMode` value for instrument-produced audio and set it at
|
||||
the one landing site.
|
||||
|
||||
**The constraint the fix MUST handle.** `bank_model.cpp`'s deserializer rejects any
|
||||
`sourceMode` value outside `MasterMix(0)..Realtime(5)` by failing the whole bank
|
||||
blob's parse (`parseSample` returns `false`), not just that one field — so appending a
|
||||
new enumerator is a forward-incompatible bank-format change: an older extension build
|
||||
reading a newer project's bank would refuse to load it entirely. This needs its own
|
||||
decision (a version-gated field, or accepting the compatibility cost) rather than a
|
||||
one-line enum append.
|
||||
|
||||
**Priority / risk.** Low / deferred. Logged at Ξ-W2-T1's review rather than folded in.
|
||||
|
||||
**Done looks like.** A baked capture's `sourceMode` reads as instrument-produced, and
|
||||
the compatibility question (how an older build reads a bank containing the new value)
|
||||
is answered rather than left to fail closed by accident.
|
||||
|
||||
## `instrument_bake` doubles peak memory on the WAV build
|
||||
|
||||
**Context (what shipped — Ξ-W2-T1, resample-bake-chain).** `runBake` (`instrument_bake.cpp`)
|
||||
copies the render's interleaved `float` buffer (`BakeAudio::interleaved`, `AudioSample
|
||||
= float`) into a `std::vector<double>` before handing it to `buildFloat32Wav`, which
|
||||
takes doubles and narrows back to float for the bank's 32-bit-float WAV contract.
|
||||
|
||||
**The wart.** The copy roughly doubles peak memory for the bake — an 8-byte double
|
||||
holding a value that started and ends as a 4-byte float — for the duration of the WAV
|
||||
build on a large bake.
|
||||
|
||||
**Intended fix.** Either give `buildFloat32Wav` (or a sibling entry point) a
|
||||
`float`-input overload so the bake path narrows nothing it doesn't already own in
|
||||
`float`, or narrow lazily during the WAV build instead of pre-copying the whole
|
||||
buffer.
|
||||
|
||||
**The constraint the fix MUST handle.** `buildFloat32Wav`'s `double` parameter is
|
||||
shared with every other caller in `core/capture/wav_codec`; a fix must not change
|
||||
those callers' contract or add a second WAV-building code path to maintain.
|
||||
|
||||
**Priority / risk.** Low / deferred. Logged at Ξ-W2-T1's review; correctness is
|
||||
unaffected, only peak memory on a large bake.
|
||||
|
||||
**Done looks like.** A bake's peak memory no longer includes a full double-precision
|
||||
copy of the rendered buffer, with `buildFloat32Wav`'s other callers unchanged.
|
||||
|
||||
## The deck layout rework — SPECCED, and the original shape SUPERSEDED
|
||||
|
||||
**Status (2026-08-01): no longer a deferral. The design notes Daniel owed this entry have
|
||||
arrived, and they change the shape.** The rework is specced in
|
||||
`docs/product/instrument-control-surface.md` §1 and sequenced as **Phase Γ** in
|
||||
`docs/PLAN.md`. This entry is retained only until that work lands, because one loose end
|
||||
below (the Θ-W4-T2 acceptance criterion) still needs an explicit disposition.
|
||||
|
||||
**What was superseded, and confirmed superseded by Daniel.** The original entry recorded a
|
||||
directive of Daniel's for **one row of much *taller* decks with knobs stacked *within* a
|
||||
deck** (his example: the filter's static knobs above its envelope knobs). **The new framing
|
||||
replaces that.** The decks stay **single-height with knobs side-by-side**; what becomes
|
||||
one row is the **sound** category (PITCH/RATE, FILTER, VELOCITY, VOICE), with the three
|
||||
envelope decks on a second **contour** row and MASTER as a double-height deck spanning both.
|
||||
The within-deck stacking idea is retired, not deferred.
|
||||
|
||||
**The measured-geometry block that used to live here has been deleted, not moved.** It was
|
||||
taken at the 840 px floor with `kDeckCellW = 48` and is wrong twice over — Θ-W6-T1 changed
|
||||
both the floor (980) and the cell metrics (60 × 74). The current, re-derived geometry — every
|
||||
group's width, both row totals, and the resulting 1190 × 680 floor — is the table in
|
||||
`docs/product/instrument-control-surface.md` §1.2. **Do not resurrect the old numbers.**
|
||||
The unresolved 864-vs-872 px VELOCITY↔VOICE adjacency-threshold discrepancy is retired with
|
||||
them; it was measured against a layout that no longer exists.
|
||||
|
||||
**The one live loose end.** Θ-W4-T2's acceptance criterion *"VELOCITY sits immediately to
|
||||
the left of the VOICE group"* is not met at the default window size today. Under the new
|
||||
layout it **is** met by construction — row 1 is PITCH/RATE, FILTER, VELOCITY, VOICE, in that
|
||||
order, at every window width — so the criterion is satisfied rather than retired. Confirm it
|
||||
when Phase Γ-W3 lands and remove this entry.
|
||||
|
||||
**Done looks like.** Phase Γ-W3 (`deck-reflow`) has landed; the VELOCITY↔VOICE adjacency
|
||||
criterion is confirmed met at the floor width; this entry is removed.
|
||||
|
||||
## The AA waveform stroke's cost on the docked bank panel's card thumbnails
|
||||
|
||||
**Context (what shipped — Θ-W6-T1, legibility-and-antialiasing).** The antialiasing
|
||||
audit fixed the min/max waveform column plot by adding an AA `LICE_FLine` stroke across
|
||||
each column's extremes, on top of the existing fill (`draw_kit.cpp` `drawWaveform`).
|
||||
`drawWaveform` is shared by the editor's hero waveform lanes, the docked bank panel's
|
||||
card thumbnails, and the browse cards — the stroke lands on all three.
|
||||
|
||||
**The wart.** Measured cost (Release, MSVC 14.44, real LICE, 24 stereo cards ×
|
||||
136 columns = 6528 columns): fill alone 0.070 ms per full-grid repaint, fill+stroke
|
||||
0.48 ms — the stroke adds ~0.41 ms, about 2.5% of a 60 Hz frame. At card-thumbnail
|
||||
scale the added smoothness is far less visible than on the editor's hero lanes, so the
|
||||
cost is paid on every repaint of every card for a benefit concentrated in one consumer.
|
||||
|
||||
**Intended fix.** The identified cheap lever: skip the stroke below a card-sized box
|
||||
and keep it only on the editor's hero lanes.
|
||||
|
||||
**The constraint the fix MUST handle.** Not done, because it is a product call about
|
||||
where the comb artifact — the min/max column plot's jagged outline — actually reads
|
||||
badly enough to matter, not a performance-forced decision (2.5% of a frame on
|
||||
hover/scroll/drag repaint, not a continuous cost, is not itself disqualifying).
|
||||
|
||||
**Priority / risk.** Low. The measurement is a one-off scratchpad number
|
||||
(`docs/product/visual-design-language.md` §8), not a standing regression guard —
|
||||
re-measure before relying on it again.
|
||||
|
||||
**Done looks like.** A size threshold (or explicit per-consumer flag) below which
|
||||
`drawWaveform` skips the AA stroke, with the panel/browse cards confirmed still
|
||||
readable and the editor's hero lanes unchanged.
|
||||
|
||||
## High-DPI host scaling is unverified (distinct from the antialiasing audit)
|
||||
|
||||
**Context (what shipped — Θ-W6-T1, legibility-and-antialiasing).** The antialiasing
|
||||
audit (item 13) confirmed every drawn surface renders smooth at 100% scale — the
|
||||
disposition table in `docs/product/visual-design-language.md` §8 is the record. That
|
||||
audit is about rasterization quality at the pixel level the plugin already draws at;
|
||||
it says nothing about what happens when a host scales the plugin window itself.
|
||||
|
||||
**The wart.** Nothing in the instrument implements `IPlugViewContentScaleSupport`. A
|
||||
host that applies DPI scaling to the plugin window resamples the already-rasterized
|
||||
output rather than asking the plugin to redraw at the target resolution — every AA
|
||||
guarantee the audit just confirmed (and the piano-key uniform-width guarantee, §8.1)
|
||||
holds only at the client-pixel level the plugin itself draws, not above it.
|
||||
|
||||
**Intended fix.** Not proposed. Implementing `IPlugViewContentScaleSupport` (or
|
||||
confirming the host compositor's resampling is acceptable without it) is the shape of
|
||||
a fix, not yet scoped.
|
||||
|
||||
**The constraint the fix MUST handle.** Not yet known — no design work has started.
|
||||
|
||||
**Priority / risk.** Not stated. Recorded as a gap, not a defect: no host behavior has
|
||||
been observed to be wrong, only unverified.
|
||||
|
||||
**Done looks like.** Either `IPlugViewContentScaleSupport` is implemented and the
|
||||
AA/uniform-width guarantees are re-verified at a scaled client size, or a decision is
|
||||
recorded that host-side resampling of the rasterized output is an accepted tradeoff.
|
||||
|
||||
## The analytic stroker's scaled fallback path is unexercised
|
||||
|
||||
**Context (what shipped — Θ-W7-T1, arc-and-spline-aa).** `blendCanvas`
|
||||
(`shell/instrument/editor_stroke.cpp`) guards against `LICE_EXT_GET_SCALING` being
|
||||
active by falling back to a per-pixel `LICE_PutPixel` path, because the primary raw-bits
|
||||
path derives its geometry from logical width/height while writing through
|
||||
`getRowSpan()` — under an active scale that would misplace the stroke or write past the
|
||||
DIB allocation.
|
||||
|
||||
**The wart.** Nothing calls `SET_SCALING` today, so the fallback path never runs. Under
|
||||
an active scale it would rasterize the coverage mask at *logical* resolution with each
|
||||
logical pixel expanded to a scale-sized block — geometrically correct but blocky rather
|
||||
than resolution-independent. This connects to the already-filed high-DPI host-scaling
|
||||
deferral above; cross-referenced here rather than duplicated.
|
||||
|
||||
**Intended fix.** Not proposed — same shape as the host-scaling deferral above:
|
||||
implementing (or verifying) genuine scale-aware rasterization is the shape of a fix, not
|
||||
yet scoped.
|
||||
|
||||
**The constraint the fix MUST handle.** Not yet known — no design work has started, and
|
||||
none can usefully start before the host-scaling deferral above is resolved, since that
|
||||
is what would first exercise this path.
|
||||
|
||||
**Priority / risk.** Low / deferred. Recorded as a gap, not a defect: the fallback is
|
||||
guarded, correct-but-blocky rather than wrong, and unreached by anything in the tree
|
||||
today.
|
||||
|
||||
**Done looks like.** Either the fallback path is exercised under a genuinely scaled
|
||||
bitmap and confirmed to place the stroke correctly, or it is redesigned to rasterize at
|
||||
physical rather than logical resolution once `IPlugViewContentScaleSupport` (or
|
||||
equivalent) makes scaling real.
|
||||
|
||||
## The loop intrinsic is folded twice: the bank blob and the instance ref can skew
|
||||
|
||||
**Context (what shipped).** Two call sites answer the same question — "does this capture
|
||||
have a sustain loop, and where?" — by different routes, and both are load-bearing:
|
||||
|
||||
- `ReaSamplerEditor::pickedMarkers` (`shell/instrument/editor_session.cpp`) resolves the
|
||||
intrinsic from the **live bank blob** first (`selectSample`), falling back to the
|
||||
instance-owned `SampleRefs` only when the blob is unreadable, then lets
|
||||
`params_.loopOverride` supersede it.
|
||||
- `ReaSamplerProcessor::reloadInstrument` (`shell/instrument/processor_reload.cpp`)
|
||||
resolves it from the **instance ref** via `resolveCapture`, which is the one
|
||||
override-beats-intrinsic fold, and that is what the bake renders and what
|
||||
`bakeWindowNeedsHold` is ultimately asked about.
|
||||
|
||||
**The wart.** The two can disagree whenever the bank blob's loop for a capture differs
|
||||
from the copy in the instance's own refs table — a recapture that moved the loop points,
|
||||
a hand-edited blob, or an instance that predates the current bank state. The face then
|
||||
draws (and the Hold predicate answers about) one loop while the engine plays another.
|
||||
|
||||
**Pre-existing.** This split predates the derived-bake-window work; the bake-Hold
|
||||
predicate is only a new *consumer* of `pickedMarkers`, not the origin of the divergence.
|
||||
|
||||
**Intended fix.** Route `pickedMarkers` through `resolveCapture` so both sites share the
|
||||
one fold, as the bank/refs paths already do elsewhere.
|
||||
|
||||
**The constraint the fix MUST handle.** `pickedMarkers` runs on the editor's mouse-down
|
||||
arbitration path (every waveform click, not just marker grabs) and deliberately skips its
|
||||
bridge read once an override is set; a unified fold must not put a bank read back on that
|
||||
path. It must also keep the browser-source semantics: the bank is where a *new* capture's
|
||||
intrinsics come from, the refs table is where the *loaded* one's live.
|
||||
|
||||
**Priority / risk.** Low. Needs a recapture-moved-the-loop scenario to observe, and the
|
||||
failure is a mis-drawn marker or a spuriously shown/hidden Hold knob, not bad audio.
|
||||
|
||||
**Done looks like.** One fold answers the intrinsic for both the editor's markers and the
|
||||
engine's reload, with a test that moves the bank's loop out from under a loaded instance
|
||||
and shows the two agreeing.
|
||||
|
||||
## `ingestHandleSectionCommand` has no unit test
|
||||
|
||||
**Context (what shipped — Ψ-W1-T3, media-explorer-section).** The Media-Explorer
|
||||
import now dispatches through two hooks — `ingestHandleCommand` (Main,
|
||||
`"hookcommand"`) and `ingestHandleSectionCommand` (Media Explorer,
|
||||
`"hookcommand2"`). Both live in `ingest.cpp`, which compiles straight into the
|
||||
`reaper_reasampler` MODULE target.
|
||||
|
||||
**The wart.** No `shell/` translation unit in this repo has a test target — every
|
||||
`<module>_tests` executable is a `core/` pure-module target. `ingestHandleSectionCommand`
|
||||
is a two-line command-id comparison; correctness here rests on code review, not CTest.
|
||||
Review verified this constraint is real and the deferral correct.
|
||||
|
||||
**Intended fix.** Make `action_registry` a linkable library and give it the repo's
|
||||
first `shell/` test target, driven by a fake `reaper_plugin_info_t`. Its own header
|
||||
(`reaper_plugin.h:153-172`) shows `Register` is a plain member-function pointer on the
|
||||
struct, not a REAPER API pointer resolved through `REAPERAPI_LoadAPI` — a fake instance
|
||||
needs no live REAPER process to exercise `rec->Register(...)` calls. Once
|
||||
`action_registry` is test-covered, move the Media-Explorer section registration into it.
|
||||
|
||||
**The constraint the fix MUST handle.** The extraction alone buys nothing:
|
||||
`action_registry` has no test target today either, so lifting `ingestHandleSectionCommand`
|
||||
into it without also standing up the test target just relocates the untested code. The
|
||||
same follow-up could collapse `ingest.cpp:466-472`'s hand-rolled `command_id`+`gaccel`
|
||||
pair onto `action_registry::registerAction`, which already does exactly that dance for
|
||||
the Q-W6 table.
|
||||
|
||||
**Priority / risk.** Low / deferred. `ingestHandleSectionCommand` is a two-branch
|
||||
comparison, reviewed and correct at this scope; the gap is the missing test seam, not a
|
||||
known defect.
|
||||
|
||||
**Done looks like.** `action_registry` is a linkable library with its own `shell/`-first
|
||||
CTest target driven by a fake `reaper_plugin_info_t`; the Media-Explorer section
|
||||
registration and `ingestHandleSectionCommand` move into it and gain unit coverage; and
|
||||
`ingest.cpp`'s own `command_id`+`gaccel` registration collapses onto
|
||||
`action_registry::registerAction` where the shapes match.
|
||||
|
||||
## The `&128` multi-track output shape is still DAW-unobserved, and a refusal now rests on it
|
||||
|
||||
**Context.** The multi-track TRACK capture no longer lands one track's audio under an
|
||||
`Ok`: `renderOffline` refuses every selected-tracks render covering more than one track,
|
||||
both scopes, naming the way out (`render_settings::isMultiTrackStemRender` /
|
||||
`multiTrackRefusalMessage`). What did NOT change is the evidence: the per-track-output
|
||||
reading of `&128` is still INFERRED from the SDK header documenting the single-file bit
|
||||
`&(4<<16)` for item/razor sources only. It has never been observed in a DAW.
|
||||
|
||||
**The wart.** The refusal is therefore as unverified as the defect it closes. If REAPER
|
||||
in fact sums a multi-track `&128` render into the single literal `RENDER_PATTERN`, the
|
||||
refusal costs a working capture — a user who selects two tracks and captures gets a
|
||||
message where a correct summed file used to land.
|
||||
|
||||
**Intended fix.** Run the observation in `docs/verify-track-scope-multitrack.md` §3 (a
|
||||
hand-driven Render dialog, source "selected tracks via master", one literal filename, two
|
||||
tracks selected — then count the files REAPER writes). If it comes back "one file per
|
||||
track", nothing to do and the inference is retired into fact. If it comes back "one
|
||||
summed file", the refusal is over-strict for the TRACK scope and should be narrowed back
|
||||
— and the ITEM-scope half is then an OPEN question, not settled: a full-extent item
|
||||
capture already sums a multi-track item selection via `&32|single-file`
|
||||
(`tests/test_render_settings.cpp:262`), so if `&128` also sums, a ranged item capture
|
||||
routed through it sums too, and keeping the item refusal in that branch would make item
|
||||
scope inconsistent with itself across the range boundary (full-extent sums, ranged
|
||||
refuses, same scope). Whether that inconsistency is acceptable or the item refusal should
|
||||
narrow too needs its own look at that point — not decided here.
|
||||
|
||||
**The constraint the fix MUST handle.** Narrowing the refusal must keep the ITEM scope
|
||||
refusing, must keep `renderOffline` the single seam (so a recipe replay cannot diverge
|
||||
from a fresh capture), and must not re-open the collapse for any caller that reaches
|
||||
`&128` later — the predicate is keyed on the render source precisely so new callers
|
||||
inherit it.
|
||||
|
||||
**Priority / risk.** Low and bounded either way: the current behavior refuses rather than
|
||||
lands wrong audio, so the cost of being wrong here is a refused capture, not a bad one.
|
||||
|
||||
**Done looks like.** The `&128` multi-track output shape is DAW-observed and written into
|
||||
`src/shell/capture/CLAUDE.md` as fact rather than inference, and the refusal is either
|
||||
kept as-is or narrowed to the item scope with that observation cited.
|
||||
|
||||
## A `SelectedItems` recipe replays against whatever items are selected then
|
||||
|
||||
**Context (surfaced by Ψ-W1-T1, capture-range-exactness).** `RunRecaptureFromSource`
|
||||
rebuilds a `CaptureRequest` from the recorded `CaptureRecipe` and resolves its source
|
||||
tracks by GUID. `renderOffline` engages `RenderTrackSelection` only when the recipe's
|
||||
source mode is `SelectedTracks`, which is what makes a ranged item capture and a
|
||||
track capture replay against their recorded tracks rather than the live selection.
|
||||
|
||||
**The wart.** A recipe whose source mode is `SelectedItems` — every pre-fix item-scope
|
||||
capture, and every post-fix full-extent one — renders `&32`, which prints whatever
|
||||
items happen to be selected when the replay fires. The recorded recipe therefore does
|
||||
not fully determine the audio it reproduces, which is what "recapture from source"
|
||||
promises.
|
||||
|
||||
**Intended fix.** Not proposed. The recipe stores tracks and a range; it carries no
|
||||
item GUIDs, so no guard on the shell side can reconstruct the item selection from
|
||||
what is recorded. Closing it means widening `CaptureRecipe` (a wire-format change with
|
||||
a version rung) or re-sourcing full-extent item captures through the tracks render too,
|
||||
which would drag them onto the isolation path for no gain.
|
||||
|
||||
**The constraint the fix MUST handle.** Widening the recipe must keep every already-
|
||||
persisted recipe readable, and must not make a replay depend on items that no longer
|
||||
exist — a deleted source item has to degrade to a stated refusal, not a silent
|
||||
substitution.
|
||||
|
||||
**Priority / risk.** Pre-existing; not introduced or worsened by the range-exactness
|
||||
work. Harmless when the user re-runs a recapture with the same items still selected,
|
||||
wrong when they do not.
|
||||
|
||||
**Done looks like.** A `SelectedItems` recapture either reproduces its recorded audio
|
||||
from the recipe alone, or refuses with a message naming what the recipe cannot pin
|
||||
down.
|
||||
|
||||
## An overlapping item on the source track itself is not isolated from a ranged item capture — DECIDED, not deferred
|
||||
|
||||
**Context (surfaced by Ψ-W1-T1, capture-range-exactness).** The re-source to the
|
||||
selected-tracks render (`&128`) needed transient upstream silencing so an item capture
|
||||
did not also print folder children and receives; `render_isolation` (`UpstreamIsolation`)
|
||||
covers both. A third widening exists in the same shape: a non-selected item on the
|
||||
SAME track that overlaps the requested range is now audible in the render, where the
|
||||
pre-fix `&32` selected-items source excluded it by construction (that source only ever
|
||||
prints the selected items).
|
||||
|
||||
**This is a decision, not a gap.** `src/shell/capture/CLAUDE.md` states the reasoning in
|
||||
full and it is not repeated here: `UpstreamIsolation`/`render_selection` silence and
|
||||
select TRACKS because the recipe that replays a capture stores tracks and a range, never
|
||||
item GUIDs — a mute plan keyed to today's overlapping item could not be recomputed at
|
||||
replay time, so muting items would make the capture stop reproducing itself. The named
|
||||
candidate (a) in `docs/PLAN.md` §Ψ-W1-T1 carried exactly this semantic edge; it was
|
||||
weighed against candidate (b) (an item-bounds render with a derived start time) and (a)
|
||||
shipped with the edge accepted rather than closed.
|
||||
|
||||
**Priority / risk.** Low in the common case (one item per track over the captured range is
|
||||
the normal shape); a project with deliberately overlapping items on one track is the one
|
||||
that surfaces it, and the practical mitigation is unchanged from before this track:
|
||||
select/move the neighbour, or capture at track scope instead.
|
||||
|
||||
**Done looks like.** Nothing to do — recorded so a future reviewer does not read the
|
||||
non-isolation as an oversight and re-propose closing it against the recipe's stated
|
||||
tracks-and-range-only shape.
|
||||
|
||||
## Resample-bake landings don't apply the lossless mono collapse to a dual-mono render
|
||||
|
||||
**Context (surfaced by Ψ-W2-T2, mono-collapse).** The collapse (`collapseCapturedFileToMono`
|
||||
/ `core/capture/wav_codec::collapseToMono`) ships for every extension capture path —
|
||||
offline, realtime, batch, recapture — but not for `bake_land.cpp`'s `landOne`, the
|
||||
resample bake's landing function. A dead-center instrument render (the common case
|
||||
that motivated Ψ.6 in the first place) is exactly the dual-mono shape the predicate
|
||||
collapses, so an un-collapsed bake keeps paying for the second channel it doesn't need.
|
||||
|
||||
**Not deferred for the reason once given.** `landOne` reads the staged file into `bytes`
|
||||
once (`bake_land.cpp:101`), parses its layout (`:105`), hashes it (`:126`), derives the
|
||||
channel count twice (`:131`, `:178`), and writes it (`:165`) — all from that same one
|
||||
buffer, so collapsing `bytes` right after the layout parse would keep the hash, the
|
||||
channel count, and the written file consistent by construction; there is no ordering
|
||||
hazard here to defer around.
|
||||
|
||||
**The real reason.** `bake_land.cpp` is Phase Ξ's freshly-landed surface
|
||||
(Ξ-W2-T1, the resample bake chain) and another team is actively remediating it. Landing
|
||||
a mutation there now would cross tracks mid-remediation for no urgent gain — the mono
|
||||
propagation this item would add is a size win, not a correctness one.
|
||||
|
||||
**A mono capture already propagates through the bake for free**, so this item is scoped
|
||||
to the dual-mono-*render* case only: `runBake` / `instrument_bake.cpp` already renders
|
||||
however many channels the dialed sound has, and `bake_render.cpp:38` reads
|
||||
`sample.channelCount()` off that render rather than hardcoding 2 — a mono-programmed
|
||||
sound already bakes to a mono file today, with no change needed.
|
||||
|
||||
**Intended fix.** Once `bake_land.cpp` is quiet, call `collapseToMono` on the staged
|
||||
`bytes` in `landOne` right after the layout parse (`:105`) and before the hash (`:126`),
|
||||
matching the offline/realtime insertion point (post-parse, pre-identity-read).
|
||||
|
||||
**Priority / risk.** Low — a size optimization on an already-correct path, not a
|
||||
precision-invariant gap; the bake's dual-mono case still lands as a valid (if larger)
|
||||
stereo file today.
|
||||
|
||||
**Done looks like.** A dead-center instrument bake lands as a 1-channel file with
|
||||
`Sample::channelCount` matching, the same way an offline dead-center capture does; a
|
||||
true-stereo bake is byte-identical to today's output.
|
||||
|
||||
## A 0-byte render can pass every gate and land as `Ok` (pre-existing, not a Ψ-W3 regression)
|
||||
|
||||
**Context (surfaced by Ψ-W3 review).** `OfflineRenderBackend::capture`'s exists-check
|
||||
(`capture.cpp:489`) passes for a 0-byte file, and the bounds gate (`:507-546`) only fires
|
||||
when `expectedFrames > 0` — an invalid/empty layout reads `expectedFrames == 0` and skips
|
||||
the gate rather than refusing. A 0-byte render can therefore reach `stampCaptureSample`
|
||||
and land as `CaptureStatus::Ok` with an empty `contentHash` and `channelCount == 0`.
|
||||
|
||||
**Not introduced by Ψ-W3.** The exists-check and the `expectedFrames > 0` guard both
|
||||
predate this track; Ψ-W3 only added the mono-collapse failure report that sits downstream
|
||||
of this hole and was careful not to assert bytes it never verified (see
|
||||
`reportCollapseFailure` in `capture.cpp`).
|
||||
|
||||
**Intended fix.** After the exists-check, also reject a 0-byte file explicitly (its own
|
||||
status, not folded into `BoundsMismatch`, since a 0-byte file was never bounds-checked at
|
||||
all) before anything downstream reads it.
|
||||
|
||||
+23
-13
@@ -157,12 +157,19 @@ load hitch and any un-persisted internal state is lost. This is an accepted cost
|
||||
of the CPU reclaim, not a bug. It must be documented at the toggle affordance so
|
||||
the user isn't surprised.
|
||||
|
||||
**Never touched:** `B_MUTE` and `I_SOLO`. The tool owns visibility, `B_MAINSEND`,
|
||||
`I_FXEN`, and FX-offline — nothing else — across every managed leaf, tagged or
|
||||
untagged. The user's mute/solo survives every toggle, untouched. This is the exact
|
||||
analog of the
|
||||
capture pillar's non-destructive invariant: **the tool never destroys the user's
|
||||
real state to do its job.**
|
||||
**Never touched:** `B_MUTE`. The tool owns visibility, `B_MAINSEND`, `I_FXEN`,
|
||||
FX-offline, and `I_SOLO` — nothing else — across every managed leaf, tagged or
|
||||
untagged.
|
||||
|
||||
**Solo is owned but never lost.** Solo is a per-mode surface: switching modes banks
|
||||
the outgoing mode's solo state, clears it, and replays the incoming mode's on
|
||||
return, verbatim. Two modes therefore never share a solo — you can solo the drum
|
||||
bus in Arrange and the sound-design chain in Design without either leaking into the
|
||||
other — and neither is destroyed. That is the same exact analog of the capture
|
||||
pillar's non-destructive invariant the flags above satisfy: **the tool never
|
||||
destroys the user's real state to do its job.** It is snapshot-and-restore, one
|
||||
level out from a single toggle to the pair of stances. Reapplying the current mode
|
||||
(tagging, project load) is not a switch and does not touch solo at all.
|
||||
|
||||
---
|
||||
|
||||
@@ -316,8 +323,10 @@ Mirrors the capture pillar's split exactly.
|
||||
- Snapshots prior flag values before parking (reads the same flags it will drive).
|
||||
- Resolves track GUIDs via `GetTrackGUID` / `guidToString` / `stringToGuid` for the
|
||||
index; never uses track index (unstable across reorders).
|
||||
- Never touches the master track's visibility flags; never touches `B_MUTE` /
|
||||
`I_SOLO` on anything.
|
||||
- On a real switch only, banks/clears/replays `I_SOLO` per the per-mode solo surface
|
||||
above.
|
||||
- Never touches the master track's visibility flags; never touches `B_MUTE` on
|
||||
anything.
|
||||
|
||||
**`persist` slice:**
|
||||
- Serialize/deserialize the view section (modes + membership + show-both + snapshots
|
||||
@@ -585,11 +594,12 @@ The settled distinction:
|
||||
touch them**: a mode toggle never shows, hides, silences, re-lanes, or re-plays a
|
||||
manual lane. Its `C_LANEPLAYS` state is the user's, left exactly as they set it.
|
||||
|
||||
This is the fixed-lane analog of the two invariants already load-bearing in D1 —
|
||||
*never touch `B_MUTE`/`I_SOLO`* and *never touch the master* — extended to a third
|
||||
surface: **never drive a lane the tool did not mint.** It is the same non-destructive
|
||||
promise (the tool owns only what it created) reaching one level deeper, into the lane
|
||||
dimension.
|
||||
This is the fixed-lane analog of the invariants already load-bearing in D1 —
|
||||
*never touch `B_MUTE`*, *never touch the master*, and *never lose the user's solo*
|
||||
(see "Never touched" above) — extended to a further surface: **never drive a lane
|
||||
the tool did not mint.** It is the same non-destructive promise (the tool owns only
|
||||
what it created, and restores what it parks) reaching one level deeper, into the
|
||||
lane dimension.
|
||||
|
||||
### Lane-ownership index (the new data)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -416,14 +416,34 @@ against the WCAG tests — §2.1):
|
||||
| Role | Pastel | Starting RGB (hex) | Job |
|
||||
|---|---|---|---|
|
||||
| `accent/primary` | pastel lime green | `~176,224,152` (`#B0E098`) | the live/active/selected signal — the eye-magnet |
|
||||
| `accent/secondary` | pastel teal | `~132,214,208` (`#84D6D0`) | categorical role A (non-active zones, a second cluster) |
|
||||
| `accent/secondary` | pastel teal | `~132,214,208` (`#84D6D0`) *(superseded, see below)* | categorical role A (non-active zones, a second cluster) |
|
||||
| `accent/tertiary` | pastel purple | `~194,170,232` (`#C2AAE8`) | categorical role B (tertiary zones, a distinct affordance class) |
|
||||
| `accent/hot` | brighter primary tint | `~200,236,178` (`#C8ECB2`) | hover / live / drag feedback (a lighter pastel-lime) |
|
||||
|
||||
**Locked values, where they diverge from the starting table (2026-07-31).** `accent/secondary`
|
||||
darkened to `#38A8A0` (same hue ~176°, same saturation, lightness 0.68 → 0.44). Its **binding
|
||||
limiter** — the pair that stops it going darker — is the velocity-curve trace on a
|
||||
*hover-lightened* `bg/cell` at **3.03:1** against a 3:1 floor, tighter than either the AA 4.5:1
|
||||
text-on-fill pair (4.91:1 on `bg/base`) or the indicator floor on rest `bg/cell` (3.94:1). ONE
|
||||
new Role was added past the original three, since "exactly three accents" is no longer a
|
||||
constraint: `overlay/trace` `#816AA6`, a muted violet for marks drawn *over* an accent fill (see
|
||||
§the two-neighbour problem below). The spectral ramp's mid stop also became its own value —
|
||||
the original pastel teal `#84D6D0` — but as a private constant, not a `Role`.
|
||||
|
||||
**The two-neighbour problem** (rule + numbers owned by `src/core/ui/CLAUDE.md`; the design
|
||||
consequence is recorded here). A mark drawn *over* an accent fill has to read against the fill
|
||||
*and* the surface behind it, which caps *any* single value at ≈3.07:1 against both. Two
|
||||
consequences shape the design rather than the palette: such a role is confined to the band it
|
||||
was picked for, and a **state** of that mark cannot be expressed as a hotter color — every
|
||||
value that clears the ceiling sits within 1.05:1 of every other, so the grabbed envelope handle
|
||||
signals by **size and a punched-out core** instead. That deliberately inverts the kit's
|
||||
"brighter = hotter" convention in this one band, because over a light fill a brighter tint is a
|
||||
*lower*-contrast tint (`accent/hot` measures 1.15:1 against the lime).
|
||||
|
||||
These are light-ish, low-saturation tints. On the **REAPER-grey surfaces** (`bg/cell` ≈
|
||||
`#3a3a3a`) they still clear **AA-large (3:1)** and the **state-indicator** floor — but the
|
||||
margin is **much smaller than it was on near-black** (roughly ~6:1–7:1 on grey vs. ~15:1
|
||||
on `#12121x`). This is a real DS-2-revision tension: **the greyer background pulls the
|
||||
margin is **much smaller than it was on near-black** (~7.6:1 primary / ~5.5:1 tertiary /
|
||||
~3.9:1 the darkened secondary on grey, vs. ~15:1 on `#12121x`). This is a real DS-2-revision tension: **the greyer background pulls the
|
||||
pastels toward the floor** at the same time the "keep it soft" rule pulls them away from
|
||||
saturation. Approach from the **soft side** (§2.1) — keep them as pastel as possible while
|
||||
still clearing the floor on **grey, not near-black** — but **if any pastel used as a state
|
||||
@@ -470,9 +490,12 @@ the rest of the UI. Concretely: the ramp runs **pastel-lime (primary, low) → p
|
||||
(secondary, mid) → pastel-purple (tertiary, high)** as a three-stop gradient through the
|
||||
accent constants (or a slightly wider pastel arc that passes *through* those three anchor
|
||||
points), keeping every stop in the pastel band. This ties the spectrum to the palette:
|
||||
the same three hues that mean "live / category A / category B" elsewhere are the endpoints
|
||||
and midpoint of the spectrum here, so the strip reads as an extension of the accent system,
|
||||
not a separate neon flourish. The **active** zone still lifts to `accent/primary` +
|
||||
the same hues that mean "live / category A / category B" elsewhere anchor the spectrum here,
|
||||
so the strip reads as an extension of the accent system, not a separate neon flourish.
|
||||
**The mid stop is its own constant, not an alias of `accent/secondary`** (decoupled
|
||||
2026-07-31): the ramp is a luminance progression while the accents are categorical roles, and
|
||||
darkening secondary for a categorical reason inverted lo→mid→hi. A monotonicity test now guards
|
||||
the ordering. The **active** zone still lifts to `accent/primary` +
|
||||
its bloom, so "which zone is live" stays unambiguous over the categorical spectral bands.
|
||||
|
||||
- **Feel:** premium, almost visualizer-grade — but soft and cohesive, a pastel spectrum
|
||||
@@ -738,3 +761,100 @@ re-skin).**
|
||||
font obligation.
|
||||
- **Phase S is not gated on Phase L** — S7–S13 proceeded in parallel; they adopted the
|
||||
kit via L3 when it landed. Phase L is complete (L1–L7 all landed).
|
||||
|
||||
---
|
||||
|
||||
## 8. Antialiasing disposition — the drawn-surface audit
|
||||
|
||||
A standing inventory of every class of drawn surface and how it answers antialiasing, so the
|
||||
audit is re-runnable rather than a one-off sweep. **The rule the table applies:** an
|
||||
axis-aligned fill or hairline has no aliasing to remove — LICE's `aa` flag is inert on a pure
|
||||
horizontal or vertical run — so "already clean" there is a statement about geometry, not a
|
||||
concession. Everything with a slope or a curve must draw through a primitive that antialiases.
|
||||
|
||||
**Primitive gotchas this audit established (verified in `vendor/WDL/WDL/lice/`):**
|
||||
|
||||
- `LICE_Line` takes INTEGER endpoints. `aa=true` antialiases the span, but the endpoints are
|
||||
still quantized; `LICE_FLine` keeps float endpoints and `LICE_ThickFLine` is *always*
|
||||
antialiased and adds width.
|
||||
- `LICE_FillTriangle` takes **no** `aa` parameter at all — its sloped edges alias, and the
|
||||
only fix inside the kit is to re-stroke those edges with an AA line in the same ink.
|
||||
- **`LICE_Arc` does not rasterize an arc.** It rasterizes a whole circle clipped to a
|
||||
rectangular bounding box per 90° chunk (`lice_arc.cpp` `__DrawArc`), and its AA circle splits
|
||||
one unit of ink across two adjacent pixels by the **fractional part of the radius**
|
||||
(`w = yf - floor(yf)`, then `wa` and `ai - wa`). A half-integer radius therefore puts 50% on
|
||||
each of two pixels at the cardinal points, and stacked radii do not tile — vertical spacing
|
||||
between rings `r` and `r-1` dilates from 1.0 px at the top to 1.41 px at 45°. Measured on the
|
||||
shipped 3-ring knob arc: weakest cross-section peak **138/255** and perpendicular weight
|
||||
**1.62–3.24 px** against a nominal 3 (67% ripple).
|
||||
- **`LICE_ThickFLine` lays its width along the MINOR axis**, so perpendicular weight is
|
||||
`wid·cos θ`. Measured at width 2: **1.41–2.00 px** across a 0–90° sweep — it thins to
|
||||
`1/√2` of nominal at every diagonal.
|
||||
- Neither of those two is usable for a stroke that must hold a consistent weight. Arcs and
|
||||
spline contours draw through the analytic stroker instead (`core/ui/stroke_aa` +
|
||||
`shell/instrument/editor_stroke`): coverage is distance-to-polyline, MAX-accumulated into a
|
||||
scratch mask and blended **once**. The single blend is the structural part — compositing
|
||||
per segment re-lays ink over the previous segment's fringe.
|
||||
- A min/max waveform column plot cannot be antialiased by the column fill itself (the columns
|
||||
are vertical). The outline is what reads as jagged, so it is stroked separately.
|
||||
|
||||
> **Methodological lesson — why this table got two rows wrong.** The original audit verified
|
||||
> *which primitive each surface called* and treated an `aa=true` argument as the answer. It
|
||||
> never verified *what the primitive rasterized*. Both misses hid behind a true-looking
|
||||
> statement: `LICE_Arc` really does antialias, and `LICE_ThickFLine` really is always
|
||||
> antialiased — neither fact says anything about opacity or perpendicular weight, which is
|
||||
> what was actually broken. **A disposition row is only earned by a measurement of the
|
||||
> rendered output** (peak alpha, weight across angle), not by reading the call site.
|
||||
|
||||
| Surface | Where | Disposition |
|
||||
|---|---|---|
|
||||
| Radial knob track arc | `editor_internal.h` `drawKnobFace` | **Fixed (2026-08-01), widened (2026-08-01)** — the stacked-radius `LICE_Arc` ring never reached an opaque core. Now ONE analytic stroke (`strokeArcAA`); `kKnobTrackArcPx` was initially left at 1 px, below the ≥2 px opaque-core threshold (`core/ui/CLAUDE.md`), and surfaced to Daniel as a by-eye call — he ruled to enlarge all sub-2 px stroker widths, so it is now 2 px and reaches a guaranteed opaque core. |
|
||||
| Radial knob value arc | `editor_internal.h` `drawKnobFace` | **Fixed (2026-08-01)** — same stroke, `kKnobValueArcPx` = 3 px, clear of the opaque-core threshold. Measured: peak **255/255** at every cross-section, weight **2.95–3.11 px** (5% ripple). |
|
||||
| Knob needle | `drawKnobFace` | **Fixed (2026-08-01)** — `LICE_ThickFLine`'s minor-axis width thinned it to `cos θ` as the knob swept. Now `strokeLineAA`, 2 px. |
|
||||
| Inner curve dial arc | `drawInnerDial` | **Fixed (2026-08-01)** — the arc shared the knob track/value arc's stacked-radius opacity defect. Same one analytic fix, at `kInnerDialArcPx` = 2 px (at the opaque-core threshold). |
|
||||
| Inner curve dial needle | `drawInnerDial` | **Converted (2026-08-01), widened (2026-08-01)** — this needle was already `LICE_FLine` (float endpoints, always AA), not `LICE_ThickFLine`; a 1 px AA line has no width to lay along a minor axis, so it never had the knob needle's `cos θ` defect. Moved to `strokeLineAA` at 1 px for one-seam consistency, not because it was broken — but 1 px is below the analytic stroker's opaque-core threshold, so it fell under Daniel's later blanket ruling and is now `kInnerDialNeedlePx` = 2 px. |
|
||||
| Staged envelope segment slopes | `editor_paint_waveform.cpp` | **Fixed (2026-08-01)** — one `strokePolylineAA` over the whole polyline, so the stage joints blend once. Vertices stay INTEGER by design: they are the positions the draggable handles are drawn at. |
|
||||
| Spline (drawn EG) contour | `editor_paint_waveform.cpp` `paintSplineOverlay` | **Fixed (2026-08-01)** — the trace was never gapped; it was fully aliased (every pixel full or empty) because the loop passed INTEGER `cy`, quantizing the slope into alternating 1/2 px steps. Now sub-pixel y (`subpixelFromPoint`) through `strokePolylineAA`. Measured: peak **255/255**, weight **1.95–2.01 px** (3% ripple). |
|
||||
| Velocity-curve popup trace | `editor_paint_curve.cpp` | **Fixed (2026-08-01)** — same cause, same treatment. |
|
||||
| Velocity-curve mini thumbnail | `editor_paint_curve.cpp` | **Fixed (2026-08-01), widened (2026-08-01)** — strokes analytically at sub-pixel y instead of integer-endpoint `LICE_Line`. Initially kept as a 1 px hairline (a 2 px trace was thought to blot at thumbnail scale), but 1 px is below the opaque-core threshold; Daniel's ruling raised `kMiniTracePx` to 2 px, same as the popup trace. |
|
||||
| Waveform min/max columns | `draw_kit.cpp` `drawWaveform` | **Fixed** — column fill unchanged (it cannot alias), plus an AA `LICE_FLine` stroke joining each column's extremes to its neighbour's, in the same ink. Shared with the docked bank panel and the browser cards. **Measured cost** (Release, MSVC 14.44, real LICE, 24 stereo cards × 136 columns = 6528 columns): fill alone 0.070 ms per full-grid repaint, fill+stroke 0.48 ms — the stroke is ~0.41 ms, about 2.5% of a 60 Hz frame, and the grid repaints on hover/scroll/drag, not continuously. One-off scratchpad measurement, 2026-08-01, harness not committed — not a standing regression guard; re-measure before relying on it again. |
|
||||
| Preview play triangle | `editor_paint_chrome.cpp` | **Fixed** — `LICE_FillTriangle` has no `aa`; its two sloped edges are re-stroked with AA `LICE_FLine`. |
|
||||
| Envelope/spline node handles (squares) | `editor_paint_waveform.cpp` | Already clean — axis-aligned `LICE_FillRect`. |
|
||||
| Envelope curve knots (circles) | `editor_paint_waveform.cpp` | Already clean — `LICE_FillCircle` with `aa=true`. |
|
||||
| Knob body disc | `drawKnobFace` / `drawInnerDial` | Already clean — `LICE_FillCircle` with `aa=true`. |
|
||||
| Buttons | `draw_kit.cpp` `drawButton` | Already clean — `LICE_RoundRect` with `aa=true`. |
|
||||
| Piano key faces + edges | `editor_paint_chrome.cpp` `drawKeyboard` | Already clean — axis-aligned fills and a vertical hairline. **See §8.1.** |
|
||||
| Loop span, crossfade region, marker bars, grab tab | `editor_paint_waveform.cpp` | Already clean — axis-aligned fills. |
|
||||
| Group fences, card/tab/tooltip borders, focus rings | deck, browse, panel painters | Already clean — `LICE_DrawRect`, axis-aligned. |
|
||||
| Surface fills + inner edge highlights | `draw_kit.cpp` `fillSurface` | Already clean — `LICE_GradRect` + axis-aligned hairlines. |
|
||||
| Embed strip (TCP/MCP) | `reasampler_embed.cpp` | Already clean — axis-aligned fills only. |
|
||||
| Docked bank panel chrome | `panel_render.cpp` | Already clean — axis-aligned fills, rects and hairlines. Its only exposure to this pass is the shared `drawWaveform`. |
|
||||
| Text | `draw_kit.cpp` `text` | Already clean — `LICE_CachedFont` AA glyph cache (§1.1). |
|
||||
|
||||
**Analytic stroker cost** (Release, MSVC, real LICE, one-off scratchpad harness 2026-08-01,
|
||||
not committed — re-measure before relying on it): 30 knob arcs **0.113 ms → 0.169 ms**; a
|
||||
500 px spline contour **0.013 ms → 0.047 ms**. About +0.09 ms per full editor repaint, on a
|
||||
surface that repaints on interaction rather than continuously. Micro-optimisation, each lever
|
||||
measured in isolation: writing the blend straight to the bitmap's bits rather than through
|
||||
`LICE_PutPixel` is the big one (arcs 0.169 vs 0.253 ms); reusing the scratch mask across
|
||||
calls matters on the contour's large bounding box (0.047 vs 0.073 ms); `float` over `double`
|
||||
is small but real (contour coverage 0.045 vs 0.051 ms). The per-row valid-extent bookkeeping
|
||||
in the mask is a **wash** against the simpler clear-the-whole-box design (0.218 vs 0.218 ms
|
||||
for a full repaint) — it wins on the contour and loses on the small arc boxes; it is kept
|
||||
because the contour is the drag-interactive surface.
|
||||
|
||||
### 8.1 Was the piano-key width defect an aliasing artifact?
|
||||
|
||||
**No.** Every piano key is an axis-aligned `LICE_FillRect` with an integer width, so there is
|
||||
no sloped or curved edge for aliasing to act on — the defect could not have had that cause.
|
||||
It was integer-division residue: `keyboard_strip` tiles same-class keys at one integer width
|
||||
and the indivisible remainder of the band width has to go *somewhere*. The fix put it in
|
||||
symmetric end margins instead of in a key, which is arithmetic, not rasterization.
|
||||
|
||||
**Does the fix survive DPI scaling?** At the client-pixel level, yes — key widths are uniform
|
||||
by construction at every client width the strip's test sweep covers. Above that level it is
|
||||
**unverified**, and for a structural reason worth keeping visible: nothing in the instrument
|
||||
implements `IPlugViewContentScaleSupport`, so a host that scales the plugin window resamples
|
||||
the already-rasterized uniform widths at the physical-pixel level, where the guarantee no
|
||||
longer applies. That is a host-scaling question, not an antialiasing one, and it is recorded
|
||||
as a gotcha in `src/core/instrument/CLAUDE.md`.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# DAW verification — track-scope capture over a multi-track selection
|
||||
|
||||
What a DAW pass must establish for the multi-track track capture, and the exact numbers
|
||||
or strings to read off. Nothing below can be closed by a unit test: every item depends on
|
||||
what REAPER actually does with a render request.
|
||||
|
||||
**Build to use.** Release, installed into `UserPlugins/`, REAPER restarted — extensions
|
||||
load at startup only. Set the docked panel's tail toggle to **None** before every cell;
|
||||
Auto adds an 8 s window and Manual a fixed one, and both would invalidate the frame-count
|
||||
readings.
|
||||
|
||||
**Project to use.** One saved project, project sample rate pinned to 48000. Two audio
|
||||
tracks, `A` and `B`, each holding one item at least 30 s long, with *audibly different*
|
||||
content (a tone on `A`, a drum loop on `B`). One folder track `F` with `A` and `B` as its
|
||||
children, used only in §5.
|
||||
|
||||
---
|
||||
|
||||
## 1. The regression floor — single-track track capture is unchanged
|
||||
|
||||
Select **track `A` only**. Make a time selection from **10.000 s to 12.000 s**. Run
|
||||
*ReaSampler: capture selected track(s)*.
|
||||
|
||||
Read off:
|
||||
|
||||
- A file appears in the project's bank folder, and one new card appears on the panel.
|
||||
- The card's length reads **2.000 s**; its frame count is **96000** (`round(12.0 × 48000)
|
||||
− round(10.0 × 48000)`). The backend refuses the capture with `BoundsMismatch` if the
|
||||
render is more than one frame off that, so a landed capture already proves the number
|
||||
to ±1 — what you are confirming here is that it landed at all.
|
||||
- The REAPER console shows **no** `ReaSampler capture failed:` line.
|
||||
- Track `A` is still the only selected track afterwards.
|
||||
- **Content, not just length.** Listen to the landed file. `A` and `B` carry *audibly
|
||||
different* content by the project setup above (tone vs. drum loop), so this is a by-ear
|
||||
check, not a null test: the capture must be the tone alone, with **no** drum-loop bleed.
|
||||
Expected: pure tone, matching `A` soloed. Failing: any trace of `B`'s drum loop audible
|
||||
in the file. This is not a tautology check — the SDK header's own `RENDER_SETTINGS` line
|
||||
admits a second reading, `(&(1|2)==0)=master mix`, under which a single-track track
|
||||
capture could render the **whole master mix** (both `A` and `B`) rather than `A` alone;
|
||||
drum-loop bleed here is exactly what that misreading would produce, and this is the
|
||||
cheapest place in the whole doc to catch it.
|
||||
|
||||
**This is the byte-identical floor.** If either cell now refuses, the change is wrong —
|
||||
the refusal must fire only above one track.
|
||||
|
||||
## 2. The defect cell — two selected tracks now refuse
|
||||
|
||||
Select **`A` and `B` together**. Time selection 10.000–12.000 s. Run *capture selected
|
||||
track(s)*.
|
||||
|
||||
Read off:
|
||||
|
||||
- The console prints exactly:
|
||||
`ReaSampler capture failed: A track capture renders the selected tracks through the
|
||||
master, and more than one track cannot land as a single file. Capture one track at a
|
||||
time, or route them into a folder/bus track and capture that (a folder's own output is
|
||||
its children summed).`
|
||||
- **No** new card on the panel, and **no** new `.wav` in the bank folder (check the folder
|
||||
directly — a stray file with nothing indexing it would mean the refusal fired too late).
|
||||
- `A` and `B` are both still selected, both still unmuted, and neither track's fader, pan,
|
||||
or FX-bypass state changed. The refusal returns before any guard is constructed, so
|
||||
there should be nothing to restore — this reading is what confirms that.
|
||||
|
||||
Repeat with a **razor area spanning both tracks** and no time selection: identical
|
||||
readings. Note that the track *selection* is what the refusal counts — a razor over two
|
||||
tracks with only `A` selected is a one-track capture and must still succeed (§1).
|
||||
|
||||
## 3. The decisive observation — what `&128` actually writes
|
||||
|
||||
**This is the one that retires an inference, and it is the reason `docs/TODO.md` still
|
||||
carries an entry.** The refusal in §2 rests on reading the SDK header's single-file bit
|
||||
`&(4<<16)` as applying to item/razor sources only, never to `&128` — so N selected tracks
|
||||
are believed to produce N files. That has never been observed.
|
||||
|
||||
Drive REAPER's own Render dialog by hand, with the extension out of the loop:
|
||||
|
||||
1. Select `A` and `B`.
|
||||
2. File → Render. **Source:** *Selected tracks via master* — the dialog wording for `&128`
|
||||
(SDK header ~3041). Do **not** pick *Stems (selected tracks)* — that is `&2`, a
|
||||
different source bit that unambiguously writes one file per track and would confirm
|
||||
nothing about `&128`.
|
||||
**Bounds:** *Custom time range*, 10.000 to 12.000 s.
|
||||
3. **File name:** a literal stem with **no wildcards at all** — e.g. `stemprobe`. Clear
|
||||
`$track` / `$item` / anything else from the pattern; the extension writes exactly one
|
||||
literal stem, so the probe must too.
|
||||
4. Render to an empty scratch folder.
|
||||
|
||||
Read off — **the file count in that folder**:
|
||||
|
||||
- **Two files** (however REAPER disambiguated them, or one file that visibly got
|
||||
overwritten): the inference holds, the §2 refusal is correct, and the `docs/TODO.md`
|
||||
entry can be closed by writing this observation into `src/shell/capture/CLAUDE.md` as
|
||||
fact.
|
||||
- **One file containing `A` and `B` summed** (confirm by ear, or by nulling it against a
|
||||
master render of the same range with only `A` and `B` unmuted): the inference is wrong,
|
||||
the §2 refusal costs a working capture, and the track-scope half should be narrowed back
|
||||
per the `docs/TODO.md` entry. The item-scope half stays either way.
|
||||
|
||||
Also record **what REAPER named the files** — that decides whether a future correct
|
||||
multi-track capture could ever be built on this source at all.
|
||||
|
||||
## 4. Recapture replays the same answer
|
||||
|
||||
Take a **single-track** track capture that carries provenance (capture a range on `A`
|
||||
whose source item is itself a bank sample, so `detectParent` fires), select its card, and
|
||||
run *re-capture from source*. It must regenerate — same audio, same 96000 frames.
|
||||
|
||||
Then construct the multi-track case: a recorded recipe whose `trackGuids` names two
|
||||
tracks. The reachable way to get one is to have captured it before this change; if no such
|
||||
entry exists in any project, record that this cell was **not exercised** rather than
|
||||
inventing one. When it is exercised, read off:
|
||||
|
||||
- `ReaSampler re-capture failed:` followed by the **same** message text as §2.
|
||||
- The bank entry is untouched — same file, same hash, same card.
|
||||
|
||||
## 5. The way out actually works
|
||||
|
||||
Route `A` and `B` into folder `F`. Select **`F` only**, time selection 10.000–12.000 s,
|
||||
capture track scope.
|
||||
|
||||
Read off: one card, 2.000 s, and the audio contains **both** `A` and `B`. This is what the
|
||||
refusal message tells the user to do, so it has to be true.
|
||||
|
||||
## 6. Realtime still accepts a multi-track selection
|
||||
|
||||
Select `A` and `B`. Run *ReaSampler: capture selected track(s) in realtime* over the same
|
||||
range. Read off: **one** card, and its audio contains both tracks. Realtime taps each
|
||||
source track with a send into one temp track, so it sums where the offline render cannot —
|
||||
the divergence from §2 is deliberate and this cell is what confirms it is real.
|
||||
|
||||
## 7. Mono collapse — what is and is not reachable
|
||||
|
||||
Capture a range on a track whose content is dead-center (a mono source panned center, or
|
||||
a duplicated-channel file), using time selection **10.000 s to 12.000 s** (2.000 s, 96000
|
||||
frames at 48000 Hz — the §1 convention, so the resulting file size is exact). The panel has
|
||||
no channel-count readout anywhere (`Sample::channelCount` is not drawn by
|
||||
`src/shell/panel/panel_render.cpp`), so read the proxy instead:
|
||||
|
||||
- Check the landed `.wav`'s size on disk (Explorer → Properties, or a directory listing). A
|
||||
successful collapse is the extension's own rebuild — canonical 44-byte header + 96000 ×
|
||||
4 bytes = **384,044 bytes**. A file near double that (~768,044 bytes, plus whatever
|
||||
REAPER's own render adds for `bext`/metadata chunks) means the collapse did not fire —
|
||||
recheck the source is genuinely dead-center before treating this as a defect.
|
||||
- The console shows **no** `the lossless mono collapse ... already reached the bank; only
|
||||
the size win from the collapse was lost.` line.
|
||||
|
||||
**Not DAW-reachable:** the collapse's *failure* branch. It fires only if the captured file
|
||||
cannot be read, or its temporary rewrite cannot be written or renamed, inside the same
|
||||
call that just rendered the file — there is no manual way to inject that fault between the
|
||||
render and the rename. The branch is covered only at its reporting seam
|
||||
(`tests/test_wav_codec.cpp`, `testCollapseOutcomeSuffixesAreDistinctStrings`), and its console line
|
||||
has never been seen in a running REAPER. If you ever do see it, the render already reached
|
||||
the bank — the report only tells you the collapse's size win was lost, not that the bytes
|
||||
were verified (see `docs/TODO.md`'s 0-byte-render entry).
|
||||
@@ -10,7 +10,10 @@ add_library(reaper_reasampler MODULE
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_orchestrator.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/bake_land.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
|
||||
@@ -32,6 +35,7 @@ add_library(reaper_reasampler MODULE
|
||||
${LICE_SRC}
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/view/view.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/view/view_solo.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/item_read.cpp
|
||||
@@ -40,11 +44,15 @@ add_library(reaper_reasampler MODULE
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/bank_actions.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/prune_action.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/ingest.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/arrange_drop_win.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/drag_out_win.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name)
|
||||
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter. The instrument
|
||||
# renders its own bake in its own process, which is what keeps the extension's link graph
|
||||
# free of the voice engine — a link edge to it here means the design drifted.
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
|
||||
# OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both
|
||||
|
||||
+23
-2
@@ -26,6 +26,8 @@
|
||||
#include "shell/actions/action_registry.h" // the registration table
|
||||
#include "shell/actions/bank_actions.h" // multi-bank action family
|
||||
#include "shell/actions/design_view_actions.h" // Design View action family
|
||||
#include "core/wire/bake_wire.h" // kBakeActionSuffix (the shared action id)
|
||||
#include "shell/capture/bake_land.h" // resample-bake landing action body
|
||||
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
|
||||
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
|
||||
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
|
||||
@@ -86,6 +88,7 @@ static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session)
|
||||
static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); }
|
||||
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
|
||||
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
|
||||
static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
|
||||
static void RunShowVersion(int) {
|
||||
// On-demand only — no unconditional startup print (routine console chatter pops
|
||||
// the console window).
|
||||
@@ -131,6 +134,12 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
||||
&RunCancelRealtime});
|
||||
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
|
||||
&RunRecaptureFromSource});
|
||||
// Invoked by a ReaSampler 9000 instance over the VST3 host bridge (and bindable, so a
|
||||
// stranded request can be landed by hand). The suffix is the wire contract itself —
|
||||
// core/wire/bake_wire owns the spelling both artifacts read.
|
||||
rows.push_back({reasampler::wire::kBakeActionSuffix,
|
||||
"land pending ReaSampler 9000 resample bake",
|
||||
&RunResampleBake});
|
||||
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
|
||||
|
||||
return rows;
|
||||
@@ -207,8 +216,8 @@ static project_config_extension_t g_projectConfig{
|
||||
nullptr, // userData
|
||||
};
|
||||
|
||||
// REAPER calls this for EVERY action fired anywhere; claim only our own id, return
|
||||
// false otherwise so REAPER keeps looking. This TU's own family dispatches through
|
||||
// REAPER calls this for every action fired in the MAIN section; claim only our own id,
|
||||
// return false otherwise so REAPER keeps looking. This TU's own family dispatches through
|
||||
// the registration table; the other families claim their own ids after it.
|
||||
static bool OnHookCommand(int command, int /*flag*/)
|
||||
{
|
||||
@@ -220,6 +229,16 @@ static bool OnHookCommand(int command, int /*flag*/)
|
||||
return false;
|
||||
}
|
||||
|
||||
// "hookcommand" covers the main section only, so actions we published into another
|
||||
// section arrive here instead. Partitioning contract: root `CLAUDE.md` §"REAPER
|
||||
// extension contract".
|
||||
static bool OnHookCommand2(KbdSectionInfo* /*sec*/, int command, int /*val*/, int /*val2*/,
|
||||
int /*relmode*/, HWND /*hwnd*/)
|
||||
{
|
||||
if (command == 0) return false;
|
||||
return reasampler::ingestHandleSectionCommand(command);
|
||||
}
|
||||
|
||||
// REAPER polls this to render each of OUR actions' checked state in menus/toolbars.
|
||||
// Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract).
|
||||
static int OnToggleAction(int command)
|
||||
@@ -246,6 +265,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
|
||||
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
|
||||
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
|
||||
g_rec->Register("-hookcommand2", (void*)&OnHookCommand2);
|
||||
reasampler::designViewUnregisterActions(g_rec);
|
||||
reasampler::bankUnregisterActions(g_rec);
|
||||
reasampler::ingestUnregisterActions(g_rec);
|
||||
@@ -298,6 +318,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
reasampler::ingestRegisterActions(rec, &g_session);
|
||||
|
||||
rec->Register("hookcommand", (void*)&OnHookCommand);
|
||||
rec->Register("hookcommand2", (void*)&OnHookCommand2);
|
||||
|
||||
// Drives project-load / Save-As detection: the timer polls the active project
|
||||
// each tick; on a project load it reloads the bank from ext state, on a Save-As
|
||||
|
||||
@@ -10,6 +10,7 @@ add_subdirectory(wire)
|
||||
add_subdirectory(audio)
|
||||
add_subdirectory(model)
|
||||
add_subdirectory(capture)
|
||||
add_subdirectory(tracking)
|
||||
add_subdirectory(reclaim)
|
||||
add_subdirectory(version)
|
||||
add_subdirectory(view)
|
||||
|
||||
@@ -45,12 +45,15 @@ Detail specific to these pure modules:
|
||||
|
||||
## Modules
|
||||
|
||||
- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner).
|
||||
- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + the lossless mono collapse + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner).
|
||||
- `capture_realtime` (`core/capture`, **renamed from `realtime_record` in Q-W3** — the Q-9 naming rider: pure module takes the stem, the shell takes the suffix, matching `drag_out`/`drag_out_win`) — the M8 realtime-record pure logic: capture scope + FX-tap point → `I_RECMODE`/`I_RECMODE_FLAGS` values, wet/dry → tap point, the recorded-file → `Sample` mapping, and the async record-phase state machine. Depends on `bank_model` for the plain `Sample`/`SourceMode` types. The transport/temp-track/send recipe lives in the shell (`shell/capture/capture_realtime_shell.cpp` + `capture_realtime_finalize.cpp`).
|
||||
- `batch_capture` — pure batch-capture planner: maps source ranges to capture units and aggregates results.
|
||||
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
|
||||
- `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates.
|
||||
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
|
||||
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), and the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family.
|
||||
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a widened render is refused rather than banked) and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all.
|
||||
- `track_topology` — the REAPER-free folder arithmetic over a project's flat `I_FOLDERDEPTH` delta list: `directChildIndices` names a folder parent's DIRECT children, the set `shell/capture/render_isolation` silences so a ranged item capture does not print its track's children. Grandchildren are excluded by construction — they reach the parent only through the child that owns them.
|
||||
- `tail_control` — the REAPER-free logic behind the docked `bank_panel`'s tail-mode toggle: the cycle order (None → Auto → Manual → None), the Manual-length clamp/scroll-wheel fine-adjust (`clampManualMs`/`adjustManualMs`, 250 ms/notch, 2000 ms default), the toggle's label text (e.g. "Tail: Manual 2.0s"), and the `TailSetting` JSON round-trip persist stores per-project.
|
||||
|
||||
## Gotchas
|
||||
@@ -60,9 +63,41 @@ Detail specific to these pure modules:
|
||||
(`reaper_plugin_functions.h` lines ~3041/~3047/~3051/~3062) — re-verify
|
||||
against the header before changing any bit value, per the root `CLAUDE.md`
|
||||
API-verification rule.
|
||||
- **The selected-items render source (`&32`) cannot narrow a window** — REAPER
|
||||
derives that render's bounds from the selected items' own extents, so
|
||||
`RENDER_BOUNDSFLAG=0` + `RENDER_STARTPOS`/`RENDER_ENDPOS` do not constrain it.
|
||||
This is an inference from the observed defect (a time selection inside a long
|
||||
item captured the whole item), NOT a header-confirmed fact. It is why
|
||||
`sourceModeForScope` routes item scope to `&32` only when the item extent
|
||||
already IS the requested window — do not re-point item scope unconditionally at
|
||||
`&32`, and do not widen the `&32` branch to windows it cannot express. This is the
|
||||
one home for that inference; the sites that act on it point here rather than
|
||||
restating it.
|
||||
- **The re-source changes the CONTENT, not the FX scope.** `fxBypassPlanFor` is keyed
|
||||
on `CaptureScope`, so a ranged item capture still hears take/item FX only — but the
|
||||
selected-tracks source prints everything upstream of the track. The shell answers
|
||||
that with a transient silencing (`shell/capture/render_isolation`) whose child-set
|
||||
walk lives here in `track_topology`; the item-vs-track asymmetry behind it is in
|
||||
`src/shell/capture/CLAUDE.md`.
|
||||
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
|
||||
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
|
||||
FX-bypass-around-render mechanism, never by a render bit.
|
||||
- **The mono collapse changes a capture's content identity, by design.**
|
||||
`hashWavContent` covers the `fmt ` body plus the `data` payload, and the collapse
|
||||
rewrites both — so a collapsed capture does NOT hash-dedup against a stereo twin of
|
||||
the same audio already in the bank. Accepted: the predicate is deterministic over
|
||||
deterministic bytes, so repeats of the same request still dedup against each other,
|
||||
which is what the bit-identical-repeats invariant actually asks for. Do not "fix"
|
||||
this by hashing pre-collapse — that would make two entries with different audio
|
||||
layouts share one identity.
|
||||
- **The collapse's minimal rebuild also drops `bext`/iXML/LIST — a source-position
|
||||
consequence, not only a hashing one.** REAPER's renderer writes a `bext` time
|
||||
reference, and REAPER's own import paths can position an item at that BWF timestamp,
|
||||
so a collapsed capture loses it while a declined (non-collapsed) capture from the same
|
||||
action keeps it — two captures from one action behave differently on re-import.
|
||||
`shell/capture/insert.cpp` is unaffected (it drives `SetEditCurPos` + `InsertMedia`
|
||||
rather than reading BWF), so this is not a defect in the shipped insert path.
|
||||
Accepted, not verified against a DAW re-import: `[verify — DAW]`.
|
||||
- `tail_control`'s `kDefaultManualTailMs`/`kManualStepMs` and
|
||||
`render_settings`'s `kMaxTailMs`/`kAutoTrimThresholdDb` are separate constants
|
||||
in separate files by design (panel-facing default/step vs. runaway-guard cap)
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
reasampler_pure_library(capture_paths SOURCES capture_paths.cpp)
|
||||
reasampler_test(capture_paths LINK capture_paths)
|
||||
|
||||
reasampler_pure_library(capture_name SOURCES capture_name.cpp)
|
||||
# capture_paths: the stem base's real contract is that sanitizeStem keeps it legal, so the
|
||||
# name tests assert the composed stem THROUGH the sanitizer rather than in isolation.
|
||||
reasampler_test(capture_name LINK capture_name capture_paths)
|
||||
|
||||
reasampler_pure_library(insert_plan SOURCES insert_plan.cpp)
|
||||
reasampler_test(insert_plan LINK insert_plan)
|
||||
|
||||
reasampler_pure_library(render_settings SOURCES render_settings.cpp LINK PUBLIC bank_model)
|
||||
reasampler_test(render_settings LINK render_settings)
|
||||
|
||||
reasampler_pure_library(render_window SOURCES render_window.cpp)
|
||||
reasampler_test(render_window LINK render_window)
|
||||
|
||||
reasampler_pure_library(track_topology SOURCES track_topology.cpp)
|
||||
reasampler_test(track_topology LINK track_topology)
|
||||
|
||||
reasampler_pure_library(batch_capture SOURCES batch_capture.cpp)
|
||||
reasampler_test(batch_capture LINK batch_capture)
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// capture_name — pure implementation. See the header.
|
||||
|
||||
#include "core/capture/capture_name.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// A track name padded with spaces would render ragged in the label and as underscores in
|
||||
// the stem, so both ends are trimmed before anything else looks at it.
|
||||
std::string trimmed(const std::string& s) {
|
||||
std::size_t b = 0;
|
||||
std::size_t e = s.size();
|
||||
auto isSpace = [](unsigned char c) {
|
||||
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
||||
};
|
||||
while (b < e && isSpace(static_cast<unsigned char>(s[b]))) ++b;
|
||||
while (e > b && isSpace(static_cast<unsigned char>(s[e - 1]))) --e;
|
||||
return s.substr(b, e - b);
|
||||
}
|
||||
|
||||
// Truncating mid-sequence would put invalid UTF-8 into the persisted label, so the cut
|
||||
// backs off over continuation bytes (10xxxxxx). The stem does not care — sanitizeStem
|
||||
// replaces every non-ASCII byte anyway — but one rule for both keeps them the same name.
|
||||
std::string truncateUtf8(const std::string& s, std::size_t maxBytes) {
|
||||
if (s.size() <= maxBytes) return s;
|
||||
std::size_t cut = maxBytes;
|
||||
while (cut > 0 && (static_cast<unsigned char>(s[cut]) & 0xC0) == 0x80) --cut;
|
||||
return s.substr(0, cut);
|
||||
}
|
||||
|
||||
int clampTo(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string formatCaptureStamp(const CaptureStamp& stamp) {
|
||||
if (stamp.month < 1 || stamp.day < 1) return {};
|
||||
char buf[24];
|
||||
std::snprintf(buf, sizeof(buf), "%02d-%02d %02d%02d",
|
||||
clampTo(stamp.month, 1, 12), clampTo(stamp.day, 1, 31),
|
||||
clampTo(stamp.hour, 0, 23), clampTo(stamp.minute, 0, 59));
|
||||
return buf;
|
||||
}
|
||||
|
||||
CaptureName composeCaptureName(const CaptureNameInputs& in) {
|
||||
std::string base;
|
||||
int named = 0;
|
||||
for (const std::string& raw : in.sourceNames) {
|
||||
const std::string n = trimmed(raw);
|
||||
if (n.empty()) continue;
|
||||
if (base.empty()) base = n;
|
||||
++named;
|
||||
}
|
||||
if (base.empty()) base = trimmed(in.fallback);
|
||||
if (base.empty()) base = "capture";
|
||||
base = truncateUtf8(base, kMaxSourceNameBytes);
|
||||
// truncateUtf8 backs off over continuation bytes, so a name whose first kMaxSourceNameBytes
|
||||
// bytes are ALL continuation bytes (0x80-0xBF) backs off to nothing — re-apply the "never an
|
||||
// empty label" fallback after truncation, not just before it.
|
||||
if (base.empty()) base = "capture";
|
||||
|
||||
CaptureName out;
|
||||
out.label = base;
|
||||
out.stemBase = base;
|
||||
|
||||
// Several sources collapse onto the first one's name plus a count of the rest — the
|
||||
// alternative (joining every name) produces a stem no one can read and a label that
|
||||
// no longer fits a card.
|
||||
if (named > 1) {
|
||||
const std::string extra = std::to_string(named - 1);
|
||||
out.label += " +" + extra;
|
||||
out.stemBase += "+" + extra;
|
||||
}
|
||||
|
||||
if (in.ordinal > 0) {
|
||||
const std::string ord = std::to_string(in.ordinal);
|
||||
out.label += " #" + ord;
|
||||
out.stemBase += "-" + ord;
|
||||
}
|
||||
|
||||
const std::string stamp = formatCaptureStamp(in.stamp);
|
||||
if (!stamp.empty()) out.label += " " + stamp;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
// capture_name — the REAPER-free composition of one capture's label and file-stem base
|
||||
// from its source-track name(s), a local-calendar discriminator, and an optional batch
|
||||
// ordinal. The shell reads the names and the clock; the SHAPE of a capture's name is
|
||||
// decided here so it is testable without a DAW.
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The capture's own moment, already broken down into LOCAL calendar fields by the shell.
|
||||
// Passing fields rather than an epoch is what keeps the format deterministic under test:
|
||||
// an epoch would render differently per machine timezone. month < 1 or day < 1 means
|
||||
// "no stamp" and suppresses the discriminator entirely.
|
||||
struct CaptureStamp {
|
||||
int month = 0; // 1-12
|
||||
int day = 0; // 1-31
|
||||
int hour = 0; // 0-23
|
||||
int minute = 0; // 0-59
|
||||
};
|
||||
|
||||
// Longest source-name prefix kept in either the label or the stem. Real track names sit
|
||||
// far under it; the bound exists so a pathological name cannot push the rendered file
|
||||
// path toward the platform's limit, and so a label and its file still read as the same
|
||||
// name.
|
||||
inline constexpr std::size_t kMaxSourceNameBytes = 64;
|
||||
|
||||
struct CaptureNameInputs {
|
||||
// Source-track names in source order — the first non-empty one names the capture,
|
||||
// the rest only contribute the "+N" multi-source marker.
|
||||
std::vector<std::string> sourceNames;
|
||||
|
||||
CaptureStamp stamp;
|
||||
|
||||
// Batch unit ordinal; <= 0 for a single capture.
|
||||
int ordinal = 0;
|
||||
|
||||
// The scope literal ("item"/"track"/"realtime"), used ONLY when no source name
|
||||
// resolved at all — otherwise the source name wins.
|
||||
std::string fallback = "capture";
|
||||
};
|
||||
|
||||
struct CaptureName {
|
||||
// Sample::displayName. Legible, carries the source name verbatim, and is explicitly
|
||||
// NOT unique (core/model/CLAUDE.md §resample_name) — the stamp serves the eye.
|
||||
std::string label;
|
||||
|
||||
// deriveBankPaths' baseName. Still passes through sanitizeStem, and stem uniqueness
|
||||
// is still entirely makeUniqueTag's job.
|
||||
std::string stemBase;
|
||||
};
|
||||
|
||||
// "MM-DD HHMM" (e.g. "08-01 1432"); empty when the stamp carries no calendar date.
|
||||
// Year is deliberately omitted: the card and the browse list are narrow, and Sample
|
||||
// carries the full createdTimestamp for anything needing the exact moment.
|
||||
std::string formatCaptureStamp(const CaptureStamp& stamp);
|
||||
|
||||
CaptureName composeCaptureName(const CaptureNameInputs& in);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -100,12 +100,44 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
||||
return c;
|
||||
}
|
||||
|
||||
SourceMode sourceModeForScope(CaptureScope scope) {
|
||||
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) {
|
||||
switch (scope) {
|
||||
case CaptureScope::Item: return SourceMode::SelectedItems;
|
||||
case CaptureScope::Item:
|
||||
return itemExtentIsWindow ? SourceMode::SelectedItems
|
||||
: SourceMode::SelectedTracks;
|
||||
case CaptureScope::Track: return SourceMode::SelectedTracks;
|
||||
}
|
||||
return SourceMode::SelectedItems; // unreachable for a valid enum; fail closed
|
||||
// Unreachable for a valid enum; fail closed to the time-bounded render, which
|
||||
// honors the requested bounds whatever the selection is.
|
||||
return SourceMode::SelectedTracks;
|
||||
}
|
||||
|
||||
bool isMultiTrackStemRender(SourceMode mode, int sourceTrackCount) {
|
||||
return mode == SourceMode::SelectedTracks && sourceTrackCount > 1;
|
||||
}
|
||||
|
||||
std::string multiTrackRefusalMessage(CaptureScope scope) {
|
||||
// Deliberately does not name realtime capture as a way out, though it is the one
|
||||
// action that sums correctly here: realtime is non-deterministic (hardware/performed
|
||||
// FX, no bit-identical-repeats guarantee), so pointing an offline refusal at it would
|
||||
// trade one invariant for another rather than just naming a substitute. A stated
|
||||
// choice, not an oversight.
|
||||
switch (scope) {
|
||||
case CaptureScope::Item:
|
||||
return "This range is narrower than the selected items, so it renders "
|
||||
"through their tracks -- and those items span more than one track, "
|
||||
"which this shape cannot land as a single file. Capture one track's "
|
||||
"items at a time, or make the range match the items' extent.";
|
||||
case CaptureScope::Track:
|
||||
return "A track capture renders the selected tracks through the master, "
|
||||
"and more than one track cannot land as a single file. Capture one "
|
||||
"track at a time, or route them into a folder/bus track and capture "
|
||||
"that (a folder's own output is its children summed).";
|
||||
}
|
||||
// Unreachable for a valid enum; a refusal with no way out is still better than a
|
||||
// silent one, so fail closed to the scope-agnostic half of the message.
|
||||
return "This selection spans more than one track, which cannot land as a single "
|
||||
"file. Capture one track at a time.";
|
||||
}
|
||||
|
||||
RangeSource inferRangeSource(bool hasRazorArea) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#pragma once
|
||||
// render_settings — the REAPER-free logic behind the capture action family:
|
||||
// sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union,
|
||||
// razor-else-time inference, the FX-scope bypass plan, and the capture-action
|
||||
// table main.cpp iterates. Bit MEANINGS below are transcribed verbatim from
|
||||
// razor-else-time inference, the FX-scope bypass plan, the capture-action
|
||||
// table main.cpp iterates, and the multi-track-stem refusal + its user-facing
|
||||
// message text. Bit MEANINGS below are transcribed verbatim from
|
||||
// reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested.
|
||||
|
||||
#include <string>
|
||||
@@ -105,9 +106,42 @@ enum class CaptureScope {
|
||||
Track,
|
||||
};
|
||||
|
||||
// The render source mode each scope drives. Item captures selected items, Track
|
||||
// captures selected tracks (via master).
|
||||
SourceMode sourceModeForScope(CaptureScope scope);
|
||||
// The render source mode each scope drives. Track scope always captures its
|
||||
// selected tracks (via master), time-bounded by RENDER_STARTPOS/ENDPOS.
|
||||
//
|
||||
// Item scope captures the selected items ONLY when `itemExtentIsWindow` — i.e.
|
||||
// when those items' own extent already prints the requested window (see
|
||||
// render_window::itemExtentPrintsWindow). REAPER's selected-items render source is
|
||||
// INFERRED to derive its bounds from the item extents, so a window strictly inside
|
||||
// (or wider than) a selected item cannot be expressed through it; that case renders
|
||||
// time-bounded through the items' own tracks. The inference is unverified — see
|
||||
// src/core/capture/CLAUDE.md §Gotchas for what it rests on.
|
||||
//
|
||||
// The FX SCOPE is unaffected by the swap (fxBypassPlanFor is keyed on CaptureScope,
|
||||
// not on the source mode, so an item capture still hears take/item FX only), but the
|
||||
// CONTENT reaching the render is not: the selected-tracks source prints everything
|
||||
// upstream of the track — its folder children and its receives — which the shell
|
||||
// transiently silences (shell/capture/render_isolation). An overlapping item on the
|
||||
// track ITSELF is deliberately not isolated; see src/shell/capture/CLAUDE.md.
|
||||
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow);
|
||||
|
||||
// True for the one render shape that cannot land as a single capture: a selected-tracks
|
||||
// render covering more than one track — a ranged item capture whose items span several
|
||||
// tracks, or any multi-track track capture. That source is read as rendering one file
|
||||
// per selected track — the single-file bit is documented for item/razor sources only
|
||||
// (SDK header ~3041), which is the whole basis for the reading and is DAW-unverified.
|
||||
// If it holds, N tracks collapse N stems onto one literal render pattern and whichever
|
||||
// file survived would land as a successful capture carrying one track's audio. The
|
||||
// caller refuses instead.
|
||||
//
|
||||
// Scope is deliberately NOT a parameter: the exposure comes from the render SOURCE,
|
||||
// which both scopes reach.
|
||||
bool isMultiTrackStemRender(SourceMode mode, int sourceTrackCount);
|
||||
|
||||
// The refusal text for the shape above. Keyed on scope because only the way OUT differs:
|
||||
// an item capture can also widen its range to the items' own extent, which a track
|
||||
// capture has no analog for. Kept beside the predicate so the two read as siblings.
|
||||
std::string multiTrackRefusalMessage(CaptureScope scope);
|
||||
|
||||
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
|
||||
//
|
||||
@@ -170,7 +204,7 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
|
||||
struct CaptureActionDef {
|
||||
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
|
||||
const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase
|
||||
const char* baseName; // file-stem base for this capture
|
||||
const char* baseName; // file-stem FALLBACK; the source track normally names the capture
|
||||
CaptureScope scope; // FX scope (item / track)
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// render_window.cpp — see the header.
|
||||
|
||||
#include "core/capture/render_window.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// Round-to-nearest, so a position that sits mid-frame maps to the frame a render
|
||||
// of it prints rather than to the frame below it.
|
||||
long long frameIndexAt(double seconds, int sampleRate) {
|
||||
return std::llround(seconds * static_cast<double>(sampleRate));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate) {
|
||||
if (sampleRate <= 0) return 0;
|
||||
if (!(endSeconds > startSeconds)) return 0;
|
||||
const long long frames =
|
||||
frameIndexAt(endSeconds, sampleRate) - frameIndexAt(startSeconds, sampleRate);
|
||||
return frames > 0 ? frames : 0;
|
||||
}
|
||||
|
||||
bool itemExtentPrintsWindow(double reqStart, double reqEnd,
|
||||
double itemStart, double itemEnd,
|
||||
int sampleRate) {
|
||||
if (sampleRate <= 0)
|
||||
return reqStart == itemStart && reqEnd == itemEnd;
|
||||
return frameIndexAt(reqStart, sampleRate) == frameIndexAt(itemStart, sampleRate)
|
||||
&& frameIndexAt(reqEnd, sampleRate) == frameIndexAt(itemEnd, sampleRate);
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
// render_window — pure frame arithmetic for a capture's requested window: the
|
||||
// frame count a project-time range occupies, and whether a render whose bounds
|
||||
// come from the selected items' own extent already prints that window.
|
||||
// NO REAPER types; unit-tested by tests/test_render_window.cpp.
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Frames the [startSeconds, endSeconds) window occupies at `sampleRate`. Both
|
||||
// edges are resolved to the NEAREST frame boundary and subtracted, so the answer
|
||||
// is a difference of frame indices rather than a rounded duration — two windows
|
||||
// of equal length at different offsets can legitimately differ by one frame.
|
||||
// Returns 0 for a non-positive rate or an empty/inverted window.
|
||||
//
|
||||
// The offline backend compares this against the rendered file's own frame count, so
|
||||
// exact-bounds failures surface as a refused capture rather than a wrong file. That
|
||||
// REAPER resolves the two edges the same way is UNVERIFIED — a DAW pass decides
|
||||
// whether the equality is exact or off by a frame.
|
||||
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate);
|
||||
|
||||
// True when a render bounded by the selected items' own extent
|
||||
// [itemStart, itemEnd) already prints exactly the requested
|
||||
// [reqStart, reqEnd) window — the one case where REAPER's selected-items render
|
||||
// source is believed to need no correction (the bounds-override inference behind
|
||||
// that is unverified; src/core/capture/CLAUDE.md §Gotchas states what it rests on).
|
||||
// Compared at frame resolution, because a sub-frame difference prints the same
|
||||
// frames. An unknown rate (<= 0) falls back to exact equality, which can only send
|
||||
// a window to the time-bounded render, never widen one.
|
||||
bool itemExtentPrintsWindow(double reqStart, double reqEnd,
|
||||
double itemStart, double itemEnd,
|
||||
int sampleRate);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,28 @@
|
||||
// track_topology.cpp — see the header.
|
||||
|
||||
#include "core/capture/track_topology.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
|
||||
int parentIndex) {
|
||||
std::vector<int> children;
|
||||
const int count = static_cast<int>(folderDepths.size());
|
||||
if (parentIndex < 0 || parentIndex >= count) return children;
|
||||
if (folderDepths[static_cast<std::size_t>(parentIndex)] != 1) return children;
|
||||
|
||||
// Depth relative to the parent: 1 immediately after it (inside its folder), and
|
||||
// 0 once the folder closes. Only tracks sitting at relative depth 1 are direct
|
||||
// children; a child that opens its own folder pushes the level to 2, which is
|
||||
// what excludes its descendants.
|
||||
int level = 1;
|
||||
for (int i = parentIndex + 1; i < count && level > 0; ++i) {
|
||||
if (level == 1) children.push_back(i);
|
||||
level += folderDepths[static_cast<std::size_t>(i)];
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
// track_topology — pure folder arithmetic over a project's track list: which tracks
|
||||
// are the DIRECT children of a folder parent, derived from the I_FOLDERDEPTH deltas
|
||||
// alone. NO REAPER types (the shell reads the deltas); unit-tested by
|
||||
// tests/test_track_topology.cpp.
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Indices of `parentIndex`'s DIRECT children, given every track's I_FOLDERDEPTH in
|
||||
// track order. I_FOLDERDEPTH is a DELTA applied AFTER its own track (SDK header
|
||||
// ~2215: 0 = normal, 1 = opens a folder, -n = closes n folders), so the depth walk
|
||||
// below is the only way to recover the tree from the flat list.
|
||||
//
|
||||
// Empty when `parentIndex` is out of range or its track does not open a folder.
|
||||
// Grandchildren are deliberately excluded: their audio reaches the parent only
|
||||
// through the direct child that owns them, so a caller silencing each direct child's
|
||||
// send-to-parent silences the whole subtree. An unterminated folder (no closing
|
||||
// negative delta) treats every remaining track as inside it, matching REAPER.
|
||||
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
|
||||
int parentIndex);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -257,6 +257,59 @@ std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
return out;
|
||||
}
|
||||
|
||||
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes) {
|
||||
MonoCollapse out;
|
||||
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid || layout.channelCount < 2) return out;
|
||||
|
||||
const std::size_t frames = layout.frameCount();
|
||||
if (frames == 0) return out;
|
||||
|
||||
const std::size_t stride = layout.channelCount;
|
||||
const std::vector<AudioSample> pcm = extractFloatFrames(bytes, layout, 0, frames);
|
||||
if (pcm.size() != frames * stride) return out; // short read -> decline, never guess
|
||||
|
||||
// Bit patterns, not values: see the header. memcpy is the only defined float->bits
|
||||
// read, and it compiles to a register move.
|
||||
auto bitsOf = [](AudioSample s) {
|
||||
std::uint32_t bits = 0;
|
||||
std::memcpy(&bits, &s, 4u);
|
||||
return bits;
|
||||
};
|
||||
for (std::size_t f = 0; f < frames; ++f) {
|
||||
const std::uint32_t first = bitsOf(pcm[f * stride]);
|
||||
for (std::size_t c = 1; c < stride; ++c) {
|
||||
if (bitsOf(pcm[f * stride + c]) != first) return out;
|
||||
}
|
||||
}
|
||||
|
||||
// float -> double -> float round-trips exactly for every finite value and for
|
||||
// +-0/+-infinity (double represents every float bit pattern in those classes), so
|
||||
// channel 0 reaches the rebuilt file unaltered. The one hole: a signaling NaN is
|
||||
// quieted by the float->double promotion, so an identical-bit sNaN pair could
|
||||
// collapse to a different bit pattern than it started with. Not reachable from
|
||||
// REAPER-rendered audio, but the bit-identical predicate above admits NaN inputs,
|
||||
// so this rebuild is not exempt from the claim it makes.
|
||||
std::vector<double> mono(frames);
|
||||
for (std::size_t f = 0; f < frames; ++f)
|
||||
mono[f] = static_cast<double>(pcm[f * stride]);
|
||||
|
||||
out.collapsed = true;
|
||||
out.bytes = buildFloat32Wav(1, layout.sampleRate, frames, mono);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string monoCollapseSuffix(MonoCollapseOutcome outcome) {
|
||||
switch (outcome) {
|
||||
case MonoCollapseOutcome::Declined: return {};
|
||||
case MonoCollapseOutcome::Collapsed: return " (collapsed to mono)";
|
||||
case MonoCollapseOutcome::Failed:
|
||||
return " (mono collapse failed -- left as captured)";
|
||||
}
|
||||
return {}; // unreachable for a valid enum; claim nothing rather than a wrong outcome
|
||||
}
|
||||
|
||||
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
||||
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
|
||||
std::uint64_t h = kFnvOffsetBasis;
|
||||
|
||||
@@ -90,6 +90,52 @@ std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
std::size_t frameCount,
|
||||
const std::vector<double>& interleaved);
|
||||
|
||||
// --- Lossless mono collapse ---------------------------------------------------
|
||||
|
||||
// The outcome of the bit-identical mono collapse. `collapsed == false` means the
|
||||
// caller must leave the source file exactly as it is — it writes nothing.
|
||||
struct MonoCollapse {
|
||||
bool collapsed = false;
|
||||
std::vector<std::uint8_t> bytes; // the rebuilt 1-channel WAV; empty unless collapsed
|
||||
};
|
||||
|
||||
// Collapses a multi-channel float32 WAV to one channel when EVERY channel of EVERY
|
||||
// frame carries the identical float BIT PATTERN. Bit equality, never an epsilon and
|
||||
// never `==` on floats: +0.0/-0.0 and two NaNs with differing payloads are NOT
|
||||
// identical and are never folded. Frame count, sample rate and bit depth are
|
||||
// preserved — only the interleave stride changes — so the collapse cannot lose
|
||||
// information, and a lossy downmix (summing differing channels) is not something
|
||||
// this can express.
|
||||
//
|
||||
// Declines for: bytes that do not parse; a file already at one channel; a zero-frame
|
||||
// file (no frame of evidence to act on); any differing channel pair.
|
||||
//
|
||||
// The rebuild is a canonical minimal WAV, so non-audio chunks (a renderer's `bext`
|
||||
// timestamp, iXML, LIST) do not survive it. That much hashWavContent already skips —
|
||||
// but the collapse rewrites the `fmt ` body and the `data` payload too, which moves
|
||||
// the file's content identity; see this directory's CLAUDE.md for what that costs,
|
||||
// including the bext/source-position consequence beyond hashing.
|
||||
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
// How applying the collapse to a captured FILE ended. `Declined` is collapseToMono's own
|
||||
// "nothing to do"; `Failed` is a read that never happened or a warranted rewrite that did
|
||||
// not land. The capture is intact and correctly measured in every case — only the report
|
||||
// tells them apart, which is why the two must not share one value.
|
||||
enum class MonoCollapseOutcome {
|
||||
Declined,
|
||||
Collapsed,
|
||||
Failed,
|
||||
};
|
||||
|
||||
// The capture message's collapse suffix — empty for Declined, so a capture that had
|
||||
// nothing to collapse reads exactly as it did before the collapse existed. Shared by
|
||||
// both backends so one outcome cannot be reported two ways. NOT user-observable on its
|
||||
// own: CaptureResult::message on a successful capture is never printed by any caller, so
|
||||
// the Collapsed/Failed text this returns reaches no one today — the one observable
|
||||
// channel for a genuine Failed outcome is the backends' own reportCollapseFailure
|
||||
// console line.
|
||||
std::string monoCollapseSuffix(MonoCollapseOutcome outcome);
|
||||
|
||||
// --- Content identity (dedup hashes) -----------------------------------------
|
||||
|
||||
// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
|
||||
|
||||
+161
-48
@@ -1,16 +1,22 @@
|
||||
# src/core/instrument — pure VST3-instrument core (engine / map / ui)
|
||||
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / ui)
|
||||
|
||||
## Scope
|
||||
|
||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three
|
||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five
|
||||
subdirectories:
|
||||
|
||||
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
|
||||
velocity curve, and master-gain taper math.
|
||||
- **`map/`** — the capture resolution + `SampleData` build, the cross-artifact
|
||||
`ComponentState` codec, and the small pure helpers the engine/shell share
|
||||
(bank-generation sync, bridge-read marshalling, note-name parsing, Trigger
|
||||
frame↔fraction conversion).
|
||||
(bank-generation sync, bridge-read marshalling, note-name parsing, the Trigger
|
||||
play-span formula).
|
||||
- **`note/`** — the programmed capture-signal model: musical-division note length, tempo
|
||||
resolution, and anchored start/end offsets — the one record and resolver the offline bake
|
||||
and any future editor of it read from, so they cannot diverge.
|
||||
- **`bake/`** — the resample bake's pure half: the programmed note resolved to a frame
|
||||
window, the offline render over a voice engine built for that render alone, and the
|
||||
ratified post-bake reset. See `bake/CLAUDE.md`.
|
||||
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
|
||||
interiors, waveform, keyboard strip, capture browser, param controls, envelope
|
||||
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
|
||||
@@ -112,18 +118,23 @@ start point, Gate has modifiable loop points too. In addition to amp env, there
|
||||
pitch envelope/curve (AD?) which is off by default."*
|
||||
|
||||
- **Gate — classic held note.** Note-on enters the amp envelope; note-off enters
|
||||
release; a sustain loop applies for held notes. Envelope is **AHDSR**: `0→1` over
|
||||
release; a sustain loop applies for held notes, cycling indefinitely until note-off, with a
|
||||
user-parameterized pre-seam crossfade at the reset (`engine/loop/`). Envelope is **AHDSR**: `0→1` over
|
||||
attack, hold at 1 over `holdFrames`, `1→sustain` over decay, hold sustain until
|
||||
note-off, `level→0` over release. `holdFrames == 0` is exactly the pre-Gate ADSR — a
|
||||
back-compat degenerate.
|
||||
- **Trigger — one-shot drum-pad.** Note-on fires playback of a defined `%` of sample
|
||||
length with a fade-in and fade-out ramp; note-off is ignored (the voice plays through,
|
||||
no sustain loop). Frame span `[startFrame, playEnd)` where `playEnd = startFrame +
|
||||
round(lengthFraction·(frames − startFrame))`; amplitude ramps `0→1` over
|
||||
`fadeInFrames` at the head and `1→0` over `fadeOutFrames` anchored to `playEnd`; fades
|
||||
clamp so `fadeInFrames + fadeOutFrames ≤ play length`. Fade curve is equal-power
|
||||
(constant-power sin/cos). **Note-off in Trigger is a no-op** — choke-on-note-off is
|
||||
held/out of scope (fork S15-F1).
|
||||
length; note-off is ignored (the voice plays through, no sustain loop). Frame span
|
||||
`[startFrame, playEnd)` where `playEnd = startFrame +
|
||||
round(lengthFraction·(frames − startFrame))`. The amplitude over that span is the staged
|
||||
**AHD** (below), not a fade pair. **Note-off in Trigger is a no-op** — choke-on-note-off
|
||||
is held/out of scope (fork S15-F1).
|
||||
|
||||
> **Superseded, do not reintroduce:** Trigger's amplitude was once a fade-in/unity/
|
||||
> fade-out shape with its own equal-power curve and its own `fadeInFrames`/`fadeOutFrames`
|
||||
> pair, clamped so the two fades fit the span. That is retired — one staged-envelope
|
||||
> design now covers what were two mechanisms. A saved instance's fades lift onto the AHD
|
||||
> at the codec boundary (attack ← fade-in, decay ← fade-out, hold ← the remainder).
|
||||
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
|
||||
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none.
|
||||
- **Pitch engine — Varispeed vs Preserve (S16).** Varispeed (current/
|
||||
@@ -135,12 +146,12 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
Contract for Gate's sustain loop under Preserve: *loop the source, shift the output*
|
||||
(loop points stay source-frame facts). `WDL_Resampler` is **not** a Preserve engine (it
|
||||
is a resampler that couples duration) — never wire it as the duration-preserving path.
|
||||
- **Pitch envelope — AD, off by default.** A short attack-decay pitch-offset curve
|
||||
(`peakSemitones` over `attackFrames`, decaying to 0 over `decayFrames`) riding on top of
|
||||
whichever pitch engine; a zero attack gives a pure percussive pitch drop. **Off by
|
||||
default** — a regression that applies pitch modulation when the envelope is disabled is
|
||||
a bug. Under Varispeed the offset is a per-frame multiply of `ratio_`; under Preserve it
|
||||
is added to the shifter's shift amount.
|
||||
- **Pitch envelope — AHD, off by default.** A pitch-offset curve rising to `peakSemitones`
|
||||
over attack, holding, then decaying to 0, riding on top of whichever pitch engine; a zero
|
||||
attack gives a pure percussive pitch drop. **Off by default** — a regression that applies
|
||||
pitch modulation when the envelope is disabled is a bug. Its hold fraction defaults to 0,
|
||||
which is exactly the attack-decay shape it grew out of. Under Varispeed the offset is a
|
||||
per-frame multiply of `ratio_`; under Preserve it is added to the shifter's shift amount.
|
||||
- **Preserve RT discipline.** The shifter pre-warms at voice-allocation; no allocation in
|
||||
`process()` in steady state. **Note (supersedes an earlier framing):** the
|
||||
shifter's onset latency (~25 ms, half-window) was once described as "an
|
||||
@@ -154,6 +165,39 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
- **S15/S16 are Tier 0–1 engine features, not Tier 2/3** — do not let the held Tier-2
|
||||
feature list (velocity layers / round-robin / filter work) drive their build shape.
|
||||
|
||||
### Live parameter delivery — a knob moves the note already sounding (settled 2026-07-30)
|
||||
|
||||
Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other envelopes. We
|
||||
must live compute, latching the parameters at note on is not acceptable. long term these will be
|
||||
automatable parameters."* It rejects the precedent, not one instance of it.
|
||||
|
||||
- **Which controls are live is ONE decision, recorded in ONE place** — `isLiveDeckParam` and
|
||||
`liveCommitFor` (`ui/deck_groups`), whose header is THE home for which controls are live and
|
||||
why each exclusion is excluded — see there rather than restating the list here.
|
||||
- **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one
|
||||
block the shell owns per instance. The member-ordering constraint that enforces it, and why,
|
||||
are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice
|
||||
tracking the knob is the DESIRED behaviour — it is the note the user is hearing.
|
||||
- **Null is the bare engine.** `live == nullptr` is byte-identical to the pre-live core, which
|
||||
is why `sampler_core`'s regression baselines needed no change.
|
||||
- **Observation is at block boundaries, never per frame.** `VoiceEngine` reads the seqlock once
|
||||
per `render()` and once per note-on; the per-sample path gained three predicted branches (the
|
||||
voice's filter-ramp check and each envelope smoother's active check), all false at rest, and
|
||||
no indirection.
|
||||
- **A fresh note SNAPS, a sounding one holds φ.** They are different entry points on purpose
|
||||
(`snapLive` vs `applyLive`): a voice that has rendered nothing has no phase to hold, and the
|
||||
φ rule reads its stage-0 position under a stale zero-length stage as a completed stage. One
|
||||
function serving both silently discarded every newly-dialled attack.
|
||||
- **The mid-stage rule is HOLD NORMALIZED STAGE POSITION** (Daniel's pick among six candidates):
|
||||
φ = elapsed/duration is held across a stage-time change, so the level is continuous by
|
||||
construction and the remainder takes its share of the new duration. Stated over normalized
|
||||
position rather than output level ON PURPOSE, so a per-segment curve exponent composes with
|
||||
it as a pure map of φ. Recomputing from absolute elapsed (which steps) is the rejected
|
||||
alternative — do not reintroduce it.
|
||||
- **Two genuine level steps are smoothed, not ruled away**: a sustain level moved while the
|
||||
voice holds it, and a stage duration dialled to exactly zero mid-stage. Both are absorbed by
|
||||
the envelope's own bounded offset smoother.
|
||||
|
||||
### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md)
|
||||
|
||||
- **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do not
|
||||
@@ -165,19 +209,56 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
- **Do not spec Tier 2/3** from this directory. Tier 2 is held, Tier 3 is
|
||||
optional-forever; don't let their feature lists drive Tier 0–1's build shape.
|
||||
|
||||
### Envelope overlay + draggable nodes (S-VIEW, settled 2026-07-27, landed)
|
||||
### The envelope overlay — one graphical surface, every envelope (S-VIEW, extended)
|
||||
|
||||
The amp envelope is drawn as a curve over the Sample view's hero waveform at the shared
|
||||
time base — Gate → the AHDSR shape, Trigger → the fade-in/unity/%-length/fade-out shape
|
||||
anchored to `playEnd`. **The overlay is directly editable — draggable nodes
|
||||
(SETTLED, S-VIEW-F2).** Dragging a node and the existing sliders are two surfaces onto
|
||||
one model: both read/write the same envelope fields of the one parameter set, so a drag
|
||||
updates the params, the sliders reflect them live, and a slider edit re-lays the nodes —
|
||||
one source of truth, structural (re-read-every-paint), not a listener chain. Nodes are
|
||||
monotonic in time (a node cannot be dragged past its neighbours) and range-clamped to the
|
||||
same per-param min/max the sliders enforce, so node-drag can never produce a param the
|
||||
slider couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — see
|
||||
`envelope_overlay` and `envelope_edit` in Modules below.
|
||||
The overlay draws ONE envelope over the Sample view's hero waveform, and WHICH one is a
|
||||
transient editor choice: each envelope deck (amp, pitch, filter) carries a corner radio, at
|
||||
most one is overlay-active, and **none is a valid resting state — the editor opens there.**
|
||||
Never persisted; it selects what is drawn, not what is played.
|
||||
|
||||
**The overlay is directly editable — draggable nodes (SETTLED, S-VIEW-F2), plus a round
|
||||
mid-segment knot per sloped stage that sets that stage's curve exponent.** A node drag, a
|
||||
knot drag and the deck knobs are surfaces onto ONE model: all three read/write the same
|
||||
fields of the one parameter set, so an edit on any of them re-lays the others — one source
|
||||
of truth, structural (re-read-every-paint), never a listener chain. Every drag is
|
||||
range-clamped to the same per-param min/max the knobs enforce, so no drag can produce a
|
||||
param a knob couldn't. Two pure modules split the forward (draw) and inverse (edit) maps —
|
||||
see `envelope_overlay` and `envelope_edit` in Modules below.
|
||||
|
||||
**Every envelope is EITHER staged or drawn, and both states persist.** Each of the three
|
||||
(amp, pitch, filter) carries a `SplineEnv` — a mode plus a contour over NORMALIZED sample time —
|
||||
beside its staged parameters. Switching modes converts and discards nothing: the inactive state
|
||||
stays saved but inert, and round-tripping restores the other mode's shape untouched. The
|
||||
consequences, each with one home:
|
||||
|
||||
- **Gate is unavailable while any EG is drawn.** A contour is a pure time function over the full
|
||||
sample length, which IS the Trigger/one-shot model. `splineActive` (`play_params.h`) is the
|
||||
predicate; `enforceGateUnavailableWhileDrawn` (`play_params.h`) is the one enforcement of it,
|
||||
called by both `resolvePlay` (`sample_map.cpp`) on the way to the engine and the editor's
|
||||
`applyControl`, so the two callers cannot drift. The editor's Gate segment refuses and paints
|
||||
Disabled off the same `splineActive` predicate.
|
||||
- **A drawn envelope's staged segment knobs go inert** — drawn-but-dead, never removed, never
|
||||
hidden — including their inner curve dials, which are reached through their outer cell.
|
||||
`deckKnobInert` (`ui/deck_groups`) is the one place that list lives. The DEPTH knobs (pitch
|
||||
peak, filter mod amount) stay live: they scale whichever shape is active.
|
||||
- **Normalized is what makes a contour length-independent.** There are no stored seconds to
|
||||
rescale, so a different-length capture replays the same shape proportionally.
|
||||
- **The contours sit on `PlayParams`/`PlaySeconds` directly, not inside the three envelope
|
||||
structs.** Those are copied whole into the live block, which must stay trivially copyable
|
||||
(`live_params.h`) — and a contour is not a live control anyway: like the velocity curves it
|
||||
travels by reload.
|
||||
|
||||
**Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:**
|
||||
pitch is always AHD; amp and filter are AHDSR in Gate and AHD in Trigger. Both mode shapes
|
||||
are STORED per envelope, so flipping modes cannot lose either mode's dialled values (the
|
||||
migration case forces it: an old instance carries both its AHDSR values and its Trigger
|
||||
fades, and one shared set could not preserve both modes' prior sound).
|
||||
|
||||
**And which LAYOUT an envelope takes follows from whether it has a sustain stage** — the
|
||||
same rule, applied once: an AHDSR right-anchors its release (the end point is fixed at the
|
||||
canvas edge and release is dragged from its top node), a sustain-less AHD maps 1:1 onto the
|
||||
waveform's time axis. The two policies coexist rather than merge; the 1:1 mapping only means
|
||||
anything for a trigger shape.
|
||||
|
||||
### Parameter ownership and persistence (D-B)
|
||||
|
||||
@@ -188,54 +269,75 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
||||
reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the
|
||||
extension's `persist` ext-state module (that would make it project-global rather than
|
||||
per-instance and leak an instrument concern into the extension's key space).
|
||||
- **Velocity curve** — the one non-back-compat surface in S-VIEW: an
|
||||
already-saved instance with no stored curve now plays every velocity at unity under the
|
||||
flat-default (Option A), not bit-identical to the old linear `velocity/127` mapping —
|
||||
a deliberate, Daniel-approved behavior change (see `velocity_curve` in Modules).
|
||||
- **Velocity curves** — three of them (amp, pitch, filter), all per-instance, edited from ONE
|
||||
deck group. The amp curve is the one non-back-compat surface in S-VIEW: an already-saved
|
||||
instance with no stored curve now plays every velocity at unity under the flat-default
|
||||
(Option A), not bit-identical to the old linear `velocity/127` mapping — a deliberate,
|
||||
Daniel-approved behavior change. The pitch and filter curves are bipolar and off by default
|
||||
(see `velocity_curve` in Modules).
|
||||
|
||||
## Modules
|
||||
|
||||
### `engine/`
|
||||
|
||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`.
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. Also the ONE home of the drawn-EG rule family — `splineActive`, `effectivePlayMode`, `enforceGateUnavailableWhileDrawn` and `effectiveLengthFraction` — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
|
||||
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
|
||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
|
||||
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
|
||||
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
|
||||
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
|
||||
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
||||
|
||||
### `map/`
|
||||
|
||||
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v9), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default.
|
||||
- `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v14), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
|
||||
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
|
||||
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
|
||||
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
|
||||
- `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions.
|
||||
- `trigger_seam` — the shared Trigger play-SPAN formula: how a %-length becomes the source-frame span the overlay draws over and the bake's window holds, threading `startFrame` correctly and clamping the fraction the same way `Voice::start` does (the engine evaluates the same formula inline rather than depending on `map/`). The spline fold every consumer must apply first — `effectiveLengthFraction` — is `play_params.h`'s, beside the rest of that rule family. (Its fade frames↔fraction converters retired with the fade pair itself.)
|
||||
|
||||
### `ui/`
|
||||
|
||||
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
||||
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, curve button, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath.
|
||||
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
|
||||
- `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means.
|
||||
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
||||
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
|
||||
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
|
||||
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
||||
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
|
||||
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
|
||||
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
|
||||
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types.
|
||||
- `deck_groups` — WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. **The cell/knob/label sizes and `sample_bands`' editor floor move as a pair** — wider cells need a wider floor width or the deck wraps to a fourth row. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, and the deck has fourteen pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
|
||||
- `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split
|
||||
from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups`
|
||||
says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` /
|
||||
`setDeckParam` (the normalized ↔ stored-seconds/fraction/position maps and their clamps),
|
||||
`resetDeckParam` (the double-click reset — the defaults are READ off a default-constructed
|
||||
`PlaySeconds`, so there is no second table of defaults to drift), and `formatEnvTimeMs`, the
|
||||
ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so
|
||||
two stage times are comparable at a glance. A display-unit decision only — nothing about the
|
||||
stored representation changes. Links the header-only `play_seconds`, deliberately not
|
||||
`sample_map`: `PlaySeconds` is the whole of what a deck edits, and linking the mapping would
|
||||
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
|
||||
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
|
||||
labels for them.
|
||||
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
|
||||
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis.
|
||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||
- `envelope_overlay` — pure amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge.
|
||||
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Gate's envelope-overlay x-axis is schematic, not PCM-aligned** (per `envelope_overlay.h`'s FA2 contract note) — it does NOT line up with the waveform under it; only Trigger's x-axis is wall-clock/PCM-aligned. Don't assume the Gate curve is time-accurate against the sample.
|
||||
- **Trigger's fade fields require a non-trivial converter, not a field copy.** `TriggerParams` (engine) stores fades as source *frames*; `AmpEnvelope` (the overlay's view struct) stores them as *fractions* of the played span. A converter is owed on both the pack (draw) and unpack (commit) directions — `trigger_seam` owns this formula; do not copy the fields directly.
|
||||
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned** — it does NOT line up with the waveform under it; only a sustain-less AHD's x-axis is wall-clock/PCM-aligned. Don't assume a gated envelope's curve is time-accurate against the sample.
|
||||
- **An AHD's Hold is a FRACTION of what attack and decay left, never a time.** That is the whole reason A+H+D ≤ span holds by construction; adding a clamp on the sum, or re-expressing Hold as a duration, reintroduces the overflow the fraction exists to prevent.
|
||||
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
|
||||
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
|
||||
- **The band-stack allocator is the ONLY vertical-inventory owner.** A band's interior module (`sample_chrome`, `knob_deck`, the waveform painters) lays out inside the rect it is handed. A band owner that re-derives its own top/bottom has forked the stack.
|
||||
@@ -243,6 +345,17 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
||||
Channel-mode (D-E) bus-renegotiation design and the earlier Preserve-onset-latency
|
||||
framing in the S16 guardrails. Root `CLAUDE.md` is the current source of truth
|
||||
for both — do not reintroduce either superseded design.
|
||||
- **A filter envelope only advances while its depth is non-zero.** `tickFilterCutoff`'s exact
|
||||
skip at `modAmount == 0` skips the envelope tick along with the solve, so dialling depth up
|
||||
mid-note starts the envelope from the note's stage-0 position rather than from where it would
|
||||
have been. Its step smoother is frozen with it — an absorbed step sits in the offset and
|
||||
emits when depth is next dialled up (bounded, and scaled by a depth ramping from 0).
|
||||
Continuous either way (the contribution starts at 0), and keeping the skip is what holds the
|
||||
at-rest per-sample path byte-identical — but don't read a live depth move as "resuming" an
|
||||
envelope that was never running.
|
||||
- **A live edit leaves the snapshot's own `sample.play` stale, on purpose.** The block, not the
|
||||
snapshot, is the audio thread's source; a new voice latches the stale copy and is corrected by
|
||||
`snapLive` before its first frame.
|
||||
- **`keyboard_strip`'s width-uniformity guarantee is client-pixel only.** Its test sweep
|
||||
covers client-pixel widths (including multiples standing in for larger client areas);
|
||||
nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
add_subdirectory(engine)
|
||||
add_subdirectory(map)
|
||||
add_subdirectory(note)
|
||||
add_subdirectory(ui)
|
||||
# Last: bake composes the three above it.
|
||||
add_subdirectory(bake)
|
||||
|
||||
# The spline EG spans all three: the shared curve + its RT cursor (engine), the dual-state
|
||||
# persistence (map), and the point-editing grammar (ui). Declared here because no one
|
||||
# subdirectory owns the seam it covers.
|
||||
reasampler_test(spline_egs LINK sampler_core sample_map component_state_io spline_edit deck_groups)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# src/core/instrument/bake — the resample bake's pure half
|
||||
|
||||
## Scope
|
||||
|
||||
The offline pass that turns the dialed instrument into a file, and the reset that hands the
|
||||
instrument back neutral afterwards. A fifth peer of `engine/` / `map/` / `note/` / `ui/`
|
||||
under `core/instrument/`, pure by the same rule — no REAPER types, no VST3 types, no host.
|
||||
|
||||
It is neither engine (it owns no voice), mapping (it resolves no capture), nor note (it
|
||||
holds no program): it is the *composition* of the three into one render, plus the one
|
||||
decision about what the render made obsolete.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **The bake renders on its OWN engine, never the live one.** `renderBake` takes its
|
||||
`SampleData` BY VALUE and detaches `SampleData::live` before constructing a `VoiceEngine`
|
||||
for the render alone. Two consequences, both load-bearing: the audio thread's live block
|
||||
can neither be observed nor disturbed by a bake, and a repeated bake of one dialed sound
|
||||
is byte-identical because nothing outside the passed value can vary between runs.
|
||||
- **The window bounds the render; the envelope does not.** Termination is structural — the
|
||||
loop runs to `BakePlan::renderFrames()` and stops. That is why a Gate bake with a sustain
|
||||
loop active terminates: the gate is released at `noteOffFrame` so the tail is real, but
|
||||
even a pathological envelope cannot run past the window.
|
||||
- **The whole signal chain is printed, master gain included** — the gain multiply in
|
||||
`bake_render.cpp` carries the argument for why.
|
||||
- **A degenerate or unholdable window is refused, not rendered.** `planBake` refuses a
|
||||
collapsed window, a non-positive rate, a window that rounds to no frames, and one past
|
||||
`kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the
|
||||
seconds→frames narrowing is undefined long before the allocation would fail. The refusal
|
||||
carries a `BakeRefusal` naming WHICH: past-the-ceiling is a real sound that will not fit,
|
||||
which reads to the user as a different sentence than an empty window.
|
||||
- **The window derives itself, and Hold is the one exception.** Trigger derives from the play
|
||||
span; Gate over an active sustain loop takes the user's Hold, because a loop sounds for as
|
||||
long as it is held and no derivation can supply a duration; Gate WITHOUT one derives from
|
||||
source exhaustion, since the read head frees the voice whether or not the gate is down.
|
||||
`bakeWindowNeedsHold` is the predicate, and it reads the ENGINE's loop fold rather than the
|
||||
loop fields, so the control that collects Hold cannot appear for a loop the voice refuses.
|
||||
- **Trailing silence is free; truncation is not.** Every derivation errs outward — the
|
||||
Varispeed bound takes the deepest reachable offset the voice can play, and every path is
|
||||
padded by the voice's terminal declick ramp (`kDeclickFrames`, unconditionally — not branched
|
||||
on the pitch engine that has the ramp today). Judge any change to this module against that
|
||||
asymmetry. What it does NOT mean is quantizing: a derived length is an exact duration and a
|
||||
finite ladder cannot express one (`note/CLAUDE.md`) — rounding up to a rung truncated any
|
||||
source past the top rung, which is the failure this asymmetry exists to prevent.
|
||||
- **The reset's survive list is written out; everything else defaults.** `resetAfterBake`
|
||||
starts from a default-constructed parameter set and copies back only the mapping facts.
|
||||
A parameter added later therefore resets by default — the safe direction, since
|
||||
under-resetting applies the same processing twice while over-resetting costs a re-dial.
|
||||
A new mapping fact must be added to the copy list explicitly.
|
||||
- **Play mode resets to TRIGGER, not to the value struct's Gate default** — the one
|
||||
classification this track made against the ratified rule rather than reading off it.
|
||||
`bake_reset.cpp` carries the argument at the assignment.
|
||||
|
||||
## Modules
|
||||
|
||||
- `bake_plan` — `defaultBakeProgram` (the whole programmed note, DERIVED from the dialed
|
||||
sound: its note length as well as its end offset), `bakeWindowNeedsHold`, `BakePlan` (the
|
||||
render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and
|
||||
`planBake`, the one `ResolvedNote` + rate -> frames resolution, answering a `PlannedBake`.
|
||||
- `bake_render` — `BakeAudio` and `renderBake`: the programmed note through the sample's
|
||||
own voice path, summed into an interleaved buffer at the source's own channel count.
|
||||
- `bake_reset` — `BakeReset` and `resetAfterBake`: the ratified reset scope, answered for
|
||||
both the parameter set and the post-mixer master gain.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`BakePlan` speaks two frame domains** — the captured file's and the render's, which are
|
||||
offset from each other whenever the note and the capture window do not start together.
|
||||
`bake_plan.h` says which field is in which; do not read them as one clock.
|
||||
- **`defaultBakeProgram`'s Varispeed bound is an upper bound, not a model.** A downward pitch
|
||||
offset makes the read head take longer to cross its span, so the window is scaled by the
|
||||
deepest downward offset the voice can reach — a shallower excursion leaves trailing silence
|
||||
in the file. Both the Trigger span and the Gate exhaustion length take it.
|
||||
- **The bake fires at the instance's PREVIEW velocity, not a constant.** Three velocity curves
|
||||
are live, so the velocity is a property of the sound being printed and not a detail of the
|
||||
render; it also feeds the Varispeed bound above (a velocity→pitch curve moves the window).
|
||||
- The render's channel count is the loaded `SampleData`'s, which is already the instance's
|
||||
channel-mode decision — a mono-mode instance bakes mono, and that is faithful, not a fold.
|
||||
@@ -0,0 +1,20 @@
|
||||
# The default program's window is derived from the DIALED sound, so the plan reads the
|
||||
# engine's value layer, its loop fold and its declick length (all sampler_core), the one
|
||||
# Trigger span formula (trigger_seam), and the note-length ladder (via note_program).
|
||||
reasampler_pure_library(bake_plan
|
||||
SOURCES bake_plan.cpp
|
||||
LINK PUBLIC note_program sampler_core trigger_seam)
|
||||
reasampler_test(bake_plan LINK bake_plan)
|
||||
|
||||
reasampler_pure_library(bake_render
|
||||
SOURCES bake_render.cpp
|
||||
LINK PUBLIC bake_plan sampler_core)
|
||||
reasampler_test(bake_render LINK bake_render)
|
||||
|
||||
# No library of its own: the derived window is a PROPERTY of bake_plan + bake_render
|
||||
# together, and this measures it end to end rather than either half in isolation.
|
||||
reasampler_test(bake_window LINK bake_plan bake_render)
|
||||
|
||||
# sample_map carries InstrumentParams, which is the whole of what a reset rewrites.
|
||||
reasampler_pure_library(bake_reset SOURCES bake_reset.cpp LINK PUBLIC sample_map)
|
||||
reasampler_test(bake_reset LINK bake_reset)
|
||||
@@ -0,0 +1,148 @@
|
||||
// See bake_plan.h.
|
||||
|
||||
#include "core/instrument/bake/bake_plan.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "core/instrument/engine/loop/loop_span.h" // resolveLoop (the one sustain-loop fold)
|
||||
#include "core/instrument/engine/voice.h" // kDeclickFrames (the terminal ramp length)
|
||||
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula)
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
using note::NoteProgram;
|
||||
using note::ResolvedNote;
|
||||
|
||||
namespace {
|
||||
|
||||
// Seconds -> frames by round-half-away-from-zero, the one conversion every field here uses,
|
||||
// so the window and its event frames cannot round against each other. Reports failure
|
||||
// rather than clamping: the double->int64 narrowing below is undefined once the product
|
||||
// leaves int64's range, which a legal offset magnitude reaches long before that.
|
||||
bool toFrames(double seconds, int rate, std::int64_t& out) {
|
||||
const double frames = seconds * static_cast<double>(rate);
|
||||
const auto ceiling = static_cast<double>(kMaxBakeFrames);
|
||||
if (!(frames >= -ceiling && frames <= ceiling)) return false; // also catches NaN
|
||||
out = static_cast<std::int64_t>(std::llround(frames));
|
||||
return true;
|
||||
}
|
||||
|
||||
// The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only
|
||||
// Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset
|
||||
// stretches how long the source takes to play out. Preserve decouples the two, and a Gate
|
||||
// release is ticked per output frame, so neither is affected.
|
||||
double downwardSemitones(const PlayParams& play, int velocity) {
|
||||
if (play.pitchEngine != PitchEngine::Varispeed) return 0.0;
|
||||
double down = (std::min)(0.0, kVelocityPitchRangeSemitones *
|
||||
play.pitchVelocityCurve.eval(velocity));
|
||||
if (play.pitchEnv.enabled) {
|
||||
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
|
||||
// the staged AHD only ever travels between 0 and the peak.
|
||||
down += play.pitchSpline.mode == EnvMode::Spline
|
||||
? -std::fabs(play.pitchEnv.peakSemitones)
|
||||
: (std::min)(0.0, play.pitchEnv.peakSemitones);
|
||||
}
|
||||
return down;
|
||||
}
|
||||
|
||||
// Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
std::int64_t effectiveStart(const SampleData& dialed) {
|
||||
const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
|
||||
const std::int64_t start = dialed.startFrame;
|
||||
return (start < 0 || start >= frameCount) ? 0 : start;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t crossfadeFrames,
|
||||
std::int64_t frameCount) {
|
||||
// resolveLoop already refuses a non-Gate voice, so this is exactly "Gate over a loop the
|
||||
// read path will honour" — the engine's decision, not a second reading of the fields.
|
||||
return engine::loop::resolveLoop(loop, crossfadeFrames, frameCount,
|
||||
mode == PlayMode::Gate)
|
||||
.active;
|
||||
}
|
||||
|
||||
bool bakeWindowNeedsHold(const SampleData& dialed) {
|
||||
return bakeWindowNeedsHold(dialed.play.playMode, dialed.loop, dialed.loopCrossfadeFrames,
|
||||
static_cast<std::int64_t>(dialed.frames.size()));
|
||||
}
|
||||
|
||||
NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
|
||||
note::Division hold, note::Velocity velocity) {
|
||||
NoteProgram p; // a quarter note, capture opening at note-on
|
||||
p.velocity = velocity;
|
||||
if (renderSampleRate <= 0) return p;
|
||||
const double rate = static_cast<double>(renderSampleRate);
|
||||
const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
|
||||
const std::int64_t start = effectiveStart(dialed);
|
||||
const double stretch =
|
||||
std::pow(2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0);
|
||||
const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
|
||||
|
||||
double endOffsetSeconds = 0.0;
|
||||
if (dialed.play.playMode == PlayMode::Trigger) {
|
||||
// Trigger ignores note-off entirely: the sound ends when the read head reaches the
|
||||
// play span's end. The note is that span, so the window closes on the sound rather
|
||||
// than on a length the voice never consulted.
|
||||
const std::int64_t span =
|
||||
map::triggerPlayLength(effectiveLengthFraction(dialed.play), frameCount, start);
|
||||
p.length = note::lengthOfSeconds(static_cast<double>(span) / rate * stretch);
|
||||
} else if (bakeWindowNeedsHold(dialed)) {
|
||||
// The loop cycles for as long as the note is held, so the hold IS the length, and the
|
||||
// release is the one stage that runs after note-off.
|
||||
p.length = note::lengthOfDivision(hold);
|
||||
endOffsetSeconds = releaseSeconds;
|
||||
} else {
|
||||
// Gate with no loop: the read head runs off the source and frees the voice whether or
|
||||
// not the gate is still down, so the maximal sound is the whole post-start span held.
|
||||
// Exact, not a ladder rung: a source longer than the ladder's top rung would otherwise
|
||||
// take that rung and release mid-sound, and rounding up to one costs trailing silence
|
||||
// on every other source.
|
||||
const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - start);
|
||||
p.length = note::lengthOfSeconds(static_cast<double>(postStart) / rate * stretch);
|
||||
endOffsetSeconds = releaseSeconds;
|
||||
}
|
||||
// The voice rings its last output out over kDeclickFrames instead of hard-cutting it, and
|
||||
// that ramp starts where the derivations above end. Added on every path, not just the
|
||||
// Preserve one that has the ramp today: trailing silence is free, a hard cut is not.
|
||||
endOffsetSeconds += static_cast<double>(kDeclickFrames) / rate;
|
||||
p.end = note::EndOffset(note::offsetFromMs(endOffsetSeconds * 1000.0));
|
||||
return p;
|
||||
}
|
||||
|
||||
PlannedBake planBake(const ResolvedNote& resolved, int sampleRate, int rootNote) {
|
||||
const PlannedBake empty{std::nullopt, BakeRefusal::EmptyWindow};
|
||||
const PlannedBake tooLong{std::nullopt, BakeRefusal::PastFrameCeiling};
|
||||
if (resolved.windowCollapsed) return empty;
|
||||
if (sampleRate <= 0) return empty;
|
||||
|
||||
// The render starts at whichever comes first, note-on or the capture opening. A POSITIVE
|
||||
// start offset is legal and means the capture opens after the note — so the head is
|
||||
// rendered and discarded, never folded away by sliding note-on later inside the window.
|
||||
const double renderStartSeconds = (std::min)(resolved.captureStartSeconds, 0.0);
|
||||
|
||||
BakePlan plan;
|
||||
plan.sampleRate = sampleRate;
|
||||
if (!toFrames(resolved.captureLengthSeconds(), sampleRate, plan.totalFrames))
|
||||
return tooLong;
|
||||
if (plan.totalFrames <= 0) return empty;
|
||||
if (!toFrames(resolved.captureStartSeconds - renderStartSeconds, sampleRate,
|
||||
plan.leadInFrames))
|
||||
return tooLong;
|
||||
if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return tooLong;
|
||||
if (!toFrames(resolved.noteOffSeconds - renderStartSeconds, sampleRate,
|
||||
plan.noteOffFrame))
|
||||
return tooLong;
|
||||
// Each field cleared the ceiling alone; the render holds their sum.
|
||||
if (plan.renderFrames() > kMaxBakeFrames) return tooLong;
|
||||
|
||||
plan.noteOffFrame = (std::max)(plan.noteOffFrame, plan.noteOnFrame);
|
||||
plan.note = std::clamp(rootNote, 0, 127);
|
||||
plan.velocity = std::clamp(static_cast<int>(resolved.velocity), 1, 127);
|
||||
return PlannedBake{plan, BakeRefusal::None};
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
@@ -0,0 +1,93 @@
|
||||
// bake_plan — the programmed note resolved against a concrete sample rate: the frames the
|
||||
// offline pass renders, the slice of them the capture keeps, and the two event frames.
|
||||
//
|
||||
// Separate from bake_render because the plan is what a preview and a bake must agree on;
|
||||
// the render is only one consumer of it.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
#include "core/instrument/engine/play_params.h" // SampleData (the dialed sound)
|
||||
#include "core/instrument/note/note_program.h"
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
// The render's frame ceiling, refused like any other degenerate window. A legal offset
|
||||
// magnitude reaches ~11.6 days, and renderBake allocates two channel buffers plus an
|
||||
// interleaved one from the window — an unbounded one is a bad_alloc inside a UI tick, not a
|
||||
// long bake. ~5.5 minutes at 48 kHz, past any musical programmed note.
|
||||
inline constexpr std::int64_t kMaxBakeFrames = 16'000'000;
|
||||
|
||||
// Whether the window needs a user-supplied hold. A Gate voice over an ACTIVE sustain loop
|
||||
// sounds for as long as it is held, by definition — there is no intrinsic end to derive, and
|
||||
// this is the ONLY case in which there isn't. Answered by the engine's own loop fold, so the
|
||||
// control that collects the hold cannot appear for a loop the voice would refuse.
|
||||
bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t crossfadeFrames,
|
||||
std::int64_t frameCount);
|
||||
bool bakeWindowNeedsHold(const SampleData& dialed);
|
||||
|
||||
// The bake's programmed note, DERIVED from the dialed sound at `renderSampleRate` (the rate
|
||||
// the bake renders at, which is what the engine's frame counts are consumed against):
|
||||
//
|
||||
// Trigger — the note IS the play span (note-off is ignored anyway), stretched by the
|
||||
// deepest downward Varispeed offset.
|
||||
// Gate, loop — `hold` is the note length; the end offset is the release.
|
||||
// Gate, no loop— the read head runs off the source and frees the voice whatever the gate is
|
||||
// doing, so the note is the whole post-start span, stretched the same way.
|
||||
//
|
||||
// Both derived lengths are EXACT durations, not ladder rungs: a source longer than the
|
||||
// ladder's top rung has no rung that covers it, and quantizing up to one overshoots every
|
||||
// other source (see note/CLAUDE.md). `hold` alone stays musical — it is a picker.
|
||||
//
|
||||
// Every case is padded by the voice's terminal declick ramp (kDeclickFrames): trailing
|
||||
// silence is free, and closing the window on the frame the ramp starts is a hard cut.
|
||||
// `hold` is read only in the Gate-with-loop case; `velocity` is the velocity the note fires
|
||||
// at, and it feeds the Varispeed stretch as well as the render.
|
||||
//
|
||||
// Takes no tempo: nothing derived here is beat-denominated. The one field that is — `hold` —
|
||||
// meets the tempo in resolveNote, with the rest of the program's beat-denominated fields.
|
||||
note::NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
|
||||
note::Division hold, note::Velocity velocity);
|
||||
|
||||
// The render window in frames. TWO domains meet here: `totalFrames` is the captured FILE's
|
||||
// length, everything else counts RENDER frames from whichever comes first, note-on or the
|
||||
// capture opening. A positive start offset (legal — it trims the attack) puts note-on at
|
||||
// render frame 0 and the file's frame 0 `leadInFrames` later; a negative one does the
|
||||
// reverse, and the file opens on silence before the note. Either event frame may sit past
|
||||
// the render, which then closes before the note ever fires — a legal empty capture.
|
||||
struct BakePlan {
|
||||
std::int64_t totalFrames = 0; // frames in the captured file
|
||||
std::int64_t leadInFrames = 0; // rendered ahead of the file's frame 0, then discarded
|
||||
std::int64_t noteOnFrame = 0; // both in render frames
|
||||
std::int64_t noteOffFrame = 0;
|
||||
// The capture's root: rendering AT root is what makes the root survivable, which is
|
||||
// why the root parameter is the one processing control a bake does not reset.
|
||||
int note = 60;
|
||||
int velocity = 100;
|
||||
int sampleRate = 0;
|
||||
|
||||
std::int64_t renderFrames() const { return leadInFrames + totalFrames; }
|
||||
};
|
||||
|
||||
// Why a window was refused. The two are different user problems and read as different
|
||||
// sentences: an empty window is a programming mistake, a window past the ceiling is a legal
|
||||
// dialed sound that simply cannot be held in one pass.
|
||||
enum class BakeRefusal : std::uint8_t {
|
||||
None,
|
||||
EmptyWindow, // collapsed, a non-positive rate, or a window that rounds to no frames
|
||||
PastFrameCeiling, // representable but longer than kMaxBakeFrames
|
||||
};
|
||||
|
||||
// The one `ResolvedNote` + rate -> frames resolution. A degenerate or unholdable window is
|
||||
// refused rather than rendered; `refusal` is None iff `plan` holds one. `rootNote` and the
|
||||
// resolved velocity are clamped into MIDI range.
|
||||
struct PlannedBake {
|
||||
std::optional<BakePlan> plan;
|
||||
BakeRefusal refusal = BakeRefusal::None;
|
||||
};
|
||||
|
||||
PlannedBake planBake(const note::ResolvedNote& resolved, int sampleRate, int rootNote);
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
@@ -0,0 +1,90 @@
|
||||
// See bake_render.h.
|
||||
|
||||
#include "core/instrument/bake/bake_render.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
namespace {
|
||||
|
||||
// A fixed render block rather than the host's. A block boundary is where the engine
|
||||
// re-observes live state, and the detach below leaves it nothing to observe — so this is
|
||||
// defence in depth against a future block-boundary read, not the reason two bakes agree.
|
||||
constexpr std::int64_t kBlockFrames = 512;
|
||||
|
||||
} // namespace
|
||||
|
||||
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear) {
|
||||
BakeAudio out;
|
||||
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out;
|
||||
// Each field bounded BEFORE the sum: renderFrames() adds them, and a hand-built plan
|
||||
// (planBake already bounds both — bake_plan.cpp) could otherwise carry leadInFrames
|
||||
// near INT64_MAX and signed-overflow inside the guard meant to catch exactly that.
|
||||
if (plan.leadInFrames < 0 || plan.leadInFrames > kMaxBakeFrames ||
|
||||
plan.totalFrames > kMaxBakeFrames) {
|
||||
return out;
|
||||
}
|
||||
if (plan.renderFrames() > kMaxBakeFrames) return out;
|
||||
|
||||
// The live block is the audio thread's moving target; a render that observed it would
|
||||
// depend on what the user happened to be dragging. The dialed values are already in
|
||||
// this SampleData's own play params, which is what the bake is meant to print.
|
||||
sample.live = nullptr;
|
||||
|
||||
const int channels = sample.channelCount();
|
||||
const auto rendered = static_cast<std::size_t>(plan.renderFrames());
|
||||
std::vector<AudioSample> left(rendered, 0.f);
|
||||
std::vector<AudioSample> right(channels == 2 ? rendered : 0u, 0.f);
|
||||
|
||||
// Pre-size the Preserve shifters here, off any audio thread, exactly as the processor
|
||||
// does for its live engine — a cold shifter would smear the onset.
|
||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||
kPreserveWindowMs * static_cast<double>(plan.sampleRate) / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, preserveWindow,
|
||||
VoiceMode::Poly, MonoTrigger::Retrigger, /*takeoverDeclick=*/false);
|
||||
|
||||
for (std::int64_t pos = 0; pos < plan.renderFrames();) {
|
||||
if (pos == plan.noteOnFrame) engine.noteOn(plan.note, plan.velocity);
|
||||
// Trigger ignores note-off by design; in Gate this is the release the programmed
|
||||
// note length bounds.
|
||||
if (pos == plan.noteOffFrame) engine.noteOff(plan.note);
|
||||
|
||||
// Stop the block at the next event frame so both land sample-accurately. An event
|
||||
// past the window (a capture that closes before the note) never bounds anything.
|
||||
std::int64_t limit = plan.renderFrames();
|
||||
if (pos < plan.noteOnFrame) limit = (std::min)(limit, plan.noteOnFrame);
|
||||
else if (pos < plan.noteOffFrame) limit = (std::min)(limit, plan.noteOffFrame);
|
||||
const std::int64_t chunk = (std::min)(limit - pos, kBlockFrames);
|
||||
if (chunk <= 0) break; // unreachable while limit > pos; a guard, not a path
|
||||
|
||||
const auto at = static_cast<std::size_t>(pos);
|
||||
const auto n = static_cast<std::size_t>(chunk);
|
||||
if (channels == 2) engine.render(left.data() + at, right.data() + at, n);
|
||||
else engine.render(left.data() + at, n);
|
||||
pos += chunk;
|
||||
}
|
||||
|
||||
out.channelCount = channels;
|
||||
out.sampleRate = plan.sampleRate;
|
||||
const auto lead = static_cast<std::size_t>(plan.leadInFrames);
|
||||
const auto total = static_cast<std::size_t>(plan.totalFrames);
|
||||
out.interleaved.resize(total * static_cast<std::size_t>(channels));
|
||||
// Printed here rather than left for the processor: resetAfterBake hands master gain
|
||||
// back to unity, so a render that only summed voices would return every iteration
|
||||
// shifted by 1/gain, and a gain dialed to silence would come back at full level. A flat
|
||||
// multiply, not the processor's per-sample ramp: the gain is constant for the whole
|
||||
// render, which is exactly what that ramp exists to converge to.
|
||||
const auto gain = static_cast<AudioSample>(masterGainLinear);
|
||||
for (std::size_t f = 0; f < total; ++f) {
|
||||
out.interleaved[f * channels] = left[lead + f] * gain;
|
||||
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f] * gain;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
@@ -0,0 +1,39 @@
|
||||
// bake_render — the offline pass: one programmed note through a voice engine built for
|
||||
// this render alone, summed into an interleaved buffer.
|
||||
//
|
||||
// Never touches a live engine and never runs on the audio thread. Takes its SampleData BY
|
||||
// VALUE for the reason this directory's CLAUDE.md records.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/bake/bake_plan.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
struct BakeAudio {
|
||||
std::vector<AudioSample> interleaved; // [f0c0, f0c1, f1c0, …]
|
||||
int channelCount = 0; // 0 = nothing rendered
|
||||
int sampleRate = 0;
|
||||
|
||||
std::int64_t frameCount() const {
|
||||
return channelCount > 0
|
||||
? static_cast<std::int64_t>(interleaved.size()) / channelCount
|
||||
: 0;
|
||||
}
|
||||
bool empty() const { return frameCount() == 0; }
|
||||
};
|
||||
|
||||
// Renders `plan` through `sample`'s own voice path, scaled by `masterGainLinear` — the
|
||||
// post-mixer gain the processor applies after the engine; see the gain multiply in
|
||||
// bake_render.cpp for why it is printed here rather than left to the processor. The result
|
||||
// is the plan's captured window: the lead-in frames are rendered and dropped. An unplayable
|
||||
// sample yields an empty result.
|
||||
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear);
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
@@ -0,0 +1,29 @@
|
||||
// See bake_reset.h.
|
||||
|
||||
#include "core/instrument/bake/bake_reset.h"
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
BakeReset resetAfterBake(const map::InstrumentParams& dialed) {
|
||||
BakeReset out;
|
||||
// The root is what the note was rendered at, so it is exactly what the new capture
|
||||
// plays back at unity — resetting it would detune every following iteration.
|
||||
out.params.rootOverride = dialed.rootOverride;
|
||||
// How far pitch tracks the keyboard is a fact about the mapping; a single rendered
|
||||
// note carries no trace of it.
|
||||
out.params.keyTrack = dialed.keyTrack;
|
||||
// There is no key-range parameter to carry (core/instrument/CLAUDE.md: no key-range
|
||||
// concept) — if one is ever added it belongs on this list, not in the defaults.
|
||||
|
||||
// Play mode is on neither ratified list, so it is classified here, and the acceptance
|
||||
// criteria decide it: the bake's product is a finished one-shot carrying its own
|
||||
// attack, span and release. Trigger plays that back verbatim — note-off ignored, the
|
||||
// default AHD flat at unity over the whole span. Gate would re-gate it: the default
|
||||
// release would cut the printed tail at note-off, and every further iteration would cut
|
||||
// the previous one's again. "Neutral" for this control means "adds no processing",
|
||||
// which is Trigger, not the value struct's own Gate default.
|
||||
out.params.play.playMode = PlayMode::Trigger;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
@@ -0,0 +1,25 @@
|
||||
// bake_reset — hand the instrument back neutral after a bake: the dialed processing now
|
||||
// lives in the recaptured audio, so the controls that produced it return to their defaults.
|
||||
//
|
||||
// The rule, ratified by Daniel: a control resets iff its effect is in the printed audio; a
|
||||
// MAPPING fact survives, because it describes how the file is played, not how it was made.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/map/sample_map.h" // InstrumentParams
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
// The two surfaces a bake resets. Master gain lives on the processor rather than in the
|
||||
// parameter set; it is answered here because renderBake prints it into the file (see
|
||||
// bake_render.cpp's gain multiply) rather than left to the shell.
|
||||
struct BakeReset {
|
||||
map::InstrumentParams params;
|
||||
double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain
|
||||
};
|
||||
|
||||
// Everything defaults; the survivors are copied back explicitly (this directory's CLAUDE.md
|
||||
// owns why that direction, and which classifications are ratified).
|
||||
BakeReset resetAfterBake(const map::InstrumentParams& dialed);
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
@@ -16,12 +16,23 @@ reasampler_test(master_gain LINK master_gain)
|
||||
# Declared before sampler_core because the voice now runs one per sounding note.
|
||||
add_subdirectory(filter)
|
||||
|
||||
# The sustain loop's validity + crossfade geometry, shared by the voice and the editor's
|
||||
# marker layer. After filter: it links play_params' dependency set, which includes it.
|
||||
add_subdirectory(loop)
|
||||
|
||||
# The live-parameter block: the value layer plus its publication, deliberately linking no
|
||||
# engine — the block is a plain value the voice observes, not a thing the engine owns.
|
||||
reasampler_pure_library(live_params
|
||||
SOURCES live_params.cpp
|
||||
LINK PUBLIC peaks velocity_curve filter curve_law)
|
||||
reasampler_test(live_params LINK live_params)
|
||||
|
||||
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
|
||||
# block render). The per-sample render half stays inline in voice.h precisely so this TU
|
||||
# boundary costs the hot path nothing.
|
||||
reasampler_pure_library(sampler_core
|
||||
SOURCES voice.cpp voice_engine.cpp
|
||||
LINK PUBLIC peaks pitch_shift velocity_curve filter)
|
||||
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span)
|
||||
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
|
||||
# or REAPER type reaching the core would fail to compile or link here.
|
||||
reasampler_test(sampler_core LINK sampler_core)
|
||||
@@ -29,3 +40,11 @@ reasampler_test(sampler_core LINK sampler_core)
|
||||
# The filter's own seams are covered by the four targets in filter/; this one covers the
|
||||
# integration: pipeline order, per-voice independence, and the off-by-default bit-identity.
|
||||
reasampler_test(sampler_filter LINK sampler_core)
|
||||
|
||||
# Live delivery is the third integration seam over the same engine: what a published block
|
||||
# does to a voice that is already sounding, and what it must leave alone.
|
||||
reasampler_test(live_delivery LINK sampler_core)
|
||||
|
||||
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
|
||||
# both mode shapes share, and the Trigger tail's terminal behaviour.
|
||||
reasampler_test(staged_envelopes LINK sampler_core)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, Trigger fade
|
||||
// shape, AD pitch offset). Concrete classes, every body defined in-class: these are called
|
||||
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, sustain-less AHD,
|
||||
// AHD pitch offset). Concrete classes, every body defined in-class: these are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, so they must inline into the render loop.
|
||||
// NEVER give them a common base or a virtual tick() — that vtable lands on the hottest
|
||||
// inner loop in the program (root CLAUDE.md, structural heuristic 3).
|
||||
@@ -9,9 +9,97 @@
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/util/curve_law.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using util::curveMap;
|
||||
|
||||
// The A/H/D split of a bounded span, in frames.
|
||||
struct AhdSpan {
|
||||
std::int64_t attack = 0;
|
||||
std::int64_t hold = 0;
|
||||
std::int64_t decay = 0;
|
||||
std::int64_t total = 0; // attack + hold + decay; <= span by construction
|
||||
};
|
||||
|
||||
// THE span split, shared by every sustain-less envelope so they cannot disagree about where a
|
||||
// stage boundary is. Attack takes at most the whole span and Decay at most what Attack left,
|
||||
// so `remaining` is non-negative without a clamp; Hold then takes its FRACTION of that
|
||||
// remainder, which is why total <= span holds for every (attack, decay, fraction) triple and
|
||||
// there is no sum to clamp. The two per-stage mins reproduce the retired Trigger fade clamp
|
||||
// exactly (head first, tail into what is left), so a migrated instance keeps its stage lengths.
|
||||
inline AhdSpan fitAhd(std::int64_t spanFrames, const AhdParams& p) {
|
||||
AhdSpan out;
|
||||
const std::int64_t span = spanFrames > 0 ? spanFrames : 0;
|
||||
std::int64_t a = p.attackFrames > 0 ? p.attackFrames : 0;
|
||||
if (a > span) a = span;
|
||||
std::int64_t d = p.decayFrames > 0 ? p.decayFrames : 0;
|
||||
if (d > span - a) d = span - a;
|
||||
const std::int64_t remaining = span - a - d;
|
||||
double frac = p.holdFraction;
|
||||
if (!(frac > 0.0)) frac = 0.0; // also catches NaN
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
out.attack = a;
|
||||
out.decay = d;
|
||||
out.hold = static_cast<std::int64_t>(static_cast<double>(remaining) * frac + 0.5);
|
||||
out.total = out.attack + out.hold + out.decay;
|
||||
return out;
|
||||
}
|
||||
|
||||
// The AHD's normalized level at `offset` frames into the span: 0 -> 1 over attack, flat 1
|
||||
// across hold, 1 -> 0 over decay, 0 outside. Pure over the offset so both the ticking pitch
|
||||
// envelope and the positional amplitude one read one shape.
|
||||
inline double ahdLevelAt(double offset, const AhdSpan& s, double attackCurve,
|
||||
double decayCurve) {
|
||||
if (offset < 0.0 || offset >= static_cast<double>(s.total)) return 0.0;
|
||||
if (s.attack > 0 && offset < static_cast<double>(s.attack)) {
|
||||
return curveMap(offset / static_cast<double>(s.attack), attackCurve);
|
||||
}
|
||||
const double decayStart = static_cast<double>(s.total - s.decay);
|
||||
if (s.decay > 0 && offset >= decayStart) {
|
||||
double t = (offset - decayStart) / static_cast<double>(s.decay);
|
||||
if (t > 1.0) t = 1.0;
|
||||
return 1.0 - curveMap(t, decayCurve);
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Absorbs a step a live parameter move would otherwise put straight into an evaluator's
|
||||
// output, as an offset that decays to EXACTLY zero — so the at-rest path carries no residue
|
||||
// and the smoother's own branch stays predictably false. Per-frame decay rather than a
|
||||
// wall-clock one, matching the voice's takeover declick; the floor is far below both domains
|
||||
// this is used in (amplitude, and semitones of pitch offset).
|
||||
class StepSmoother {
|
||||
public:
|
||||
// `step` is (level before the change - level after it): adding it back reproduces the
|
||||
// pre-change output exactly on the first frame.
|
||||
void absorb(double step) {
|
||||
offset_ += step;
|
||||
active_ = (offset_ > kFloor || offset_ < -kFloor);
|
||||
if (!active_) offset_ = 0.0;
|
||||
}
|
||||
void clear() { offset_ = 0.0; active_ = false; }
|
||||
bool active() const { return active_; }
|
||||
|
||||
// This frame's offset; decays afterwards, latching inactive at the floor.
|
||||
double advance() {
|
||||
const double out = offset_;
|
||||
offset_ *= kDecay;
|
||||
if (offset_ < kFloor && offset_ > -kFloor) {
|
||||
offset_ = 0.0;
|
||||
active_ = false;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr double kDecay = 0.95;
|
||||
static constexpr double kFloor = 1e-5;
|
||||
double offset_ = 0.0;
|
||||
bool active_ = false;
|
||||
};
|
||||
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
@@ -24,6 +112,11 @@ namespace reasampler {
|
||||
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
|
||||
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
|
||||
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
|
||||
//
|
||||
// stagePos_ is the elapsed position within the current stage. It is a double rather than a
|
||||
// frame count only so applyLive can hold a fractional normalized position; every value it
|
||||
// takes on the un-edited path is integral, so the segment math is bit-identical to the
|
||||
// integer-counter engine.
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
@@ -34,25 +127,124 @@ public:
|
||||
void noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Gate off: enter Release from the CURRENT level — release-before-sustain releases from
|
||||
// the partial attack/decay level, not from sustainLevel.
|
||||
// the partial attack/decay level, not from sustainLevel. A running smoother deliberately
|
||||
// survives: it is mid-glide, and cutting it here would reintroduce the step it absorbed.
|
||||
void noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
}
|
||||
|
||||
// Live parameter delivery to a fresh voice — one that has NOT yet rendered a frame, whose
|
||||
// latched copy may predate the newest edit. It takes the params outright: there is no
|
||||
// phase to hold and nothing to be continuous with. applyLive cannot serve here in either
|
||||
// direction — with a stale duration of 0 its phi rule reads stagePos_ == 0 as a COMPLETED
|
||||
// stage and discards the newly-dialled time, and with a stale duration > 0 against a new 0
|
||||
// it absorbs a full-scale step into a voice that has emitted nothing, fading the onset in.
|
||||
void snapLive(const AdsrParams& params) {
|
||||
params_ = params;
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Live parameter delivery to a SOUNDING voice. The mid-stage rule is HOLD NORMALIZED
|
||||
// STAGE POSITION: phi = elapsed/duration is kept fixed across the change, so this frame's
|
||||
// level is unchanged by construction and the remainder of the stage takes its share of the
|
||||
// newly-dialled duration. The rule is expressed over normalized position, never over
|
||||
// output level, so a per-segment curve exponent composes with it as a pure map of phi.
|
||||
//
|
||||
// Two cases phi cannot cover, both absorbed by the smoother rather than allowed to step:
|
||||
// a sustain level moved while the voice holds it (sustain is a level, not a timed stage),
|
||||
// and a stage duration dialled to exactly zero mid-stage (the stage ceases to exist and
|
||||
// completes at its terminal level).
|
||||
void applyLive(const AdsrParams& params) {
|
||||
const double before = stageLevel(params_);
|
||||
const double oldDuration = stageDuration(params_);
|
||||
const double newDuration = stageDuration(params);
|
||||
if (newDuration > 0.0) {
|
||||
stagePos_ = (oldDuration > 0.0) ? stagePos_ * (newDuration / oldDuration)
|
||||
: newDuration; // a collapsed stage was complete
|
||||
}
|
||||
params_ = params;
|
||||
const double after = stageLevel(params_);
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
// Once Release completes the envelope latches Finished and returns 0.0 forever (until
|
||||
// the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
|
||||
// output frame.
|
||||
//
|
||||
// While the smoother runs the return may sit OUTSIDE [0,1] by the offset it is decaying
|
||||
// (bounded by the step it absorbed). finished() ignores that residue, so a Release that
|
||||
// completes with an offset still decaying is hard-cut when the voice frees — the audible
|
||||
// remainder of a step the smoother had already taken most of.
|
||||
double tick() {
|
||||
const double out = tickStage();
|
||||
return smooth_.active() ? out + smooth_.advance() : out;
|
||||
}
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
// The level tick() would emit right now under `params` without advancing anything. THE one
|
||||
// home for every segment's shape: tickStage owns only the advance and the stage
|
||||
// transitions and reads its output from here, so a per-segment curve added later lands in
|
||||
// one place and the smoother can never size a step against a different curve than the
|
||||
// output takes.
|
||||
double stageLevel(const AdsrParams& params) const {
|
||||
switch (stage_) {
|
||||
case Stage::Attack: {
|
||||
if (params.attackFrames <= 0) return 1.0;
|
||||
double l = stagePos_ / static_cast<double>(params.attackFrames);
|
||||
if (l > 1.0) l = 1.0;
|
||||
return curveMap(l, params.attackCurve);
|
||||
}
|
||||
case Stage::Hold:
|
||||
// A zero-length hold falls straight through to Decay on the next tick, whose
|
||||
// level at position 0 is 1.0 — unless decay is zero too, which lands on sustain.
|
||||
if (params.holdFrames > 0) return 1.0;
|
||||
return (params.decayFrames <= 0) ? params.sustainLevel : 1.0;
|
||||
case Stage::Decay: {
|
||||
if (params.decayFrames <= 0) return params.sustainLevel;
|
||||
double t = stagePos_ / static_cast<double>(params.decayFrames);
|
||||
if (t > 1.0) t = 1.0; // never bites on the un-edited path (transitions at >=)
|
||||
return 1.0 + (params.sustainLevel - 1.0) * curveMap(t, params.decayCurve);
|
||||
}
|
||||
case Stage::Sustain:
|
||||
return params.sustainLevel;
|
||||
case Stage::Release: {
|
||||
if (params.releaseFrames <= 0) return 0.0;
|
||||
double t = stagePos_ / static_cast<double>(params.releaseFrames);
|
||||
if (t > 1.0) t = 1.0;
|
||||
return releaseFrom_ * (1.0 - curveMap(t, params.releaseCurve));
|
||||
}
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// The current stage's dialled duration under `params`; 0 for the untimed stages.
|
||||
double stageDuration(const AdsrParams& params) const {
|
||||
switch (stage_) {
|
||||
case Stage::Attack: return static_cast<double>(params.attackFrames);
|
||||
case Stage::Hold: return static_cast<double>(params.holdFrames);
|
||||
case Stage::Decay: return static_cast<double>(params.decayFrames);
|
||||
case Stage::Release: return static_cast<double>(params.releaseFrames);
|
||||
default: return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
double tickStage() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
@@ -60,19 +252,13 @@ public:
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
if (params_.attackFrames <= 0) {
|
||||
level_ = 1.0;
|
||||
} else {
|
||||
level_ = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.attackFrames);
|
||||
if (level_ > 1.0) level_ = 1.0;
|
||||
}
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.attackFrames)) {
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
@@ -84,43 +270,38 @@ public:
|
||||
// extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general
|
||||
// recursion).
|
||||
return tick();
|
||||
// recursion). Re-enters the STAGE evaluator, never tick(), so a running
|
||||
// smoother is applied exactly once per frame.
|
||||
return tickStage();
|
||||
}
|
||||
level_ = 1.0;
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.holdFrames)) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
if (params_.decayFrames <= 0) {
|
||||
level_ = params_.sustainLevel;
|
||||
} else {
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.decayFrames);
|
||||
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||
}
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.decayFrames)) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
stagePos_ = 0.0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = params_.sustainLevel;
|
||||
level_ = stageLevel(params_);
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
@@ -129,13 +310,10 @@ public:
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.releaseFrames);
|
||||
level_ = releaseFrom_ * (1.0 - t);
|
||||
if (level_ < 0.0) level_ = 0.0;
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.releaseFrames)) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
@@ -145,122 +323,163 @@ public:
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
std::int64_t framesInStage_ = 0;
|
||||
double stagePos_ = 0.0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||
// offset into the span (not output frames): under Varispeed a transposed voice consumes
|
||||
// source faster than output, so driving the fades off the read position keeps fade-in/out
|
||||
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
|
||||
// time-boxed by the play length and note-off-immune.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
|
||||
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
|
||||
// length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve) {
|
||||
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||
curve_ = curve;
|
||||
finished_ = (playLength_ <= 0);
|
||||
|
||||
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end).
|
||||
// A negative fade is treated as 0. When both fades together exceed the play length,
|
||||
// shrink the fade-out first (the head fade-in is the more perceptually load-bearing
|
||||
// onset ramp), then the fade-in — never letting either go negative or the sum exceed
|
||||
// the span.
|
||||
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||
if (fi > playLength_) fi = playLength_;
|
||||
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||
fadeIn_ = fi;
|
||||
fadeOut_ = fo;
|
||||
// The sustain-less AHD amplitude shape, evaluated at a source-frame offset into the span
|
||||
// rather than by ticking output frames: under Varispeed a transposed voice consumes source
|
||||
// faster than output, so driving the shape off the read position keeps every stage boundary on
|
||||
// the same source frames regardless of engine. Note-off-immune and time-boxed by the span.
|
||||
//
|
||||
// Positional means there is no phase counter to hold across a live edit, so the phi rule
|
||||
// AdsrEnvelope applies has nothing to act on here; a live reshape is a level step, absorbed by
|
||||
// the same bounded smoother.
|
||||
class AhdEnvelope {
|
||||
public:
|
||||
// `spanFrames` is the bound the stages are fitted into — (playEnd - startFrame) for the
|
||||
// Trigger amp and filter envelopes. A zero/negative span finishes immediately.
|
||||
void configure(std::int64_t spanFrames, const AhdParams& params) {
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
fit(params, /*latchFinished=*/false); // a fresh note starts from a clean read
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
|
||||
// or past playLength. Pure over the offset so it composes with either pitch engine's
|
||||
// read rate.
|
||||
// Peer of AdsrEnvelope::snapLive: a voice that has rendered nothing takes the new shape
|
||||
// outright, with no step to absorb.
|
||||
void snapLive(const AhdParams& params) {
|
||||
fit(params, /*latchFinished=*/false);
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Live delivery to a sounding voice at its current `sourceOffset`. See the class note for
|
||||
// why this smooths rather than holding a normalized position.
|
||||
void applyLive(double sourceOffset, const AhdParams& params) {
|
||||
const double before = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
// LATCHED: a voice already read past its fitted total must never resurge because a
|
||||
// later live move reopened the total. Reachable on any active() voice, including one
|
||||
// ringing out past its own end (voice.h) where tickAmplitude() still runs.
|
||||
fit(params, /*latchFinished=*/true);
|
||||
const double after = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
|
||||
// Amplitude at `sourceOffset` = (readPos - startFrame). Latches finished() at or past the
|
||||
// fitted total, which is what frees the voice.
|
||||
double amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset < 0.0 ||
|
||||
sourceOffset >= static_cast<double>(playLength_)) {
|
||||
// At/past the play length the one-shot is done; the voice also frees on
|
||||
// readPos >= playEnd.
|
||||
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||
if (finished_ || sourceOffset >= static_cast<double>(fit_.total)) {
|
||||
if (sourceOffset >= static_cast<double>(fit_.total)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over
|
||||
// [playLength_-fadeOut_, playLength_). Unity between. The two ramps never overlap
|
||||
// (configure clamps fadeIn_ + fadeOut_ <= length). The offset is fractional (the read
|
||||
// head is fractional under repitch), so the ramps are smooth rather than stepped.
|
||||
double amp = 1.0;
|
||||
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_);
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
const double out = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
return smooth_.active() ? out + smooth_.advance() : out;
|
||||
}
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
const AhdSpan& stages() const { return fit_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
// `latchFinished`: once true, a re-fit can only ever KEEP finished_ true, never clear it —
|
||||
// see applyLive above for why. configure()/snapLive() pass false: those are a fresh read
|
||||
// (new note or a not-yet-rendered voice), which must compute finished_ from scratch.
|
||||
void fit(const AhdParams& p, bool latchFinished) {
|
||||
fit_ = fitAhd(span_, p);
|
||||
attackCurve_ = p.attackCurve;
|
||||
decayCurve_ = p.decayCurve;
|
||||
const bool empty = (fit_.total <= 0);
|
||||
finished_ = latchFinished ? (finished_ || empty) : empty;
|
||||
}
|
||||
|
||||
std::int64_t span_ = 0;
|
||||
AhdSpan fit_;
|
||||
double attackCurve_ = util::kCurveNeutral;
|
||||
double decayCurve_ = util::kCurveNeutral;
|
||||
bool finished_ = true;
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past
|
||||
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past the AHD),
|
||||
// advancing one frame. The voice converts it to a ratio multiply (Varispeed) or a shift-amount
|
||||
// add (Preserve). Unlike the amplitude AHD this owns its own position counter — pitch-envelope
|
||||
// time is wall-clock output frames — so the mid-stage rule applies in full.
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
// `spanFrames` is the playable span the Hold fraction is taken against.
|
||||
void configure(std::int64_t spanFrames, const PitchEnvParams& params) {
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
params_ = params;
|
||||
fit_ = fitAhd(span_, params.shape);
|
||||
pos_ = 0.0;
|
||||
}
|
||||
void noteOn() { pos_ = 0.0; smooth_.clear(); }
|
||||
|
||||
// Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice
|
||||
// that has rendered nothing takes the new shape and depth outright. `enabled` is a discrete
|
||||
// toggle travelling by reload, so the caller's copy of it is deliberately ignored.
|
||||
void snapLive(const PitchEnvParams& params) {
|
||||
params_.peakSemitones = params.peakSemitones;
|
||||
params_.shape = params.shape;
|
||||
fit_ = fitAhd(span_, params_.shape);
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized
|
||||
// position within whichever leg the envelope is in, and absorb the depth step (peak is a
|
||||
// level, not a duration).
|
||||
void applyLive(const PitchEnvParams& params) {
|
||||
const double before = offsetAt();
|
||||
const AhdSpan next = fitAhd(span_, params.shape);
|
||||
pos_ = holdPhase(fit_, next);
|
||||
params_.peakSemitones = params.peakSemitones;
|
||||
params_.shape = params.shape;
|
||||
fit_ = next;
|
||||
const double after = offsetAt();
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
|
||||
double tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
|
||||
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||
const double peak = params_.peakSemitones;
|
||||
|
||||
double offset;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||
} else if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||
offset = peak * (1.0 - t);
|
||||
} else {
|
||||
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
++pos_;
|
||||
return offset;
|
||||
const double offset = offsetAt();
|
||||
pos_ += 1.0;
|
||||
return smooth_.active() ? offset + smooth_.advance() : offset;
|
||||
}
|
||||
|
||||
private:
|
||||
// The semitone offset at the current position — the shared evaluator for both tick() and
|
||||
// applyLive's before/after comparison.
|
||||
double offsetAt() const {
|
||||
if (!params_.enabled) return 0.0;
|
||||
return params_.peakSemitones *
|
||||
ahdLevelAt(pos_, fit_, params_.shape.attackCurve, params_.shape.decayCurve);
|
||||
}
|
||||
|
||||
// The position under `next` holding the normalized position within whichever leg pos_ is
|
||||
// in. A leg dialled to zero completes: the position lands on that leg's new end.
|
||||
double holdPhase(const AhdSpan& old, const AhdSpan& next) const {
|
||||
const double oa = static_cast<double>(old.attack);
|
||||
const double oh = static_cast<double>(old.hold);
|
||||
const double od = static_cast<double>(old.decay);
|
||||
const double na = static_cast<double>(next.attack);
|
||||
const double nh = static_cast<double>(next.hold);
|
||||
const double nd = static_cast<double>(next.decay);
|
||||
if (pos_ < oa) return (na > 0.0) ? pos_ * (na / oa) : na;
|
||||
if (pos_ < oa + oh) return (nh > 0.0) ? na + (pos_ - oa) * (nh / oh) : na + nh;
|
||||
if (pos_ < oa + oh + od) {
|
||||
return (nd > 0.0) ? na + nh + (pos_ - oa - oh) * (nd / od) : na + nh + nd;
|
||||
}
|
||||
return na + nh + nd; // already past the envelope: stay past it under the new shape
|
||||
}
|
||||
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
std::int64_t span_ = 0;
|
||||
AhdSpan fit_;
|
||||
double pos_ = 0.0;
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -234,6 +234,13 @@ topology.
|
||||
genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz — real coefficient
|
||||
narrowing, not measurement-window noise, and comfortably inside the test's 0.4% tolerance
|
||||
either way. Do not reintroduce a direct-form kernel.
|
||||
- **A coefficient jump here produces no isolated output spike, measured.** Preserving state
|
||||
across `prepare()` is strong enough that even an instantaneous cutoff/Q/morph jump leaves the
|
||||
boundary frame inside the signal's own frame-to-frame range — a single-frame-spike metric
|
||||
cannot detect one. What the caller's per-frame glide prevents is therefore the *parameter*
|
||||
arriving as a step (and the zipper of repeated steps at control rate), not a click at the
|
||||
jump itself. A test claiming to prove the glide must measure how fast the output diverges,
|
||||
not how far one frame moves; `live_delivery_tests` does.
|
||||
- **`prepare()` deliberately does not clear state** — a live parameter move must glide, not
|
||||
click. Call `reset()` at note-on. **Exception: the non-positive-rate bypass path.** There,
|
||||
`a1=1, a2=a3=0` makes both state updates the exact identity and `bypassMix()` never reads
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// live_params.cpp — the fold from the parameter set to the live block, and the ramp-step law.
|
||||
// See live_params.h for the publication contract.
|
||||
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
LiveValues foldLive(const PlayParams& params) {
|
||||
LiveValues v;
|
||||
v.filterSettings = params.filter.settings;
|
||||
v.filterModAmount = params.filter.modAmount;
|
||||
v.filterVelAmount = params.filter.velAmount;
|
||||
v.filterKeyTrack = params.filter.keyTrack;
|
||||
v.filterEnv = params.filter.env;
|
||||
v.filterAhd = params.filter.trigEnv;
|
||||
v.adsr = params.adsr;
|
||||
v.ampAhd = params.trigAhd;
|
||||
v.pitchEnv = params.pitchEnv;
|
||||
return v;
|
||||
}
|
||||
|
||||
double liveRampStep(double sampleRate) {
|
||||
if (!(sampleRate > 0.0)) return 0.0; // also catches NaN
|
||||
return 1.0 / (kLiveRampSeconds * sampleRate);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,139 @@
|
||||
#pragma once
|
||||
// live_params.h — the live playback-parameter block: the plain value bundle the audio thread
|
||||
// observes once per BLOCK, the single-writer seqlock that publishes it without a lock or a
|
||||
// torn read, the ONE fold from PlayParams that keeps the two representations in step, and the
|
||||
// per-frame ramp that keeps a block-rate step inaudible. Ownership belongs above every
|
||||
// instrument snapshot (see SampleData::live).
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// Full-scale glide time for a live control move (wall-clock seconds), so every smoothed
|
||||
// control in the program settles on the one time base the post-mixer gain ramp already uses.
|
||||
inline constexpr double kLiveRampSeconds = 0.020;
|
||||
|
||||
// Every continuously-valued playback control, in the SAME domains the engine latches at
|
||||
// note-on (normalized control positions, envelope times already resolved to frames). What is
|
||||
// deliberately absent is as load-bearing as what is present: velocity and everything derived
|
||||
// from it, the note number and its pitch ratio, and the decoded PCM are facts about the note
|
||||
// event, not controls, and stay latched at note-on. The discrete toggles (play mode, pitch
|
||||
// engine, filter enable/law, pitch-envelope enable, channel mode) travel by reload instead.
|
||||
//
|
||||
// morphLaw rides inside filterSettings only because it is cheaper to carry the whole struct to
|
||||
// the filter's prepare() than to splice it back; it changes only across a reload, which
|
||||
// republishes this block, so the two can never disagree.
|
||||
// Each envelope carries BOTH mode shapes: which one a voice applies is fixed at note-on by
|
||||
// its play mode, so publishing both keeps the block one shape regardless of mode. The pitch
|
||||
// envelope's `enabled` rides along inside its params only because the struct is carried whole;
|
||||
// PitchEnvelope ignores it, since a toggle travels by reload.
|
||||
struct LiveValues {
|
||||
filter::FilterSettings filterSettings{};
|
||||
double filterModAmount = 0.0;
|
||||
// The DEPTH scaling the velocity curve, not the curve's value: the note's velocity is
|
||||
// latched, its depth is a control, exactly as filterKeyTrack is a control over a latched
|
||||
// note number.
|
||||
double filterVelAmount = 0.0;
|
||||
double filterKeyTrack = 0.0;
|
||||
AdsrParams filterEnv{};
|
||||
AhdParams filterAhd{};
|
||||
AdsrParams adsr{};
|
||||
AhdParams ampAhd{};
|
||||
PitchEnvParams pitchEnv{};
|
||||
};
|
||||
|
||||
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
|
||||
static_assert(std::is_trivially_copyable_v<LiveValues>,
|
||||
"the live block is copied under a seqlock — it must stay a plain value");
|
||||
|
||||
// The ONE derivation of the live block from the parameter set. Every publisher goes through
|
||||
// here so there is a single site to keep in step with PlayParams.
|
||||
LiveValues foldLive(const PlayParams& params);
|
||||
|
||||
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
|
||||
// and an even generation; the reader copies the block and re-checks the generation, retrying
|
||||
// a bounded number of times, so it can never act on a half-applied edit. Wait-free for the
|
||||
// reader: after the retry budget it reports "nothing new" and the caller keeps its last good
|
||||
// snapshot rather than spinning on the audio thread.
|
||||
//
|
||||
// SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the
|
||||
// generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the
|
||||
// block, and a reader then accepts a torn block as coherent. Every publisher must serialize.
|
||||
//
|
||||
// The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock:
|
||||
// the fences give correct ordering, but the concurrent read of a non-atomic object is a data
|
||||
// race under the C++ object model, so TSan/UBSan will report it. That report is expected, not
|
||||
// a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain
|
||||
// value the audio thread can copy in one shot.
|
||||
//
|
||||
// The writer interface deliberately assumes NO particular thread beyond single-writer, so a
|
||||
// host's own parameter-change queue (delivered on the audio thread with sample offsets) can
|
||||
// drive it later without a redesign.
|
||||
class LiveParams {
|
||||
public:
|
||||
// A generation of 0 means "never published"; the first publish lands on 2.
|
||||
void publish(const LiveValues& values) {
|
||||
const std::uint32_t gen = seq_.load(std::memory_order_relaxed);
|
||||
seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
values_ = values;
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
// Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and
|
||||
// stall every reader until the NEXT publish — a silent mode, unlike a loud one.
|
||||
const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2;
|
||||
seq_.store(next, std::memory_order_release); // even: complete and coherent
|
||||
}
|
||||
|
||||
// Copies the block into `out` and returns the generation actually observed, or 0 when
|
||||
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
|
||||
// a torn copy and MUST be discarded — compare the return against 0 before using it).
|
||||
std::uint32_t read(LiveValues& out, int maxAttempts = 4) const {
|
||||
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
|
||||
const std::uint32_t before = seq_.load(std::memory_order_acquire);
|
||||
if (before == 0) return 0; // never published
|
||||
if ((before & 1u) != 0u) continue; // writer mid-update
|
||||
std::atomic_thread_fence(std::memory_order_acquire);
|
||||
out = values_;
|
||||
std::atomic_thread_fence(std::memory_order_acquire);
|
||||
if (seq_.load(std::memory_order_relaxed) == before) return before;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private:
|
||||
std::atomic<std::uint32_t> seq_{0};
|
||||
LiveValues values_{};
|
||||
};
|
||||
|
||||
// Linear per-frame glide with EXACT termination: once the target is within one step the value
|
||||
// becomes the target itself. An asymptotic smoother would leave the value forever a hair off,
|
||||
// pinning the filter's exact-equality cutoff skip on the always-re-solve path; this returns to
|
||||
// the skip path the moment the move completes. A non-positive step snaps (no rate known yet).
|
||||
struct ValueRamp {
|
||||
double value = 0.0;
|
||||
double target = 0.0;
|
||||
double step = 0.0;
|
||||
|
||||
bool moving() const { return value != target; }
|
||||
void set(double v) { value = v; target = v; }
|
||||
void aim(double t) { target = t; }
|
||||
|
||||
// Advances one frame; returns whether the value actually moved.
|
||||
bool tick() {
|
||||
if (value == target) return false;
|
||||
const double delta = target - value;
|
||||
if (step <= 0.0 || (delta <= step && delta >= -step)) value = target;
|
||||
else value += (delta > 0.0) ? step : -step;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Per-frame ramp step for a control whose full travel is 1.0, at `sampleRate`. A non-positive
|
||||
// rate yields 0 — the ramp then snaps rather than inventing a rate.
|
||||
double liveRampStep(double sampleRate);
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,69 @@
|
||||
# src/core/instrument/engine/loop — the sustain loop's span rule
|
||||
|
||||
## Scope
|
||||
|
||||
One pure module, `loop_span`: the ONE fold that turns a stored `SampleLoop` + crossfade
|
||||
length into the `ResolvedLoop` the voice's read path wraps on, plus the editor's default
|
||||
handle placement for a capture with no loop. No REAPER, no VST3, no allocation, no I/O.
|
||||
Everything lives in `reasampler::instrument::engine::loop`.
|
||||
|
||||
`resolveLoop` is cold — called once per note-on and by the editor. `crossfadeWeight` is
|
||||
header-inline because it is evaluated per voice per sample.
|
||||
|
||||
## Invariants
|
||||
|
||||
### The crossfade is PRE-SEAM, and that is what makes it one tap
|
||||
|
||||
The fade runs over the last `crossfade` frames before `end`, blending the material running
|
||||
into `end` toward the material running into `start`. The incoming material is the same read
|
||||
head one loop length earlier, so the second tap is `pos - length` — no second position to
|
||||
advance, no second wrap rule, no state. At `end` the incoming tap has arrived at `start`,
|
||||
which is exactly where the wrap puts the head, so the seam is continuous by construction
|
||||
rather than by a fade that merely hides it.
|
||||
|
||||
The consequence is a hard clamp: **`crossfade <= start`**. A loop starting at frame 0 has no
|
||||
material ahead of it and therefore gets no crossfade, whatever the user dialled — honest
|
||||
rather than silently reading before the buffer.
|
||||
|
||||
### The weight is normalized over `crossfade - 1`, so the last rendered frame lands AT 1
|
||||
|
||||
`crossfadeWeight` normalizes by `1/(crossfade-1)`, not `1/crossfade`: `d` at the last rendered
|
||||
frame (`end - 1`) is always exactly `crossfade - 1` — the ceiling's own threshold — for every
|
||||
REAL crossfade length (`crossfade >= 2`), so that frame is the incoming tap outright rather than
|
||||
a blend approaching it. The seam across the wrap is therefore the material's OWN one-frame step
|
||||
(`sampler_core_tests`, `testSeamStepMatchesTheNaturalStepForAnyCrossfade`), not a residual that
|
||||
merely shrinks with a longer fade — the earlier `1/crossfade` normalization left `(xf-1)/xf` at
|
||||
that frame, which is what the `crossfade - 1` fix closes. `crossfade == 1` degenerates to the
|
||||
hard seam instead: its one frame sits at `d == 0`, caught by the `d <= 0` floor before the
|
||||
multiply/ceiling ever runs, so `fadeInv` is guarded to 0 rather than dividing by zero.
|
||||
|
||||
### Linear, not equal-power
|
||||
|
||||
The two taps are one loop length apart in the same material and are usually well correlated,
|
||||
where an equal-power pair bulges. Linear also costs a subtract and a multiply on a path that
|
||||
forbids a transcendental. The filter's morph crossfade is equal-power for a reason specific
|
||||
to quadrature taps (`engine/filter/CLAUDE.md`) — that reasoning does not transfer here.
|
||||
|
||||
**Known exception:** a full-mix or stem bounce (in this tool's own stated material scope) is
|
||||
not quasi-periodic, so its two taps are effectively decorrelated — a linear pair then dips
|
||||
~3 dB at the fade midpoint the way it wouldn't on a correlated tonal/one-shot loop. Accepted
|
||||
rather than fixed: an equal-power pair would cost the transcendental this path forbids, and the
|
||||
dip is a fade-region loudness wobble, not the seam click the crossfade exists to kill.
|
||||
|
||||
### An invalid span is refused, never repaired
|
||||
|
||||
An inverted span, a span reaching past the PCM, a negative start, a Trigger voice: all yield
|
||||
`active == false`, so the note plays straight through. Repairing a corrupt span into a
|
||||
plausible one would make a wrong loop audible and a bug invisible; the crossfade length is
|
||||
the one field that IS clamped rather than refused, because its bound is a property of the
|
||||
loop it sits in rather than of the user's intent.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`crossfadeWeight` assumes its argument is already wrapped** into `[start, end)`. The
|
||||
ceiling at 1.0 is a belt against an unwrapped caller, not permission to skip the wrap —
|
||||
an unwrapped position would otherwise extrapolate past the incoming tap.
|
||||
- **`defaultLoopBounds` is a UI default living in an engine module** on purpose: the span the
|
||||
user is offered and the span `resolveLoop` will accept have to be one definition, and the
|
||||
previous frame-0 default put the loop-start handle underneath the start marker where no
|
||||
grab could reach it.
|
||||
@@ -0,0 +1,7 @@
|
||||
# The loop's validity rule and crossfade geometry. Links peaks/filter/velocity_curve/curve_law
|
||||
# (play_params' own dependency set) — deliberately not the voice engine: the resolve is a fold
|
||||
# over plain values, which is what lets the editor share it without pulling the engine in.
|
||||
reasampler_pure_library(loop_span
|
||||
SOURCES loop_span.cpp
|
||||
LINK PUBLIC peaks filter velocity_curve curve_law)
|
||||
reasampler_test(loop_span LINK loop_span)
|
||||
@@ -0,0 +1,39 @@
|
||||
// loop_span.cpp — see loop_span.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/engine/loop/loop_span.h"
|
||||
|
||||
namespace reasampler::instrument::engine::loop {
|
||||
|
||||
ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
|
||||
std::int64_t frameCount, bool gateMode) {
|
||||
ResolvedLoop out;
|
||||
// Trigger is a one-shot by definition, so the loop is not merely unused there — it is
|
||||
// absent, and the read path branches on this one flag.
|
||||
if (!gateMode || !loop.hasLoop) return out;
|
||||
if (loop.start < 0 || loop.end <= loop.start || loop.end > frameCount) return out;
|
||||
|
||||
out.active = true;
|
||||
out.start = loop.start;
|
||||
out.end = loop.end;
|
||||
out.length = loop.end - loop.start;
|
||||
|
||||
// The incoming tap reads at `pos - length`, i.e. over [start - crossfade, start) — so the
|
||||
// fade cannot outrun the material ahead of the loop, nor the loop itself.
|
||||
std::int64_t xf = crossfadeFrames;
|
||||
if (xf < 0) xf = 0;
|
||||
const std::int64_t bound = maxCrossfade(out.start, out.length);
|
||||
if (xf > bound) xf = bound;
|
||||
out.crossfade = xf;
|
||||
out.fadeBegin = static_cast<double>(out.end - xf);
|
||||
// xf == 1 has no fractional region to normalize (crossfadeWeight's d <= 0 check already
|
||||
// catches its only frame) — guard rather than divide by zero.
|
||||
out.fadeInv = xf > 1 ? 1.0 / static_cast<double>(xf - 1) : 0.0;
|
||||
return out;
|
||||
}
|
||||
|
||||
LoopBounds defaultLoopBounds(std::int64_t frameCount) {
|
||||
if (frameCount <= 0) return LoopBounds{};
|
||||
return LoopBounds{frameCount - frameCount / 4, frameCount};
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::loop
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
// loop_span.h — the sustain loop's ONE validity/clamp rule plus its pre-seam crossfade
|
||||
// geometry. The resolve is cold (note-on, editor); crossfadeWeight is header-inline because
|
||||
// it sits on the per-voice-per-sample read.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // AudioSample
|
||||
#include "core/instrument/engine/play_params.h" // SampleLoop
|
||||
|
||||
namespace reasampler::instrument::engine::loop {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// The crossfade's own bound: it cannot outrun the material ahead of the loop (`start` source
|
||||
// frames precede it) nor the loop's own length (the incoming tap is one loop length behind the
|
||||
// head). Shared by resolveLoop's clamp and the editor's drag clamp so the two cannot diverge.
|
||||
inline std::int64_t maxCrossfade(std::int64_t start, std::int64_t length) {
|
||||
return start < length ? start : length;
|
||||
}
|
||||
|
||||
// A sustain loop folded against one capture: validity, geometry, and the clamped crossfade.
|
||||
// `active == false` leaves every other field zero, so a caller can wrap on the flag alone.
|
||||
//
|
||||
// The fade is PRE-SEAM and its incoming tap is the same read head one loop length earlier —
|
||||
// `pos - length`, no second position to advance. See CLAUDE.md for why that shape, and for
|
||||
// the `crossfade <= start` bound it forces.
|
||||
struct ResolvedLoop {
|
||||
bool active = false;
|
||||
std::int64_t start = 0;
|
||||
std::int64_t end = 0; // half-open
|
||||
std::int64_t length = 0; // end - start
|
||||
std::int64_t crossfade = 0; // source frames; 0 = hard seam
|
||||
double fadeBegin = 0.0; // end - crossfade
|
||||
double fadeInv = 0.0; // 1 / (crossfade - 1); 0 when crossfade <= 1
|
||||
};
|
||||
|
||||
// Folds a stored loop + crossfade against the decoded sample. Refuses anything the read path
|
||||
// could not honour — a non-Gate mode, an unset loop, an inverted or empty span, a span
|
||||
// reaching outside the PCM — by returning an inactive result rather than a repaired one, so a
|
||||
// corrupt span silently plays through instead of reading out of bounds.
|
||||
ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
|
||||
std::int64_t frameCount, bool gateMode);
|
||||
|
||||
// Weight of the INCOMING (pre-loop-start) tap at source position `pos`: 0 before the fade
|
||||
// region, reaching exactly 1 at the LAST rendered frame (`end - 1`), not merely approaching it —
|
||||
// normalizing over `crossfade - 1` rather than `crossfade` is what buys that: d at `end - 1` is
|
||||
// always exactly `crossfade - 1`, the ceiling's own threshold, for any REAL crossfade
|
||||
// (`crossfade >= 2`). That last frame is therefore the incoming tap outright, which is exactly
|
||||
// the value the wrap hands over, so the step across the seam is the material's own natural step
|
||||
// — not a residual that merely shrinks with a longer fade. `crossfade == 1` degenerates to the
|
||||
// hard seam instead: its one frame sits at `d == 0`, caught by the `d <= 0` floor below before
|
||||
// the ceiling ever runs. `pos` must already be wrapped into [start, end) — the ceiling is a belt
|
||||
// for a caller that has not wrapped yet, not a licence to skip it.
|
||||
inline double crossfadeWeight(const ResolvedLoop& lp, double pos) {
|
||||
if (lp.crossfade <= 0) return 0.0;
|
||||
const double d = pos - lp.fadeBegin;
|
||||
if (d <= 0.0) return 0.0;
|
||||
return d < static_cast<double>(lp.crossfade - 1) ? d * lp.fadeInv : 1.0;
|
||||
}
|
||||
|
||||
// Linear-interpolated read at a plain (non-wrapping) fractional source position. The crossfade
|
||||
// tap sits one loop length behind the head, i.e. BEFORE the loop start, so it never needs the
|
||||
// wrap partner the main read uses.
|
||||
inline double lerpSource(const std::vector<AudioSample>& pcm, std::int64_t frameCount,
|
||||
double pos) {
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(pos);
|
||||
const std::int64_t i1 = i0 + 1;
|
||||
const double frac = pos - static_cast<double>(i0);
|
||||
const double a = (i0 >= 0 && i0 < frameCount) ? static_cast<double>(pcm[i0]) : 0.0;
|
||||
const double b = (i1 >= 0 && i1 < frameCount) ? static_cast<double>(pcm[i1]) : 0.0;
|
||||
return a + (b - a) * frac;
|
||||
}
|
||||
|
||||
// One integer source frame with the loop crossfade already blended in — the Preserve path's
|
||||
// read, and the start()-time ring prime's. `pos` must be a valid index; `xw` is crossfadeWeight
|
||||
// at that position (0 blends nothing).
|
||||
inline AudioSample crossfadedSource(const std::vector<AudioSample>& pcm, const ResolvedLoop& lp,
|
||||
std::int64_t pos, double xw) {
|
||||
const double v = static_cast<double>(pcm[static_cast<std::size_t>(pos)]);
|
||||
if (xw <= 0.0) return static_cast<AudioSample>(v);
|
||||
const std::int64_t tap = pos - lp.length;
|
||||
if (tap < 0) return static_cast<AudioSample>(v);
|
||||
const double in = static_cast<double>(pcm[static_cast<std::size_t>(tap)]);
|
||||
return static_cast<AudioSample>(v + xw * (in - v));
|
||||
}
|
||||
|
||||
// Where the editor parks the loop handles for a capture that has none — the last quarter,
|
||||
// where a sustain loop actually goes. Lives here, next to the validity rule, so the span a
|
||||
// user is offered and the span the engine will accept are one definition.
|
||||
struct LoopBounds {
|
||||
std::int64_t start = 0;
|
||||
std::int64_t end = 0;
|
||||
};
|
||||
LoopBounds defaultLoopBounds(std::int64_t frameCount);
|
||||
|
||||
} // namespace reasampler::instrument::engine::loop
|
||||
@@ -11,9 +11,12 @@
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/filter/voice_filter.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
#include "core/util/curve_law.h" // the per-segment curve exponent domain + its neutral
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace instrument::engine { class LiveParams; } // live_params.h; SampleData holds a pointer
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::VelocityCurve;
|
||||
|
||||
@@ -37,34 +40,58 @@ inline constexpr int kMaxVoiceCount = 32;
|
||||
inline constexpr int kDefaultVoiceCount = 16;
|
||||
|
||||
// AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
|
||||
// The three curve exponents shape the SLOPED stages only — Hold and Sustain are flat by
|
||||
// definition and carry none. `curve_law.h` owns what an exponent means.
|
||||
struct AdsrParams {
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t holdFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double sustainLevel = 1.0; // 0..1
|
||||
std::int64_t releaseFrames = 0;
|
||||
double attackCurve = util::kCurveNeutral;
|
||||
double decayCurve = util::kCurveNeutral;
|
||||
double releaseCurve = util::kCurveNeutral;
|
||||
};
|
||||
|
||||
// Attack -> Hold -> Decay over a bounded span: the shape every SUSTAIN-LESS envelope takes
|
||||
// (the Trigger amp, the Trigger filter envelope, the pitch envelope). Hold is a FRACTION of
|
||||
// the span left after attack and decay, never a time of its own — fitAhd (envelopes.h) owns
|
||||
// why a fraction, not a time.
|
||||
struct AhdParams {
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double holdFraction = 1.0; // 0..1 of the span remaining after attack + decay
|
||||
double attackCurve = util::kCurveNeutral;
|
||||
double decayCurve = util::kCurveNeutral;
|
||||
};
|
||||
|
||||
// Which shape an envelope takes: the STAGED knobs, or a free-drawn SPLINE contour. Both states
|
||||
// are stored side by side and neither converts into the other, so a mode flip is reversible and
|
||||
// lossless — the inactive one is saved but inert, edited only by switching back to it.
|
||||
enum class EnvMode { Staged, Spline };
|
||||
|
||||
// The free-drawn alternative to a staged envelope: a contour over NORMALIZED sample time,
|
||||
// covering the full sample length. Normalized is what makes it length-independent — a
|
||||
// different-length capture replays the same shape proportionally, with no stored seconds to
|
||||
// rescale. The default is the smooth y = 1 - x downward slope.
|
||||
struct SplineEnv {
|
||||
EnvMode mode = EnvMode::Staged;
|
||||
VelocityCurve contour = VelocityCurve::rampDown();
|
||||
};
|
||||
|
||||
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
|
||||
// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
|
||||
// honor the start point. Default Gate so an instrument with no params set plays as before.
|
||||
// note-off-immune, no sustain loop, plays a % of sample length shaped by the AHD. Both honor
|
||||
// the start point. Default Gate so an instrument with no params set plays as before.
|
||||
enum class PlayMode { Gate, Trigger };
|
||||
|
||||
// Playback covers [startFrame, playEnd), playEnd = startFrame +
|
||||
// round(lengthFraction*(frames - startFrame)). Amplitude ramps 0->1 over fadeInFrames at the
|
||||
// head and 1->0 over fadeOutFrames anchored to playEnd; unity between. Fades clamp so
|
||||
// fadeIn + fadeOut <= play length. The voice frees when the head reaches playEnd.
|
||||
// Trigger's play SPAN: [startFrame, playEnd), playEnd = startFrame +
|
||||
// round(lengthFraction*(frames - startFrame)). The voice frees when the head reaches playEnd.
|
||||
// The amplitude SHAPE over that span is PlayParams::trigAhd — the fade-in/fade-out pair that
|
||||
// used to live here is retired; do not reintroduce a second amplitude mechanism.
|
||||
struct TriggerParams {
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span to play
|
||||
std::int64_t fadeInFrames = 0;
|
||||
std::int64_t fadeOutFrames = 0;
|
||||
};
|
||||
|
||||
// EQUAL_POWER (constant-power sin/cos) is the click-free default for Trigger's ramps; LINEAR is
|
||||
// the build-time residual.
|
||||
enum class FadeCurve { EqualPower, Linear };
|
||||
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
||||
|
||||
// VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long).
|
||||
// PRESERVE: the read advances at the source rate while a PitchShifter transposes the output
|
||||
// (an octave up keeps its length).
|
||||
@@ -81,14 +108,15 @@ inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
// of real source, so output frame 0 is source frame 0 regardless of window size.
|
||||
inline constexpr double kPreserveWindowMs = 50.0;
|
||||
|
||||
// AD pitch-modulation envelope, off by default (enabled=false -> offset always 0 -> bit-identical
|
||||
// to the un-modulated engine). At note-on the offset rises to peakSemitones over attackFrames,
|
||||
// then falls to 0 over decayFrames; a zero attack gives a pure percussive pitch drop.
|
||||
// AHD pitch-modulation envelope, off by default (enabled=false -> offset always 0 ->
|
||||
// bit-identical to the un-modulated engine). At note-on the offset rises to peakSemitones over
|
||||
// attack, holds there, then falls to 0 over decay; a zero attack gives a pure percussive pitch
|
||||
// drop. The hold fraction defaults to 0 so an instance predating the stage plays exactly as its
|
||||
// attack-decay predecessor did.
|
||||
struct PitchEnvParams {
|
||||
bool enabled = false;
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
AhdParams shape{0, 0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
|
||||
};
|
||||
|
||||
// Per-voice resonant filter, off by default (enabled=false -> the render path skips it
|
||||
@@ -102,30 +130,96 @@ struct FilterParams {
|
||||
bool enabled = false;
|
||||
instrument::engine::filter::FilterSettings settings;
|
||||
double modAmount = 0.0; // bipolar [-1,+1], envelope -> cutoff
|
||||
double velAmount = 0.0; // bipolar [-1,+1], velocity -> cutoff
|
||||
double velAmount = 0.0; // bipolar [-1,+1], scales velocityCurve's output
|
||||
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
|
||||
AdsrParams env; // the same staged AHDSR the amp runs; frames
|
||||
// Shapes velocity before velAmount scales it. Linear rather than the amp's flat() default
|
||||
// because a flat curve under a depth control would make every velocity the same offset;
|
||||
// the no-op at rest is velAmount == 0, not the curve. NOTE: this default only governs a
|
||||
// FRESH FilterParams — the shared codec's corrupt/truncated-point-list repair
|
||||
// (VelocityCurve::fromPoints, used for both this curve and the amp's) still degrades to
|
||||
// flat() regardless, since that repair has no curve-specific fallback.
|
||||
VelocityCurve velocityCurve = VelocityCurve::linear();
|
||||
// The filter envelope takes the same shape the amp does under the active play mode:
|
||||
// AHDSR in Gate, AHD in Trigger. Both are stored, so a mode flip cannot lose either
|
||||
// mode's dialled values (see core/instrument/CLAUDE.md).
|
||||
AdsrParams env; // Gate: the same staged AHDSR the amp runs; frames
|
||||
AhdParams trigEnv; // Trigger: the same staged AHD the amp runs; frames
|
||||
// Velocity -> cutoff, in the normalized cutoff domain. The contribution is
|
||||
// velAmount * velocityCurve.eval(velocity): the BIPOLAR curve carries the shape (and its
|
||||
// own sign), the depth knob scales it, and BOTH apply. The curve is flat at 0 by default,
|
||||
// so no depth setting produces velocity modulation until a curve is drawn.
|
||||
VelocityCurve velocityCurve = VelocityCurve::zero();
|
||||
};
|
||||
|
||||
// Full-scale of the velocity->pitch curve: y = +/-1 transposes by this many semitones. Shared
|
||||
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
|
||||
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
|
||||
|
||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||
// Varispeed, pitch envelope off, filter off) — core regression tests rely on this; the
|
||||
// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine.
|
||||
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
|
||||
// on this; the Preserve product default is layered on at (de)serialization, see
|
||||
// kDefaultPitchEngine.
|
||||
struct PlayParams {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrParams adsr;
|
||||
TriggerParams trigger;
|
||||
AdsrParams adsr; // Gate amp
|
||||
TriggerParams trigger; // Trigger play span
|
||||
AhdParams trigAhd; // Trigger amp
|
||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||
PitchEnvParams pitchEnv;
|
||||
// Velocity -> pitch offset, scaled by kVelocityPitchRangeSemitones. Bipolar and flat at 0
|
||||
// by default, so it transposes nothing until a curve is drawn. Folded into the voice's
|
||||
// baseRatio_ at note-on — it is fixed for the note's lifetime, so it costs no per-frame work.
|
||||
VelocityCurve pitchVelocityCurve = VelocityCurve::zero();
|
||||
FilterParams filter;
|
||||
// The three drawn contours: the alternative to adsr/trigAhd, to pitchEnv.shape, and to
|
||||
// filter.env/trigEnv respectively. They sit HERE rather than inside the three envelope
|
||||
// structs because those are copied whole into the live block, which must stay trivially
|
||||
// copyable (live_params.h) — and a contour is not a live control anyway: like the velocity
|
||||
// curves it travels by reload.
|
||||
SplineEnv ampSpline;
|
||||
SplineEnv pitchSpline;
|
||||
SplineEnv filterSpline;
|
||||
};
|
||||
|
||||
// Whether ANY of the three envelopes is drawn rather than staged. Templated over the two
|
||||
// parameter representations (frames and the editor's seconds mirror) because both spell the
|
||||
// three fields identically and the rule must not be written twice — compile-time dispatch,
|
||||
// no runtime cost, off every hot path.
|
||||
//
|
||||
// THE consequence, and its one home: a spline contour is a pure time function over the full
|
||||
// sample length, which IS the Trigger/one-shot playback model — so Gate is not available while
|
||||
// any spline EG is active. resolvePlay enforces it on the way to the engine; the editor's
|
||||
// play-mode toggle refuses the Gate segment so the two agree.
|
||||
//
|
||||
// The pitch/filter terms are gated on their own `enabled` flag to match Voice::start's binder
|
||||
// (voice.cpp only binds pitchSplineCur_/filterSplineCur_ when that flag is set): without this,
|
||||
// a Spline mode flip on a disabled pitch/filter envelope would cost Gate for zero modulation,
|
||||
// since the binder would never actually engage. Amp has no such flag, so it counts unconditionally.
|
||||
template <class Play>
|
||||
bool splineActive(const Play& p) {
|
||||
return p.ampSpline.mode == EnvMode::Spline ||
|
||||
(p.pitchEnv.enabled && p.pitchSpline.mode == EnvMode::Spline) ||
|
||||
(p.filter.enabled && p.filterSpline.mode == EnvMode::Spline);
|
||||
}
|
||||
|
||||
// The mode the engine will actually run, and the one home of splineActive's rule (see its doc
|
||||
// above). Header-inline and allocation-free: play_params.h sits on the per-voice-per-sample
|
||||
// include path. Every caller — resolvePlay (sample_map.cpp), the editor's applyControl, and
|
||||
// the editor's read-only predicates — routes through one of these two, so none of them can
|
||||
// drift into a second reading of the fields.
|
||||
template <class Play>
|
||||
PlayMode effectivePlayMode(const Play& p) {
|
||||
return splineActive(p) ? PlayMode::Trigger : p.playMode;
|
||||
}
|
||||
|
||||
template <class Play>
|
||||
void enforceGateUnavailableWhileDrawn(Play& p) {
|
||||
p.playMode = effectivePlayMode(p);
|
||||
}
|
||||
|
||||
// The %-length the voice ACTUALLY plays. Same rule family, same reason it is templated: a drawn
|
||||
// contour is a pure time function over the full sample length, so any active spline EG folds
|
||||
// the fraction to 1.0 while the stored knob goes inert — but the stored value survives, so a
|
||||
// pre-spline setting is still there to be read. Every consumer of the Trigger span must fold it
|
||||
// here or it silently plays/draws/bakes a fraction of the take.
|
||||
template <class Play>
|
||||
double effectiveLengthFraction(const Play& p) {
|
||||
return splineActive(p) ? 1.0 : p.trigger.lengthFraction;
|
||||
}
|
||||
|
||||
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
||||
// marker — a held note past the sample end goes silent rather than looping a zero span.
|
||||
struct SampleLoop {
|
||||
@@ -151,6 +245,12 @@ struct SampleData {
|
||||
int rootNote = 60;
|
||||
SampleLoop loop;
|
||||
|
||||
// Pre-seam crossfade at the loop reset, in SOURCE frames — a source-timeline quantity
|
||||
// like the loop points it belongs to, so no rate resolves it. 0 (the default) is the
|
||||
// hard seam every instance predating the field plays. engine/loop/loop_span.h owns what
|
||||
// the fade actually does and how it clamps.
|
||||
std::int64_t loopCrossfadeFrames = 0;
|
||||
|
||||
// Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior.
|
||||
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
|
||||
std::int64_t startFrame = 0;
|
||||
@@ -166,6 +266,14 @@ struct SampleData {
|
||||
|
||||
PlayParams play;
|
||||
|
||||
// The live-parameter block a sounding voice tracks, or null for the bare latched engine
|
||||
// (the default — with no block attached the core is byte-identical to the pre-live one).
|
||||
// NON-OWNING and deliberately not per-snapshot: the shell owns ONE block that outlives
|
||||
// every instrument snapshot, so a voice still ringing out of the drain slot follows the
|
||||
// same knob as a live one. That is the desired behaviour — it is the note the user is
|
||||
// hearing. Do not "fix" it by moving ownership into the snapshot.
|
||||
const instrument::engine::LiveParams* live = nullptr;
|
||||
|
||||
// A framesR of a different length than frames is treated as absent — a malformed pair
|
||||
// never half-plays.
|
||||
int channelCount() const {
|
||||
|
||||
@@ -11,159 +11,168 @@ namespace reasampler::instrument::engine {
|
||||
namespace {
|
||||
|
||||
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
|
||||
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); }
|
||||
double clampValue(double a, CurveDomain d) { return std::clamp(a, curveYMin(d), kCurveYMax); }
|
||||
|
||||
// X spans the width for [0,127]; Y spans (height-1) rows for amp [0,1] with amp 1 at the TOP
|
||||
// (pixel y increases downward, so this axis is inverted relative to amp).
|
||||
// X spans the width for [0,127]; Y spans (height-1) rows for the domain's range with its max at
|
||||
// the TOP (pixel y increases downward, so this axis is inverted relative to the value).
|
||||
double velPerPixel(const VelocityCurve::Box& box) {
|
||||
const int w = std::max(0, box.width);
|
||||
if (w <= 0) return 0.0;
|
||||
return (kVelMax - kVelMin) / static_cast<double>(w);
|
||||
}
|
||||
double ampPerPixel(const VelocityCurve::Box& box) {
|
||||
double valuePerPixel(const VelocityCurve::Box& box, CurveDomain d) {
|
||||
const int h = std::max(0, box.height);
|
||||
if (h <= 1) return 0.0;
|
||||
return (kAmpMax - kAmpMin) / static_cast<double>(h - 1);
|
||||
return (kCurveYMax - curveYMin(d)) / static_cast<double>(h - 1);
|
||||
}
|
||||
// The integer maps are these rounded — ONE mapping, so a sub-pixel trace and an integer hit-test
|
||||
// cannot drift. Rounding the OFFSET (not the absolute coordinate) keeps the int results identical
|
||||
// to what they were before the sub-pixel form existed: the offset is non-negative, so truncation
|
||||
// is floor regardless of where the box sits.
|
||||
double velToXf(const VelocityCurve::Box& box, double velocity) {
|
||||
const int w = std::max(0, box.width);
|
||||
if (w <= 0) return static_cast<double>(box.left);
|
||||
const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin);
|
||||
return static_cast<double>(box.left) + frac * static_cast<double>(w);
|
||||
}
|
||||
double valueToYf(const VelocityCurve::Box& box, double value, CurveDomain d) {
|
||||
const int h = std::max(0, box.height);
|
||||
if (h <= 1) return static_cast<double>(box.top);
|
||||
const double lo = curveYMin(d);
|
||||
const double frac = (clampValue(value, d) - lo) / (kCurveYMax - lo);
|
||||
return static_cast<double>(box.top) + (1.0 - frac) * static_cast<double>(h - 1);
|
||||
}
|
||||
int velToX(const VelocityCurve::Box& box, double velocity) {
|
||||
const int w = std::max(0, box.width);
|
||||
if (w <= 0) return box.left;
|
||||
const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin);
|
||||
return box.left + static_cast<int>(frac * static_cast<double>(w) + 0.5);
|
||||
return box.left +
|
||||
static_cast<int>(velToXf(box, velocity) - static_cast<double>(box.left) + 0.5);
|
||||
}
|
||||
int ampToY(const VelocityCurve::Box& box, double amp) {
|
||||
const int h = std::max(0, box.height);
|
||||
if (h <= 1) return box.top;
|
||||
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
|
||||
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
|
||||
int valueToY(const VelocityCurve::Box& box, double value, CurveDomain d) {
|
||||
return box.top +
|
||||
static_cast<int>(valueToYf(box, value, d) - static_cast<double>(box.top) + 0.5);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VelocityCurve VelocityCurve::flat() {
|
||||
VelocityCurve c;
|
||||
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}};
|
||||
const double n = curveNeutral(CurveDomain::Unipolar);
|
||||
c.points_ = {{kVelMin, n}, {kVelMax, n}};
|
||||
return c;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::linear() {
|
||||
VelocityCurve c;
|
||||
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}};
|
||||
c.points_ = {{kVelMin, 0.0}, {kVelMax, kCurveYMax}};
|
||||
return c;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
||||
VelocityCurve VelocityCurve::zero() {
|
||||
VelocityCurve c;
|
||||
c.domain_ = CurveDomain::Bipolar;
|
||||
const double n = curveNeutral(CurveDomain::Bipolar);
|
||||
c.points_ = {{kVelMin, n}, {kVelMax, n}};
|
||||
return c;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::rampDown() {
|
||||
VelocityCurve c;
|
||||
c.points_ = {{kVelMin, kCurveYMax, false}, {kVelMax, 0.0, false}};
|
||||
return c;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain) {
|
||||
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
|
||||
// duplicate-X knots).
|
||||
// Trim before the endpoint synthesis below can add up to two more, then again after, so a
|
||||
// corrupt over-long blob lands at exactly the ceiling with its two endpoints intact.
|
||||
if (pts.size() > kMaxCurvePoints) pts.resize(kMaxCurvePoints);
|
||||
for (VelocityPoint& p : pts) {
|
||||
p.velocity = clampVelocity(p.velocity);
|
||||
p.amp = clampAmp(p.amp);
|
||||
p.value = clampValue(p.value, domain);
|
||||
}
|
||||
std::stable_sort(pts.begin(), pts.end(),
|
||||
[](const VelocityPoint& a, const VelocityPoint& b) {
|
||||
return a.velocity < b.velocity;
|
||||
});
|
||||
if (pts.size() < 2) return flat();
|
||||
if (pts.size() < 2) {
|
||||
return domain == CurveDomain::Bipolar ? zero() : flat();
|
||||
}
|
||||
if (pts.front().velocity > kVelMin) {
|
||||
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
|
||||
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().value, pts.front().hard});
|
||||
} else {
|
||||
pts.front().velocity = kVelMin;
|
||||
}
|
||||
if (pts.back().velocity < kVelMax) {
|
||||
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
|
||||
pts.push_back(VelocityPoint{kVelMax, pts.back().value, pts.back().hard});
|
||||
} else {
|
||||
pts.back().velocity = kVelMax;
|
||||
}
|
||||
if (pts.size() > kMaxCurvePoints) {
|
||||
// Drop the interior points nearest the end, never an endpoint.
|
||||
pts.erase(pts.begin() + static_cast<std::ptrdiff_t>(kMaxCurvePoints) - 1,
|
||||
pts.end() - 1);
|
||||
}
|
||||
VelocityCurve c;
|
||||
c.domain_ = domain;
|
||||
c.points_ = std::move(pts);
|
||||
return c;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
|
||||
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
|
||||
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
|
||||
// makes the spline reproduce a straight line to ~1e-15 for linear()-style input.
|
||||
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
|
||||
if (dPrev * dNext <= 0.0) return 0.0;
|
||||
const double w1 = 2.0 * spanNext + spanPrev;
|
||||
const double w2 = spanNext + 2.0 * spanPrev;
|
||||
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
double VelocityCurve::eval(double velocity) const {
|
||||
if (points_.empty()) return kAmpMax;
|
||||
if (points_.size() == 1) return clampAmp(points_[0].amp);
|
||||
if (points_.empty()) return curveNeutral(domain_);
|
||||
if (points_.size() == 1) return clampValue(points_[0].value, domain_);
|
||||
const double v = clampVelocity(velocity);
|
||||
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
|
||||
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
|
||||
if (v <= points_.front().velocity) return clampValue(points_.front().value, domain_);
|
||||
if (v >= points_.back().velocity) return clampValue(points_.back().value, domain_);
|
||||
// Linear walk: this overload is the COLD one (a note-on, a paint column). The per-sample
|
||||
// reader is SplineCursor, which shares the same tangent + Hermite functions.
|
||||
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
|
||||
const VelocityPoint& a = points_[i];
|
||||
const VelocityPoint& b = points_[i + 1];
|
||||
if (v >= a.velocity && v <= b.velocity) {
|
||||
const double span = b.velocity - a.velocity;
|
||||
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
|
||||
if (span <= 0.0) return clampAmp(b.amp);
|
||||
|
||||
// Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.amp, b.amp]
|
||||
// between the two knots (no overshoot), reproducing a straight line for collinear input.
|
||||
const double d = (b.amp - a.amp) / span;
|
||||
|
||||
double mA = d;
|
||||
if (i > 0) {
|
||||
const VelocityPoint& prev = points_[i - 1];
|
||||
const double spanPrev = a.velocity - prev.velocity;
|
||||
if (spanPrev > 0.0) {
|
||||
const double dPrev = (a.amp - prev.amp) / spanPrev;
|
||||
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
|
||||
} else {
|
||||
mA = 0.0;
|
||||
if (span <= 0.0) return clampValue(b.value, domain_);
|
||||
const double d = (b.value - a.value) / span;
|
||||
const SegmentTangents m = segmentTangents(points_.data(), points_.size(), i, d, span);
|
||||
const double y = hermiteAt(a.value, b.value, span, m.mA, m.mB,
|
||||
(v - a.velocity) / span);
|
||||
return clampValue(y, domain_);
|
||||
}
|
||||
}
|
||||
double mB = d;
|
||||
if (i + 2 < points_.size()) {
|
||||
const VelocityPoint& next = points_[i + 2];
|
||||
const double spanNext = next.velocity - b.velocity;
|
||||
if (spanNext > 0.0) {
|
||||
const double dNext = (next.amp - b.amp) / spanNext;
|
||||
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
|
||||
} else {
|
||||
mB = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
const double t = (v - a.velocity) / span;
|
||||
const double t2 = t * t;
|
||||
const double t3 = t2 * t;
|
||||
const double h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
|
||||
const double h10 = t3 - 2.0 * t2 + t;
|
||||
const double h01 = -2.0 * t3 + 3.0 * t2;
|
||||
const double h11 = t3 - t2;
|
||||
const double y = h00 * a.amp + h10 * span * mA + h01 * b.amp + h11 * span * mB;
|
||||
return clampAmp(y);
|
||||
}
|
||||
}
|
||||
return clampAmp(points_.back().amp); // unreachable (v is between the endpoints)
|
||||
return clampValue(points_.back().value, domain_); // unreachable (v is between the endpoints)
|
||||
}
|
||||
|
||||
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
|
||||
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
|
||||
int VelocityCurve::addPoint(double velocity, double value) {
|
||||
// At the ceiling the add is REFUSED outright rather than trading a point away — the existing
|
||||
// contour must come through an over-add bit-identical.
|
||||
if (points_.size() >= kMaxCurvePoints) return -1;
|
||||
const VelocityPoint p{clampVelocity(velocity), clampValue(value, domain_), false};
|
||||
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
|
||||
std::size_t i = 0;
|
||||
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
|
||||
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
|
||||
return i;
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
|
||||
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double amp) {
|
||||
bool VelocityCurve::toggleHard(std::size_t index) {
|
||||
if (index >= points_.size()) return false;
|
||||
points_[index].hard = !points_[index].hard;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VelocityCurve::setHard(std::size_t index, bool hard) {
|
||||
if (index >= points_.size()) return false;
|
||||
points_[index].hard = hard;
|
||||
return true;
|
||||
}
|
||||
|
||||
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double value) {
|
||||
if (index >= points_.size()) return VelocityPoint{}; // no-op (out of range)
|
||||
const bool isFirst = (index == 0);
|
||||
const bool isLast = (index + 1 == points_.size());
|
||||
|
||||
double newAmp = clampAmp(amp);
|
||||
double newValue = clampValue(value, domain_);
|
||||
double newVel;
|
||||
if (isFirst) {
|
||||
newVel = kVelMin;
|
||||
@@ -174,7 +183,7 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
|
||||
const double hi = points_[index + 1].velocity;
|
||||
newVel = std::clamp(clampVelocity(velocity), lo, hi);
|
||||
}
|
||||
points_[index] = VelocityPoint{newVel, newAmp};
|
||||
points_[index] = VelocityPoint{newVel, newValue, points_[index].hard};
|
||||
return points_[index];
|
||||
}
|
||||
|
||||
@@ -185,12 +194,18 @@ bool VelocityCurve::deletePoint(std::size_t index) {
|
||||
return true;
|
||||
}
|
||||
|
||||
VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const VelocityPoint& p) {
|
||||
return CurvePixel{velToX(box, p.velocity), ampToY(box, p.amp)};
|
||||
VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box,
|
||||
const VelocityPoint& p) const {
|
||||
return CurvePixel{velToX(box, p.velocity), valueToY(box, p.value, domain_)};
|
||||
}
|
||||
|
||||
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
|
||||
// Exact inverse of velToX/ampToY (within one pixel); degenerate dims collapse the same way.
|
||||
VelocityCurve::CurvePixelF VelocityCurve::subpixelFromPoint(const Box& box,
|
||||
const VelocityPoint& p) const {
|
||||
return CurvePixelF{velToXf(box, p.velocity), valueToYf(box, p.value, domain_)};
|
||||
}
|
||||
|
||||
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) const {
|
||||
// Exact inverse of velToX/valueToY (within one pixel); degenerate dims collapse the same way.
|
||||
VelocityPoint p;
|
||||
const int w = std::max(0, box.width);
|
||||
const int h = std::max(0, box.height);
|
||||
@@ -198,17 +213,19 @@ VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
|
||||
? kVelMin
|
||||
: clampVelocity(kVelMin + static_cast<double>(x - box.left) / static_cast<double>(w) *
|
||||
(kVelMax - kVelMin));
|
||||
p.amp = (h <= 1)
|
||||
? kAmpMax
|
||||
: clampAmp(kAmpMax - static_cast<double>(y - box.top) / static_cast<double>(h - 1) *
|
||||
(kAmpMax - kAmpMin));
|
||||
const double lo = curveYMin(domain_);
|
||||
p.value = (h <= 1)
|
||||
? kCurveYMax
|
||||
: clampValue(kCurveYMax - static_cast<double>(y - box.top) / static_cast<double>(h - 1) *
|
||||
(kCurveYMax - lo),
|
||||
domain_);
|
||||
return p;
|
||||
}
|
||||
|
||||
int VelocityCurve::pointAtPixel(const Box& box, int x, int y) const {
|
||||
for (std::size_t i = 0; i < points_.size(); ++i) {
|
||||
const int px = velToX(box, points_[i].velocity);
|
||||
const int py = ampToY(box, points_[i].amp);
|
||||
const int py = valueToY(box, points_[i].value, domain_);
|
||||
if (std::abs(x - px) <= kCurveNodeGrabRadius && std::abs(y - py) <= kCurveNodeGrabRadius) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
@@ -221,22 +238,25 @@ VelocityCurve VelocityCurve::resolvePointDrag(const VelocityCurve& grabCurve, st
|
||||
VelocityCurve out = grabCurve;
|
||||
if (index >= out.points_.size()) return out; // out of range -> no motion
|
||||
const double velPerPx = velPerPixel(box);
|
||||
const double ampPerPx = ampPerPixel(box);
|
||||
if (velPerPx <= 0.0 || ampPerPx <= 0.0) return out; // degenerate box -> no motion
|
||||
const double valPerPx = valuePerPixel(box, grabCurve.domain_);
|
||||
if (velPerPx <= 0.0 || valPerPx <= 0.0) return out; // degenerate box -> no motion
|
||||
|
||||
const VelocityPoint& grab = grabCurve.points_[index];
|
||||
const double newVel = grab.velocity + static_cast<double>(dxPixels) * velPerPx;
|
||||
// Y increases downward but amp increases upward, so a downward drag (positive dy) LOWERS amp.
|
||||
const double newAmp = grab.amp - static_cast<double>(dyPixels) * ampPerPx;
|
||||
out.movePoint(index, newVel, newAmp); // applies box + neighbour-X + endpoint-pin clamps
|
||||
// Y increases downward but the value increases upward, so a downward drag (positive dy)
|
||||
// LOWERS the value.
|
||||
const double newValue = grab.value - static_cast<double>(dyPixels) * valPerPx;
|
||||
out.movePoint(index, newVel, newValue); // applies box + neighbour-X + endpoint-pin clamps
|
||||
return out;
|
||||
}
|
||||
|
||||
bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
|
||||
if (domain_ != other.domain_) return false;
|
||||
if (points_.size() != other.points_.size()) return false;
|
||||
for (std::size_t i = 0; i < points_.size(); ++i) {
|
||||
if (std::fabs(points_[i].velocity - other.points_[i].velocity) > eps) return false;
|
||||
if (std::fabs(points_[i].amp - other.points_[i].amp) > eps) return false;
|
||||
if (std::fabs(points_[i].value - other.points_[i].value) > eps) return false;
|
||||
if (points_[i].hard != other.points_[i].hard) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,71 +1,169 @@
|
||||
// velocity_curve.h — velocity->amp transfer curve. eval(velocity) is called once per note-on
|
||||
// in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit pixel Box
|
||||
// rather than a Rect: this module sits below sampler_core in the link graph and must not gain
|
||||
// a transitive dependency on editor-layout types.
|
||||
// velocity_curve.h — THE monotone spline, shared by every consumer: the three velocity
|
||||
// transfer curves (amp gain, pitch offset, filter cutoff offset), evaluated once per note-on,
|
||||
// and the spline EGs, evaluated per voice per sample through SplineCursor. Editor
|
||||
// hit-test/inverse-map take an explicit pixel Box rather than a Rect: this module sits below
|
||||
// sampler_core in the link graph and must not gain a dependency on editor-layout types.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
|
||||
inline constexpr double kVelMin = 0.0;
|
||||
inline constexpr double kVelMax = 127.0;
|
||||
inline constexpr double kAmpMin = 0.0;
|
||||
inline constexpr double kAmpMax = 1.0;
|
||||
// The curve's canonical X span. For the three velocity consumers it IS the MIDI velocity
|
||||
// domain; a spline EG maps normalized sample time onto the same span, which is what lets one
|
||||
// implementation serve both without a second X domain to keep in sync.
|
||||
inline constexpr double kCurveXMin = 0.0;
|
||||
inline constexpr double kCurveXMax = 127.0;
|
||||
inline constexpr double kVelMin = kCurveXMin; // the velocity consumers' spelling of the span
|
||||
inline constexpr double kVelMax = kCurveXMax;
|
||||
inline constexpr double kCurveYMax = 1.0;
|
||||
|
||||
// Point-count ceiling. A MUSICAL bound, not a performance one: long rhythmic phrases need the
|
||||
// resolution, and at roughly two points per articulation event 128 is about four bars of 16ths.
|
||||
// Segment lookup is logarithmic (<=7 steps at this ceiling), so there is no performance case for
|
||||
// lowering it. DO NOT LOWER.
|
||||
inline constexpr std::size_t kMaxCurvePoints = 128;
|
||||
|
||||
// The curve's Y range. UNIPOLAR [0,1] is a GAIN — the amp's domain, where the do-nothing
|
||||
// curve is flat at 1. BIPOLAR [-1,1] is a SIGNED modulation shape — the pitch and filter
|
||||
// domains, where the do-nothing curve is flat at 0 and the sign picks the direction. A
|
||||
// bipolar curve does not preclude a depth control beside it: the filter has one, and the two
|
||||
// compose multiplicatively (play_params.h).
|
||||
enum class CurveDomain { Unipolar, Bipolar };
|
||||
|
||||
constexpr double curveYMin(CurveDomain d) { return d == CurveDomain::Bipolar ? -1.0 : 0.0; }
|
||||
|
||||
// The value that changes nothing in each domain — unity gain, or zero modulation. THE one home
|
||||
// for that value: eval()'s own empty-curve fallback reads it directly, and flat()/zero() (what
|
||||
// fromPoints' sub-2-point fallback constructs) are built from it too, so a corrupt blob always
|
||||
// loses the shaping rather than inventing one, however the fallback is reached.
|
||||
constexpr double curveNeutral(CurveDomain d) { return d == CurveDomain::Bipolar ? 0.0 : 1.0; }
|
||||
|
||||
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
|
||||
// through the named constructors / addPoint rather than pushing raw points.
|
||||
struct VelocityPoint {
|
||||
double velocity = 0.0; // X, [0,127]
|
||||
double amp = 0.0; // Y, [0,1]
|
||||
double velocity = 0.0; // X, over the canonical span
|
||||
double value = 0.0; // Y, in the owning curve's domain
|
||||
// A HARD point does no smoothing on either side: it terminates the monotone sub-curve, so
|
||||
// the two adjacent segments meet at their own natural angle instead of a shared derivative.
|
||||
// Points are smooth by default; see segmentTangents for the mechanism.
|
||||
bool hard = false;
|
||||
};
|
||||
|
||||
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
|
||||
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
|
||||
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
|
||||
// makes the spline reproduce a straight line for linear()-style input.
|
||||
inline double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
|
||||
if (dPrev * dNext <= 0.0) return 0.0;
|
||||
const double w1 = 2.0 * spanNext + spanPrev;
|
||||
const double w2 = spanNext + 2.0 * spanPrev;
|
||||
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
|
||||
}
|
||||
|
||||
struct SegmentTangents {
|
||||
double mA = 0.0;
|
||||
double mB = 0.0;
|
||||
};
|
||||
|
||||
// The Hermite tangents for segment [i, i+1] of an X-ordered point array, where `d` is that
|
||||
// segment's secant slope and `span` its X width (> 0).
|
||||
//
|
||||
// A HARD point is treated exactly as the array's own end is: the tangent there is the segment's
|
||||
// own secant, so smoothing stops at it. That single rule is the whole hard-point enhancement —
|
||||
// the contour becomes one or more monotone splines joined at their natural angles, and each
|
||||
// sub-curve keeps Fritsch-Carlson's no-overshoot guarantee because m == d satisfies its bound.
|
||||
inline SegmentTangents segmentTangents(const VelocityPoint* p, std::size_t n, std::size_t i,
|
||||
double d, double span) {
|
||||
SegmentTangents t{d, d};
|
||||
if (i > 0 && !p[i].hard) {
|
||||
const double spanPrev = p[i].velocity - p[i - 1].velocity;
|
||||
t.mA = (spanPrev > 0.0)
|
||||
? fritschCarlsonTangent((p[i].value - p[i - 1].value) / spanPrev, d, spanPrev,
|
||||
span)
|
||||
: 0.0;
|
||||
}
|
||||
if (i + 2 < n && !p[i + 1].hard) {
|
||||
const double spanNext = p[i + 2].velocity - p[i + 1].velocity;
|
||||
t.mB = (spanNext > 0.0)
|
||||
? fritschCarlsonTangent(d, (p[i + 2].value - p[i + 1].value) / spanNext, span,
|
||||
spanNext)
|
||||
: 0.0;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// The cubic Hermite basis evaluated at t in [0,1] across a segment of width `span`.
|
||||
inline double hermiteAt(double y0, double y1, double span, double mA, double mB, double t) {
|
||||
const double t2 = t * t;
|
||||
const double t3 = t2 * t;
|
||||
return (2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * span * mA +
|
||||
(-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * span * mB;
|
||||
}
|
||||
|
||||
// Pick radius (px) around a node's drawn point for the editor hit-test.
|
||||
inline constexpr int kCurveNodeGrabRadius = 6;
|
||||
|
||||
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
|
||||
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
|
||||
// overshoots a segment's amp range. For collinear knots the tangents reduce to the secant slope,
|
||||
// so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints (velocity
|
||||
// 0 and 127) are load-bearing: they keep eval total over the domain and are never deletable.
|
||||
// An X-ordered list of control points spanning the canonical X span, evaluated as ONE OR MORE
|
||||
// monotone cubic Hermite splines (Fritsch-Carlson slope limiting) joined at the hard points — a
|
||||
// genuine curve, not a polyline, that provably never overshoots any segment's value range. The
|
||||
// guarantee is PER SEGMENT, so a contour is free to rise and fall. For collinear knots the
|
||||
// tangents reduce to the secant slope, so the spline reproduces linear()'s straight line to
|
||||
// within ~1e-15. The two endpoints are load-bearing: they keep eval total over the domain and
|
||||
// are never deletable.
|
||||
class VelocityCurve {
|
||||
public:
|
||||
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the default — see
|
||||
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
|
||||
// velocity_curve in the directory CLAUDE.md for why this isn't bit-identical to the
|
||||
// pre-existing linear() response.
|
||||
static VelocityCurve flat();
|
||||
static VelocityCurve linear();
|
||||
// The bipolar default: flat at 0, so velocity modulates nothing until a curve is drawn.
|
||||
static VelocityCurve zero();
|
||||
// y = 1 - x: the smooth downward slope a freshly created spline EG opens on. Two collinear
|
||||
// knots, so it is straight — and straight is smooth. NOT a change to any velocity curve's
|
||||
// own default.
|
||||
static VelocityCurve rampDown();
|
||||
|
||||
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
|
||||
// each point, stable-sorts by velocity, forces both endpoints present (synthesized if
|
||||
// missing), falls back to flat() if fewer than 2 usable points remain. A corrupt/truncated
|
||||
// blob yields a well-formed curve, never an invariant-violating one.
|
||||
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
|
||||
// each point into `domain`, stable-sorts by velocity, forces both endpoints present
|
||||
// (synthesized if missing), falls back to the domain's neutral curve if fewer than 2 usable
|
||||
// points remain. A corrupt/truncated blob yields a well-formed curve, never an
|
||||
// invariant-violating one.
|
||||
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain);
|
||||
|
||||
CurveDomain domain() const { return domain_; }
|
||||
const std::vector<VelocityPoint>& points() const { return points_; }
|
||||
std::size_t size() const { return points_.size(); }
|
||||
|
||||
// Degenerate cases (shouldn't occur post-construction): empty curve returns kAmpMax; a
|
||||
// one-point curve returns that point's amp.
|
||||
// Degenerate cases (shouldn't occur post-construction): empty curve returns the domain's
|
||||
// neutral; a one-point curve returns that point's value.
|
||||
double eval(double velocity) const;
|
||||
|
||||
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
|
||||
// subsequent move can separate them. Returns the inserted index.
|
||||
std::size_t addPoint(double velocity, double amp);
|
||||
// subsequent move can separate them. Returns the inserted index, or -1 when the curve is
|
||||
// already at kMaxCurvePoints — a refusal leaves the contour bit-identical.
|
||||
int addPoint(double velocity, double value);
|
||||
|
||||
// Flips a point between hard and smooth. Out-of-range index is a no-op returning false.
|
||||
// Permitted on the endpoints, where it changes nothing evaluable: an endpoint's outward
|
||||
// tangent is already its own secant, which is what hard means.
|
||||
bool toggleHard(std::size_t index);
|
||||
bool setHard(std::size_t index, bool hard);
|
||||
|
||||
// Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
|
||||
// endpoints are pinned in X (only their amp moves); out-of-range index is a no-op.
|
||||
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
|
||||
// endpoints are pinned in X (only their value moves); out-of-range index is a no-op.
|
||||
VelocityPoint movePoint(std::size_t index, double velocity, double value);
|
||||
|
||||
// Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op
|
||||
// returning false.
|
||||
bool deletePoint(std::size_t index);
|
||||
|
||||
// The drawn box, in pixels: X = velocity across the width, Y = amp UP the height (amp 1 at
|
||||
// top). Passed explicitly rather than a Rect — see header preamble.
|
||||
// The drawn box, in pixels: X = velocity across the width, Y = value UP the height (the
|
||||
// domain's max at top). Passed explicitly rather than a Rect — see header preamble.
|
||||
struct Box {
|
||||
int left = 0;
|
||||
int top = 0;
|
||||
@@ -82,14 +180,25 @@ public:
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
};
|
||||
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
|
||||
CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p) const;
|
||||
|
||||
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space click
|
||||
// lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 reads amp 1.
|
||||
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
|
||||
// The SAME mapping before rounding: pixelFromPoint IS this, rounded, so a sub-pixel trace and
|
||||
// an integer hit-test cannot drift. An antialiased stroke needs the fraction — quantizing y to
|
||||
// a whole pixel forces the slope into alternating 1/2-px steps, and that beat-frequency
|
||||
// staircase is what read as a dotted line where a contour steepened.
|
||||
struct CurvePixelF {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
};
|
||||
CurvePixelF subpixelFromPoint(const Box& box, const VelocityPoint& p) const;
|
||||
|
||||
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space
|
||||
// click lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1
|
||||
// reads the domain's max (the top row is what a collapsed box draws).
|
||||
VelocityPoint pointFromPixel(const Box& box, int x, int y) const;
|
||||
|
||||
// `grabCurve` is the curve as of mouse-down (shell snapshots it so the delta is absolute).
|
||||
// Maps the pixel delta to velocity/amp over the box, then applies movePoint's clamp. Zero
|
||||
// Maps the pixel delta to velocity/value over the box, then applies movePoint's clamp. Zero
|
||||
// width/height box or out-of-range index returns grabCurve unchanged.
|
||||
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
|
||||
const Box& box, int dxPixels, int dyPixels);
|
||||
@@ -97,9 +206,103 @@ public:
|
||||
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
|
||||
|
||||
private:
|
||||
// Private: an implicit-default curve is empty (no endpoints) and Unipolar, so a stray
|
||||
// default-construction wouldn't fail loudly — it would eval() to unity gain everywhere,
|
||||
// or a full +/-1 (a full-scale transpose / wide-open filter) if ever read as bipolar. Build
|
||||
// through flat()/linear()/zero()/fromPoints(), all of which establish the endpoint invariant.
|
||||
VelocityCurve() = default;
|
||||
|
||||
// Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
|
||||
// invariant, mutators preserve it.
|
||||
std::vector<VelocityPoint> points_;
|
||||
CurveDomain domain_ = CurveDomain::Unipolar;
|
||||
};
|
||||
|
||||
// The RT read head over a contour: an indexed segment search plus one Hermite evaluation, with
|
||||
// the segment and its two tangents cached across samples so a monotone read costs one compare.
|
||||
// Header-inline, branch-only, NO allocation and NO virtual dispatch — it runs per voice per
|
||||
// sample. A jump (a loop wrap, a fresh note) falls back to a binary search, <= 7 steps at the
|
||||
// 128-point ceiling.
|
||||
//
|
||||
// Holds a RAW POINTER into the bound curve's point array: the caller guarantees the curve
|
||||
// outlives the cursor. The voice binds against its SampleData, which has exactly that lifetime.
|
||||
class SplineCursor {
|
||||
public:
|
||||
// Binds `c` if it has an evaluable segment; a shorter curve leaves the cursor inactive so
|
||||
// the caller's `if (active())` skips the whole spline path.
|
||||
void bind(const VelocityCurve& c) {
|
||||
const std::vector<VelocityPoint>& pts = c.points();
|
||||
if (pts.size() < 2) { clear(); return; }
|
||||
pts_ = pts.data();
|
||||
n_ = pts.size();
|
||||
select(0);
|
||||
}
|
||||
void clear() { pts_ = nullptr; n_ = 0; }
|
||||
bool active() const { return n_ >= 2; }
|
||||
// True once the cursor has settled on the contour's LAST segment. On its own this does NOT
|
||||
// make a 0 read here a terminus: the final segment's LEFT endpoint can also be 0 (a 2-point
|
||||
// contour is nothing but a single "final" segment starting at frame 0), which would read 0
|
||||
// while about to rise. Voice::tickAmplitude pairs this with segmentEndValue() == 0 — the
|
||||
// segment's RIGHT endpoint, i.e. the whole contour's true end — before calling a 0 read the
|
||||
// note's genuine permanent terminus.
|
||||
bool onFinalSegment() const { return seg_ + 2 == n_; }
|
||||
// The CURRENT SEGMENT's right endpoint — not a contour-level concept despite the name's
|
||||
// shape; it is the whole contour's terminal Y only when paired with onFinalSegment() (see
|
||||
// there). Named for what it returns, not for its one call site's use of it.
|
||||
double segmentEndValue() const { return y1_; }
|
||||
|
||||
// `phase` is normalized position over the contour's whole span, [0,1]; out-of-range clamps
|
||||
// to the terminal values (a note past its span holds the contour's last level).
|
||||
double eval(double phase) {
|
||||
const double x = (phase <= 0.0) ? kCurveXMin
|
||||
: (phase >= 1.0) ? kCurveXMax
|
||||
: kCurveXMin + phase * (kCurveXMax - kCurveXMin);
|
||||
if (x <= x0_ && seg_ == 0) return y0_;
|
||||
if (x >= x1_ && seg_ + 2 == n_) return y1_;
|
||||
// x <= x0_ (not just <): landing exactly on the cached segment's LEFT edge normally
|
||||
// reproduces y0_ either way, but at a duplicate-X step (coincident knots with
|
||||
// DIFFERENT Y) the cached segment may be the LATER of the two — re-locate so a query
|
||||
// sitting exactly on the shared X always resolves through locate()'s tie-break, which
|
||||
// agrees with the cold VelocityCurve::eval's first-containing-segment rule.
|
||||
if (x <= x0_ || x > x1_) locate(x);
|
||||
if (span_ <= 0.0) return y1_; // coincident-X knots: a step, no interior to blend
|
||||
return hermiteAt(y0_, y1_, span_, mA_, mB_, (x - x0_) / span_);
|
||||
}
|
||||
|
||||
private:
|
||||
// The common case is the next segment (a monotone read walking forward); anything else is a
|
||||
// binary search over the X-ordered array.
|
||||
void locate(double x) {
|
||||
if (x > x1_ && seg_ + 2 < n_ && x <= pts_[seg_ + 2].velocity) { select(seg_ + 1); return; }
|
||||
// Leftmost segment containing x: smallest lo with pts_[lo+1].velocity >= x. At
|
||||
// coincident-X knots (a drawn step) this picks the FIRST segment ending at the shared X,
|
||||
// matching VelocityCurve::eval's cold linear walk — the two readers must agree here or a
|
||||
// backwards/jumping read can return a different knot's Y than a forward one would.
|
||||
std::size_t lo = 0, hi = n_ - 2;
|
||||
while (lo < hi) {
|
||||
const std::size_t mid = lo + (hi - lo) / 2;
|
||||
if (pts_[mid + 1].velocity < x) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
select(lo);
|
||||
}
|
||||
|
||||
void select(std::size_t i) {
|
||||
seg_ = i;
|
||||
x0_ = pts_[i].velocity;
|
||||
x1_ = pts_[i + 1].velocity;
|
||||
y0_ = pts_[i].value;
|
||||
y1_ = pts_[i + 1].value;
|
||||
span_ = x1_ - x0_;
|
||||
const SegmentTangents t =
|
||||
segmentTangents(pts_, n_, i, span_ > 0.0 ? (y1_ - y0_) / span_ : 0.0, span_);
|
||||
mA_ = t.mA;
|
||||
mB_ = t.mB;
|
||||
}
|
||||
|
||||
const VelocityPoint* pts_ = nullptr;
|
||||
std::size_t n_ = 0;
|
||||
std::size_t seg_ = 0;
|
||||
double x0_ = 0.0, x1_ = 0.0, y0_ = 0.0, y1_ = 0.0, span_ = 0.0, mA_ = 0.0, mB_ = 0.0;
|
||||
};
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
|
||||
@@ -49,12 +49,14 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
|
||||
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
||||
// amount both derive from it below).
|
||||
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
const PlayParams& p = sample.play;
|
||||
// Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ here rather
|
||||
// than costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed
|
||||
// read-rate bias and Preserve shift amount both derive from it below).
|
||||
velPitchRatio_ = velocityPitchRatio(p.pitchVelocityCurve, velocity);
|
||||
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
@@ -64,52 +66,113 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
startFrame_ = start; // the span-offset origin: readPos - startFrame
|
||||
|
||||
// The one fold of the stored span + crossfade into what the read path wraps on.
|
||||
loop_ = instrument::engine::loop::resolveLoop(sample.loop, sample.loopCrossfadeFrames,
|
||||
frameCount, playMode_ == PlayMode::Gate);
|
||||
|
||||
// Bind whichever EGs are drawn. Rebound on EVERY note-on rather than cached: a reload hands
|
||||
// the engine a fresh SampleData, so a stale pointer into the previous one is the bug this
|
||||
// avoids. A Staged EG clears its cursor, which is what keeps the per-sample path off the
|
||||
// spline branch entirely.
|
||||
splineScale_ = frameCount > 0 ? 1.0 / static_cast<double>(frameCount) : 0.0;
|
||||
if (p.ampSpline.mode == EnvMode::Spline) ampSplineCur_.bind(p.ampSpline.contour);
|
||||
else ampSplineCur_.clear();
|
||||
if (p.pitchEnv.enabled && p.pitchSpline.mode == EnvMode::Spline) {
|
||||
pitchSplineCur_.bind(p.pitchSpline.contour);
|
||||
pitchSplineDepth_ = p.pitchEnv.peakSemitones;
|
||||
} else {
|
||||
pitchSplineCur_.clear();
|
||||
pitchSplineDepth_ = 0.0;
|
||||
}
|
||||
if (p.filter.enabled && p.filterSpline.mode == EnvMode::Spline) {
|
||||
filterSplineCur_.bind(p.filterSpline.contour);
|
||||
} else {
|
||||
filterSplineCur_.clear();
|
||||
}
|
||||
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
|
||||
// frames from stored seconds at load time); Trigger = the time-boxed fade-in/out over the
|
||||
// % play length.
|
||||
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
|
||||
const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t trigSpan = 0;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where
|
||||
// playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
// Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)) —
|
||||
// map/trigger_seam.h's formula, evaluated inline because the engine does not depend on
|
||||
// map/. The spline fold is effectiveLengthFraction (play_params.h); a second copy of it
|
||||
// here is what let a stored-but-inert %-knob shorten the bake while the voice played
|
||||
// the whole take.
|
||||
double frac = effectiveLengthFraction(p);
|
||||
if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
static_cast<double>(postStart) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
if (playLen > postStart) playLen = postStart;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
trigSpan = playLen;
|
||||
ampAhd_.configure(playLen, p.trigAhd);
|
||||
}
|
||||
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
|
||||
// stages lay 1:1 over the waveform from the start point. postStart is a SOURCE-frame count
|
||||
// and this envelope counts OUTPUT frames (envelopes.h), so Varispeed — which consumes
|
||||
// baseRatio_ source frames per output frame — needs the span converted, or a transposed
|
||||
// note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source
|
||||
// rate, so its two domains already coincide.
|
||||
// Divides by baseRatio_ alone, though the actual Varispeed read rate is baseRatio_ x
|
||||
// envFactor — a deep pitch envelope makes this a first-order approximation, not exact.
|
||||
// Strictly better than the un-converted source-frame span it replaced.
|
||||
const double pitchSpan =
|
||||
(pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0))
|
||||
? static_cast<double>(postStart)
|
||||
: static_cast<double>(postStart) / baseRatio_;
|
||||
pitchEnv_.configure(static_cast<std::int64_t>(pitchSpan + 0.5), p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// A restart lands every live glide back on the new note's own values, at a step derived
|
||||
// from this sample's rate rather than any assumed one.
|
||||
filterRamping_ = false;
|
||||
const double rampStep = instrument::engine::liveRampStep(
|
||||
static_cast<double>(sample.sampleRate));
|
||||
rBaseCutoff_.step = rampStep;
|
||||
rModAmount_.step = rampStep;
|
||||
rResonance_.step = rampStep;
|
||||
rMorph_.step = rampStep;
|
||||
rDrive_.step = rampStep;
|
||||
|
||||
// Filter: reset() clears integrator state for the new note (prepare() preserves it —
|
||||
// voice_filter.h / filter/CLAUDE.md). Velocity maps through the curve once here, off the
|
||||
// per-frame path, exactly as the amp's velocityGain_ does.
|
||||
filterOn_ = p.filter.enabled;
|
||||
if (filterOn_) {
|
||||
filterSettings_ = p.filter.settings;
|
||||
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
|
||||
filterModAmount_ = p.filter.modAmount;
|
||||
filterKeyTrack_ = p.filter.keyTrack;
|
||||
filterVelOffset_ =
|
||||
p.filter.velAmount * p.filter.velocityCurve.eval(static_cast<double>(velocity));
|
||||
filterVelCurve_ = p.filter.velocityCurve.eval(static_cast<double>(velocity));
|
||||
filterVelOffset_ = p.filter.velAmount * filterVelCurve_;
|
||||
filterRate_ = static_cast<double>(sample.sampleRate);
|
||||
rModAmount_.set(p.filter.modAmount);
|
||||
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
|
||||
rMorph_.set(static_cast<double>(p.filter.settings.morphNorm));
|
||||
rDrive_.set(static_cast<double>(p.filter.settings.driveNorm));
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
filterEnv_.configure(p.filter.env);
|
||||
filterEnv_.noteOn();
|
||||
} else {
|
||||
filterAhd_.configure(trigSpan, p.filter.trigEnv);
|
||||
}
|
||||
filter_.reset();
|
||||
updateFilterCutoffBase(note);
|
||||
// The note's ONE full solve — Q, morph and drive are constants for its lifetime, so
|
||||
// every later re-solve is the cheap cutoff-only path. A modulated voice supersedes this
|
||||
// cutoff in tickFilterCutoff on its first frame, before any sample reaches the kernel.
|
||||
// The note's ONE full solve — Q, morph and drive are constants for its lifetime unless
|
||||
// a live move glides them, so every later re-solve is the cheap cutoff-only path. A
|
||||
// modulated voice supersedes this cutoff in tickFilterCutoff on its first frame,
|
||||
// before any sample reaches the kernel.
|
||||
instrument::engine::filter::FilterSettings s = p.filter.settings;
|
||||
s.cutoffNorm = filterBaseCutoff_;
|
||||
filter_.prepare(s, filterRate_);
|
||||
@@ -128,9 +191,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
// no per-frame shifter cost.
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
const bool loopWrap = sustainLoopUsable();
|
||||
const SampleLoop& loop = sample.loop;
|
||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||
const bool loopWrap = loop_.active;
|
||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
||||
// The prime may only carry playable source. The per-frame feed stops at feedBound
|
||||
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
|
||||
@@ -155,12 +216,17 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
while (q >= loop.end) q -= loopLen;
|
||||
while (q >= loop_.end) q -= loop_.length;
|
||||
}
|
||||
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk. The prime runs
|
||||
// the SAME crossfade the per-frame feed does — a ring primed with an un-faded
|
||||
// seam would put the click back one window into the note.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||
(q < frameCount)
|
||||
? crossfadedSource(pcmCh, loop_, q, crossfadeWeight(loop_,
|
||||
static_cast<double>(q)))
|
||||
: 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
@@ -178,6 +244,66 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
|
||||
// Each envelope applies only the shape its play mode selected at note-on; the block
|
||||
// carries both so the mode never changes what is published.
|
||||
//
|
||||
// A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
|
||||
// flag: a voice that has rendered nothing has no phase to hold and nothing to be
|
||||
// continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
|
||||
const bool gate = (playMode_ == PlayMode::Gate);
|
||||
if (snap) {
|
||||
if (gate) env_.snapLive(live.adsr);
|
||||
else ampAhd_.snapLive(live.ampAhd);
|
||||
pitchEnv_.snapLive(live.pitchEnv);
|
||||
} else {
|
||||
if (gate) env_.applyLive(live.adsr);
|
||||
else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
|
||||
pitchEnv_.applyLive(live.pitchEnv);
|
||||
}
|
||||
// The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but
|
||||
// pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_,
|
||||
// which already glides through rModAmount_'s live ramp regardless of spline state (below),
|
||||
// this is the one place a live pitch-depth move must be re-applied by hand. Only meaningful
|
||||
// while pitchSplineCur_ is bound; harmless (and cheap) to set otherwise.
|
||||
pitchSplineDepth_ = live.pitchEnv.peakSemitones;
|
||||
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
|
||||
|
||||
if (snap) {
|
||||
if (gate) filterEnv_.snapLive(live.filterEnv);
|
||||
else filterAhd_.snapLive(live.filterAhd);
|
||||
} else {
|
||||
if (gate) filterEnv_.applyLive(live.filterEnv);
|
||||
else filterAhd_.applyLive(sourceOffset(), live.filterAhd);
|
||||
}
|
||||
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
|
||||
filterKeyTrack_ = live.filterKeyTrack;
|
||||
// The note's curve value stays latched; only the depth over it is live. Both this and the
|
||||
// key-track depth land in the base cutoff, so they glide through rBaseCutoff_ below.
|
||||
filterVelOffset_ = live.filterVelAmount * filterVelCurve_;
|
||||
filterSettings_.morphLaw = live.filterSettings.morphLaw;
|
||||
const double baseTarget = filterCutoffBaseTarget(note_);
|
||||
if (snap) {
|
||||
rBaseCutoff_.set(baseTarget);
|
||||
rModAmount_.set(live.filterModAmount);
|
||||
rResonance_.set(static_cast<double>(live.filterSettings.resonanceNorm));
|
||||
rMorph_.set(static_cast<double>(live.filterSettings.morphNorm));
|
||||
rDrive_.set(static_cast<double>(live.filterSettings.driveNorm));
|
||||
filterBaseCutoff_ = static_cast<float>(baseTarget);
|
||||
filterModAmount_ = live.filterModAmount;
|
||||
filterRamping_ = false;
|
||||
prepareFilterFromRamps();
|
||||
return;
|
||||
}
|
||||
rBaseCutoff_.aim(baseTarget);
|
||||
rModAmount_.aim(live.filterModAmount);
|
||||
rResonance_.aim(static_cast<double>(live.filterSettings.resonanceNorm));
|
||||
rMorph_.aim(static_cast<double>(live.filterSettings.morphNorm));
|
||||
rDrive_.aim(static_cast<double>(live.filterSettings.driveNorm));
|
||||
filterRamping_ = rBaseCutoff_.moving() || rModAmount_.moving() || rResonance_.moving() ||
|
||||
rMorph_.moving() || rDrive_.moving();
|
||||
}
|
||||
|
||||
void Voice::retune(int note) {
|
||||
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||
@@ -186,7 +312,12 @@ void Voice::retune(int note) {
|
||||
// legato phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_ || sample_ == nullptr) return;
|
||||
note_ = note;
|
||||
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
|
||||
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the
|
||||
// baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the
|
||||
// first note's domain — consistent with "touch nothing else," but the drift lives here.
|
||||
// The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ —
|
||||
// one gesture, one strike.
|
||||
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack) * velPitchRatio_;
|
||||
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
|
||||
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
|
||||
if (filterOn_) updateFilterCutoffBase(note);
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#include "core/instrument/engine/envelopes.h"
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
#include "core/instrument/engine/filter/voice_filter.h"
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
#include "core/instrument/engine/loop/loop_span.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
@@ -23,8 +25,13 @@ namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::PitchShifter;
|
||||
using instrument::engine::SplineCursor;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
using instrument::engine::loop::ResolvedLoop;
|
||||
using instrument::engine::loop::crossfadeWeight;
|
||||
using instrument::engine::loop::crossfadedSource;
|
||||
using instrument::engine::loop::lerpSource;
|
||||
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
|
||||
// reference-frequency needed.
|
||||
@@ -42,6 +49,14 @@ inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// 2^(curve(velocity) * kVelocityPitchRangeSemitones / 12): the velocity->pitch transpose, which
|
||||
// the voice folds into baseRatio_ once at note-on. A curve flat at 0 — the default — yields
|
||||
// EXACTLY 1.0 at every velocity and skips the pow, so an undrawn curve transposes nothing.
|
||||
inline double velocityPitchRatio(const VelocityCurve& curve, int velocity) {
|
||||
const double semis = curve.eval(static_cast<double>(velocity)) * kVelocityPitchRangeSemitones;
|
||||
return (semis == 0.0) ? 1.0 : std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// One octave expressed in the cutoff control's normalized domain, read out of the filter
|
||||
// module's OWN inverse rather than re-derived from its endpoints — the log law belongs to
|
||||
// filter_params, and a second copy here could drift from it. Evaluated at note-on only.
|
||||
@@ -59,13 +74,24 @@ inline double filterNormPerOctave() {
|
||||
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless of
|
||||
// the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
|
||||
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
|
||||
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
|
||||
// amplitude was instantly ~1 (a zero-attack Trigger or Gate) got zero
|
||||
// compensation and kept the full click — the difference-seed has no such hole. Off by
|
||||
// default so the bare core stays byte-identical to the pre-fix engine; the processor
|
||||
// shell opts in.
|
||||
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
||||
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
||||
|
||||
// How many frames the ramp emits before the weight drops under the floor. Counted the way
|
||||
// advanceFrame runs it — emit, decay, test — rather than solved in closed form, so the two
|
||||
// can never disagree. RATE-INDEPENDENT: the decay is per frame, not per second, so an offline
|
||||
// pass at any rate pads by the same count.
|
||||
inline constexpr std::int64_t declickRampFrames() {
|
||||
std::int64_t n = 0;
|
||||
for (double w = 1.0; w >= kDeclickFloor; w *= kDeclickDecay) ++n;
|
||||
return n;
|
||||
}
|
||||
inline constexpr std::int64_t kDeclickFrames = declickRampFrames();
|
||||
|
||||
// A single voice: one active note playing the loaded capture, repitched and enveloped.
|
||||
// Reads the sample by fractional frame position with linear interpolation, advancing by the
|
||||
// pitch ratio; loops the sustain region for held notes past the loop end.
|
||||
@@ -115,6 +141,16 @@ public:
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
|
||||
// Applies the live-parameter block to a voice that is already sounding (or, with `snap`,
|
||||
// to one just started). Called at BLOCK boundaries by VoiceEngine — never per frame — so
|
||||
// the per-sample shape is unchanged; every continuous control glides toward its new value
|
||||
// from here rather than jumping to it. `snap` takes the values outright — glides AND
|
||||
// envelopes: a fresh note has nothing to glide from, and its copy may predate the edit.
|
||||
//
|
||||
// What is NOT here is the point: velocity and its curve result, the note number and the
|
||||
// pitch ratio, and the decoded PCM stay latched at note-on.
|
||||
void applyLive(const instrument::engine::LiveValues& live, bool snap);
|
||||
|
||||
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
|
||||
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
|
||||
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
|
||||
@@ -140,36 +176,46 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
|
||||
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
|
||||
// by the output anchor, the Preserve feed, and the start()-time ring prime.
|
||||
bool sustainLoopUsable() const {
|
||||
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
|
||||
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
|
||||
}
|
||||
// The read head as a fraction of the whole sample — the domain every spline EG is a pure
|
||||
// function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on
|
||||
// its opening value.
|
||||
double splinePhase() const { return readPos_ * splineScale_; }
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
|
||||
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
|
||||
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
|
||||
// advanceFrame frees the voice.
|
||||
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
|
||||
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
|
||||
// per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
|
||||
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
|
||||
// frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees
|
||||
// the voice.
|
||||
double tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
// playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it —
|
||||
// splineActive, play_params.h); the guard is a pure-core defense against a hand-built
|
||||
// SampleData pairing Gate with an amp spline, which would otherwise bypass env_
|
||||
// entirely — release() then has no envelope to end, and an active sustain loop rings
|
||||
// forever.
|
||||
if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) {
|
||||
// Early-free at a genuine permanent terminus (the spline analogue of a staged AHD's
|
||||
// finished()) — onFinalSegment()/segmentEndValue()'s own doc comments own the why.
|
||||
amp = ampSplineCur_.eval(splinePhase());
|
||||
if (amp == 0.0 && ampSplineCur_.onFinalSegment() &&
|
||||
ampSplineCur_.segmentEndValue() == 0.0) {
|
||||
amplitudeDone_ = true;
|
||||
}
|
||||
} else if (playMode_ == PlayMode::Gate) {
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Anchored to the source offset so fades land on the same source frames under
|
||||
// either engine's read rate. The voice also frees on readPos_ >= playEnd_ in
|
||||
// advanceFrame; finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
amp = ampAhd_.amplitudeAt(sourceOffset());
|
||||
if (ampAhd_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// Frames into the Trigger play span at the current read head — the domain both
|
||||
// sustain-less envelopes are evaluated over.
|
||||
double sourceOffset() const { return readPos_ - static_cast<double>(startFrame_); }
|
||||
|
||||
// Advances the filter envelope and re-solves the corner from the modulated cutoff. The
|
||||
// solve is UNQUANTIZED: the corner tracks the envelope continuously, so a sweep glides
|
||||
// rather than staircasing. State preservation across the solve is voice_filter's own
|
||||
@@ -184,8 +230,15 @@ private:
|
||||
// through both so a moved base always re-solves.
|
||||
void tickFilterCutoff() {
|
||||
if (filterModAmount_ == 0.0 && filterSolved_) return;
|
||||
double cut = static_cast<double>(filterBaseCutoff_) +
|
||||
filterModAmount_ * filterEnv_.tick();
|
||||
// The filter envelope takes the amp's shape under the active mode — AHDSR in Gate,
|
||||
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
|
||||
// branch is perfectly predicted.
|
||||
const double envOut = filterSplineCur_.active()
|
||||
? filterSplineCur_.eval(splinePhase())
|
||||
: ((playMode_ == PlayMode::Gate)
|
||||
? filterEnv_.tick()
|
||||
: filterAhd_.amplitudeAt(sourceOffset()));
|
||||
double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut;
|
||||
if (cut < 0.0) cut = 0.0;
|
||||
if (cut > 1.0) cut = 1.0;
|
||||
const float cutNorm = static_cast<float>(cut);
|
||||
@@ -196,9 +249,10 @@ private:
|
||||
}
|
||||
|
||||
// The cutoff position before the envelope: the stored knob position plus this note's
|
||||
// velocity offset and key-tracking. Recomputed at note-on and at a legato retune (both
|
||||
// move the note), never per frame.
|
||||
void updateFilterCutoffBase(int note) {
|
||||
// velocity offset and key-tracking. Evaluated at note-on, at a legato retune (both move
|
||||
// the note), and when a live move changes the knob position or the key-track depth —
|
||||
// never per frame.
|
||||
double filterCutoffBaseTarget(int note) const {
|
||||
double base = filterCutoffNorm_ + filterVelOffset_;
|
||||
if (filterKeyTrack_ != 0.0 && sample_ != nullptr) {
|
||||
base += filterKeyTrack_ *
|
||||
@@ -207,10 +261,52 @@ private:
|
||||
}
|
||||
if (base < 0.0) base = 0.0;
|
||||
if (base > 1.0) base = 1.0;
|
||||
return base;
|
||||
}
|
||||
|
||||
// Takes the base outright (no glide) — a note-on or a retune is a new note position, not a
|
||||
// knob move, so there is nothing to glide from.
|
||||
void updateFilterCutoffBase(int note) {
|
||||
const double base = filterCutoffBaseTarget(note);
|
||||
rBaseCutoff_.set(base);
|
||||
filterBaseCutoff_ = static_cast<float>(base);
|
||||
filterSolved_ = false; // forces the next frame to solve
|
||||
}
|
||||
|
||||
// The full solve, from the tone-control ramps' current values, at the current base cutoff —
|
||||
// the same shape start() performs, and it leaves the same solved-cutoff bookkeeping behind
|
||||
// so an unmoved live block reproduces start()'s state exactly. State is preserved across
|
||||
// prepare() by contract (voice_filter.h), which is what makes a live tone move glide
|
||||
// rather than click.
|
||||
void prepareFilterFromRamps() {
|
||||
filterSettings_.resonanceNorm = static_cast<float>(rResonance_.value);
|
||||
filterSettings_.morphNorm = static_cast<float>(rMorph_.value);
|
||||
filterSettings_.driveNorm = static_cast<float>(rDrive_.value);
|
||||
filterSettings_.cutoffNorm = filterBaseCutoff_;
|
||||
filter_.prepare(filterSettings_, filterRate_);
|
||||
filterSolvedCutoff_ = filterBaseCutoff_;
|
||||
filterSolved_ = true;
|
||||
}
|
||||
|
||||
// Advances the five live filter-control glides by one frame. Q, morph and drive are
|
||||
// prepare()-cadence constants, so a move on any of them costs the full solve while the
|
||||
// glide runs (~20 ms) and nothing once it lands; the base cutoff and the mod depth feed
|
||||
// tickFilterCutoff's own cheap cutoff-only solve instead.
|
||||
void tickFilterRamps() {
|
||||
bool tone = false;
|
||||
if (rResonance_.tick()) tone = true;
|
||||
if (rMorph_.tick()) tone = true;
|
||||
if (rDrive_.tick()) tone = true;
|
||||
if (rBaseCutoff_.tick()) {
|
||||
filterBaseCutoff_ = static_cast<float>(rBaseCutoff_.value);
|
||||
filterSolved_ = false;
|
||||
}
|
||||
if (rModAmount_.tick()) filterModAmount_ = rModAmount_.value;
|
||||
if (tone) prepareFilterFromRamps();
|
||||
filterRamping_ = rResonance_.moving() || rMorph_.moving() || rDrive_.moving() ||
|
||||
rBaseCutoff_.moving() || rModAmount_.moving();
|
||||
}
|
||||
|
||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly.
|
||||
@@ -230,6 +326,22 @@ private:
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
// Rings the voice's last rendered output out instead of hard-cutting it when the read head
|
||||
// reaches the end of its span, on the PRESERVE path only. Varispeed's final sample is real
|
||||
// source content at its natural end and its stop is left byte-identical; Preserve's is
|
||||
// recycled synthetic tail (freezeTail stops the writer a full window before the read head
|
||||
// arrives), whose level bears no relation to the source's own ending — cutting it at
|
||||
// whatever amplitude the splice machinery happens to be at is the end-of-sample click.
|
||||
// Reuses the takeover blend so the boundary frame reproduces the last level exactly.
|
||||
void seedTerminalDeclick() {
|
||||
if (pitchEngine_ != PitchEngine::Preserve) return;
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickWeight_ = 1.0;
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
|
||||
@@ -249,14 +361,13 @@ private:
|
||||
const bool haveR = stereo && sample_->channelCount() == 2;
|
||||
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
||||
|
||||
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A
|
||||
// valid, non-zero-length loop wraps the read head back into [start, end); a
|
||||
// zero-length loop is "no loop". Under Preserve the loop is over the source read
|
||||
// (loop the source, shift the output).
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
const bool loopUsable = sustainLoopUsable();
|
||||
if (loopUsable) {
|
||||
const double loopLen = static_cast<double>(loop.end - loop.start);
|
||||
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). The
|
||||
// span was folded once at note-on (loop_span.h); an invalid or absent loop leaves
|
||||
// loop_.active false and this whole path off. Under Preserve the loop is over the
|
||||
// source read (loop the source, shift the output).
|
||||
const ResolvedLoop& loop = loop_;
|
||||
if (loop.active) {
|
||||
const double loopLen = static_cast<double>(loop.length);
|
||||
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||
}
|
||||
@@ -272,7 +383,14 @@ private:
|
||||
// play span ends within the ramp). With no declick (the common case) this is
|
||||
// byte-identical to the plain idle-out.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
// The NOTE is over the moment the read head leaves its span, whether or not a ramp
|
||||
// still rings: no later frame can carry envelope output. Latching here is what keeps
|
||||
// a ringing-out voice out of soundingNote() — the Preserve cap would otherwise
|
||||
// refuse a new onset, and mono legato would retune a voice already past its end
|
||||
// (silencing the new note) for the whole ~4 ms ramp.
|
||||
amplitudeDone_ = true;
|
||||
if (declickPending_) seedDeclick();
|
||||
if (!declickActive_) seedTerminalDeclick();
|
||||
if (declickActive_) {
|
||||
// Bounded blend at silence: outCurrent == 0, so the blend is
|
||||
// w*(ref − 0) == w*ref. The weight decays by kDeclickDecay each frame,
|
||||
@@ -296,8 +414,22 @@ private:
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
|
||||
const double amp = tickAmplitude();
|
||||
// Peer of the read-head exhaustion path above: a Trigger AHD whose stages end BEFORE
|
||||
// the play span (a zero decay, which the shape deliberately keeps expressible) cuts the
|
||||
// same synthetic Preserve tail at whatever level it was at. Seeded from lastOut, which
|
||||
// still holds the PREVIOUS frame — this one is already silent. Gate is left out of THIS
|
||||
// site only: its amplitude reaches zero through a release, so nothing here is cut
|
||||
// mid-level. The exhaustion path above deliberately does NOT exclude Gate — a held Gate
|
||||
// note whose source runs out with no loop is cut at its sustain level, and under
|
||||
// Preserve that cut lands on the same recycled synthetic tail.
|
||||
if (amplitudeDone_ && amp == 0.0 && !declickActive_ &&
|
||||
playMode_ == PlayMode::Trigger) {
|
||||
seedTerminalDeclick();
|
||||
}
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
const double pitchEnvSemis = pitchSplineCur_.active()
|
||||
? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase())
|
||||
: pitchEnv_.tick();
|
||||
|
||||
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
|
||||
// pow entirely — no per-frame transcendental on the common path.
|
||||
@@ -315,9 +447,8 @@ private:
|
||||
// rings were primed with that window at start()), under the same sustain-loop wrap
|
||||
// rule, reading integer source frames (nothing to interpolate). Past the last real
|
||||
// frame the shifter's writer is frozen — it recycles the real tail it already holds.
|
||||
if (loopUsable) {
|
||||
const std::int64_t loopLen = loop.end - loop.start;
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
|
||||
if (loop.active) {
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loop.length;
|
||||
}
|
||||
// feedPos_ runs one window ahead of readPos_; the last real source frame is
|
||||
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
|
||||
@@ -333,7 +464,11 @@ private:
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
|
||||
// shift the output.
|
||||
const double feedXw = crossfadeWeight(loop, static_cast<double>(feedPos_));
|
||||
const AudioSample feedL =
|
||||
feedOk ? crossfadedSource(pcm, loop, feedPos_, feedXw) : 0.0f;
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||
@@ -352,7 +487,7 @@ private:
|
||||
// not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR =
|
||||
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
|
||||
@@ -376,7 +511,7 @@ private:
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
|
||||
const double frac = readPos_ - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (loopUsable && i1 >= loop.end) {
|
||||
if (loop.active && i1 >= loop.end) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
@@ -391,12 +526,23 @@ private:
|
||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||
outRlocal = srcR;
|
||||
}
|
||||
// Loop crossfade: blend toward the same read head one loop length earlier, which
|
||||
// is the material the wrap is about to hand over to. Zero outside the fade region
|
||||
// (and always, with no fade dialled), so the un-crossfaded read stays exactly the
|
||||
// shape it was.
|
||||
const double xw = crossfadeWeight(loop, readPos_);
|
||||
if (xw > 0.0) {
|
||||
const double tap = readPos_ - static_cast<double>(loop.length);
|
||||
outL += xw * (lerpSource(pcm, frameCount, tap) - outL);
|
||||
if (stereo) outRlocal += xw * (lerpSource(pcmR, frameCount, tap) - outRlocal);
|
||||
}
|
||||
ratio_ = baseRatio_ * envFactor;
|
||||
}
|
||||
|
||||
// Skipped whole when disengaged (the default), so an un-filtered render stays
|
||||
// bit-identical to the pre-filter engine.
|
||||
if (filterOn_) {
|
||||
if (filterRamping_) tickFilterRamps(); // false at rest: one predicted branch
|
||||
tickFilterCutoff();
|
||||
outL = static_cast<double>(filter_.process(0, static_cast<float>(outL)));
|
||||
// Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the
|
||||
@@ -458,38 +604,73 @@ private:
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double baseRatio_ = 1.0; // key-tracked repitch ratio, with velocity->pitch folded in
|
||||
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
|
||||
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
|
||||
// Gate uses env_ (AHDSR); Trigger uses ampAhd_ — only one active per voice (selected by
|
||||
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
|
||||
// readPos_ >= playEnd_).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
AhdEnvelope ampAhd_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; the span-offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// The three drawn contours, bound at note-on to the loaded capture's own point arrays (the
|
||||
// SampleData outlives the voice — same contract as sample_). A Staged EG leaves its cursor
|
||||
// inactive, so a purely staged instrument's per-sample path gains three predicted branches
|
||||
// and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every contour
|
||||
// shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
|
||||
SplineCursor ampSplineCur_;
|
||||
SplineCursor pitchSplineCur_;
|
||||
SplineCursor filterSplineCur_;
|
||||
double splineScale_ = 0.0;
|
||||
double pitchSplineDepth_ = 0.0;
|
||||
|
||||
// The sustain loop folded ONCE at note-on: the sample, the play mode and the stored span
|
||||
// are all fixed for the note's lifetime, so re-deriving validity per frame bought nothing.
|
||||
// Shared by the output anchor, the Preserve feed, and the start()-time ring prime.
|
||||
ResolvedLoop loop_;
|
||||
|
||||
// The voice's OWN filter and filter envelope — per-voice, never shared, so two notes at
|
||||
// different envelope phases are filtered independently. filterCutoffNorm_ keeps the
|
||||
// unmodulated knob position the base is rebuilt from. Q, morph and drive are note-constants
|
||||
// solved once by start()'s prepare(), which is why every later re-solve is cutoff-only.
|
||||
// filterRate_ <= 0 makes prepare() bypass rather than invent a rate.
|
||||
instrument::engine::filter::VoiceFilter filter_;
|
||||
AdsrEnvelope filterEnv_;
|
||||
AdsrEnvelope filterEnv_; // Gate
|
||||
AhdEnvelope filterAhd_; // Trigger
|
||||
bool filterOn_ = false;
|
||||
double filterRate_ = 0.0;
|
||||
double filterCutoffNorm_ = 1.0;
|
||||
double filterModAmount_ = 0.0;
|
||||
double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note
|
||||
// The curve's value at THIS note's velocity — a fact about the note, latched at note-on —
|
||||
// and the product with the live depth, which a live depth move recomputes.
|
||||
double filterVelCurve_ = 0.0;
|
||||
double filterVelOffset_ = 0.0;
|
||||
double filterKeyTrack_ = 0.0;
|
||||
instrument::engine::filter::FilterSettings filterSettings_{}; // the note's tone controls
|
||||
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
|
||||
float filterSolvedCutoff_ = 1.0f; // the position the live coefficients were solved from
|
||||
bool filterSolved_ = false; // false forces the next frame to solve
|
||||
|
||||
// Live-parameter glides (live_params.h). Every one is parked at its target unless a move
|
||||
// is in flight, so filterRamping_ is false and the per-sample path keeps the pre-live
|
||||
// engine's exact shape. All five live in the filter's control domains — the envelopes
|
||||
// need no ramp here, because holding normalized stage position is continuous by
|
||||
// construction and their two genuine level steps are absorbed inside AdsrEnvelope /
|
||||
// PitchEnvelope themselves.
|
||||
bool filterRamping_ = false;
|
||||
instrument::engine::ValueRamp rBaseCutoff_;
|
||||
instrument::engine::ValueRamp rModAmount_;
|
||||
instrument::engine::ValueRamp rResonance_;
|
||||
instrument::engine::ValueRamp rMorph_;
|
||||
instrument::engine::ValueRamp rDrive_;
|
||||
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
//
|
||||
|
||||
@@ -33,6 +33,32 @@ VoiceEngine::VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||
}
|
||||
}
|
||||
|
||||
bool VoiceEngine::refreshLive() {
|
||||
const instrument::engine::LiveParams* block = sample_.live;
|
||||
if (block == nullptr) return false; // bare engine: the latched note-on values stand
|
||||
instrument::engine::LiveValues observed;
|
||||
const std::uint32_t generation = block->read(observed);
|
||||
if (generation == 0 || generation == liveGeneration_) return false;
|
||||
liveGeneration_ = generation;
|
||||
live_ = observed;
|
||||
haveLive_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void VoiceEngine::applyLiveToActive() {
|
||||
if (!refreshLive()) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (voice.active()) voice.applyLive(live_, /*snap=*/false);
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
|
||||
refreshLive();
|
||||
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
|
||||
voice.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
|
||||
// that have finished their note but are still ringing out a declick tail. A ramp-only
|
||||
@@ -122,8 +148,7 @@ std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// RETRIGGER takeover / first note of a phrase: (re)start the voice. The declick opt-in
|
||||
// rides every mono restart; start() self-gates it on the voice being ACTIVE, so a
|
||||
// first-note fresh start never ramps — only a hard cut of a sounding tone.
|
||||
v.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
startVoice(v, note, velocity);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -150,8 +175,7 @@ void VoiceEngine::monoNoteOff(int note) {
|
||||
// Retrigger fallback: re-strike the fallen-back-to note at its own original velocity.
|
||||
// Peer restart site of monoNoteOn's takeover — same declick opt-in (the fallback also
|
||||
// hard-cuts the sounding tone).
|
||||
v.start(fb.note, fb.velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
startVoice(v, fb.note, fb.velocity);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
@@ -174,8 +198,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
// active, so a free-voice start never ramps — only an at-cap steal, which is the same hard
|
||||
// cut of a sounding tone as the mono retrig takeover.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
startVoice(voices_[v], note, velocity);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -225,6 +248,7 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap.
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
applyLiveToActive(); // block boundary, once — never inside the frame loop
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
@@ -240,6 +264,7 @@ void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t fram
|
||||
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||
applyLiveToActive(); // block boundary, once — never inside the frame loop
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/live_params.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/voice.h"
|
||||
|
||||
@@ -106,6 +107,23 @@ private:
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// --- Live-parameter observation (live_params.h) ---
|
||||
// The ONE place the seqlock is read: at block start and at each note-on, on the audio
|
||||
// thread, never per frame. A torn or never-published read leaves the last good snapshot
|
||||
// in place rather than spinning. Returns whether a NEW generation landed.
|
||||
bool refreshLive();
|
||||
// Block-boundary refresh: pushes a newly-observed generation into every sounding voice,
|
||||
// which glides toward it. No-op when nothing changed (and when no block is attached).
|
||||
void applyLiveToActive();
|
||||
// The one restart path: start the voice, hand it the live values outright (it has nothing
|
||||
// to glide from), and stamp its age. Shared by the poly steal and both mono restarts so
|
||||
// no restart site can miss the live handoff.
|
||||
void startVoice(Voice& voice, int note, int velocity);
|
||||
|
||||
instrument::engine::LiveValues live_{};
|
||||
std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet
|
||||
bool haveLive_ = false;
|
||||
|
||||
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
@@ -2,8 +2,8 @@ reasampler_pure_library(bridge_marshal SOURCES bridge_marshal.cpp)
|
||||
reasampler_test(bridge_marshal LINK bridge_marshal)
|
||||
|
||||
reasampler_pure_library(trigger_seam SOURCES trigger_seam.cpp)
|
||||
# Links only trigger_seam — not even editor_geometry — the plainest data-boundary proof
|
||||
# available.
|
||||
# Plain frame arithmetic over doubles: no engine, no value layer, no editor geometry. The
|
||||
# %-length fold it used to host lives with its siblings in play_params.h.
|
||||
reasampler_test(trigger_seam LINK trigger_seam)
|
||||
|
||||
reasampler_pure_library(bank_sync
|
||||
@@ -17,17 +17,30 @@ reasampler_test(bank_sync LINK bank_sync)
|
||||
# the voice engine: velocity_curve (the curve field) and master_gain (the wire gain cap) only.
|
||||
# play_params.h also pulls in filter/'s headers (FilterSettings, MorphLaw) for the v9 filter
|
||||
# tail -- plain value types, so no filter symbol is linked and this stays true.
|
||||
# Two TUs on the format's OWN seam: the envelope's version ladder and the payload's, which
|
||||
# the format already keeps on independent version axes (see component_state_io.h).
|
||||
reasampler_pure_library(component_state_io
|
||||
SOURCES component_state_io.cpp
|
||||
LINK PUBLIC velocity_curve master_gain)
|
||||
SOURCES component_state_io.cpp params_payload.cpp
|
||||
LINK PUBLIC velocity_curve master_gain curve_law musical_division)
|
||||
# Links only component_state_io, deliberately no sampler_core/pitch_shift: the structural
|
||||
# proof the codec is engine-free, which is what keeps engine object code out of the extension.
|
||||
reasampler_test(component_state_io LINK component_state_io)
|
||||
|
||||
# The stored seconds value layer, header-only (hence INTERFACE) — PlaySeconds and the four
|
||||
# stage-time structs it composes. Split from sample_map so a consumer that only edits those
|
||||
# values reaches them WITHOUT the bank model and the WAV codec: the editor's deck_values
|
||||
# binding is exactly that consumer, and linking sample_map for one value struct would put
|
||||
# bank_book + wav_codec into a test whose subject is a knob. Links the same value-layer set
|
||||
# play_params.h needs (velocity_curve's out-of-line zero() is a default member initializer).
|
||||
add_library(play_seconds INTERFACE)
|
||||
target_include_directories(play_seconds INTERFACE ${REASAMPLER_SRC_DIR})
|
||||
target_link_libraries(play_seconds INTERFACE velocity_curve peaks curve_law)
|
||||
|
||||
# The mapping's product is plain SampleData, so the voice engine is not a dependency.
|
||||
reasampler_pure_library(sample_map
|
||||
SOURCES sample_map.cpp
|
||||
LINK PUBLIC bank_book wav_codec velocity_curve peaks)
|
||||
LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law
|
||||
musical_division)
|
||||
# Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning
|
||||
# both halves of the mapping/codec split where the frozen-format assertions live.
|
||||
reasampler_test(sample_map LINK sample_map component_state_io)
|
||||
|
||||
@@ -1,292 +1,29 @@
|
||||
// component_state_io — the ComponentState envelope + params-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v9).
|
||||
// Every wire format is FROZEN — byte-identical across revisions.
|
||||
// component_state_io — the ComponentState ENVELOPE codec. See component_state_io.h for both
|
||||
// format ladders (envelope v1..v11, params payload v1..v11); the payload half lives in
|
||||
// params_payload, which grows on its own version axis. Every wire format is FROZEN —
|
||||
// byte-identical across revisions.
|
||||
|
||||
#include "core/instrument/map/component_state_io.h"
|
||||
|
||||
#include <algorithm> // std::min (bounded curve-point reserve)
|
||||
#include <cassert> // assert (v3-lift projectRate guard)
|
||||
#include <cmath> // std::isfinite (v8 master-gain validation)
|
||||
#include <cstring> // std::memcpy (serializeSelection)
|
||||
#include <utility> // std::move
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
|
||||
#include "core/instrument/map/params_payload.h" // the payload half of this codec
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
using engine::masterGainMaxLinear;
|
||||
using reasampler::wire::ByteReader;
|
||||
using reasampler::wire::asU64;
|
||||
using reasampler::wire::bitsToDouble;
|
||||
using reasampler::wire::doubleToBits;
|
||||
using reasampler::wire::putLE;
|
||||
|
||||
namespace {
|
||||
|
||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
|
||||
// payload that carried at least one zone: the first zone's capture, which supersedes the
|
||||
// envelope's selection id (see the adoption rule in the header).
|
||||
struct PayloadRead {
|
||||
InstrumentParams params;
|
||||
std::string adoptedSampleId;
|
||||
};
|
||||
|
||||
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
|
||||
// the two shapes cannot drift byte-for-byte.
|
||||
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
out.push_back(p.rootOverride ? 1 : 0);
|
||||
if (p.rootOverride) {
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
|
||||
}
|
||||
out.push_back(p.loopOverride ? 1 : 0);
|
||||
if (p.loopOverride) {
|
||||
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(p.loopOverride->start));
|
||||
putLE(out, asU64(p.loopOverride->end));
|
||||
}
|
||||
out.push_back(p.startPoint ? 1 : 0);
|
||||
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
||||
}
|
||||
|
||||
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
|
||||
// The amp curve (v7) and the filter's own curve (v9) share this shape.
|
||||
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
||||
const std::vector<VelocityPoint>& pts = curve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& pt : pts) {
|
||||
putLE(out, doubleToBits(pt.velocity));
|
||||
putLE(out, doubleToBits(pt.amp));
|
||||
}
|
||||
}
|
||||
|
||||
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||
// shape independent of the envelope version (see component_state_io.h).
|
||||
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
putLE(out, kParamsFormatMarker);
|
||||
putLE(out, kParamsPayloadVersion);
|
||||
putOverrides(out, p);
|
||||
|
||||
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
|
||||
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
|
||||
const PlaySeconds& pp = p.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// Key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(p.keyTrack));
|
||||
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
|
||||
// velocity + amp as doubles (endpoints included, so N >= 2).
|
||||
putCurve(out, p.velocityCurve);
|
||||
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
|
||||
// whole payload stays one numeric shape.
|
||||
const FilterSeconds& f = pp.filter;
|
||||
out.push_back(f.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
|
||||
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
|
||||
putLE(out, doubleToBits(f.modAmount));
|
||||
putLE(out, doubleToBits(f.velAmount));
|
||||
putLE(out, doubleToBits(f.keyTrack));
|
||||
putLE(out, doubleToBits(f.env.attackSeconds));
|
||||
putLE(out, doubleToBits(f.env.holdSeconds));
|
||||
putLE(out, doubleToBits(f.env.decaySeconds));
|
||||
putLE(out, doubleToBits(f.env.sustainLevel));
|
||||
putLE(out, doubleToBits(f.env.releaseSeconds));
|
||||
putCurve(out, f.velocityCurve);
|
||||
}
|
||||
|
||||
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||
// v8 single-record reader so the two can never disagree about field order.
|
||||
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.fadeInFrames = r.i64();
|
||||
p.play.trigger.fadeOutFrames = r.i64();
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
|
||||
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
|
||||
// defensively; a truncated read leaves `curve` at whatever default it came in with.
|
||||
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
||||
// can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) {
|
||||
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
}
|
||||
|
||||
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
|
||||
// which is what makes a v8 blob play bit-identically under the new codec.
|
||||
void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
||||
FilterSeconds& f = p.play.filter;
|
||||
f.enabled = (r.u8() != 0);
|
||||
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
|
||||
: engine::filter::MorphLaw::HighBandLow;
|
||||
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
|
||||
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
|
||||
double modAmount = bitsToDouble(r.u64());
|
||||
double velAmount = bitsToDouble(r.u64());
|
||||
double keyTrack = bitsToDouble(r.u64());
|
||||
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
|
||||
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
|
||||
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
|
||||
f.env.attackSeconds = bitsToDouble(r.u64());
|
||||
f.env.holdSeconds = bitsToDouble(r.u64());
|
||||
f.env.decaySeconds = bitsToDouble(r.u64());
|
||||
f.env.sustainLevel = bitsToDouble(r.u64());
|
||||
f.env.releaseSeconds = bitsToDouble(r.u64());
|
||||
readCurveTail(r, f.velocityCurve);
|
||||
}
|
||||
|
||||
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
|
||||
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
|
||||
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
|
||||
// no file and no bank entry.
|
||||
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
|
||||
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
|
||||
// blobs carry seconds directly and need no rate.
|
||||
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
|
||||
PayloadRead out;
|
||||
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
bool adopted = false;
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
|
||||
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
|
||||
InstrumentParams p;
|
||||
std::string sampleId;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
sampleId = r.str(idLen);
|
||||
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
|
||||
r.i32(); // highNote
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) p.rootOverride = r.i32();
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
|
||||
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
|
||||
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
|
||||
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.fadeInFrames = r.i64();
|
||||
p.play.trigger.fadeOutFrames = r.i64();
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} else if (secondsPlay) {
|
||||
readSecondsPlayTail(r, p);
|
||||
}
|
||||
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
||||
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
||||
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||
if (curveTail) readCurveTail(r, p.velocityCurve);
|
||||
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
||||
// leave the seconds product defaults on p.play.
|
||||
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
||||
if (!adopted) {
|
||||
out.params = std::move(p);
|
||||
out.adoptedSampleId = std::move(sampleId);
|
||||
adopted = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
||||
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
|
||||
// v1 (a plain small zone count).
|
||||
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||
std::uint32_t pv = 0; // 0 = v1, no marker
|
||||
if (r.peekU32() == kParamsFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
}
|
||||
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
|
||||
|
||||
PayloadRead out;
|
||||
InstrumentParams& p = out.params;
|
||||
const std::uint8_t hasRoot = r.u8();
|
||||
if (hasRoot) p.rootOverride = r.i32();
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
readSecondsPlayTail(r, p);
|
||||
p.keyTrack = bitsToDouble(r.u64());
|
||||
readCurveTail(r, p.velocityCurve);
|
||||
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
||||
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||
// the same degrade-don't-throw contract the zone ladder always had.
|
||||
if (!r.ok) return PayloadRead{};
|
||||
return out;
|
||||
}
|
||||
|
||||
// Apply a payload read to the state: the adoption rule (a retired payload's first zone
|
||||
// supersedes the envelope's selection id) lives here, once.
|
||||
void applyPayload(ComponentState& out, PayloadRead read) {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// own links are velocity_curve + master_gain (wire value validation), never the engine.
|
||||
//
|
||||
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
|
||||
// payload v1..v8) must be preserved exactly.
|
||||
// payload v1..v14) must be preserved exactly. This header is the ONE home for both ladders
|
||||
// and every version constant; the payload half is IMPLEMENTED in params_payload.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
@@ -70,22 +71,96 @@ namespace reasampler::instrument::map {
|
||||
// startPoint (iff set); the v5 play tail verbatim (SECONDS); 8-byte LE keyTrack; then the
|
||||
// velocity curve (count + points) as in v7.
|
||||
//
|
||||
// v9 (CURRENT WRITE FORMAT) is v8 PLUS the per-voice filter tail, appended after the velocity
|
||||
// curve: 1 byte enabled; 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles,
|
||||
// widened from the module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte
|
||||
// LE modAmount, velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release
|
||||
// SECONDS; then the filter's OWN velocity curve (count + points, same shape as v7's). A v8
|
||||
// blob is a strict prefix, so it lifts to the off/neutral filter default and plays
|
||||
// bit-identically.
|
||||
// v9 is v8 PLUS the per-voice filter tail, appended after the velocity curve: 1 byte enabled;
|
||||
// 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles, widened from the
|
||||
// module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte LE modAmount,
|
||||
// velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release SECONDS; then
|
||||
// the filter's OWN velocity curve (count + points, same shape as v7's). A v8 blob is a strict
|
||||
// prefix, so it lifts to the off/neutral filter default and plays bit-identically.
|
||||
//
|
||||
// v10 is v9 PLUS the staged-curve tail, appended after the filter's velocity curve, all
|
||||
// 8-byte LE doubles in this order: amp AHDSR attack/decay/release curve
|
||||
// exponents; the Trigger amp AHD (attack SECONDS, decay SECONDS, hold FRACTION, attack curve,
|
||||
// decay curve); the pitch envelope's hold FRACTION + attack/decay curve exponents; the filter
|
||||
// AHDSR's attack/decay/release curve exponents; the filter's Trigger AHD (same five fields as
|
||||
// the amp's). A v9-or-older blob is a strict prefix and lifts to the neutral exponent 1.0.
|
||||
//
|
||||
// v11 is v10 PLUS one 8-byte LE int64: the loop crossfade in SOURCE frames (a source-timeline
|
||||
// quantity like the loop points, so no rate resolves it). A v10-or-older blob is a strict
|
||||
// prefix and lifts to 0 — the hard seam it always played.
|
||||
//
|
||||
// v12 (CURRENT WRITE FORMAT) is v11 PLUS the velocity->PITCH transfer curve (count + points,
|
||||
// the same shape as v7's), appended after the loop crossfade. Its y is a normalized fraction
|
||||
// of kVelocityPitchRangeSemitones (play_params.h) — a full-scale constant that lives OUTSIDE
|
||||
// this frozen ladder, so retuning it re-tunes every saved v12 project's pitch-curve throw. It
|
||||
// also RE-TAGS the DOMAIN of one frozen slot inside the v9 filter tail: that curve's y is read
|
||||
// as BIPOLAR [-1,+1] from v12 on, having been UNIPOLAR [0,1] before. Every other filter slot,
|
||||
// velAmount included, keeps its meaning — the cutoff contribution is still
|
||||
// velAmount * curve(velocity).
|
||||
// PRE-v12 LIFT: a domain re-tag and nothing more. A pre-v12 curve's stored y values all lie in
|
||||
// [0,1], which is inside [-1,+1], so the widened box-clamp alters no knot and eval is unchanged
|
||||
// at every velocity — a pre-v12 project sounds identical without any rounding argument. A
|
||||
// pre-v12 blob carries no pitch curve at all and lifts to the bipolar flat-at-zero default,
|
||||
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
|
||||
// drawn into the negative half comes back with that half clamped to 0.
|
||||
//
|
||||
// v13 (CURRENT WRITE FORMAT) is v12 PLUS the DUAL Staged/Spline envelope state, appended after
|
||||
// the velocity->pitch curve. Its two halves, in order:
|
||||
// (a) the three spline EGs — amp, pitch, filter, in that order. Each: 1 byte mode (0 Staged /
|
||||
// 1 Spline), then a SPLINE CURVE block: 4-byte LE point count N, then per point 8-byte LE
|
||||
// x + 8-byte LE y (doubles) + 1 byte hard. x spans the curve's canonical [0,127] (a
|
||||
// normalized-time contour maps onto that same span — velocity_curve.h owns why one span
|
||||
// serves both), y is UNIPOLAR [0,1]; the pitch and filter depth knobs scale it.
|
||||
// (b) the HARD-FLAG tails for the three v7/v9/v12 velocity curves — amp, filter, pitch, in
|
||||
// that order. Each: 4-byte LE count N, then N bytes. Those three curve blocks are FROZEN
|
||||
// at 16 bytes/point and cannot grow a per-point flag, so the flags ride here instead. A
|
||||
// tail whose count does not match the curve as read is IGNORED (the curve keeps its
|
||||
// flags-off default) rather than applied to the wrong knots — a repaired blob loses the
|
||||
// hard points, never misplaces them.
|
||||
// A v12-or-older blob is a strict prefix and lifts to {Staged, the y = 1 - x default contour}
|
||||
// on all three EGs with no hard point anywhere, so it plays exactly as it did.
|
||||
//
|
||||
// v14 (CURRENT WRITE FORMAT) is v13 PLUS the resample bake's Hold division, appended after the
|
||||
// hard-flag tails: 4-byte LE quarterExponent (two's-complement int32) + 1 byte modifier (0
|
||||
// Straight / 1 Dotted / 2 Triplet). Decoded through makeDivision, which clamps both fields —
|
||||
// never memcpy'd into the type (core/instrument/note/CLAUDE.md owns why). A v13-or-older blob
|
||||
// is a strict prefix and lifts to one bar, and Hold reaches no audio path, so a pre-v14
|
||||
// instance plays and bakes identically except where its window was underived to begin with.
|
||||
// A blob truncated INSIDE this tail costs the Hold alone rather than resetting the record —
|
||||
// the same revive discipline the v13 hard-flag tails follow, and for the same reason.
|
||||
//
|
||||
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
|
||||
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
|
||||
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
|
||||
// seconds at the project rate the reader is handed. v10+ writes ZERO into both — the values
|
||||
// live in the AHD now, so a DOWNGRADE to a pre-v10 binary loses the Trigger amp shape.
|
||||
//
|
||||
// LOSSY UNDER A RATE MISMATCH. The fades were SOURCE frames and the AHD stores wall-clock
|
||||
// seconds, so the lift divides by the PROJECT rate while the build later multiplies by the
|
||||
// DECODE rate: a file whose own rate differs from the project's comes back scaled by that
|
||||
// ratio (a 441-frame fade on a 44.1 kHz file in a 48 kHz project resolves to 405 source
|
||||
// frames, ~8% short). The codec cannot close this — it never sees the file — and deferring the
|
||||
// lift to build time would mean carrying the retired fade pair through the parameter set,
|
||||
// reintroducing the mechanism the AHD replaced.
|
||||
//
|
||||
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
|
||||
|
||||
// The exponents the lifted fades take. The AHD's curve law is phi^p (core/util/curve_law.h),
|
||||
// which cannot reproduce the retired pair's equal-power sin/cos exactly at ANY exponent — so
|
||||
// the lift takes the MINIMAX fit rather than the linear neutral, which is free (one constant,
|
||||
// written once here) and several times closer. The two differ because the two stages fit
|
||||
// different forms: attack fits phi^p to sin(pi*phi/2), decay fits 1 - t^q to cos(pi*t/2).
|
||||
// The measured bounds are asserted in tests/test_component_state_io.cpp. Every OTHER curve on
|
||||
// a migrated blob still lifts to the neutral — only the fades had a prior shape to reproduce.
|
||||
inline constexpr double kTriggerFadeLiftAttackCurve = 0.6133;
|
||||
inline constexpr double kTriggerFadeLiftDecayCurve = 1.7437;
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The params-payload format version and its detection marker. The marker is a high sentinel
|
||||
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
||||
// a reader detects record shape independent of the envelope version.
|
||||
inline constexpr std::uint32_t kParamsPayloadVersion = 9; // v8 + the per-voice filter tail
|
||||
inline constexpr std::uint32_t kParamsPayloadVersion = 14; // v13 + the bake Hold division
|
||||
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
|
||||
@@ -98,6 +173,25 @@ inline constexpr std::uint32_t kParamsSingleRecordVersion = 8;
|
||||
// self-describing, mirroring the envelope's version constants.
|
||||
inline constexpr std::uint32_t kParamsFilterVersion = 9;
|
||||
|
||||
// v9 + the staged-curve tail (curve exponents, the Trigger AHDs, the pitch Hold fraction).
|
||||
inline constexpr std::uint32_t kParamsCurveVersion = 10;
|
||||
|
||||
// v10 + the loop-crossfade frame count.
|
||||
inline constexpr std::uint32_t kParamsLoopVersion = 11;
|
||||
|
||||
// v11 + the velocity->pitch curve; the appended tail branches on THIS, never on
|
||||
// kParamsPayloadVersion. The filter curve's v12 domain re-tag needs no branch of its own — a
|
||||
// pre-v12 curve's y values are already valid bipolar ones.
|
||||
inline constexpr std::uint32_t kParamsVelocityVersion = 12;
|
||||
|
||||
// v12 + the dual Staged/Spline state; the appended tail branches on THIS, never on
|
||||
// kParamsPayloadVersion.
|
||||
inline constexpr std::uint32_t kParamsSplineVersion = 13;
|
||||
|
||||
// v13 + the bake Hold division; the appended tail branches on THIS, never on
|
||||
// kParamsPayloadVersion.
|
||||
inline constexpr std::uint32_t kParamsBakeHoldVersion = 14;
|
||||
|
||||
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
|
||||
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
|
||||
// (frames / projectRate = seconds) — the same rate the build already receives, so the
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
// params_payload.cpp — see params_payload.h. The format ladder it implements is documented
|
||||
// in component_state_io.h; every wire format below is FROZEN.
|
||||
|
||||
#include "core/instrument/map/params_payload.h"
|
||||
|
||||
#include <algorithm> // std::min (bounded curve-point reserve)
|
||||
#include <cassert> // assert (v3-lift projectRate guard)
|
||||
#include <cmath> // std::isfinite (wire-value validation)
|
||||
#include <utility> // std::move
|
||||
|
||||
#include "core/util/curve_law.h" // clampCurve / kCurveNeutral (wire validation)
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
using reasampler::wire::ByteReader;
|
||||
using reasampler::wire::asU64;
|
||||
using reasampler::wire::bitsToDouble;
|
||||
using reasampler::wire::doubleToBits;
|
||||
using reasampler::wire::putLE;
|
||||
|
||||
namespace {
|
||||
|
||||
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
|
||||
// the two shapes cannot drift byte-for-byte.
|
||||
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
out.push_back(p.rootOverride ? 1 : 0);
|
||||
if (p.rootOverride) {
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
|
||||
}
|
||||
out.push_back(p.loopOverride ? 1 : 0);
|
||||
if (p.loopOverride) {
|
||||
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(p.loopOverride->start));
|
||||
putLE(out, asU64(p.loopOverride->end));
|
||||
}
|
||||
out.push_back(p.startPoint ? 1 : 0);
|
||||
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
||||
}
|
||||
|
||||
// A velocity curve: 4-byte LE control-point count, then per point velocity + value as doubles.
|
||||
// The amp curve (v7), the filter's own curve (v9) and the pitch curve (v12) share this shape;
|
||||
// the y DOMAIN is not on the wire — it is a property of the slot, so the reader supplies it.
|
||||
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
||||
const std::vector<VelocityPoint>& pts = curve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& pt : pts) {
|
||||
putLE(out, doubleToBits(pt.velocity));
|
||||
putLE(out, doubleToBits(pt.value));
|
||||
}
|
||||
}
|
||||
|
||||
// A spline EG: 1 byte mode, then the contour as count + (x, y, hard) per point. Distinct from
|
||||
// putCurve because the three velocity-curve blocks are frozen at 16 bytes/point and cannot grow
|
||||
// the hard flag; this block was born with it.
|
||||
void putSplineEnv(std::vector<std::uint8_t>& out, const SplineEnv& s) {
|
||||
out.push_back(s.mode == EnvMode::Spline ? 1 : 0);
|
||||
const std::vector<VelocityPoint>& pts = s.contour.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& pt : pts) {
|
||||
putLE(out, doubleToBits(pt.velocity));
|
||||
putLE(out, doubleToBits(pt.value));
|
||||
out.push_back(pt.hard ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// The hard flags of an already-written velocity curve: count + one byte per point.
|
||||
void putHardFlags(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
||||
const std::vector<VelocityPoint>& pts = curve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& pt : pts) out.push_back(pt.hard ? 1 : 0);
|
||||
}
|
||||
|
||||
// A stored AHD's five doubles, in one order shared by every AHD on the wire.
|
||||
void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
|
||||
putLE(out, doubleToBits(a.attackSeconds));
|
||||
putLE(out, doubleToBits(a.decaySeconds));
|
||||
putLE(out, doubleToBits(a.holdFraction));
|
||||
putLE(out, doubleToBits(a.attackCurve));
|
||||
putLE(out, doubleToBits(a.decayCurve));
|
||||
}
|
||||
// THE lift of the retired Trigger fade pair onto the AHD that replaced it: Attack takes the
|
||||
// fade-in, Decay the fade-out, Hold the whole remainder — so a zero fade-out lands Decay = 0
|
||||
// and the abrupt end an old instance could express stays representable. The seconds conversion
|
||||
// and its rate-mismatch bound, and the two fitted exponents, are documented in
|
||||
// component_state_io.h. A v10-or-newer blob overwrites all five fields from its own tail.
|
||||
void liftTriggerFades(std::int64_t fadeInFrames, std::int64_t fadeOutFrames, double projectRate,
|
||||
AhdSeconds& out) {
|
||||
const double rate = projectRate > 0.0 ? projectRate : 1.0;
|
||||
out.attackSeconds = static_cast<double>(fadeInFrames > 0 ? fadeInFrames : 0) / rate;
|
||||
out.decaySeconds = static_cast<double>(fadeOutFrames > 0 ? fadeOutFrames : 0) / rate;
|
||||
out.holdFraction = 1.0;
|
||||
out.attackCurve = kTriggerFadeLiftAttackCurve;
|
||||
out.decayCurve = kTriggerFadeLiftDecayCurve;
|
||||
}
|
||||
|
||||
// A wire double whose consumers assume a domain they cannot check: the seconds fields reach
|
||||
// resolvePlay's static_cast<std::int64_t> and peakSemitones reaches the bake's pow() and the
|
||||
// voice's ratio multiply — both undefined or poisoning on NaN. Degrades to the field's own
|
||||
// construction default, so a damaged blob loses that field rather than the record.
|
||||
double finiteOr(double v, double fallback) { return std::isfinite(v) ? v : fallback; }
|
||||
|
||||
// A root-note override off the wire. Clamped HERE and not only where it is consumed: planBake
|
||||
// clamps the note it renders at into MIDI range while the SampleData keeps the raw override as
|
||||
// its root, and the two disagreeing makes the read rate something other than 1 — which
|
||||
// mis-sizes the bake's window in the truncating direction.
|
||||
int clampMidiNote(int note) { return (std::max)(0, (std::min)(127, note)); }
|
||||
|
||||
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||
// v8 single-record reader so the two can never disagree about field order.
|
||||
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p, double projectRate) {
|
||||
const PlaySeconds fallback; // the construction defaults, read rather than restated
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = finiteOr(bitsToDouble(r.u64()), fallback.adsr.holdSeconds);
|
||||
p.play.trigger.lengthFraction =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.trigger.lengthFraction);
|
||||
const std::int64_t fadeIn = r.i64();
|
||||
const std::int64_t fadeOut = r.i64();
|
||||
liftTriggerFades(fadeIn, fadeOut, projectRate, p.play.trigAhd);
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.shape.attackSeconds =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.shape.attackSeconds);
|
||||
p.play.pitchEnv.shape.decaySeconds =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.shape.decaySeconds);
|
||||
p.play.pitchEnv.peakSemitones =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.peakSemitones);
|
||||
p.play.adsr.attackSeconds = finiteOr(bitsToDouble(r.u64()), fallback.adsr.attackSeconds);
|
||||
p.play.adsr.decaySeconds = finiteOr(bitsToDouble(r.u64()), fallback.adsr.decaySeconds);
|
||||
p.play.adsr.sustainLevel = finiteOr(bitsToDouble(r.u64()), fallback.adsr.sustainLevel);
|
||||
p.play.adsr.releaseSeconds = finiteOr(bitsToDouble(r.u64()), fallback.adsr.releaseSeconds);
|
||||
}
|
||||
|
||||
// Read a velocity curve tail into `curve`, interpreting its y values in `domain` — the domain
|
||||
// is not on the wire, it is a property of the slot. fromPoints repairs the X-order/endpoint
|
||||
// invariant defensively; a truncated read leaves `curve` at whatever default it came in with.
|
||||
void readCurveTail(ByteReader& r, VelocityCurve& curve,
|
||||
reasampler::instrument::engine::CurveDomain domain) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
||||
// can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double value = bitsToDouble(r.u64());
|
||||
// A NaN velocity breaks fromPoints' stable_sort (not a strict weak ordering with NaN
|
||||
// present); a NaN value reaches the RT eval's multiply. Same non-finite-falls-back-to-0
|
||||
// guard as every other wire double this codec reads.
|
||||
pts.push_back(VelocityPoint{std::isfinite(vel) ? vel : 0.0,
|
||||
std::isfinite(value) ? value : 0.0});
|
||||
}
|
||||
if (r.ok) {
|
||||
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain);
|
||||
}
|
||||
}
|
||||
|
||||
// Read a spline EG. A truncated read leaves `s` at its Staged/default-contour construction
|
||||
// value, which is what makes a pre-v13 blob play exactly as it did.
|
||||
void readSplineEnv(ByteReader& r, SplineEnv& s) {
|
||||
const bool spline = (r.u8() != 0);
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (17 bytes/point) so a corrupt huge count
|
||||
// can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 17));
|
||||
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
||||
const double x = bitsToDouble(r.u64());
|
||||
const double y = bitsToDouble(r.u64());
|
||||
const bool hard = (r.u8() != 0);
|
||||
// Same NaN guard as readCurveTail: an x NaN breaks fromPoints' sort, a y NaN reaches
|
||||
// SplineCursor::eval's multiply into the per-sample amp gain.
|
||||
pts.push_back(VelocityPoint{std::isfinite(x) ? x : 0.0, std::isfinite(y) ? y : 0.0, hard});
|
||||
}
|
||||
if (!r.ok) return;
|
||||
s.mode = spline ? EnvMode::Spline : EnvMode::Staged;
|
||||
if (pts.size() < 2) {
|
||||
// fromPoints' own sub-2-point fallback is flat()/zero() by DOMAIN — the neutral velocity
|
||||
// curve response (a full-open gate). A spline EG's documented neutral is y = 1 - x
|
||||
// instead, so a malformed/short block substitutes that rather than fromPoints' default.
|
||||
s.contour = VelocityCurve::rampDown();
|
||||
return;
|
||||
}
|
||||
s.contour = VelocityCurve::fromPoints(std::move(pts),
|
||||
reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||
}
|
||||
|
||||
// A block whose declared length overruns what the blob still holds has no realignment point:
|
||||
// every byte after it belongs to a block that was truncated, so a later tail that reads them
|
||||
// as its own gets an ARBITRARY value — and a tail that clamps (the bake Hold does) turns that
|
||||
// into a legal-looking fabrication rather than an obvious one. Draining is what makes the
|
||||
// stream's end honest: each later tail then reads past it and degrades to absent through its
|
||||
// own revive, while the record that parsed cleanly ahead of the damage survives.
|
||||
void drainUnaligned(ByteReader& r) { r.pos = r.bytes.size(); }
|
||||
|
||||
// Apply a hard-flag tail to an already-read velocity curve. A count that disagrees with the
|
||||
// curve fromPoints actually produced — including an out-of-bounds or truncated one — is
|
||||
// dropped rather than applied to shifted knots, and the whole params record parsed ahead of
|
||||
// this tail survives (component_state_io.h's documented promise): if THIS call is what tripped
|
||||
// r.ok (a truncated count field), it is revived before returning. An r.ok already false on
|
||||
// entry (an earlier, unrelated field genuinely truncated) is left alone — that failure is not
|
||||
// this tail's to forgive.
|
||||
void readHardFlags(ByteReader& r, VelocityCurve& curve) {
|
||||
const bool enteredOk = r.ok;
|
||||
const std::uint32_t count = r.u32();
|
||||
if (!r.ok) {
|
||||
if (enteredOk) r.ok = true; // a truncated count field: nothing to apply
|
||||
return;
|
||||
}
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
if (count > remaining) {
|
||||
drainUnaligned(r); // the flags this count promised are not all there
|
||||
return;
|
||||
}
|
||||
std::vector<std::uint8_t> flags;
|
||||
flags.reserve(count);
|
||||
for (std::uint32_t i = 0; i < count; ++i) flags.push_back(r.u8());
|
||||
if (flags.size() != curve.size()) return;
|
||||
for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
|
||||
}
|
||||
|
||||
// Read the v14 bake Hold. Same revive discipline as readHardFlags directly above, and for the
|
||||
// same reason: this tail reaches no audio path, so a blob truncated inside it must cost the
|
||||
// Hold alone and not reset the whole record that parsed cleanly ahead of it. It sits LAST, so
|
||||
// a truncation stranding the hard flags strands this too — reviving in only one of the two
|
||||
// would still wipe the record.
|
||||
void readBakeHold(ByteReader& r, InstrumentParams& p) {
|
||||
const bool enteredOk = r.ok;
|
||||
const std::int32_t exponent = r.i32();
|
||||
const std::uint8_t modifier = r.u8();
|
||||
if (!r.ok) {
|
||||
if (enteredOk) r.ok = true;
|
||||
return;
|
||||
}
|
||||
// makeDivision clamps BOTH fields, so a corrupt pair becomes the nearest legal rung
|
||||
// rather than an unrepresentable one — never a memcpy into the type.
|
||||
p.bakeHold = note::makeDivision(exponent, static_cast<note::DivisionModifier>(modifier));
|
||||
}
|
||||
|
||||
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
|
||||
// which is what makes a v8 blob play bit-identically under the new codec. The curve reads as
|
||||
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
|
||||
// v12 domain re-tag needs no version branch (see component_state_io.h).
|
||||
void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
||||
const FilterSeconds fallback; // the construction defaults, read rather than restated
|
||||
FilterSeconds& f = p.play.filter;
|
||||
f.enabled = (r.u8() != 0);
|
||||
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
|
||||
: engine::filter::MorphLaw::HighBandLow;
|
||||
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
|
||||
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
|
||||
double modAmount = bitsToDouble(r.u64());
|
||||
double velAmount = bitsToDouble(r.u64());
|
||||
double keyTrack = bitsToDouble(r.u64());
|
||||
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
|
||||
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
|
||||
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
|
||||
f.env.attackSeconds = finiteOr(bitsToDouble(r.u64()), fallback.env.attackSeconds);
|
||||
f.env.holdSeconds = finiteOr(bitsToDouble(r.u64()), fallback.env.holdSeconds);
|
||||
f.env.decaySeconds = finiteOr(bitsToDouble(r.u64()), fallback.env.decaySeconds);
|
||||
f.env.sustainLevel = finiteOr(bitsToDouble(r.u64()), fallback.env.sustainLevel);
|
||||
f.env.releaseSeconds = finiteOr(bitsToDouble(r.u64()), fallback.env.releaseSeconds);
|
||||
readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||
}
|
||||
|
||||
// A curve exponent off the wire. A corrupt/non-finite value degrades to the LINEAR neutral
|
||||
// rather than to an endpoint: neutral is the one exponent that cannot change how a stage
|
||||
// sounds, so a damaged blob loses the shaping instead of inventing one.
|
||||
double readCurveExponent(ByteReader& r) {
|
||||
const double v = bitsToDouble(r.u64());
|
||||
return std::isfinite(v) ? reasampler::util::clampCurve(v) : reasampler::util::kCurveNeutral;
|
||||
}
|
||||
|
||||
void readAhd(ByteReader& r, AhdSeconds& a) {
|
||||
// attackSeconds/decaySeconds reach resolvePlay's static_cast<std::int64_t> (sample_map.cpp)
|
||||
// unguarded — UB on NaN, and on a large-enough finite value — so a corrupt/non-finite wire
|
||||
// value degrades to 0 seconds rather than reaching that cast, the same guard readSecondsPlayTail
|
||||
// and the v9 filter tail already apply to their own wall-clock fields.
|
||||
const double attack = bitsToDouble(r.u64());
|
||||
const double decay = bitsToDouble(r.u64());
|
||||
a.attackSeconds = std::isfinite(attack) ? attack : 0.0;
|
||||
a.decaySeconds = std::isfinite(decay) ? decay : 0.0;
|
||||
const double frac = bitsToDouble(r.u64());
|
||||
a.holdFraction = std::isfinite(frac) ? frac : 0.0;
|
||||
a.attackCurve = readCurveExponent(r);
|
||||
a.decayCurve = readCurveExponent(r);
|
||||
}
|
||||
|
||||
// Read the v10 staged-curve tail into `p`. A blob that stops short leaves the neutral
|
||||
// exponents and the fade-lifted Trigger AHD, which is what makes a v9 blob play as before.
|
||||
void readCurveStageTail(ByteReader& r, InstrumentParams& p) {
|
||||
PlaySeconds& pp = p.play;
|
||||
pp.adsr.attackCurve = readCurveExponent(r);
|
||||
pp.adsr.decayCurve = readCurveExponent(r);
|
||||
pp.adsr.releaseCurve = readCurveExponent(r);
|
||||
readAhd(r, pp.trigAhd);
|
||||
const double pitchHold = bitsToDouble(r.u64());
|
||||
pp.pitchEnv.shape.holdFraction = std::isfinite(pitchHold) ? pitchHold : 0.0;
|
||||
pp.pitchEnv.shape.attackCurve = readCurveExponent(r);
|
||||
pp.pitchEnv.shape.decayCurve = readCurveExponent(r);
|
||||
pp.filter.env.attackCurve = readCurveExponent(r);
|
||||
pp.filter.env.decayCurve = readCurveExponent(r);
|
||||
pp.filter.env.releaseCurve = readCurveExponent(r);
|
||||
readAhd(r, pp.filter.trigEnv);
|
||||
}
|
||||
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
|
||||
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
|
||||
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
|
||||
// no file and no bank entry.
|
||||
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
|
||||
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
|
||||
// blobs carry seconds directly and need no rate.
|
||||
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
|
||||
PayloadRead out;
|
||||
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
bool adopted = false;
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
|
||||
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
|
||||
InstrumentParams p;
|
||||
std::string sampleId;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
sampleId = r.str(idLen);
|
||||
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
|
||||
r.i32(); // highNote
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) p.rootOverride = clampMidiNote(r.i32());
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
|
||||
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
|
||||
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
|
||||
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
|
||||
const PlaySeconds fallback; // same guard as readSecondsPlayTail's peer fields
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.trigger.lengthFraction =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.trigger.lengthFraction);
|
||||
const std::int64_t fadeIn = r.i64();
|
||||
const std::int64_t fadeOut = r.i64();
|
||||
liftTriggerFades(fadeIn, fadeOut, liftRate, p.play.trigAhd);
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.shape.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.shape.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.peakSemitones =
|
||||
finiteOr(bitsToDouble(r.u64()), fallback.pitchEnv.peakSemitones);
|
||||
} else if (secondsPlay) {
|
||||
readSecondsPlayTail(r, p, projectRate);
|
||||
}
|
||||
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
||||
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat(). A NaN
|
||||
// reaches keyTrackedRatio -> baseRatio_ -> readPos_'s per-sample cast (voice.h) — the
|
||||
// same guard the v8+ single-record reader applies to its own keyTrack below.
|
||||
if (keyTrackTail) p.keyTrack = finiteOr(bitsToDouble(r.u64()), InstrumentParams{}.keyTrack);
|
||||
if (curveTail) {
|
||||
readCurveTail(r, p.velocityCurve,
|
||||
reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||
}
|
||||
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
||||
// leave the seconds product defaults on p.play.
|
||||
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
||||
if (!adopted) {
|
||||
out.params = std::move(p);
|
||||
out.adoptedSampleId = std::move(sampleId);
|
||||
adopted = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||
// shape independent of the envelope version (see component_state_io.h).
|
||||
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
putLE(out, kParamsFormatMarker);
|
||||
putLE(out, kParamsPayloadVersion);
|
||||
putOverrides(out, p);
|
||||
|
||||
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
|
||||
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
|
||||
const PlaySeconds& pp = p.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
// The retired fade pair's two frozen slots (see the header): the shape stays, the values
|
||||
// moved into the Trigger AHD tail below.
|
||||
putLE(out, asU64(std::int64_t{0}));
|
||||
putLE(out, asU64(std::int64_t{0}));
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.shape.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.shape.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// Key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(p.keyTrack));
|
||||
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
|
||||
// velocity + amp as doubles (endpoints included, so N >= 2).
|
||||
putCurve(out, p.velocityCurve);
|
||||
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
|
||||
// whole payload stays one numeric shape.
|
||||
const FilterSeconds& f = pp.filter;
|
||||
out.push_back(f.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
|
||||
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
|
||||
putLE(out, doubleToBits(f.modAmount));
|
||||
putLE(out, doubleToBits(f.velAmount));
|
||||
putLE(out, doubleToBits(f.keyTrack));
|
||||
putLE(out, doubleToBits(f.env.attackSeconds));
|
||||
putLE(out, doubleToBits(f.env.holdSeconds));
|
||||
putLE(out, doubleToBits(f.env.decaySeconds));
|
||||
putLE(out, doubleToBits(f.env.sustainLevel));
|
||||
putLE(out, doubleToBits(f.env.releaseSeconds));
|
||||
putCurve(out, f.velocityCurve);
|
||||
// v10: the staged-curve tail.
|
||||
putLE(out, doubleToBits(pp.adsr.attackCurve));
|
||||
putLE(out, doubleToBits(pp.adsr.decayCurve));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseCurve));
|
||||
putAhd(out, pp.trigAhd);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.shape.holdFraction));
|
||||
putLE(out, doubleToBits(pp.pitchEnv.shape.attackCurve));
|
||||
putLE(out, doubleToBits(pp.pitchEnv.shape.decayCurve));
|
||||
putLE(out, doubleToBits(f.env.attackCurve));
|
||||
putLE(out, doubleToBits(f.env.decayCurve));
|
||||
putLE(out, doubleToBits(f.env.releaseCurve));
|
||||
putAhd(out, f.trigEnv);
|
||||
// v11: the loop crossfade, in SOURCE frames.
|
||||
putLE(out, asU64(p.loopCrossfadeFrames));
|
||||
// v12: the velocity->pitch curve.
|
||||
putCurve(out, pp.pitchVelocityCurve);
|
||||
// v13: the dual Staged/Spline state — the three contours, then the hard flags the three
|
||||
// frozen velocity-curve blocks above had no room for.
|
||||
putSplineEnv(out, pp.ampSpline);
|
||||
putSplineEnv(out, pp.pitchSpline);
|
||||
putSplineEnv(out, pp.filterSpline);
|
||||
putHardFlags(out, p.velocityCurve);
|
||||
putHardFlags(out, f.velocityCurve);
|
||||
putHardFlags(out, pp.pitchVelocityCurve);
|
||||
// v14: the bake's Hold division, as its {quarterExponent, modifier} pair — never its
|
||||
// picker index, which the ladder gaining a rung would silently re-map.
|
||||
putLE(out, static_cast<std::uint32_t>(
|
||||
static_cast<std::int32_t>(p.bakeHold.quarterExponent())));
|
||||
out.push_back(static_cast<std::uint8_t>(p.bakeHold.modifier()));
|
||||
}
|
||||
|
||||
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
||||
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
|
||||
// v1 (a plain small zone count).
|
||||
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||
std::uint32_t pv = 0; // 0 = v1, no marker
|
||||
if (r.peekU32() == kParamsFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
}
|
||||
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
|
||||
|
||||
PayloadRead out;
|
||||
InstrumentParams& p = out.params;
|
||||
const std::uint8_t hasRoot = r.u8();
|
||||
if (hasRoot) p.rootOverride = clampMidiNote(r.i32());
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
readSecondsPlayTail(r, p, projectRate);
|
||||
// NaN reaches keyTrackedRatio (voice.h) -> baseRatio_ -> readPos_'s per-sample
|
||||
// static_cast<std::int64_t> — UB on the per-sample path. Guarded here, codec-side, so
|
||||
// that path needs no check of its own.
|
||||
p.keyTrack = finiteOr(bitsToDouble(r.u64()), InstrumentParams{}.keyTrack);
|
||||
readCurveTail(r, p.velocityCurve, reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
||||
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
|
||||
if (pv >= kParamsLoopVersion) {
|
||||
// A negative fade is meaningless and would reach resolveLoop's clamp anyway; refusing
|
||||
// it here keeps the parameter set itself sane for the editor that reads it back.
|
||||
const std::int64_t xf = r.i64();
|
||||
p.loopCrossfadeFrames = xf > 0 ? xf : 0;
|
||||
}
|
||||
if (pv >= kParamsVelocityVersion) {
|
||||
readCurveTail(r, p.play.pitchVelocityCurve,
|
||||
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||
}
|
||||
if (pv >= kParamsSplineVersion) {
|
||||
readSplineEnv(r, p.play.ampSpline);
|
||||
readSplineEnv(r, p.play.pitchSpline);
|
||||
readSplineEnv(r, p.play.filterSpline);
|
||||
readHardFlags(r, p.velocityCurve);
|
||||
readHardFlags(r, p.play.filter.velocityCurve);
|
||||
readHardFlags(r, p.play.pitchVelocityCurve);
|
||||
}
|
||||
if (pv >= kParamsBakeHoldVersion) readBakeHold(r, p);
|
||||
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||
// the same degrade-don't-throw contract the zone ladder always had.
|
||||
if (!r.ok) return PayloadRead{};
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
// params_payload — the params-payload half of the ComponentState codec, split from the
|
||||
// ENVELOPE half on the axis the format itself already has: the payload carries its OWN
|
||||
// version and grows independently of the envelope's, so the two version ladders are two
|
||||
// responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay
|
||||
// serialize/deserializeComponentState; nothing outside the codec calls these.
|
||||
//
|
||||
// The format ladder (payload v1..v11) is documented in component_state_io.h, which stays its
|
||||
// one home. EVERY wire format is FROZEN.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// The payload's version constants and the prose ladder stay in component_state_io.h, their
|
||||
// one home — this half implements them rather than re-declaring them.
|
||||
#include "core/instrument/map/component_state_io.h"
|
||||
#include "core/wire/bytes.h" // ByteReader
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
|
||||
// payload that carried at least one zone: the first zone's capture, which supersedes the
|
||||
// envelope's selection id (see the adoption rule in component_state_io.h).
|
||||
struct PayloadRead {
|
||||
InstrumentParams params;
|
||||
std::string adoptedSampleId;
|
||||
};
|
||||
|
||||
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||
// shape independent of the envelope version.
|
||||
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p);
|
||||
|
||||
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
||||
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
|
||||
// v1 (a plain small zone count). `projectRate` converts the LEGACY v3 wall-clock frame counts
|
||||
// and the retired Trigger fade pair to the seconds domain at the read boundary.
|
||||
PayloadRead readParamsPayload(reasampler::wire::ByteReader& r, double projectRate);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
// play_seconds — the STORED, wall-clock-SECONDS value layer the instrument edits and
|
||||
// serializes: PlaySeconds and the four stage-time structs it composes. Header-only, and split
|
||||
// from sample_map so a consumer that only edits these values (the editor's deck binding) does
|
||||
// not link the bank model and the WAV codec to reach one value struct. `resolvePlay`, which
|
||||
// turns them into the engine's frame domain, stays in sample_map with the rest of the mapping.
|
||||
|
||||
#include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / …
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
using instrument::engine::VelocityCurve;
|
||||
|
||||
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
|
||||
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
|
||||
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
|
||||
// build. Quantities anchored to the source file's timeline (start point, loop points,
|
||||
// Trigger %-length + fades) stay in source frames/fractions, carried through unchanged
|
||||
// (TriggerParams reused verbatim).
|
||||
//
|
||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time; the
|
||||
// three curve exponents are dimensionless too (curve_law.h owns their domain).
|
||||
struct AdsrSeconds {
|
||||
double attackSeconds = 0.003; // tier-0 default
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060; // tier-0 default
|
||||
double attackCurve = util::kCurveNeutral;
|
||||
double decayCurve = util::kCurveNeutral;
|
||||
double releaseCurve = util::kCurveNeutral;
|
||||
};
|
||||
|
||||
// The stored sustain-less AHD: wall-clock stage times in SECONDS, Hold as a FRACTION of the
|
||||
// span left after them (AhdParams owns why a fraction, not a time).
|
||||
struct AhdSeconds {
|
||||
double attackSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double holdFraction = 1.0;
|
||||
double attackCurve = util::kCurveNeutral;
|
||||
double decayCurve = util::kCurveNeutral;
|
||||
};
|
||||
|
||||
// The stored AHD pitch envelope. enabled + peakSemitones are dimensionless. The hold fraction
|
||||
// defaults to 0 so an instance predating the stage plays as its attack-decay predecessor did.
|
||||
struct PitchEnvSeconds {
|
||||
bool enabled = false;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
AhdSeconds shape{0.0, 0.0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
|
||||
};
|
||||
|
||||
// The stored mirror of the engine's FilterParams (play_params.h, which owns what each field
|
||||
// MEANS). Only the envelope differs between the two: the control positions and depths are
|
||||
// rate-free already, so this block is a seconds/frames split of one field, not of the whole
|
||||
// struct. The env default is a flat unity, so `enabled` is the only thing standing between a
|
||||
// loaded blob and the pre-filter sound.
|
||||
struct FilterSeconds {
|
||||
bool enabled = false;
|
||||
engine::filter::FilterSettings settings;
|
||||
double modAmount = 0.0;
|
||||
double velAmount = 0.0;
|
||||
double keyTrack = 0.0;
|
||||
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate
|
||||
AhdSeconds trigEnv; // Trigger
|
||||
VelocityCurve velocityCurve = VelocityCurve::zero();
|
||||
};
|
||||
|
||||
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||
// frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
|
||||
// from the engine-facing PlayParams (frames).
|
||||
struct PlaySeconds {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrSeconds adsr; // Gate amp: AHDSR (seconds)
|
||||
TriggerParams trigger; // Trigger play span (%-length)
|
||||
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
|
||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
|
||||
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
|
||||
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
|
||||
FilterSeconds filter; // per-voice filter, off by default
|
||||
// The three drawn contours, in the same slots the engine bundle carries them (play_params.h
|
||||
// owns why they sit beside the envelopes rather than inside them). Normalized over the
|
||||
// sample's own length, so resolvePlay needs no rate for them.
|
||||
SplineEnv ampSpline;
|
||||
SplineEnv pitchSpline;
|
||||
SplineEnv filterSpline;
|
||||
};
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -203,8 +203,8 @@ DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
}
|
||||
|
||||
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
|
||||
// fades) carry through untouched, already frames/fractions.
|
||||
// seconds -> frames at the LIVE rate; the source-timeline quantity (trigger %-length)
|
||||
// carries through untouched, already a fraction.
|
||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
const auto secToFrames = [sr](double sec) {
|
||||
@@ -212,6 +212,16 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
|
||||
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
|
||||
AhdParams a;
|
||||
a.attackFrames = secToFrames(s.attackSeconds);
|
||||
a.decayFrames = secToFrames(s.decaySeconds);
|
||||
a.holdFraction = s.holdFraction;
|
||||
a.attackCurve = s.attackCurve;
|
||||
a.decayCurve = s.decayCurve;
|
||||
return a;
|
||||
};
|
||||
PlayParams out;
|
||||
out.playMode = stored.playMode;
|
||||
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
||||
@@ -219,12 +229,16 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
|
||||
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
|
||||
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
|
||||
out.trigger = stored.trigger; // source-frame / fraction, unchanged
|
||||
out.adsr.attackCurve = stored.adsr.attackCurve; // dimensionless
|
||||
out.adsr.decayCurve = stored.adsr.decayCurve;
|
||||
out.adsr.releaseCurve = stored.adsr.releaseCurve;
|
||||
out.trigger = stored.trigger; // fraction, unchanged
|
||||
out.trigAhd = resolveAhd(stored.trigAhd);
|
||||
out.pitchEngine = stored.pitchEngine;
|
||||
out.pitchEnv.enabled = stored.pitchEnv.enabled;
|
||||
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
||||
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
||||
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
||||
out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape);
|
||||
out.pitchVelocityCurve = stored.pitchVelocityCurve; // transfer curve, not a time
|
||||
// Filter: the control positions are already rate-free and carry through untouched; only
|
||||
// its envelope resolves to frames.
|
||||
out.filter.enabled = stored.filter.enabled;
|
||||
@@ -238,6 +252,20 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
out.filter.env.decayFrames = secToFrames(stored.filter.env.decaySeconds);
|
||||
out.filter.env.sustainLevel = stored.filter.env.sustainLevel;
|
||||
out.filter.env.releaseFrames = secToFrames(stored.filter.env.releaseSeconds);
|
||||
out.filter.env.attackCurve = stored.filter.env.attackCurve;
|
||||
out.filter.env.decayCurve = stored.filter.env.decayCurve;
|
||||
out.filter.env.releaseCurve = stored.filter.env.releaseCurve;
|
||||
out.filter.trigEnv = resolveAhd(stored.filter.trigEnv);
|
||||
// The three drawn contours are normalized over the sample's own length, so no rate resolves
|
||||
// them — they carry through verbatim, which is also what makes a different-length capture
|
||||
// replay the same shape proportionally.
|
||||
out.ampSpline = stored.ampSpline;
|
||||
out.pitchSpline = stored.pitchSpline;
|
||||
out.filterSpline = stored.filterSpline;
|
||||
// Every field splineActive reads on `out` is already copied from `stored` above, so this
|
||||
// enforces the same rule enforceGateUnavailableWhileDrawn's doc comment (play_params.h)
|
||||
// describes — the editor's applyControl is the other caller, so the two cannot drift.
|
||||
enforceGateUnavailableWhileDrawn(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -253,6 +281,7 @@ ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams
|
||||
// The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start).
|
||||
// The bank is never mutated.
|
||||
rs.loop = params.loopOverride ? *params.loopOverride : ref.loop;
|
||||
rs.loopCrossfadeFrames = params.loopCrossfadeFrames;
|
||||
rs.startFrame = params.startPoint ? *params.startPoint : 0;
|
||||
rs.play = params.play; // SECONDS; buildSampleData resolves to frames
|
||||
return rs;
|
||||
@@ -289,6 +318,7 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded)
|
||||
data.sampleRate = decoded.sampleRate;
|
||||
data.rootNote = resolved.rootNote;
|
||||
data.loop = resolved.loop;
|
||||
data.loopCrossfadeFrames = resolved.loopCrossfadeFrames;
|
||||
data.startFrame = resolved.startFrame;
|
||||
data.keyTrack = resolved.keyTrack;
|
||||
data.velocityCurve = resolved.velocityCurve;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// The bank is read over the live-state seam, audio over the file seam; both raw inputs
|
||||
// cross the bridge/file boundary in the shell, everything after (bank parse via the shared
|
||||
// bank_book JSON path, sample pick, channel policy, SampleData build) is pure and
|
||||
// unit-tested here. Links bank_book, wav_codec, and play_params (all pure) — deliberately
|
||||
// NOT the voice engine: the build's product is plain SampleData.
|
||||
// unit-tested here. Links bank_book, wav_codec, play_params, and play_seconds (all pure) —
|
||||
// deliberately NOT the voice engine: the build's product is plain SampleData.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
||||
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
|
||||
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the stored seconds value layer)
|
||||
#include "core/instrument/note/musical_division.h" // Division (the bake hold's value domain)
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
@@ -134,58 +136,7 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
|
||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount, int which);
|
||||
|
||||
// --- Stored (wall-clock SECONDS) play params ----------------------------------
|
||||
//
|
||||
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
|
||||
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
|
||||
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
|
||||
// build. Quantities anchored to the source file's timeline (start point, loop points,
|
||||
// Trigger %-length + fades) stay in source frames/fractions, carried through unchanged
|
||||
// (TriggerParams reused verbatim).
|
||||
//
|
||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
||||
struct AdsrSeconds {
|
||||
double attackSeconds = 0.003; // tier-0 default
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060; // tier-0 default
|
||||
};
|
||||
|
||||
// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless.
|
||||
struct PitchEnvSeconds {
|
||||
bool enabled = false;
|
||||
double attackSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The stored mirror of the engine's FilterParams (play_params.h, which owns what each field
|
||||
// MEANS). Only the envelope differs between the two: the control positions and depths are
|
||||
// rate-free already, so this block is a seconds/frames split of one field, not of the whole
|
||||
// struct. The env default is a flat unity, so `enabled` is the only thing standing between a
|
||||
// loaded blob and the pre-filter sound.
|
||||
struct FilterSeconds {
|
||||
bool enabled = false;
|
||||
engine::filter::FilterSettings settings;
|
||||
double modAmount = 0.0;
|
||||
double velAmount = 0.0;
|
||||
double keyTrack = 0.0;
|
||||
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0};
|
||||
VelocityCurve velocityCurve = VelocityCurve::linear();
|
||||
};
|
||||
|
||||
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||
// frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
|
||||
// from the engine-facing PlayParams (frames).
|
||||
struct PlaySeconds {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
||||
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
|
||||
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||
FilterSeconds filter; // per-voice filter, off by default
|
||||
};
|
||||
// --- Seconds -> frames --------------------------------------------------------
|
||||
|
||||
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
|
||||
// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
|
||||
@@ -211,6 +162,12 @@ struct InstrumentParams {
|
||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic
|
||||
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
||||
|
||||
// Pre-seam crossfade at the loop reset, in SOURCE frames — a source-timeline quantity
|
||||
// like the loop points, so it needs no rate to resolve and cannot be rescaled by a
|
||||
// project/file rate mismatch. 0 is the hard seam a blob predating the field lifts to.
|
||||
// Never a bank fact: the fade is a performance choice, the loop points are the file's.
|
||||
std::int64_t loopCrossfadeFrames = 0;
|
||||
|
||||
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
|
||||
// (100%, standard 12-tone-ET) is the default — a blob predating this field lifts to
|
||||
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
|
||||
@@ -232,6 +189,12 @@ struct InstrumentParams {
|
||||
// tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch
|
||||
// engine, pitch env off. An older blob lacking this tail lifts to exactly these.
|
||||
PlaySeconds play;
|
||||
|
||||
// How long the resample bake holds the gate. Read ONLY when the bake window cannot be
|
||||
// derived — a Gate voice over an active sustain loop, which sounds indefinitely
|
||||
// (bake_plan.h's bakeWindowNeedsHold is the predicate). Default one bar; a blob predating
|
||||
// the field lifts to it, and no other bake changes.
|
||||
note::Division bakeHold = note::makeDivision(2, note::DivisionModifier::Straight);
|
||||
};
|
||||
|
||||
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
|
||||
@@ -243,6 +206,7 @@ struct ResolvedCapture {
|
||||
double keyTrack = 1.0;
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
SampleLoop loop; // effective: loopOverride, else bank intrinsic
|
||||
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
|
||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
|
||||
PlaySeconds play; // stored SECONDS; resolved to frames at build
|
||||
};
|
||||
|
||||
@@ -10,18 +10,10 @@ std::int64_t triggerPlayLength(double lengthFraction,
|
||||
std::int64_t frameCount,
|
||||
std::int64_t startFrame) {
|
||||
const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - startFrame);
|
||||
if (postStart <= 0 || lengthFraction <= 0.0) return 0;
|
||||
return static_cast<std::int64_t>(lengthFraction * static_cast<double>(postStart) + 0.5);
|
||||
}
|
||||
|
||||
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength) {
|
||||
if (playLength <= 0) return 0.0;
|
||||
return static_cast<double>(fadeFrames) / static_cast<double>(playLength);
|
||||
}
|
||||
|
||||
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) {
|
||||
if (playLength <= 0) return 0;
|
||||
return static_cast<std::int64_t>(fadeFraction * static_cast<double>(playLength) + 0.5);
|
||||
if (postStart <= 0 || !(lengthFraction > 0.0)) return 0; // also catches NaN
|
||||
const double frac = (std::min)(1.0, lengthFraction);
|
||||
const auto len = static_cast<std::int64_t>(frac * static_cast<double>(postStart) + 0.5);
|
||||
return (std::min)(postStart, (std::max)(std::int64_t{0}, len));
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// trigger_seam — converts Trigger fade lengths between the engine domain (TriggerParams:
|
||||
// SOURCE FRAMES, anchored to the source-timeline read pointer) and the overlay domain
|
||||
// (AmpEnvelope: FRACTIONS in [0,1] of the played span, so the drawn shape stays invariant
|
||||
// across sample-rate changes). Owns the one shared pack/unpack formula so both directions
|
||||
// stay consistent; reasampler_editor calls these from packEnvelope/unpackEnvelope.
|
||||
// trigger_seam — the shared Trigger play-span formula: how a %-length becomes the source-frame
|
||||
// span the voice plays, the overlay draws over, and the bake's window holds. One home so those
|
||||
// cannot disagree about where a Trigger note ends.
|
||||
//
|
||||
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
||||
// playLengthFrames = round(clamp01(lengthFraction) * (frameCount - startFrame))
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -12,18 +10,16 @@
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
|
||||
// `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0
|
||||
// or lengthFraction <= 0.
|
||||
// `startFrame` is the effective start point (0 when absent), `lengthFraction` the EFFECTIVE one
|
||||
// — fold it through effectiveLengthFraction (play_params.h) first, or a stored-but-inert %-knob
|
||||
// shortens the span. Returns 0 for an empty post-start span and for any fraction that is not
|
||||
// above zero, NaN included; the fraction is clamped to 1.0 and the result to the span, so a
|
||||
// corrupt stored value cannot reach past the source.
|
||||
//
|
||||
// Voice::start evaluates this same clamped formula inline rather than calling it: the engine
|
||||
// does not depend on `map/`, in either direction.
|
||||
std::int64_t triggerPlayLength(double lengthFraction,
|
||||
std::int64_t frameCount,
|
||||
std::int64_t startFrame);
|
||||
|
||||
// PACK direction (draw path): frames -> fraction of play span. Not clamped here — the
|
||||
// caller clamps to [0,1] when filling AmpEnvelope (envelope_edit owns that logic).
|
||||
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
|
||||
|
||||
// UNPACK direction (commit path): fraction -> nearest source frame.
|
||||
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# src/core/instrument/note — the programmed capture signal
|
||||
|
||||
## Scope
|
||||
|
||||
The pure model of the note the sampler plays to itself when it resamples: how long it
|
||||
sounds, how hard, and how far around it the capture window opens. A fourth peer of
|
||||
`engine/` / `map/` / `ui/` under `core/instrument/`, and pure by the same rule — no REAPER
|
||||
types, no VST3 types, no host at all.
|
||||
|
||||
It exists as its own directory because it is neither engine (it renders nothing), mapping
|
||||
(it resolves no capture and builds no `SampleData`), nor UI (it computes no geometry). It
|
||||
is a performance *description* plus its arithmetic, read by two consumers that must not
|
||||
diverge: the bake that renders it, and any surface that comes to edit it. Today the bake
|
||||
DERIVES the whole record from the dialed sound rather than asking for it — the one field a
|
||||
derivation cannot supply is the Gate-over-a-loop note length, and that arrives as a single
|
||||
`Division` (see `bake/CLAUDE.md`), not as a hand-programmed record. Everything else it
|
||||
derives is an exact duration; see the note-length invariant below.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **One record, one resolver.** `NoteProgram` is the single source of truth and
|
||||
`resolveNote` the single way to turn it into times. A preview that computes its own
|
||||
window, or a bake that does, is the exact divergence this module exists to prevent — the
|
||||
criterion is structural (one path), not "the numbers looked close."
|
||||
- **The tempo comes in as a parameter.** The BPM in effect at the project cursor is read by
|
||||
the shell. Nothing here may reach for it, and no tempo is hardcoded anywhere in the
|
||||
directory — `Tempo` has no default and cannot be constructed without one.
|
||||
- **Resolved times are rate-free seconds.** The standing ruling: no sample rate appears
|
||||
here; the caller converts seconds to frames against the live rate.
|
||||
- **A note length is EITHER a musical division or an exact duration, and which one says who
|
||||
produced it.** *(Amends the earlier "musical-division-only" rule, which was settled when
|
||||
every length was hand-programmed through a picker.)* A PICKED length stays a `Division`: a
|
||||
picker's rungs are the point, and the tempo-relative reading — the same record meaning a
|
||||
different duration at a different tempo — is what the user asked for. A DERIVED length is
|
||||
exact seconds, because the ladder is finite: a source longer than its top rung has no rung
|
||||
that covers it, so quantizing up saturates and releases the note mid-sound, and on every
|
||||
shorter source it buys trailing silence for nothing. `NoteLength` holds one or the other and
|
||||
`noteLengthSeconds` resolves both, so no reader can pick the wrong denomination. Today the
|
||||
bake is the only producer of each: its window derivations are exact, and the Gate-over-a-loop
|
||||
Hold knob is the one picker.
|
||||
- **A division persists as its `{quarterExponent, modifier}` pair, never as its picker
|
||||
index.** The index is presentation order and would silently re-map every saved record if
|
||||
the ladder ever gained a rung or a modifier.
|
||||
- **An offset stores the denomination it was entered in** — see `OffsetAmount` in
|
||||
`note_program.h` for why.
|
||||
- **Every value type establishes its domain at construction, so every field `resolveNote`
|
||||
returns is finite for every constructible program and tempo.** `Tempo::fromBpm` rejects,
|
||||
alone, because an unusable BPM has no nearest usable one to fall to. `Division`,
|
||||
`OffsetAmount`, and `Velocity` clamp, because an off-ladder rung, an unrepresentable
|
||||
magnitude, and an out-of-range velocity each do. Each has exactly one door (`makeDivision`,
|
||||
`offsetOf`, `Velocity::of`); `Division` and `OffsetAmount` block any other path with a
|
||||
private value constructor, `Velocity` with a private member that only `of()` writes —
|
||||
either way an out-of-domain value cannot be held, only passed in. That is what lets every
|
||||
reader branch without a fallback, equality compare fields raw, and `resolveNote` return
|
||||
finite times for every constructible input with no failure path and no validity flag.
|
||||
- **The module will not tell a caller a record is junk, because a junk record cannot exist
|
||||
here.** Corruption is only visible where raw bytes are: a codec sees both the bytes it
|
||||
read and the value construction produced, and reporting the difference is the codec's job.
|
||||
Do not add a validity flag to `NoteProgram` or `ResolvedNote` to carry that signal upward
|
||||
— `windowCollapsed` describes a legal program, and is not the seed of an error channel.
|
||||
- **Does not carry a MIDI note number.** `NoteProgram` describes timing and velocity only;
|
||||
render pitch is deferred to a later additive field (Ξ-W2) rather than assumed to live
|
||||
here.
|
||||
|
||||
## Modules
|
||||
|
||||
- `musical_division` — the note-length ladder: 1/64 through 64/1 (a rung is the base-2
|
||||
exponent of its length in quarter notes, -4..8), each straight, dotted (x3/2), or triplet
|
||||
(x2/3); the 39-entry picker order; and the `"1/8."` / `"1/4t"` label notation. Beats only
|
||||
— see `musical_division.h` for why it links no tempo.
|
||||
- `tempo` — a validated project tempo plus every beats <-> seconds <-> ms conversion, and
|
||||
`kMaxConvertibleMagnitude`, the beats-or-ms ceiling the whole directory caps its domains
|
||||
to. `fromBpm` validates by running the extreme conversions rather than by testing the
|
||||
`60/bpm` reciprocal they start from — that reciprocal stays finite well past the point the
|
||||
multiply after it overflows.
|
||||
- `note_program` — `Velocity` (clamped 1..127), the two-denomination `NoteLength` and its
|
||||
resolver, the denominated `OffsetAmount` and its unit toggle, the anchored `StartOffset` /
|
||||
`EndOffset`, the `NoteProgram` record, and `resolveNote`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **A beat is a quarter note** — see `tempo.h` for why. A beats *readout* that should track
|
||||
a compound meter's dotted-quarter pulse would need the time signature threaded in — it is
|
||||
not, deliberately. `src/core/ui/card_meta.cpp` is the sibling module that *does* fold
|
||||
`timeSigDenom` into its own seconds-per-beat — a different, also-correct convention for a
|
||||
different job; don't read the divergence as a bug in either.
|
||||
- **`StartOffset` and `EndOffset` are distinct types on purpose.** They hold the same
|
||||
payload and differ only in what they anchor to (note-on and note-off respectively);
|
||||
collapsing them into one type with an anchor field makes the swap a runtime bug instead
|
||||
of a compile error.
|
||||
- **Signs are uniform: positive is later in time.** So Daniel's "capture from 20 ms before
|
||||
note-on" is a *negative* start offset, and a negative end offset truncates before release.
|
||||
Both are legal; `resolveNote` only refuses to invert the window.
|
||||
- **ms <-> beats round-trips are lossless to double precision, not bit-identical.** The
|
||||
conversion is a multiply/divide pair; compare with an epsilon.
|
||||
- **`Division` and `OffsetAmount` are trivially copyable, so a `memcpy` of a wire record
|
||||
bypasses every door.** Decode field-by-field through `makeDivision`/`offsetOf` (the pattern
|
||||
`src/core/wire/bytes.h` already uses) instead — never `memcpy` raw bytes into either type.
|
||||
- **Editing the ms field of a beats-stored offset stores beats, and the ms readout will then
|
||||
move with the tempo.** `withMsView` keeps the stored denomination on purpose, so typing 250
|
||||
into the ms field of a beats offset stores 0.5 beats at 120 BPM. That is the intended
|
||||
semantic, but it is a UI-visible surprise worth a word in the popup: `redenominate` — the
|
||||
unit toggle — is the only thing that changes which denomination is stored.
|
||||
@@ -0,0 +1,13 @@
|
||||
reasampler_pure_library(musical_division SOURCES musical_division.cpp)
|
||||
# Links only musical_division — the beats-only contract (see musical_division.h) needs no
|
||||
# tempo in the link line.
|
||||
reasampler_test(musical_division LINK musical_division)
|
||||
|
||||
reasampler_pure_library(tempo SOURCES tempo.cpp)
|
||||
reasampler_test(tempo LINK tempo)
|
||||
|
||||
# note_program links exactly these two: it composes the ladder and the tempo and nothing else.
|
||||
reasampler_pure_library(note_program
|
||||
SOURCES note_program.cpp
|
||||
LINK PUBLIC musical_division tempo)
|
||||
reasampler_test(note_program LINK note_program)
|
||||
@@ -0,0 +1,68 @@
|
||||
// musical_division.cpp — see musical_division.h. Pure; standard library only.
|
||||
|
||||
#include "core/instrument/note/musical_division.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::note {
|
||||
namespace {
|
||||
|
||||
double modifierFactor(DivisionModifier m) {
|
||||
switch (m) {
|
||||
case DivisionModifier::Dotted: return 1.5;
|
||||
case DivisionModifier::Triplet: return 2.0 / 3.0;
|
||||
case DivisionModifier::Straight: break;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
int clampExponent(int quarterExponent) {
|
||||
return (std::max)(kMinQuarterExponent, (std::min)(kMaxQuarterExponent, quarterExponent));
|
||||
}
|
||||
|
||||
// The underlying type is unsigned, so an out-of-enum byte can only be too large.
|
||||
DivisionModifier clampModifier(DivisionModifier m) {
|
||||
return static_cast<int>(m) < kModifierCount ? m : DivisionModifier::Straight;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool operator==(Division a, Division b) {
|
||||
return a.quarterExponent() == b.quarterExponent() && a.modifier() == b.modifier();
|
||||
}
|
||||
|
||||
bool operator!=(Division a, Division b) { return !(a == b); }
|
||||
|
||||
Division makeDivision(int quarterExponent, DivisionModifier modifier) {
|
||||
return Division(static_cast<std::int8_t>(clampExponent(quarterExponent)),
|
||||
clampModifier(modifier));
|
||||
}
|
||||
|
||||
double divisionBeats(Division d) {
|
||||
return std::ldexp(1.0, d.quarterExponent()) * modifierFactor(d.modifier());
|
||||
}
|
||||
|
||||
Division divisionAt(int index) {
|
||||
const int clamped = (std::max)(0, (std::min)(kDivisionCount - 1, index));
|
||||
return makeDivision(kMinQuarterExponent + clamped / kModifierCount,
|
||||
static_cast<DivisionModifier>(clamped % kModifierCount));
|
||||
}
|
||||
|
||||
int divisionIndex(Division d) {
|
||||
return (d.quarterExponent() - kMinQuarterExponent) * kModifierCount
|
||||
+ static_cast<int>(d.modifier());
|
||||
}
|
||||
|
||||
std::string divisionLabel(Division d) {
|
||||
const int e = d.quarterExponent();
|
||||
// Both branches meet at e == 2 ("1/1"): a division's written form is its length in
|
||||
// whole notes, which is 2^(e-2).
|
||||
std::string label = e <= 2 ? "1/" + std::to_string(1 << (2 - e))
|
||||
: std::to_string(1 << (e - 2)) + "/1";
|
||||
if (d.modifier() == DivisionModifier::Dotted) label += '.';
|
||||
else if (d.modifier() == DivisionModifier::Triplet) label += 't';
|
||||
return label;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::note
|
||||
@@ -0,0 +1,75 @@
|
||||
// musical_division — the note-length ladder the capture signal is programmed from: 1/64
|
||||
// through 64/1, each straight, dotted, or triplet. Lengths are in BEATS only; the tempo
|
||||
// resolution belongs to `tempo`, which keeps this ladder provable without one.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
|
||||
namespace reasampler::instrument::note {
|
||||
|
||||
enum class DivisionModifier : std::uint8_t {
|
||||
Straight,
|
||||
Dotted, // x 3/2
|
||||
Triplet, // x 2/3
|
||||
};
|
||||
|
||||
// A rung of the ladder is the base-2 exponent of its length in quarter notes: -4 is 1/64,
|
||||
// 0 is 1/4, 2 is 1/1, 8 is 64/1. Holding the exponent rather than a table of literal beat
|
||||
// counts keeps every straight and dotted length exactly representable in double.
|
||||
inline constexpr int kMinQuarterExponent = -4;
|
||||
inline constexpr int kMaxQuarterExponent = 8;
|
||||
inline constexpr int kRungCount = kMaxQuarterExponent - kMinQuarterExponent + 1;
|
||||
inline constexpr int kModifierCount = 3;
|
||||
inline constexpr int kDivisionCount = kRungCount * kModifierCount;
|
||||
|
||||
// The longest programmable note — the dotted top rung — so a caller composing this ladder
|
||||
// with the tempo conversions can check the two domains against each other at compile time.
|
||||
inline constexpr double kMaxDivisionBeats = (1 << kMaxQuarterExponent) * 1.5;
|
||||
|
||||
class Division;
|
||||
|
||||
// Off-ladder inputs clamp rather than reject: the only ways to reach one are a corrupt
|
||||
// persisted record or a picker bug, and the nearest legal length beats a nonsense duration.
|
||||
// An unnamed modifier byte has no nearest rung to fall to, so it takes the field's default.
|
||||
Division makeDivision(int quarterExponent, DivisionModifier modifier);
|
||||
|
||||
// In-domain by construction — `makeDivision` is the only door and it clamps BOTH fields, so
|
||||
// every reader below trusts the stored pair instead of re-clamping it, and equality compares
|
||||
// the two fields raw without disagreeing with any of them.
|
||||
class Division {
|
||||
public:
|
||||
Division() = default; // 1/4 straight
|
||||
|
||||
constexpr std::int8_t quarterExponent() const { return quarterExponent_; }
|
||||
constexpr DivisionModifier modifier() const { return modifier_; }
|
||||
|
||||
private:
|
||||
Division(std::int8_t quarterExponent, DivisionModifier modifier)
|
||||
: quarterExponent_(quarterExponent), modifier_(modifier) {}
|
||||
friend Division makeDivision(int quarterExponent, DivisionModifier modifier);
|
||||
|
||||
std::int8_t quarterExponent_ = 0;
|
||||
DivisionModifier modifier_ = DivisionModifier::Straight;
|
||||
};
|
||||
|
||||
static_assert(!std::is_constructible_v<Division, int, DivisionModifier>,
|
||||
"makeDivision must be the only way to give a Division a rung");
|
||||
|
||||
bool operator==(Division a, Division b);
|
||||
bool operator!=(Division a, Division b);
|
||||
|
||||
// Length in beats (quarter notes). Always > 0, and never above kMaxDivisionBeats.
|
||||
double divisionBeats(Division d);
|
||||
|
||||
// Picker order: shortest rung first, straight/dotted/triplet within each rung. Index is
|
||||
// presentation order only — see this directory's CLAUDE.md before persisting one.
|
||||
Division divisionAt(int index);
|
||||
int divisionIndex(Division d);
|
||||
|
||||
// The notation divisions are named in: "1/16", "1/8.", "1/4t", "4/1".
|
||||
std::string divisionLabel(Division d);
|
||||
|
||||
} // namespace reasampler::instrument::note
|
||||
@@ -0,0 +1,128 @@
|
||||
// note_program.cpp — see note_program.h. Pure; standard library only.
|
||||
|
||||
#include "core/instrument/note/note_program.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::note {
|
||||
|
||||
Velocity Velocity::of(int value) {
|
||||
Velocity v;
|
||||
v.value_ = static_cast<std::uint8_t>((std::max)(kMin, (std::min)(kMax, value)));
|
||||
return v;
|
||||
}
|
||||
|
||||
bool operator==(Velocity a, Velocity b) { return a.value() == b.value(); }
|
||||
|
||||
bool operator==(OffsetAmount a, OffsetAmount b) {
|
||||
return a.magnitude() == b.magnitude() && a.denomination() == b.denomination();
|
||||
}
|
||||
|
||||
bool operator!=(OffsetAmount a, OffsetAmount b) { return !(a == b); }
|
||||
|
||||
OffsetAmount offsetOf(double magnitude, Denomination denomination) {
|
||||
const double bounded =
|
||||
std::isnan(magnitude) ? 0.0
|
||||
: (std::max)(-kMaxConvertibleMagnitude,
|
||||
(std::min)(kMaxConvertibleMagnitude, magnitude));
|
||||
const bool named = denomination == Denomination::Milliseconds
|
||||
|| denomination == Denomination::Beats;
|
||||
return OffsetAmount(bounded, named ? denomination : Denomination::Milliseconds);
|
||||
}
|
||||
|
||||
NoteLength lengthOfDivision(Division division) { return NoteLength(division, 0.0, false); }
|
||||
|
||||
NoteLength lengthOfSeconds(double seconds) {
|
||||
const double bounded = std::isnan(seconds)
|
||||
? 0.0
|
||||
: (std::max)(0.0, (std::min)(kMaxLengthSeconds, seconds));
|
||||
return NoteLength(Division{}, bounded, true);
|
||||
}
|
||||
|
||||
bool operator==(NoteLength a, NoteLength b) {
|
||||
if (a.exact_ != b.exact_) return false;
|
||||
return a.exact_ ? a.seconds_ == b.seconds_ : a.division_ == b.division_;
|
||||
}
|
||||
|
||||
bool operator!=(NoteLength a, NoteLength b) { return !(a == b); }
|
||||
|
||||
double noteLengthSeconds(NoteLength length, Tempo tempo) {
|
||||
return length.exact_ ? length.seconds_ : tempo.beatsToSeconds(divisionBeats(length.division_));
|
||||
}
|
||||
|
||||
OffsetAmount offsetFromMs(double ms) { return offsetOf(ms, Denomination::Milliseconds); }
|
||||
|
||||
OffsetAmount offsetFromBeats(double beats) { return offsetOf(beats, Denomination::Beats); }
|
||||
|
||||
// Milliseconds is pinned AFTER the switch rather than by a `default:` inside it, so the
|
||||
// switch stays exhaustive over the enum and a third denomination trips switch-exhaustiveness
|
||||
// diagnostics here instead of silently resolving as ms in all three. Those diagnostics are
|
||||
// off at this project's warning level, so read it as a signpost — the tests are the gate.
|
||||
double offsetMs(OffsetAmount amount, Tempo tempo) {
|
||||
switch (amount.denomination()) {
|
||||
case Denomination::Beats: return tempo.beatsToMs(amount.magnitude());
|
||||
case Denomination::Milliseconds: break;
|
||||
}
|
||||
return amount.magnitude();
|
||||
}
|
||||
|
||||
double offsetBeats(OffsetAmount amount, Tempo tempo) {
|
||||
switch (amount.denomination()) {
|
||||
case Denomination::Beats: return amount.magnitude();
|
||||
case Denomination::Milliseconds: break;
|
||||
}
|
||||
return tempo.msToBeats(amount.magnitude());
|
||||
}
|
||||
|
||||
double offsetSeconds(OffsetAmount amount, Tempo tempo) {
|
||||
switch (amount.denomination()) {
|
||||
case Denomination::Beats: return tempo.beatsToSeconds(amount.magnitude());
|
||||
case Denomination::Milliseconds: break;
|
||||
}
|
||||
return msToSeconds(amount.magnitude());
|
||||
}
|
||||
|
||||
OffsetAmount redenominate(OffsetAmount amount, Denomination to, Tempo tempo) {
|
||||
// Defense-in-depth, not a discriminating guard: the branch below already treats any
|
||||
// non-Beats target as Milliseconds, so an unnamed `to` resolves the same way whether or
|
||||
// not it is routed through offsetOf first. Kept because a future third denomination
|
||||
// would make this the one place that still pins it.
|
||||
const Denomination target = offsetOf(0.0, to).denomination();
|
||||
if (amount.denomination() == target) return amount;
|
||||
return target == Denomination::Beats ? offsetFromBeats(offsetBeats(amount, tempo))
|
||||
: offsetFromMs(offsetMs(amount, tempo));
|
||||
}
|
||||
|
||||
OffsetAmount withMsView(OffsetAmount amount, double ms, Tempo tempo) {
|
||||
return amount.denomination() == Denomination::Beats ? offsetFromBeats(tempo.msToBeats(ms))
|
||||
: offsetFromMs(ms);
|
||||
}
|
||||
|
||||
OffsetAmount withBeatsView(OffsetAmount amount, double beats, Tempo tempo) {
|
||||
return amount.denomination() == Denomination::Beats
|
||||
? offsetFromBeats(beats)
|
||||
: offsetFromMs(tempo.beatsToMs(beats));
|
||||
}
|
||||
|
||||
bool operator==(const NoteProgram& a, const NoteProgram& b) {
|
||||
return a.length == b.length && a.start.amount() == b.start.amount()
|
||||
&& a.end.amount() == b.end.amount() && a.velocity == b.velocity;
|
||||
}
|
||||
|
||||
bool operator!=(const NoteProgram& a, const NoteProgram& b) { return !(a == b); }
|
||||
|
||||
ResolvedNote resolveNote(const NoteProgram& program, Tempo tempo) {
|
||||
ResolvedNote out;
|
||||
out.noteOffSeconds = noteLengthSeconds(program.length, tempo);
|
||||
out.captureStartSeconds = offsetSeconds(program.start.amount(), tempo);
|
||||
const double rawEndSeconds = out.noteOffSeconds + offsetSeconds(program.end.amount(), tempo);
|
||||
// An inverted window has no meaning to a renderer, so a far-negative end offset yields a
|
||||
// zero-length capture the caller can reject rather than a negative one it cannot.
|
||||
out.windowCollapsed = rawEndSeconds < out.captureStartSeconds;
|
||||
out.captureEndSeconds = (std::max)(rawEndSeconds, out.captureStartSeconds);
|
||||
out.velocity = program.velocity.value();
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::note
|
||||
@@ -0,0 +1,183 @@
|
||||
// note_program — the programmed capture signal: one note length, one velocity, two anchored
|
||||
// offsets, and the one resolver a preview and a bake must share.
|
||||
//
|
||||
// Resolved times are rate-free seconds (this directory's CLAUDE.md: the standing ruling).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
|
||||
#include "core/instrument/note/musical_division.h"
|
||||
#include "core/instrument/note/tempo.h"
|
||||
|
||||
namespace reasampler::instrument::note {
|
||||
|
||||
// The ladder and the offsets both feed the tempo conversions, so both must sit inside the
|
||||
// domain fromBpm validates — checked here because this is the one file that composes them.
|
||||
static_assert(kMaxDivisionBeats <= kMaxConvertibleMagnitude,
|
||||
"the note-length ladder must stay inside the tempo conversions' domain");
|
||||
|
||||
class Velocity {
|
||||
public:
|
||||
static constexpr int kMin = 1; // 0 is note-off in MIDI; a programmed note must sound
|
||||
static constexpr int kMax = 127;
|
||||
|
||||
Velocity() = default;
|
||||
static Velocity of(int value); // clamped into [kMin, kMax]
|
||||
|
||||
constexpr std::uint8_t value() const { return value_; }
|
||||
|
||||
private:
|
||||
std::uint8_t value_ = 100;
|
||||
};
|
||||
|
||||
bool operator==(Velocity a, Velocity b);
|
||||
|
||||
enum class Denomination : std::uint8_t { Milliseconds, Beats };
|
||||
|
||||
class OffsetAmount;
|
||||
|
||||
// The one door. Normalizes both fields so nothing downstream has to: a magnitude past
|
||||
// +/-kMaxConvertibleMagnitude clamps to it, a NaN magnitude — which names no value to clamp
|
||||
// toward — becomes zero, and a denomination outside the enum becomes Milliseconds, the
|
||||
// field's own default. A corrupt persisted record therefore resolves to a plausible offset
|
||||
// rather than an unrepresentable one, and no two readers can disagree about which.
|
||||
OffsetAmount offsetOf(double magnitude, Denomination denomination);
|
||||
OffsetAmount offsetFromMs(double ms);
|
||||
OffsetAmount offsetFromBeats(double beats);
|
||||
|
||||
// One magnitude, in the denomination it was entered in; the other view is derived on demand
|
||||
// and never stored. Which one was entered is itself the intent: a beats offset must follow a
|
||||
// tempo change and a ms offset must hold still, and only a stored denomination says which.
|
||||
class OffsetAmount {
|
||||
public:
|
||||
OffsetAmount() = default;
|
||||
|
||||
constexpr double magnitude() const { return magnitude_; }
|
||||
constexpr Denomination denomination() const { return denomination_; }
|
||||
|
||||
private:
|
||||
OffsetAmount(double magnitude, Denomination denomination)
|
||||
: magnitude_(magnitude), denomination_(denomination) {}
|
||||
friend OffsetAmount offsetOf(double magnitude, Denomination denomination);
|
||||
|
||||
double magnitude_ = 0.0;
|
||||
Denomination denomination_ = Denomination::Milliseconds;
|
||||
};
|
||||
|
||||
static_assert(!std::is_constructible_v<OffsetAmount, double, Denomination>,
|
||||
"offsetOf must be the only way to give an OffsetAmount a value");
|
||||
|
||||
bool operator==(OffsetAmount a, OffsetAmount b);
|
||||
bool operator!=(OffsetAmount a, OffsetAmount b);
|
||||
|
||||
double offsetMs(OffsetAmount amount, Tempo tempo);
|
||||
double offsetBeats(OffsetAmount amount, Tempo tempo);
|
||||
double offsetSeconds(OffsetAmount amount, Tempo tempo);
|
||||
|
||||
// The unit toggle: the same instant restated in the other denomination.
|
||||
OffsetAmount redenominate(OffsetAmount amount, Denomination to, Tempo tempo);
|
||||
|
||||
// Edit the magnitude via its non-stored view without changing which denomination is stored
|
||||
// — a popup's ms and beats fields both stay live no matter which one the offset was entered
|
||||
// in; only `redenominate` changes the stored denomination itself.
|
||||
OffsetAmount withMsView(OffsetAmount amount, double ms, Tempo tempo);
|
||||
OffsetAmount withBeatsView(OffsetAmount amount, double beats, Tempo tempo);
|
||||
|
||||
// The largest exact note length, in seconds — the ms ceiling restated in the unit an exact
|
||||
// length is entered in, so a length and an offset cap at the same instant.
|
||||
inline constexpr double kMaxLengthSeconds = msToSeconds(kMaxConvertibleMagnitude);
|
||||
|
||||
class NoteLength;
|
||||
|
||||
// The two doors. A DERIVED length is exact: `lengthOfSeconds` is what every computed duration
|
||||
// takes, and rounding one onto the ladder is what truncates a source longer than the ladder's
|
||||
// top rung. A PICKED length is a `Division`, because a picker's rungs are the point. Both
|
||||
// clamp: a negative or NaN duration names no length and becomes zero, a magnitude past
|
||||
// kMaxLengthSeconds clamps to it, and `makeDivision` already holds the rung's own domain.
|
||||
NoteLength lengthOfDivision(Division division);
|
||||
NoteLength lengthOfSeconds(double seconds);
|
||||
|
||||
// One length, in whichever denomination its door established. There is no accessor per
|
||||
// denomination: `noteLengthSeconds` resolves both, so no reader can pick the wrong one.
|
||||
class NoteLength {
|
||||
public:
|
||||
NoteLength() = default; // a quarter note — Division's own default
|
||||
|
||||
private:
|
||||
NoteLength(Division division, double seconds, bool exact)
|
||||
: division_(division), seconds_(seconds), exact_(exact) {}
|
||||
friend NoteLength lengthOfDivision(Division division);
|
||||
friend NoteLength lengthOfSeconds(double seconds);
|
||||
friend double noteLengthSeconds(NoteLength length, Tempo tempo);
|
||||
friend bool operator==(NoteLength a, NoteLength b);
|
||||
|
||||
Division division_{};
|
||||
double seconds_ = 0.0;
|
||||
bool exact_ = false;
|
||||
};
|
||||
|
||||
static_assert(!std::is_constructible_v<NoteLength, Division>,
|
||||
"lengthOfDivision must be the only way to give a NoteLength a rung");
|
||||
|
||||
bool operator==(NoteLength a, NoteLength b);
|
||||
bool operator!=(NoteLength a, NoteLength b);
|
||||
|
||||
double noteLengthSeconds(NoteLength length, Tempo tempo);
|
||||
|
||||
// Two types rather than one carrying an anchor field: the anchor is then unswappable at
|
||||
// compile time. Sign is uniform — positive is later in time — so a capture that opens before
|
||||
// the note is a negative start offset, and a negative end offset truncates before release.
|
||||
class StartOffset {
|
||||
public:
|
||||
StartOffset() = default;
|
||||
explicit StartOffset(OffsetAmount amount) : amount_(amount) {}
|
||||
OffsetAmount amount() const { return amount_; }
|
||||
|
||||
private:
|
||||
OffsetAmount amount_{};
|
||||
};
|
||||
|
||||
class EndOffset {
|
||||
public:
|
||||
EndOffset() = default;
|
||||
explicit EndOffset(OffsetAmount amount) : amount_(amount) {}
|
||||
OffsetAmount amount() const { return amount_; }
|
||||
|
||||
private:
|
||||
OffsetAmount amount_{};
|
||||
};
|
||||
|
||||
static_assert(!std::is_constructible_v<StartOffset, EndOffset>,
|
||||
"StartOffset and EndOffset must not be interchangeable at compile time");
|
||||
static_assert(!std::is_convertible_v<OffsetAmount, StartOffset>,
|
||||
"the anchor constructor must stay explicit");
|
||||
|
||||
struct NoteProgram {
|
||||
NoteLength length{};
|
||||
StartOffset start{};
|
||||
EndOffset end{};
|
||||
Velocity velocity{};
|
||||
};
|
||||
|
||||
bool operator==(const NoteProgram& a, const NoteProgram& b);
|
||||
bool operator!=(const NoteProgram& a, const NoteProgram& b);
|
||||
|
||||
struct ResolvedNote {
|
||||
double noteOffSeconds = 0.0; // == the note's sounding length, note-on being 0
|
||||
double captureStartSeconds = 0.0; // negative when the capture opens before the note
|
||||
double captureEndSeconds = 0.0;
|
||||
std::uint8_t velocity = Velocity{}.value(); // resolveNote always overwrites this
|
||||
// True when the programmed end offset inverted the window and resolveNote collapsed it
|
||||
// to zero length instead — lets a popup explain an empty window rather than just show one.
|
||||
bool windowCollapsed = false;
|
||||
|
||||
double captureLengthSeconds() const { return captureEndSeconds - captureStartSeconds; }
|
||||
};
|
||||
|
||||
// Total: every field of the result is finite for every constructible program and tempo,
|
||||
// which is why there is no failure path here. See this directory's CLAUDE.md.
|
||||
ResolvedNote resolveNote(const NoteProgram& program, Tempo tempo);
|
||||
|
||||
} // namespace reasampler::instrument::note
|
||||
@@ -0,0 +1,35 @@
|
||||
// tempo.cpp — see tempo.h. Pure; standard library only.
|
||||
|
||||
#include "core/instrument/note/tempo.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::note {
|
||||
namespace {
|
||||
constexpr double kSecondsPerMinute = 60.0;
|
||||
} // namespace
|
||||
|
||||
std::optional<Tempo> Tempo::fromBpm(double beatsPerMinute) {
|
||||
if (!std::isfinite(beatsPerMinute) || beatsPerMinute <= 0.0) return std::nullopt;
|
||||
// Guard by running the conversions, not by testing the 60/bpm reciprocal they start
|
||||
// from: that reciprocal stays finite for BPMs whose beatsToMs has already overflowed,
|
||||
// because the conversions scale it by up to kMaxConvertibleMagnitude. Both directions
|
||||
// are checked — one overflows at an absurdly slow tempo, the other at an absurdly fast
|
||||
// one. Calling them here is what keeps the guard from drifting away from what they do.
|
||||
const Tempo candidate(beatsPerMinute);
|
||||
if (!std::isfinite(candidate.beatsToMs(kMaxConvertibleMagnitude))) return std::nullopt;
|
||||
if (!std::isfinite(candidate.msToBeats(kMaxConvertibleMagnitude))) return std::nullopt;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
double Tempo::secondsPerBeat() const { return kSecondsPerMinute / bpm_; }
|
||||
|
||||
double Tempo::beatsToSeconds(double beats) const { return beats * secondsPerBeat(); }
|
||||
|
||||
double Tempo::secondsToBeats(double seconds) const { return seconds / secondsPerBeat(); }
|
||||
|
||||
double Tempo::beatsToMs(double beats) const { return secondsToMs(beatsToSeconds(beats)); }
|
||||
|
||||
double Tempo::msToBeats(double ms) const { return secondsToBeats(msToSeconds(ms)); }
|
||||
|
||||
} // namespace reasampler::instrument::note
|
||||
@@ -0,0 +1,49 @@
|
||||
// tempo — a validated project tempo and every beats <-> seconds <-> ms conversion a
|
||||
// beat-denominated capture value resolves through.
|
||||
//
|
||||
// A BEAT IS A QUARTER NOTE — REAPER states project tempo in quarter notes per minute
|
||||
// regardless of time signature, so a division resolves without one.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <type_traits>
|
||||
|
||||
namespace reasampler::instrument::note {
|
||||
|
||||
inline constexpr double kMsPerSecond = 1000.0;
|
||||
|
||||
constexpr double msToSeconds(double ms) { return ms / kMsPerSecond; }
|
||||
constexpr double secondsToMs(double seconds) { return seconds * kMsPerSecond; }
|
||||
|
||||
// The largest magnitude, in beats or in milliseconds, the conversions below are required to
|
||||
// keep finite. `fromBpm` validates against it and every caller caps its own domain to it, so
|
||||
// the two halves of the totality claim meet at one number. Astronomically above anything
|
||||
// musical — a billion milliseconds is eleven days — so nothing real is excluded.
|
||||
inline constexpr double kMaxConvertibleMagnitude = 1e9;
|
||||
|
||||
class Tempo {
|
||||
public:
|
||||
// Rejects rather than clamps, alone among this module's doors: an unusable BPM has no
|
||||
// nearest usable one to fall to. See this directory's CLAUDE.md for the rule.
|
||||
static std::optional<Tempo> fromBpm(double beatsPerMinute);
|
||||
|
||||
double bpm() const { return bpm_; }
|
||||
double secondsPerBeat() const;
|
||||
|
||||
double beatsToSeconds(double beats) const;
|
||||
double secondsToBeats(double seconds) const;
|
||||
double beatsToMs(double beats) const;
|
||||
double msToBeats(double ms) const;
|
||||
|
||||
private:
|
||||
explicit Tempo(double beatsPerMinute) : bpm_(beatsPerMinute) {}
|
||||
double bpm_;
|
||||
};
|
||||
|
||||
static_assert(!std::is_default_constructible_v<Tempo>,
|
||||
"Tempo must not be constructible without a validated BPM");
|
||||
static_assert(!std::is_constructible_v<Tempo, double>,
|
||||
"fromBpm must be the only way to give a Tempo a value");
|
||||
|
||||
} // namespace reasampler::instrument::note
|
||||
@@ -7,7 +7,9 @@ reasampler_pure_library(sample_bands SOURCES sample_bands.cpp LINK PUBLIC editor
|
||||
reasampler_test(sample_bands LINK sample_bands)
|
||||
|
||||
reasampler_pure_library(sample_chrome SOURCES sample_chrome.cpp LINK PUBLIC sample_bands)
|
||||
reasampler_test(sample_chrome LINK sample_chrome)
|
||||
# knob_deck is linked for the test only: the knob size the shell hands chromeRects is
|
||||
# kDeckKnobSize, and the test reads the real constant rather than copying its value.
|
||||
reasampler_test(sample_chrome LINK sample_chrome knob_deck)
|
||||
|
||||
reasampler_pure_library(embed_strip SOURCES embed_strip.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(embed_strip LINK embed_strip)
|
||||
@@ -35,7 +37,7 @@ reasampler_test(browser_scroll LINK browser_scroll)
|
||||
reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(param_slider LINK param_slider)
|
||||
|
||||
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry curve_law)
|
||||
reasampler_test(envelope_overlay LINK envelope_overlay)
|
||||
|
||||
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
|
||||
@@ -51,10 +53,38 @@ reasampler_test(knob_deck LINK knob_deck)
|
||||
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
|
||||
reasampler_pure_library(deck_groups
|
||||
SOURCES deck_groups.cpp
|
||||
LINK PUBLIC knob_deck velocity_curve peaks)
|
||||
LINK PUBLIC knob_deck velocity_curve peaks curve_law)
|
||||
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
|
||||
# needs the band allocator deck_groups itself has no reason to depend on.
|
||||
reasampler_test(deck_groups LINK deck_groups sample_bands)
|
||||
|
||||
# The point-editing grammar both spline consumers share, so it links the curve itself (unlike
|
||||
# envelope_overlay/envelope_edit, which stay engine-free — the staged envelopes touch no curve).
|
||||
reasampler_pure_library(spline_edit
|
||||
SOURCES spline_edit.cpp
|
||||
LINK PUBLIC editor_geometry velocity_curve)
|
||||
# waveform_view and sample_bands are linked for the test only: resolveWaveformClaim's
|
||||
# smallest-target-first tests build the real node/tab/marker geometry
|
||||
# editor_input_waveform.cpp's mouseDownWaveform composes (the shell that calls it has no test
|
||||
# target of its own), which needs waveform_view's marker/tab primitives and sample_bands'
|
||||
# kWaveformMinHeight floor.
|
||||
reasampler_test(spline_edit LINK spline_edit waveform_view sample_bands)
|
||||
|
||||
# The deck's VALUE binding, split from its composition on the same axis deck_groups was split
|
||||
# from knob_deck. Links the header-only play_seconds, NOT sample_map: PlaySeconds is all a deck
|
||||
# knob edits, and sample_map would drag the bank model and the WAV codec in behind it. Same for
|
||||
# the filter's MorphLaw — an enum, so no filter symbol is linked.
|
||||
reasampler_pure_library(deck_values
|
||||
SOURCES deck_values.cpp
|
||||
LINK PUBLIC deck_groups play_seconds envelope_overlay)
|
||||
reasampler_test(deck_values LINK deck_values)
|
||||
|
||||
# The bake Hold knob's value domain. Links the ladder alone — it computes no geometry, so it
|
||||
# does not even take editor_geometry.
|
||||
reasampler_pure_library(bake_hold SOURCES bake_hold.cpp LINK PUBLIC musical_division)
|
||||
reasampler_test(bake_hold LINK bake_hold)
|
||||
|
||||
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(curve_popup LINK curve_popup)
|
||||
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
|
||||
# proving that takes a curve of each domain mapped through the one curveBox.
|
||||
reasampler_test(curve_popup LINK curve_popup velocity_curve)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// bake_hold.cpp — see bake_hold.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/bake_hold.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kLastSlot = note::kDivisionCount - 1;
|
||||
|
||||
using Order = std::array<int, note::kDivisionCount>;
|
||||
|
||||
// Slot -> picker index, sorted by LENGTH. The ladder's own order is presentation order, in
|
||||
// which a rung's triplet is shorter than the previous rung's dotted (musical_division.cpp) —
|
||||
// so addressing it directly makes a knob whose whole meaning is duration shorten the note at
|
||||
// every rung boundary. Built once; every length on the ladder is distinct, so the sort is total.
|
||||
const Order& bySlot() {
|
||||
static const Order order = [] {
|
||||
Order a{};
|
||||
for (int i = 0; i < note::kDivisionCount; ++i) a[static_cast<std::size_t>(i)] = i;
|
||||
std::sort(a.begin(), a.end(), [](int l, int r) {
|
||||
return note::divisionBeats(note::divisionAt(l)) <
|
||||
note::divisionBeats(note::divisionAt(r));
|
||||
});
|
||||
return a;
|
||||
}();
|
||||
return order;
|
||||
}
|
||||
|
||||
// The inverse: picker index -> slot.
|
||||
const Order& toSlot() {
|
||||
static const Order inverse = [] {
|
||||
Order a{};
|
||||
const Order& forward = bySlot();
|
||||
for (int slot = 0; slot < note::kDivisionCount; ++slot)
|
||||
a[static_cast<std::size_t>(forward[static_cast<std::size_t>(slot)])] = slot;
|
||||
return a;
|
||||
}();
|
||||
return inverse;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
note::Division bakeHoldFromNorm(double norm) {
|
||||
if (!(norm > 0.0)) return note::divisionAt(bySlot()[0]); // also catches NaN
|
||||
if (norm >= 1.0) return note::divisionAt(bySlot()[kLastSlot]);
|
||||
// Round to nearest so each rung owns an equal slice of the knob's travel.
|
||||
const int slot = static_cast<int>(norm * kLastSlot + 0.5);
|
||||
return note::divisionAt(bySlot()[static_cast<std::size_t>(slot)]);
|
||||
}
|
||||
|
||||
double bakeHoldNorm(note::Division hold) {
|
||||
const int slot = toSlot()[static_cast<std::size_t>(note::divisionIndex(hold))];
|
||||
return static_cast<double>(slot) / static_cast<double>(kLastSlot);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
// bake_hold — how one radial knob addresses the note-length ladder: the Hold control's
|
||||
// normalized [0,1] position mapped onto a Division and back. The ladder itself is
|
||||
// musical_division's; only the knob's travel ORDER is decided here.
|
||||
|
||||
#include "core/instrument/note/musical_division.h"
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// [0,1] across the ladder ordered by LENGTH, shortest first — NOT the ladder's picker order,
|
||||
// which is presentation order and would shorten the note at every rung boundary. Out-of-range
|
||||
// or non-finite input clamps to an end rather than wrapping — a knob cannot express anything
|
||||
// else.
|
||||
note::Division bakeHoldFromNorm(double norm);
|
||||
|
||||
// The inverse: the position that reproduces `hold` exactly, so a knob painted from a stored
|
||||
// value and then released without moving cannot shift it a rung.
|
||||
double bakeHoldNorm(note::Division hold);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -1,4 +1,5 @@
|
||||
// curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor.
|
||||
// curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor, shared
|
||||
// by all three curves: the sheet is domain-agnostic, and only the curve's own y map differs.
|
||||
// Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via
|
||||
// these rects.
|
||||
//
|
||||
|
||||
@@ -9,12 +9,18 @@ namespace reasampler::instrument::ui {
|
||||
namespace {
|
||||
int id(DeckParam p) { return static_cast<int>(p); }
|
||||
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
|
||||
// no wider than its knob row — the ceiling is PITCH ENV's, whose caption row lands exactly on
|
||||
// its four-cell knob row at 23. Raising it reflows the deck's first row.
|
||||
constexpr int kEnvModeSegW = 23;
|
||||
} // namespace
|
||||
|
||||
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
|
||||
double deckNormFromBipolar(double value) { return clamp(value, -1.0, 1.0) * 0.5 + 0.5; }
|
||||
|
||||
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
const bool trigger = (playMode == PlayMode::Trigger);
|
||||
std::vector<DeckGroupDesc> out;
|
||||
{
|
||||
DeckGroupDesc pitch;
|
||||
@@ -28,8 +34,13 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
DeckGroupDesc penv;
|
||||
penv.id = kGroupPitchEnv;
|
||||
penv.captionWidth = 58;
|
||||
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
|
||||
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
|
||||
// The mode toggle rides the caption slack rather than the knob row — costs no group
|
||||
// width; see this module's CLAUDE.md bullet (knob_deck) for the headroom this relies on.
|
||||
penv.captionToggle2 = {id(DeckParam::kPitchEnvMode), kEnvModeSegW};
|
||||
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
|
||||
id(DeckParam::kPitchEnvHold),
|
||||
id(DeckParam::kPitchEnvDecay),
|
||||
id(DeckParam::kPitchEnvDepth)};
|
||||
out.push_back(std::move(penv));
|
||||
@@ -54,30 +65,52 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
DeckGroupDesc fenv;
|
||||
fenv.id = kGroupFilterEnv;
|
||||
fenv.captionWidth = 66;
|
||||
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
|
||||
fenv.captionToggle2 = {id(DeckParam::kFilterEnvMode), kEnvModeSegW};
|
||||
if (trigger) {
|
||||
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
|
||||
id(DeckParam::kFilterTrigDecay), -1, -1};
|
||||
} else {
|
||||
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
|
||||
id(DeckParam::kFilterEnvHold),
|
||||
id(DeckParam::kFilterEnvDecay),
|
||||
id(DeckParam::kFilterEnvSustain),
|
||||
id(DeckParam::kFilterEnvRelease)};
|
||||
}
|
||||
out.push_back(std::move(fenv));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc amp;
|
||||
amp.id = kGroupAmpEnv;
|
||||
amp.captionWidth = 78;
|
||||
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
|
||||
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
|
||||
if (playMode == PlayMode::Gate) {
|
||||
amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW};
|
||||
if (trigger) {
|
||||
// The play span first, then the AHD that shapes it, time-ordered left-to-right so
|
||||
// the row reads like the drawn envelope. One reserve (-1) keeps the group's width —
|
||||
// and therefore its neighbours' placement — identical across a mode flip; its
|
||||
// pixels go to the four cells that remain (knob_deck.h).
|
||||
amp.cellIds = {id(DeckParam::kTrigLength), id(DeckParam::kTrigAttack),
|
||||
id(DeckParam::kTrigHold), id(DeckParam::kTrigDecay), -1};
|
||||
} else {
|
||||
amp.cellIds = {id(DeckParam::kAttack), id(DeckParam::kHold),
|
||||
id(DeckParam::kDecay), id(DeckParam::kSustain),
|
||||
id(DeckParam::kRelease)};
|
||||
} else {
|
||||
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
|
||||
// the drawn envelope), plus two blanks (see knob_deck.h's blank-cell contract).
|
||||
amp.cellIds = {id(DeckParam::kTrigFadeIn), id(DeckParam::kTrigLength),
|
||||
id(DeckParam::kTrigFadeOut), -1, -1};
|
||||
}
|
||||
out.push_back(std::move(amp));
|
||||
}
|
||||
{
|
||||
// The three velocity transfer curves share one home, immediately left of VOICE: they
|
||||
// shape three different destinations but are one gesture, and MASTER is reserved for
|
||||
// post-voice-mixer concerns.
|
||||
DeckGroupDesc vel;
|
||||
vel.id = kGroupVelocity;
|
||||
vel.captionWidth = 54;
|
||||
vel.cellIds = {id(DeckParam::kAmpVelCurve), id(DeckParam::kPitchVelCurve),
|
||||
id(DeckParam::kFilterVelCurve)};
|
||||
out.push_back(std::move(vel));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc voice;
|
||||
voice.id = kGroupVoice;
|
||||
@@ -97,4 +130,212 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
return out;
|
||||
}
|
||||
|
||||
CurveTarget curveTargetFor(int controlId) {
|
||||
switch (static_cast<DeckParam>(controlId)) {
|
||||
case DeckParam::kAmpVelCurve: return CurveTarget::kAmp;
|
||||
case DeckParam::kPitchVelCurve: return CurveTarget::kPitch;
|
||||
case DeckParam::kFilterVelCurve: return CurveTarget::kFilter;
|
||||
default: return CurveTarget::kNone;
|
||||
}
|
||||
}
|
||||
|
||||
DeckParam curveParamFor(DeckParam knob) {
|
||||
switch (knob) {
|
||||
case DeckParam::kAttack: return DeckParam::kAttackCurve;
|
||||
case DeckParam::kDecay: return DeckParam::kDecayCurve;
|
||||
case DeckParam::kRelease: return DeckParam::kReleaseCurve;
|
||||
case DeckParam::kTrigAttack: return DeckParam::kTrigAttackCurve;
|
||||
case DeckParam::kTrigDecay: return DeckParam::kTrigDecayCurve;
|
||||
case DeckParam::kPitchEnvAttack: return DeckParam::kPitchEnvAttackCurve;
|
||||
case DeckParam::kPitchEnvDecay: return DeckParam::kPitchEnvDecayCurve;
|
||||
case DeckParam::kFilterEnvAttack: return DeckParam::kFilterEnvAttackCurve;
|
||||
case DeckParam::kFilterEnvDecay: return DeckParam::kFilterEnvDecayCurve;
|
||||
case DeckParam::kFilterEnvRelease: return DeckParam::kFilterEnvReleaseCurve;
|
||||
case DeckParam::kFilterTrigAttack: return DeckParam::kFilterTrigAttackCurve;
|
||||
case DeckParam::kFilterTrigDecay: return DeckParam::kFilterTrigDecayCurve;
|
||||
default: return DeckParam::kCount;
|
||||
}
|
||||
}
|
||||
|
||||
bool isLiveDeckParam(DeckParam id) {
|
||||
switch (id) {
|
||||
case DeckParam::kAttack:
|
||||
case DeckParam::kHold:
|
||||
case DeckParam::kDecay:
|
||||
case DeckParam::kSustain:
|
||||
case DeckParam::kRelease:
|
||||
case DeckParam::kTrigAttack:
|
||||
case DeckParam::kTrigHold:
|
||||
case DeckParam::kTrigDecay:
|
||||
case DeckParam::kPitchEnvAttack:
|
||||
case DeckParam::kPitchEnvHold:
|
||||
case DeckParam::kPitchEnvDecay:
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
case DeckParam::kFilterMorph:
|
||||
case DeckParam::kFilterCutoff:
|
||||
case DeckParam::kFilterQ:
|
||||
case DeckParam::kFilterDrive:
|
||||
case DeckParam::kFilterModAmt:
|
||||
case DeckParam::kFilterVel:
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
case DeckParam::kFilterEnvAttack:
|
||||
case DeckParam::kFilterEnvHold:
|
||||
case DeckParam::kFilterEnvDecay:
|
||||
case DeckParam::kFilterEnvSustain:
|
||||
case DeckParam::kFilterEnvRelease:
|
||||
case DeckParam::kFilterTrigAttack:
|
||||
case DeckParam::kFilterTrigHold:
|
||||
case DeckParam::kFilterTrigDecay:
|
||||
case DeckParam::kAttackCurve:
|
||||
case DeckParam::kDecayCurve:
|
||||
case DeckParam::kReleaseCurve:
|
||||
case DeckParam::kTrigAttackCurve:
|
||||
case DeckParam::kTrigDecayCurve:
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
return true;
|
||||
// Listed rather than defaulted so a newly added control is a COMPILE error here (the
|
||||
// -Wswitch gate is GCC/Clang; MSVC's C4062 is off at this project's warning level)
|
||||
// instead of silently defaulting to non-live. Reasons live in the header.
|
||||
case DeckParam::kPlayMode:
|
||||
case DeckParam::kPitchEngine:
|
||||
case DeckParam::kTrigLength:
|
||||
case DeckParam::kPitchEnvEnable:
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kFilterEnable:
|
||||
case DeckParam::kAmpVelCurve:
|
||||
case DeckParam::kPitchVelCurve:
|
||||
case DeckParam::kFilterVelCurve:
|
||||
case DeckParam::kFilterLaw:
|
||||
case DeckParam::kAmpEnvSelect:
|
||||
case DeckParam::kPitchEnvSelect:
|
||||
case DeckParam::kFilterEnvSelect:
|
||||
// A mode toggle names a different envelope, not a different setting of one — the same
|
||||
// reason every other discrete toggle above is excluded.
|
||||
case DeckParam::kAmpEnvMode:
|
||||
case DeckParam::kPitchEnvMode:
|
||||
case DeckParam::kFilterEnvMode:
|
||||
case DeckParam::kVoiceCount:
|
||||
case DeckParam::kVoiceMode:
|
||||
case DeckParam::kMonoTrigger:
|
||||
case DeckParam::kMasterGain:
|
||||
case DeckParam::kCount: // not a control
|
||||
return false;
|
||||
}
|
||||
return false; // unreachable for a valid enumerator; silences a warning.
|
||||
}
|
||||
|
||||
OverlayEnv overlayEnvForRadio(int radioId) {
|
||||
switch (static_cast<DeckParam>(radioId)) {
|
||||
case DeckParam::kAmpEnvSelect: return OverlayEnv::kAmp;
|
||||
case DeckParam::kPitchEnvSelect: return OverlayEnv::kPitch;
|
||||
case DeckParam::kFilterEnvSelect: return OverlayEnv::kFilter;
|
||||
default: return OverlayEnv::kNone;
|
||||
}
|
||||
}
|
||||
|
||||
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) {
|
||||
const OverlayEnv picked = overlayEnvForRadio(radioId);
|
||||
if (picked == OverlayEnv::kNone) return current; // not a radio: nothing selects
|
||||
return (current == picked) ? OverlayEnv::kNone : picked;
|
||||
}
|
||||
|
||||
OverlayEnv overlayEnvForModeToggle(int toggleId) {
|
||||
switch (static_cast<DeckParam>(toggleId)) {
|
||||
case DeckParam::kAmpEnvMode: return OverlayEnv::kAmp;
|
||||
case DeckParam::kPitchEnvMode: return OverlayEnv::kPitch;
|
||||
case DeckParam::kFilterEnvMode: return OverlayEnv::kFilter;
|
||||
default: return OverlayEnv::kNone;
|
||||
}
|
||||
}
|
||||
|
||||
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state) {
|
||||
switch (env) {
|
||||
case OverlayEnv::kPitch: return state.pitchEnvEnabled;
|
||||
case OverlayEnv::kFilter: return state.filterEnabled;
|
||||
case OverlayEnv::kAmp:
|
||||
case OverlayEnv::kNone:
|
||||
return true;
|
||||
}
|
||||
return true; // unreachable for a valid enumerator; silences a warning.
|
||||
}
|
||||
|
||||
bool overlayEnvInert(OverlayEnv env, const DeckEnableState& state) {
|
||||
if (env == OverlayEnv::kNone) return false;
|
||||
if (!overlayEnvEnabled(env, state)) return true;
|
||||
switch (env) {
|
||||
case OverlayEnv::kPitch: return state.pitchSpline;
|
||||
case OverlayEnv::kFilter: return state.filterSpline;
|
||||
case OverlayEnv::kAmp: return state.ampSpline;
|
||||
case OverlayEnv::kNone:
|
||||
return false;
|
||||
}
|
||||
return false; // unreachable for a valid enumerator; silences a warning.
|
||||
}
|
||||
|
||||
bool deckKnobInert(DeckParam id, const DeckEnableState& state) {
|
||||
switch (id) {
|
||||
case DeckParam::kAttack:
|
||||
case DeckParam::kHold:
|
||||
case DeckParam::kDecay:
|
||||
case DeckParam::kSustain:
|
||||
case DeckParam::kRelease:
|
||||
case DeckParam::kTrigAttack:
|
||||
case DeckParam::kTrigHold:
|
||||
case DeckParam::kTrigDecay:
|
||||
return state.ampSpline;
|
||||
// The Trigger %-length knob is inert whenever ANY spline is active (not just the amp's):
|
||||
// a drawn contour always covers the full sample length (splineActive, play_params.h), so
|
||||
// the engine ignores lengthFraction in that case regardless of which EG is drawn.
|
||||
case DeckParam::kTrigLength:
|
||||
return state.ampSpline || state.pitchSpline || state.filterSpline;
|
||||
case DeckParam::kPitchEnvAttack:
|
||||
case DeckParam::kPitchEnvHold:
|
||||
case DeckParam::kPitchEnvDecay:
|
||||
return !state.pitchEnvEnabled || state.pitchSpline;
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
return !state.pitchEnvEnabled;
|
||||
case DeckParam::kFilterEnvAttack:
|
||||
case DeckParam::kFilterEnvHold:
|
||||
case DeckParam::kFilterEnvDecay:
|
||||
case DeckParam::kFilterEnvSustain:
|
||||
case DeckParam::kFilterEnvRelease:
|
||||
case DeckParam::kFilterTrigAttack:
|
||||
case DeckParam::kFilterTrigHold:
|
||||
case DeckParam::kFilterTrigDecay:
|
||||
return !state.filterEnabled || state.filterSpline;
|
||||
case DeckParam::kFilterMorph:
|
||||
case DeckParam::kFilterCutoff:
|
||||
case DeckParam::kFilterQ:
|
||||
case DeckParam::kFilterDrive:
|
||||
case DeckParam::kFilterModAmt:
|
||||
case DeckParam::kFilterVel:
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
// The filter's velocity curve sits in the VELOCITY group but is a filter parameter:
|
||||
// it goes inert with every other one, so no surface can reach a param the knobs can't.
|
||||
case DeckParam::kFilterVelCurve:
|
||||
return !state.filterEnabled;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool liveCommitFor(LiveDragKind kind, int paramId) {
|
||||
switch (kind) {
|
||||
case LiveDragKind::kDeckKnob:
|
||||
return paramId >= 0 && paramId < static_cast<int>(DeckParam::kCount) &&
|
||||
isLiveDeckParam(static_cast<DeckParam>(paramId));
|
||||
case LiveDragKind::kEnvNode:
|
||||
return true;
|
||||
case LiveDragKind::kOther:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/engine/play_params.h" // PlayMode (the AMP group's Gate/Trigger face)
|
||||
#include "core/instrument/engine/play_params.h" // PlayMode (the mode-dependent group faces)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
@@ -17,18 +17,20 @@ namespace reasampler::instrument::ui {
|
||||
enum class DeckParam {
|
||||
kPlayMode = 0, // Gate | Trigger toggle
|
||||
kPitchEngine, // Varispeed | Preserve toggle
|
||||
kAttack, // AHDSR attack (Gate) / —
|
||||
kHold, // AHDSR hold (Gate)
|
||||
kDecay, // AHDSR decay (Gate)
|
||||
kSustain, // AHDSR sustain (Gate)
|
||||
kRelease, // AHDSR release (Gate)
|
||||
kTrigLength, // Trigger %-length
|
||||
kTrigFadeIn, // Trigger fade-in
|
||||
kTrigFadeOut, // Trigger fade-out
|
||||
kPitchEnvEnable, // AD pitch envelope on|off
|
||||
kPitchEnvAttack, // AD pitch attack
|
||||
kPitchEnvDecay, // AD pitch decay
|
||||
kPitchEnvDepth, // AD pitch depth in +/- semitones
|
||||
kAttack, // amp AHDSR attack (Gate)
|
||||
kHold, // amp AHDSR hold (Gate)
|
||||
kDecay, // amp AHDSR decay (Gate)
|
||||
kSustain, // amp AHDSR sustain (Gate)
|
||||
kRelease, // amp AHDSR release (Gate)
|
||||
kTrigLength, // Trigger play span, % of the post-start length
|
||||
kTrigAttack, // amp AHD attack (Trigger)
|
||||
kTrigHold, // amp AHD hold, % of the span left after attack + decay
|
||||
kTrigDecay, // amp AHD decay (Trigger)
|
||||
kPitchEnvEnable, // AHD pitch envelope on|off
|
||||
kPitchEnvAttack,
|
||||
kPitchEnvHold, // % of the span left after attack + decay
|
||||
kPitchEnvDecay,
|
||||
kPitchEnvDepth, // AHD pitch depth in +/- semitones
|
||||
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
|
||||
// Filter. The four control positions map through filter_params' own laws; the three
|
||||
// depths are bipolar and centred at zero.
|
||||
@@ -38,14 +40,45 @@ enum class DeckParam {
|
||||
kFilterQ, // resonance
|
||||
kFilterDrive, // in-loop drive depth
|
||||
kFilterModAmt, // filter envelope -> cutoff, +/-100%
|
||||
kFilterVel, // velocity -> cutoff, +/-100%
|
||||
kFilterVel, // velocity curve -> cutoff depth, +/-100%
|
||||
kFilterKeyTrack, // note -> cutoff, 0..200%
|
||||
kFilterLaw, // morph law row toggle: HP-BP-LP | HP-notch-LP
|
||||
kFilterEnvAttack,
|
||||
kFilterEnvAttack, // filter AHDSR (Gate)
|
||||
kFilterEnvHold,
|
||||
kFilterEnvDecay,
|
||||
kFilterEnvSustain,
|
||||
kFilterEnvRelease,
|
||||
kFilterTrigAttack, // filter AHD (Trigger)
|
||||
kFilterTrigHold,
|
||||
kFilterTrigDecay,
|
||||
// Curve exponents. These never get a cell of their own — each is the INNER DIAL of the
|
||||
// stage knob it shapes (see curveParamFor), which is why only sloped stages have one.
|
||||
kAttackCurve,
|
||||
kDecayCurve,
|
||||
kReleaseCurve,
|
||||
kTrigAttackCurve,
|
||||
kTrigDecayCurve,
|
||||
kPitchEnvAttackCurve,
|
||||
kPitchEnvDecayCurve,
|
||||
kFilterEnvAttackCurve,
|
||||
kFilterEnvDecayCurve,
|
||||
kFilterEnvReleaseCurve,
|
||||
kFilterTrigAttackCurve,
|
||||
kFilterTrigDecayCurve,
|
||||
// VELOCITY: the three transfer-curve cells. Each opens the curve popup rather than
|
||||
// dragging a value — see curveTargetFor, which is also what tells a cell apart from a knob.
|
||||
kAmpVelCurve,
|
||||
kPitchVelCurve,
|
||||
kFilterVelCurve,
|
||||
// Overlay selection radios — transient view state, not parameters.
|
||||
kAmpEnvSelect,
|
||||
kPitchEnvSelect,
|
||||
kFilterEnvSelect,
|
||||
// Staged | Spline mode per envelope. Both states persist either way (play_params.h's
|
||||
// SplineEnv); this only picks which one plays and which one the overlay edits.
|
||||
kAmpEnvMode,
|
||||
kPitchEnvMode,
|
||||
kFilterEnvMode,
|
||||
// Deck-only controls: processor-side per-instance params — routed to the processor
|
||||
// setters, never through the parameter set.
|
||||
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||
@@ -63,15 +96,111 @@ enum DeckGroupId {
|
||||
kGroupFilter,
|
||||
kGroupFilterEnv,
|
||||
kGroupAmpEnv,
|
||||
kGroupVelocity,
|
||||
kGroupVoice,
|
||||
kGroupMaster,
|
||||
};
|
||||
|
||||
// Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE
|
||||
// one place a control id resolves to a curve target — paint (draw a curve thumbnail, not a
|
||||
// dial) and hit-test (open a popup, not start a drag) both read this predicate rather than
|
||||
// re-deriving which ids are curve cells. What each resolved target then shows (which stored
|
||||
// curve, which title) is a separate switch — see the shell's curveFor/curveTitle.
|
||||
enum class CurveTarget { kNone, kAmp, kPitch, kFilter };
|
||||
CurveTarget curveTargetFor(int controlId);
|
||||
|
||||
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the
|
||||
// two instance-wide groups. `playMode` picks the AMP group's face, via knob_deck's blank-cell
|
||||
// reservation (knob_deck.h) so a mode flip never reflows the neighbouring groups.
|
||||
// two instance-wide groups. `playMode` picks the AMP and FILTER ENV groups' faces — AHDSR in
|
||||
// Gate, AHD in Trigger — via knob_deck's cell-width reservation (knob_deck.h) so a mode flip
|
||||
// never reflows the neighbouring groups and never leaves a hole in the narrower face.
|
||||
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
|
||||
|
||||
// The curve-exponent control a stage knob's INNER DIAL edits, or kCount when the knob shapes
|
||||
// no curve. THE one place the "every stage except Hold and Sustain is sloped" rule is written
|
||||
// down: a knob with no entry here draws no inner dial and its inner region resolves as an
|
||||
// ordinary knob grab.
|
||||
DeckParam curveParamFor(DeckParam knob);
|
||||
|
||||
// Whether control `id` is delivered LIVE — straight to the voices that are already sounding —
|
||||
// rather than through an instrument reload. The line is drawn at continuously-valued playback
|
||||
// controls, so this is a routing decision at the editor's commit site rather than a property
|
||||
// of any one knob; moving a control across the line is a change here and nowhere else.
|
||||
//
|
||||
// THE home for why each excluded control is excluded. Three continuous controls are outside
|
||||
// the live set, plus every discrete toggle and the overlay radios:
|
||||
// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable)
|
||||
// name a different sound rather than a different setting of one;
|
||||
// - the three capture-anchored overrides (root, loop span, start frame) name positions in
|
||||
// the decoded PCM;
|
||||
// - kKeyTrack and the three velocity-curve cells feed values a voice latches at note-on by
|
||||
// design (the pitch ratio and the curve results), so live delivery would retune or re-gain
|
||||
// a note already struck. kFilterVel is NOT one of them: it is the DEPTH over the filter
|
||||
// curve's latched result, the exact shape kFilterKeyTrack already has over the latched note
|
||||
// number, and it glides through the same base-cutoff ramp;
|
||||
// - kTrigLength resolves playEnd_, a fact about the note, not a setting of it;
|
||||
// - the overlay radios select what the editor DRAWS and reach no parameter at all.
|
||||
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
|
||||
// inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too.
|
||||
bool isLiveDeckParam(DeckParam id);
|
||||
|
||||
// The editor drag kinds that can commit live, in this pure module's own vocabulary (the
|
||||
// shell's DragKind maps onto it) so the WHOLE routing decision — not just the predicate — is
|
||||
// testable without a host.
|
||||
enum class LiveDragKind { kOther, kDeckKnob, kEnvNode };
|
||||
|
||||
// Whether a drag of `kind` commits live. A deck knob is live per isLiveDeckParam (negative ids
|
||||
// are the shell's processor-side sentinels and out-of-range ids are not controls, so neither
|
||||
// reaches the enum); an envelope-node drag is live in either mode, since every stage value it
|
||||
// can reach — AHDSR or AHD, on any of the three envelopes — is itself live.
|
||||
bool liveCommitFor(LiveDragKind kind, int paramId);
|
||||
|
||||
// Which envelope the waveform overlay draws and edits. Exclusive across the three envelope
|
||||
// decks, and kNone is a valid resting state — the editor opens there. Transient view state:
|
||||
// never persisted, never a parameter.
|
||||
enum class OverlayEnv { kNone, kAmp, kPitch, kFilter };
|
||||
|
||||
// The envelope a deck's overlay-select radio picks; kNone for any other control id.
|
||||
OverlayEnv overlayEnvForRadio(int radioId);
|
||||
|
||||
// The selection a click on `radioId` produces from `current`. Two rules, provable here rather
|
||||
// than in the shell: picking another deck's radio switches to it (exclusivity), and clicking
|
||||
// the ACTIVE one clears back to kNone — "no envelope shown" is a state the user can get back
|
||||
// to, not an error. A non-radio id leaves the selection alone.
|
||||
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
|
||||
|
||||
// The group states the two inert predicates below read. One struct rather than a growing
|
||||
// parameter list, so adding a gate is a change at the two predicates and nowhere else.
|
||||
struct DeckEnableState {
|
||||
bool pitchEnvEnabled = false;
|
||||
bool filterEnabled = false;
|
||||
bool ampSpline = false; // the amp EG is drawn rather than staged
|
||||
bool pitchSpline = false;
|
||||
bool filterSpline = false;
|
||||
};
|
||||
|
||||
// Which envelope a Staged|Spline mode toggle belongs to; kNone for any other control id.
|
||||
OverlayEnv overlayEnvForModeToggle(int toggleId);
|
||||
|
||||
// Whether `env`'s deck group is switched on at all. Amp has no enable toggle and is always on.
|
||||
// The gate BOTH overlay modes share — a disabled group's contour is as dead as its knobs.
|
||||
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state);
|
||||
|
||||
// Whether the STAGED overlay for `env` is INERT: its deck group is off, so its knobs are
|
||||
// drawn-but-dead and a node drag on the same params must be too — otherwise a drag reaches a
|
||||
// param a knob couldn't (envelope_edit.h). An envelope in SPLINE mode is inert here too: the
|
||||
// staged nodes are not what the overlay is editing.
|
||||
bool overlayEnvInert(OverlayEnv env, const DeckEnableState& state);
|
||||
|
||||
// Whether a deck knob cell is drawn-but-dead: the pitch envelope's four knobs while it is
|
||||
// disabled, the filter group's tone/modulation knobs (plus its VELOCITY cell, a filter
|
||||
// parameter that just sits in that group) while the filter is disabled, and every STAGED
|
||||
// SEGMENT knob of an envelope switched to Spline. The segment knobs' inner curve dials go with
|
||||
// them — the dial is reached through its outer cell, so one predicate covers both. The DEPTH
|
||||
// knobs (pitch peak, filter mod amount) stay live in either mode: they scale whichever shape is
|
||||
// active rather than describing a stage. Mirrors overlayEnvInert's shape for the deck's own
|
||||
// mouse-down/paint (the shell's deckKnobDisabled is a thin int-id wrapper over this).
|
||||
bool deckKnobInert(DeckParam id, const DeckEnableState& state);
|
||||
|
||||
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
|
||||
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
|
||||
// centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints.
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// deck_values.cpp — see deck_values.h. Pure value math; no host types.
|
||||
|
||||
#include "core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value)
|
||||
#include "core/util/clamp01.h"
|
||||
#include "core/util/curve_law.h" // the ONE curve-exponent domain
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using engine::filter::MorphLaw;
|
||||
using util::clamp01;
|
||||
|
||||
namespace {
|
||||
|
||||
double secToNorm(double seconds) { return clamp01(seconds / kEnvTimeMaxSeconds); }
|
||||
double normToSec(double norm) { return clamp01(norm) * kEnvTimeMaxSeconds; }
|
||||
|
||||
} // namespace
|
||||
|
||||
double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
||||
switch (id) {
|
||||
case DeckParam::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
|
||||
case DeckParam::kAmpEnvMode: return play.ampSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
|
||||
case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
|
||||
case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
|
||||
case DeckParam::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
|
||||
case DeckParam::kAttack: return secToNorm(play.adsr.attackSeconds);
|
||||
case DeckParam::kHold: return secToNorm(play.adsr.holdSeconds);
|
||||
case DeckParam::kDecay: return secToNorm(play.adsr.decaySeconds);
|
||||
case DeckParam::kSustain: return clamp01(play.adsr.sustainLevel);
|
||||
case DeckParam::kRelease: return secToNorm(play.adsr.releaseSeconds);
|
||||
case DeckParam::kAttackCurve: return util::knobNormFromCurve(play.adsr.attackCurve);
|
||||
case DeckParam::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve);
|
||||
case DeckParam::kReleaseCurve: return util::knobNormFromCurve(play.adsr.releaseCurve);
|
||||
case DeckParam::kTrigLength: return clamp01(play.trigger.lengthFraction);
|
||||
case DeckParam::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds);
|
||||
case DeckParam::kTrigHold: return clamp01(play.trigAhd.holdFraction);
|
||||
case DeckParam::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds);
|
||||
case DeckParam::kTrigAttackCurve: return util::knobNormFromCurve(play.trigAhd.attackCurve);
|
||||
case DeckParam::kTrigDecayCurve: return util::knobNormFromCurve(play.trigAhd.decayCurve);
|
||||
case DeckParam::kPitchEnvEnable: return play.pitchEnv.enabled ? 1.0 : 0.0;
|
||||
case DeckParam::kPitchEnvAttack: return secToNorm(play.pitchEnv.shape.attackSeconds);
|
||||
case DeckParam::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction);
|
||||
case DeckParam::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds);
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
return util::knobNormFromCurve(play.pitchEnv.shape.attackCurve);
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
return util::knobNormFromCurve(play.pitchEnv.shape.decayCurve);
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
// Signed depth centred at 0.5 (0.5 == 0 semitones).
|
||||
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
||||
// Filter. The four tone controls ARE the module's normalized positions — stored and
|
||||
// shown as-is, so the knob travel is exactly filter_params' own law.
|
||||
case DeckParam::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0;
|
||||
case DeckParam::kFilterLaw:
|
||||
return play.filter.settings.morphLaw == MorphLaw::HighNotchLow ? 1.0 : 0.0;
|
||||
case DeckParam::kFilterMorph: return clamp01(play.filter.settings.morphNorm);
|
||||
case DeckParam::kFilterCutoff: return clamp01(play.filter.settings.cutoffNorm);
|
||||
case DeckParam::kFilterQ: return clamp01(play.filter.settings.resonanceNorm);
|
||||
case DeckParam::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
|
||||
case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
|
||||
case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
|
||||
case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax);
|
||||
case DeckParam::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
|
||||
case DeckParam::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
|
||||
case DeckParam::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
|
||||
case DeckParam::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
|
||||
case DeckParam::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
return util::knobNormFromCurve(play.filter.env.attackCurve);
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
return util::knobNormFromCurve(play.filter.env.decayCurve);
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
return util::knobNormFromCurve(play.filter.env.releaseCurve);
|
||||
case DeckParam::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds);
|
||||
case DeckParam::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction);
|
||||
case DeckParam::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds);
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
return util::knobNormFromCurve(play.filter.trigEnv.attackCurve);
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
return util::knobNormFromCurve(play.filter.trigEnv.decayCurve);
|
||||
default: return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
switch (id) {
|
||||
case DeckParam::kPlayMode:
|
||||
// Gate is refused while any EG is drawn — see splineActive (play_params.h). The
|
||||
// segment paints Disabled for the same reason, so the refusal is never a surprise.
|
||||
if (segment == 0 && splineActive(play)) break;
|
||||
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
break;
|
||||
// Switching TO Spline drops Gate, which the spline model has no place for. Switching
|
||||
// back does NOT restore it: the previous mode is not stored, and silently re-gating an
|
||||
// instrument the user has since heard as a one-shot is the worse surprise.
|
||||
case DeckParam::kAmpEnvMode:
|
||||
case DeckParam::kPitchEnvMode:
|
||||
case DeckParam::kFilterEnvMode: {
|
||||
const EnvMode m = (segment == 1) ? EnvMode::Spline : EnvMode::Staged;
|
||||
if (id == DeckParam::kAmpEnvMode) play.ampSpline.mode = m;
|
||||
else if (id == DeckParam::kPitchEnvMode) play.pitchSpline.mode = m;
|
||||
else play.filterSpline.mode = m;
|
||||
break;
|
||||
}
|
||||
case DeckParam::kPitchEngine:
|
||||
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
break;
|
||||
case DeckParam::kAttack: play.adsr.attackSeconds = normToSec(value); break;
|
||||
case DeckParam::kHold: play.adsr.holdSeconds = normToSec(value); break;
|
||||
case DeckParam::kDecay: play.adsr.decaySeconds = normToSec(value); break;
|
||||
case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break;
|
||||
case DeckParam::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
|
||||
case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kTrigLength:
|
||||
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays
|
||||
// nothing.
|
||||
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
||||
break;
|
||||
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = normToSec(value); break;
|
||||
case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break;
|
||||
case DeckParam::kTrigAttackCurve:
|
||||
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kTrigDecayCurve:
|
||||
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
|
||||
case DeckParam::kPitchEnvAttack:
|
||||
play.pitchEnv.shape.attackSeconds = normToSec(value); break;
|
||||
case DeckParam::kPitchEnvHold:
|
||||
play.pitchEnv.shape.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kPitchEnvDecay:
|
||||
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
||||
break;
|
||||
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
|
||||
case DeckParam::kFilterLaw:
|
||||
play.filter.settings.morphLaw =
|
||||
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
|
||||
break;
|
||||
case DeckParam::kFilterMorph:
|
||||
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterCutoff:
|
||||
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterQ:
|
||||
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterDrive:
|
||||
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
|
||||
case DeckParam::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
|
||||
case DeckParam::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
||||
case DeckParam::kFilterEnvAttack:
|
||||
play.filter.env.attackSeconds = normToSec(value); break;
|
||||
case DeckParam::kFilterEnvHold:
|
||||
play.filter.env.holdSeconds = normToSec(value); break;
|
||||
case DeckParam::kFilterEnvDecay:
|
||||
play.filter.env.decaySeconds = normToSec(value); break;
|
||||
case DeckParam::kFilterEnvSustain:
|
||||
play.filter.env.sustainLevel = clamp01(value); break;
|
||||
case DeckParam::kFilterEnvRelease:
|
||||
play.filter.env.releaseSeconds = normToSec(value); break;
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
play.filter.env.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterTrigAttack:
|
||||
play.filter.trigEnv.attackSeconds = normToSec(value); break;
|
||||
case DeckParam::kFilterTrigHold:
|
||||
play.filter.trigEnv.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kFilterTrigDecay:
|
||||
play.filter.trigEnv.decaySeconds = normToSec(value); break;
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
default: break;
|
||||
}
|
||||
// ONE normalization point for every control that can flip splineActive — a mode toggle
|
||||
// (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an
|
||||
// already-Spline pitch/filter envelope newly active. Applying it once here, rather than at
|
||||
// each site that could cause the flip, is what keeps a future such control from reopening
|
||||
// the same hole. `resolvePlay` (sample_map.cpp) is the other caller of the shared helper.
|
||||
enforceGateUnavailableWhileDrawn(play);
|
||||
}
|
||||
|
||||
void resetDeckParam(DeckParam id, PlaySeconds& play) {
|
||||
const PlaySeconds defaults;
|
||||
setDeckParam(id, play, deckParamNorm(id, defaults), 0);
|
||||
}
|
||||
|
||||
void formatEnvTimeMs(double seconds, char* buf, std::size_t len) {
|
||||
if (!buf || len == 0) return;
|
||||
const double ms = seconds * 1000.0;
|
||||
std::snprintf(buf, len, ms < 10.0 ? "%.1f ms" : "%.0f ms", ms);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,54 @@
|
||||
// deck_values.h — the deck's control-id <-> parameter-set BINDING and its display units: the
|
||||
// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, the
|
||||
// double-click reset, and the ms time-constant formatter. Split from the editor shell so the
|
||||
// whole domain map is provable without a host; deck_groups owns WHICH controls exist, this owns
|
||||
// what each one's value MEANS.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target)
|
||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using map::PlaySeconds;
|
||||
|
||||
// Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly what the
|
||||
// parameter set stores. READ from the overlay's schematic scale rather than restated: the AHDSR
|
||||
// schematic anchors a maxed knob at the canvas edge, which only holds while the two agree.
|
||||
inline constexpr double kEnvTimeMaxSeconds = kGateStageMaxSeconds;
|
||||
|
||||
// Pitch depth throw: +/-kVelocityPitchRangeSemitones, centred. The one throw the pitch
|
||||
// envelope's peak and the velocity->pitch curve's full scale both speak (play_params.h).
|
||||
inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
|
||||
|
||||
// Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls.
|
||||
inline constexpr double kKeyTrackMax = 2.0;
|
||||
|
||||
// The normalized [0,1] a control shows: seconds over the ceiling, levels and fractions as-is,
|
||||
// signed depths centred at 0.5, curve exponents over their logarithmic travel. Controls backed
|
||||
// by per-instance state rather than the parameter set (voice count, master gain, the pitch
|
||||
// key-track scalar, preview velocity) are not here — the shell reads those from the processor.
|
||||
double deckParamNorm(DeckParam id, const PlaySeconds& play);
|
||||
|
||||
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
|
||||
// Mutates `play` in place, touching exactly the one field the control names.
|
||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
|
||||
|
||||
// Resets `id` to its default. The default IS what a fresh PlaySeconds carries, so there is no
|
||||
// second table of defaults to drift from the real one. It arrives via the norm round trip, so
|
||||
// landing EXACTLY on a stage time (0.003 s attack, 0.060 s release) depends on
|
||||
// kEnvTimeMaxSeconds being a power of two — x/2^n*2^n is lossless, an arbitrary ceiling is not.
|
||||
// Move that ceiling off a power of two and a reset lands a mantissa bit off its own default.
|
||||
// For knob-valued controls — a toggle has no reset gesture.
|
||||
void resetDeckParam(DeckParam id, PlaySeconds& play);
|
||||
|
||||
// A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in
|
||||
// one unit so two stage times are comparable at a glance. Sub-10 ms keeps one decimal so a short
|
||||
// attack is not rounded to a bare "0 ms". Writes at most `len` bytes including the terminator.
|
||||
void formatEnvTimeMs(double seconds, char* buf, std::size_t len);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -2,11 +2,18 @@
|
||||
|
||||
#include "core/instrument/ui/envelope_edit.h"
|
||||
|
||||
#include "core/util/clamp01.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath> // std::fabs
|
||||
#include <cstdlib> // std::abs
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using util::clamp01;
|
||||
using util::curveFromMidLevel;
|
||||
using util::curveMidLevel;
|
||||
|
||||
namespace {
|
||||
|
||||
// Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion).
|
||||
@@ -30,49 +37,108 @@ double levelPerPixel(const Rect& area) {
|
||||
return 1.0 / static_cast<double>(h - 1);
|
||||
}
|
||||
|
||||
// Origin + ReleaseStart are draw-only anchors, not grabbable.
|
||||
// Origin is a draw-only anchor; so is an AHDSR's ReleaseEnd, which is pinned to the right edge
|
||||
// (release is dragged from ReleaseStart instead).
|
||||
bool isDraggable(EnvNode n) {
|
||||
switch (n) {
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
case EnvNode::ReleaseEnd:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in
|
||||
// Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver.
|
||||
bool nodeInMode(EnvNode n, EnvMode m) {
|
||||
// Guards the degenerate baseline's cross-kind vertices, and keeps the sustain-only nodes off an
|
||||
// AHD. Applied by both the hit-test and the drag resolver.
|
||||
bool nodeInKind(EnvNode n, EnvKind k) {
|
||||
switch (n) {
|
||||
case EnvNode::AttackEnd:
|
||||
case EnvNode::HoldEnd:
|
||||
case EnvNode::DecayEnd:
|
||||
case EnvNode::ReleaseEnd:
|
||||
return m == EnvMode::Gate;
|
||||
case EnvNode::FadeInEnd:
|
||||
case EnvNode::FadeOutStart:
|
||||
case EnvNode::LengthEnd:
|
||||
return m == EnvMode::Trigger;
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::AttackCurve:
|
||||
case EnvNode::DecayCurve:
|
||||
return true;
|
||||
case EnvNode::ReleaseStart:
|
||||
case EnvNode::ReleaseCurve:
|
||||
return k == EnvKind::Ahdsr;
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseEnd:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The two endpoint levels of the segment a curve knot shapes. `ok` is false when the segment
|
||||
// is level (nothing a curve could express), so the drag is a no-op rather than a division.
|
||||
struct SegmentLevels {
|
||||
double start = 0.0;
|
||||
double end = 0.0;
|
||||
bool ok = false;
|
||||
};
|
||||
SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
|
||||
const double sus = clamp01(env.sustainLevel);
|
||||
SegmentLevels s;
|
||||
switch (knot) {
|
||||
case EnvNode::AttackCurve: s = {0.0, 1.0, true}; break;
|
||||
case EnvNode::DecayCurve:
|
||||
s = {1.0, env.kind == EnvKind::Ahdsr ? sus : 0.0, true};
|
||||
break;
|
||||
case EnvNode::ReleaseCurve: s = {sus, 0.0, true}; break;
|
||||
default: return s;
|
||||
}
|
||||
if (s.start == s.end) s.ok = false;
|
||||
return s;
|
||||
}
|
||||
|
||||
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
|
||||
// curve_law's inverse (curve_law.h owns why the knot and the inner dial share this one law).
|
||||
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
|
||||
const Rect& area, int dyPixels) {
|
||||
const SegmentLevels seg = segmentLevels(grabEnv, knot);
|
||||
if (!seg.ok) return grabExponent;
|
||||
const double span = seg.end - seg.start;
|
||||
// segmentLevels only rejects an EXACTLY level segment; a near-level one (e.g. sustain
|
||||
// 0.99) still passes with a tiny divisor here, so one pixel of drag can swing `u` by
|
||||
// ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of
|
||||
// level travel — a segment thinner than that is visually a no-op drag anyway.
|
||||
if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent;
|
||||
const double grabLevel = seg.start + span * curveMidLevel(grabExponent);
|
||||
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
|
||||
return curveFromMidLevel((newLevel - seg.start) / span);
|
||||
}
|
||||
|
||||
// An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see
|
||||
// resolveNodeDrag's DecayEnd case. At holdFraction == 1.0 that derivative is exactly 0, so a
|
||||
// drag there can never change anything; when it ALSO coincides with HoldEnd (decay ~ 0) it is a
|
||||
// dead handle sitting on top of a live one. Excluded from the grabbable set in that exact case
|
||||
// only — a functional DecayEnd (holdFraction < 1) stays grabbable even when it coincides.
|
||||
bool ahdDecayEndIsDead(const StageEnvelope& env, const std::vector<EnvVertex>& poly) {
|
||||
if (env.kind != EnvKind::Ahd) return false;
|
||||
if (1.0 - clamp01(env.holdFraction) > 1e-9) return false;
|
||||
EnvVertex hold, decay;
|
||||
bool haveHold = false, haveDecay = false;
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.node == EnvNode::HoldEnd) { hold = v; haveHold = true; }
|
||||
else if (v.node == EnvNode::DecayEnd) { decay = v; haveDecay = true; }
|
||||
}
|
||||
return haveHold && haveDecay && hold.x == decay.x;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
|
||||
int y) {
|
||||
NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double totalSeconds,
|
||||
int x, int y) {
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
|
||||
// Nearest draggable, mode-matching node within the pick radius wins (Chebyshev distance);
|
||||
// ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
|
||||
// coincidence (FadeOutStart overlaps LengthEnd and wins).
|
||||
const bool dropDeadDecayEnd = ahdDecayEndIsDead(env, poly);
|
||||
// Nearest draggable, kind-matching node within the pick radius wins (Chebyshev distance);
|
||||
// ties go to the earlier draw-order node. Knots are appended last, so a knot coincident
|
||||
// with an endpoint handle loses — a drag there stays a time edit.
|
||||
NodeHit best;
|
||||
int bestDist = kNodeGrabRadius + 1;
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue;
|
||||
if (!isDraggable(v.node) || !nodeInKind(v.node, env.kind)) continue;
|
||||
if (dropDeadDecayEnd && v.node == EnvNode::DecayEnd) continue;
|
||||
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
|
||||
if (dist < bestDist) { // strict-less-than keeps ties at the earlier draw order
|
||||
bestDist = dist;
|
||||
@@ -82,11 +148,11 @@ NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double tota
|
||||
return best;
|
||||
}
|
||||
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels) {
|
||||
AmpEnvelope out = grabEnv;
|
||||
if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) return out;
|
||||
StageEnvelope out = grabEnv;
|
||||
if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out;
|
||||
|
||||
const Rect& rect = area.rect;
|
||||
const double secPerPx = secondsPerPixel(rect, totalSeconds);
|
||||
@@ -94,63 +160,91 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Over
|
||||
const double dSec = static_cast<double>(dxPixels) * secPerPx;
|
||||
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
|
||||
|
||||
if (grabEnv.kind == EnvKind::Ahdsr) {
|
||||
switch (node) {
|
||||
// Gate: each cumulative-time node edits its own segment duration. Non-negative durations
|
||||
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node can
|
||||
// never cross a neighbour) — the [0, max] clamp is the whole constraint.
|
||||
// Each cumulative-time node edits its own segment duration. Non-negative durations
|
||||
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node
|
||||
// can never cross a neighbour) — the [0, max] clamp is the whole constraint.
|
||||
case EnvNode::AttackEnd:
|
||||
out.attackSeconds =
|
||||
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
|
||||
break;
|
||||
case EnvNode::HoldEnd:
|
||||
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
|
||||
out.holdSeconds =
|
||||
std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
|
||||
break;
|
||||
case EnvNode::DecayEnd: {
|
||||
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
|
||||
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double lvlPerPx = levelPerPixel(rect);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
|
||||
out.decaySeconds =
|
||||
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
|
||||
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
||||
break;
|
||||
}
|
||||
case EnvNode::ReleaseEnd:
|
||||
out.releaseSeconds =
|
||||
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
|
||||
break;
|
||||
|
||||
// Trigger: fades + length are fractions. X pixels convert to a fraction of the played
|
||||
// span (fades) or the whole sample (length). fadeIn + fadeOut <= 1 keeps the two fade
|
||||
// nodes from crossing (each clamps against the other).
|
||||
case EnvNode::FadeInEnd: {
|
||||
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
|
||||
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
|
||||
const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0;
|
||||
const double hi = std::min(bounds.maxFadeInFraction,
|
||||
1.0 - std::max(0.0, grabEnv.fadeOutFraction));
|
||||
out.fadeInFraction = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
|
||||
break;
|
||||
}
|
||||
case EnvNode::FadeOutStart: {
|
||||
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
|
||||
// FadeOutStart sits at (1 - fadeOut) of the played span; dragging it LEFT (negative dx)
|
||||
// lengthens the fade-out. So the fade-out fraction moves OPPOSITE the pixel delta.
|
||||
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
|
||||
const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0;
|
||||
const double hi = std::min(bounds.maxFadeOutFraction,
|
||||
1.0 - std::max(0.0, grabEnv.fadeInFraction));
|
||||
out.fadeOutFraction = std::clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
|
||||
break;
|
||||
}
|
||||
case EnvNode::LengthEnd: {
|
||||
// LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it.
|
||||
const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0;
|
||||
out.lengthFraction = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
|
||||
break;
|
||||
}
|
||||
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
|
||||
// The release runs from this node to the anchored right edge, so dragging LEFT
|
||||
// (negative dx) lengthens it — the delta enters with the opposite sign.
|
||||
out.releaseSeconds =
|
||||
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
|
||||
break;
|
||||
case EnvNode::AttackCurve:
|
||||
out.attackCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
|
||||
break;
|
||||
case EnvNode::DecayCurve:
|
||||
out.decayCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
|
||||
break;
|
||||
case EnvNode::ReleaseCurve:
|
||||
out.releaseCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// AHD: the x-axis is the waveform's own, so a stage node moves at 1:1 wall-clock scale.
|
||||
const AhdSplit s = splitAhdSeconds(grabEnv);
|
||||
switch (node) {
|
||||
case EnvNode::AttackEnd:
|
||||
out.attackSeconds =
|
||||
std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
|
||||
break;
|
||||
case EnvNode::HoldEnd: {
|
||||
// Hold is a fraction of what attack and decay left, so the node's pixel motion
|
||||
// converts through that remainder. A zero remainder leaves nothing to divide by and
|
||||
// nothing the drag could express.
|
||||
const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay;
|
||||
if (rem <= 0.0) break;
|
||||
out.holdFraction = clamp01((s.hold + dSec) / rem);
|
||||
break;
|
||||
}
|
||||
case EnvNode::DecayEnd: {
|
||||
// DecayEnd is DRAWN at t0 + total, and total = attack + decay + (span-attack-decay)
|
||||
// * holdFraction, so d(total)/d(decay) = 1 - holdFraction: Hold eats a holdFraction
|
||||
// share of whatever decay gives up. Scaling by 1/(1-frac) makes the drawn endpoint
|
||||
// track the cursor 1:1, matching every other node. At frac == 1.0 (the Trigger
|
||||
// default) Hold consumes the WHOLE remainder regardless of decay's value, so the
|
||||
// derivative is exactly 0 — no scale recovers motion there, and decaySeconds is left
|
||||
// unchanged rather than divided by zero.
|
||||
const double denom = 1.0 - clamp01(grabEnv.holdFraction);
|
||||
if (denom > 1e-9) {
|
||||
out.decaySeconds =
|
||||
std::clamp(grabEnv.decaySeconds + dSec / denom, 0.0, bounds.maxDecaySeconds);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EnvNode::AttackCurve:
|
||||
out.attackCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
|
||||
break;
|
||||
case EnvNode::DecayCurve:
|
||||
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
// envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
|
||||
// envelope nodes. Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested
|
||||
// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
|
||||
// envelope nodes and their mid-segment curve knots. The inverse of envelope_overlay's forward
|
||||
// (draw) map; both read/write the same StageEnvelope fields, so node drag / knot drag / knob
|
||||
// edit are one model — see core/instrument/CLAUDE.md's "envelope overlay" section for why.
|
||||
// Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested outside the DAW.
|
||||
//
|
||||
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
|
||||
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the one
|
||||
// parameter set every paint), so a node drag and a slider edit are two views on one source of
|
||||
// truth.
|
||||
//
|
||||
// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
|
||||
// between time predecessor/successor) and range-clamped to the same per-param [min,max] the
|
||||
// slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side).
|
||||
//
|
||||
// Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time,
|
||||
// Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only
|
||||
// editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa).
|
||||
// Time-only nodes drag on X; DecayEnd in an AHDSR drags on both axes (X = decay time, Y =
|
||||
// sustain level); a curve knot drags on Y alone (curve_law.h owns the exponent domain). Origin
|
||||
// is never draggable, and neither is an AHDSR's ReleaseEnd — anchored to the right edge, with
|
||||
// release dragged from ReleaseStart instead. A node is only editable in its own kind.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -21,7 +15,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect
|
||||
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
|
||||
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvKind, StageEnvelope, EnvVertex
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
@@ -29,45 +23,42 @@ namespace reasampler::instrument::ui {
|
||||
// kMarkerGrabWidth.
|
||||
inline constexpr int kNodeGrabRadius = 6;
|
||||
|
||||
// Per-param clamp bounds the shell supplies — the same maxima its sliders map [0,1] onto.
|
||||
// Lower bound is always 0; the monotonic-in-time constraint tightens further at edit time.
|
||||
// Defaults are placeholders; the shell overrides with its live slider domain.
|
||||
// Per-param clamp bounds the shell supplies — the same maxima its knobs map [0,1] onto.
|
||||
// Lower bound is always 0. Defaults are placeholders; the shell overrides with its live domain.
|
||||
struct EnvClampBounds {
|
||||
double maxAttackSeconds = 4.0;
|
||||
double maxHoldSeconds = 4.0;
|
||||
double maxDecaySeconds = 4.0;
|
||||
double maxReleaseSeconds = 4.0;
|
||||
double maxFadeInFraction = 1.0;
|
||||
double maxFadeOutFraction = 1.0;
|
||||
double maxLengthFraction = 1.0;
|
||||
// sustainLevel is always [0,1] — no shell knob needed.
|
||||
// sustainLevel is always [0,1] and the hold FRACTION is always [0,1] — no shell knob needed.
|
||||
};
|
||||
|
||||
// Which node a grab at (x, y) lands on, given the current envelope/rect/duration (the same
|
||||
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node;
|
||||
// Origin/ReleaseStart and nodes from the other mode never hit. Nearest node within the radius
|
||||
// wins (Chebyshev distance); an exact tie goes to the earlier draw-order node — this only matters
|
||||
// for Trigger's zero-fade-out coincidence (FadeOutStart overlaps LengthEnd and wins, so the fade
|
||||
// can be dragged open from zero). Gate nodes never coincide (forward map enforces
|
||||
// kGateNodeSepPx), so every Gate handle is independently grabbable.
|
||||
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node.
|
||||
// Nearest node within the radius wins (Chebyshev distance); an exact tie goes to the earlier
|
||||
// draw-order node, and since knots are appended last, a coincident endpoint handle wins over a
|
||||
// knot rather than the drag silently becoming a curve edit. An AHD's DecayEnd is excluded
|
||||
// entirely when it coincides with HoldEnd AND holdFraction == 1.0 — there it cannot move
|
||||
// (resolveNodeDrag's derivative is 0), so it is dropped rather than won by draw order.
|
||||
struct NodeHit {
|
||||
bool hit = false;
|
||||
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
|
||||
};
|
||||
// Takes the waveform overlay (not a lane) — see waveform_view.h's overlay contract.
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
|
||||
int y);
|
||||
NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double totalSeconds,
|
||||
int x, int y);
|
||||
|
||||
// Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the
|
||||
// shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/
|
||||
// `dyPixels` is the pixel delta since grab.
|
||||
// * X delta -> the node's time param, shifted via the same linear map as timeToX, clamped to
|
||||
// [0, per-param max] and to its monotonic-in-time neighbours.
|
||||
// * Y delta -> the level param, only for DecayEnd; clamped to [0,1]. Ignored for time-only nodes.
|
||||
// * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns
|
||||
// Resolves a drag of `node` to a new StageEnvelope. `grabEnv` is the envelope as of grab time
|
||||
// (the shell snapshots it on button-down so the delta is absolute, not accumulated);
|
||||
// `dxPixels`/`dyPixels` is the pixel delta since grab.
|
||||
// * X delta -> the node's time param, at the same scale the forward map drew it, clamped to
|
||||
// [0, per-param max].
|
||||
// * Y delta -> the level param (AHDSR DecayEnd's sustain) or, on a knot, the segment's curve
|
||||
// exponent. Ignored for time-only nodes.
|
||||
// * A non-draggable node, an other-kind node, a zero-size area, or totalSeconds <= 0 returns
|
||||
// `grabEnv` unchanged.
|
||||
// Only the dragged node's param(s) change. Pure.
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels);
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using util::clamp01;
|
||||
using util::curveMap;
|
||||
using util::curveMidLevel;
|
||||
|
||||
int timeToX(const Rect& area, double totalSeconds, double t) {
|
||||
const int w = std::max(0, area.width);
|
||||
@@ -21,20 +23,14 @@ int timeToX(const Rect& area, double totalSeconds, double t) {
|
||||
return area.x + static_cast<int>(px + 0.5);
|
||||
}
|
||||
|
||||
int gateTimedWidth(const Rect& area) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (w <= 0) return 0;
|
||||
const int sustainPx =
|
||||
static_cast<int>(kGateSustainDisplayFraction * static_cast<double>(w) + 0.5);
|
||||
return std::max(1, w - sustainPx);
|
||||
}
|
||||
|
||||
double gatePxPerSecond(const Rect& area) {
|
||||
const int timedW = gateTimedWidth(area);
|
||||
if (timedW <= 0) return 0.0;
|
||||
// Minus the four per-segment separation bases and the last in-bounds column, floored at 1.
|
||||
const double usable =
|
||||
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
|
||||
const int w = std::max(0, area.width);
|
||||
if (w <= 0) return 0.0;
|
||||
// The four timed stages share the canvas minus their four separation bases and the last
|
||||
// in-bounds column; whatever they leave IS the sustain plateau, which is why a zero release
|
||||
// puts the plateau's end one separation short of the right edge rather than a fixed
|
||||
// fraction of the way across.
|
||||
const double usable = std::max(1.0, static_cast<double>(w - 1 - 4 * kGateNodeSepPx));
|
||||
return usable / (4.0 * kGateStageMaxSeconds);
|
||||
}
|
||||
|
||||
@@ -50,20 +46,37 @@ int levelToY(const Rect& area, double level) {
|
||||
return area.y + static_cast<int>(dy);
|
||||
}
|
||||
|
||||
AhdSplit splitAhdSeconds(const StageEnvelope& env) {
|
||||
AhdSplit out;
|
||||
const double span = std::max(0.0, env.spanSeconds);
|
||||
double a = std::max(0.0, env.attackSeconds);
|
||||
if (a > span) a = span;
|
||||
double d = std::max(0.0, env.decaySeconds);
|
||||
if (d > span - a) d = span - a;
|
||||
const double remaining = span - a - d;
|
||||
out.attack = a;
|
||||
out.decay = d;
|
||||
out.hold = remaining * clamp01(env.holdFraction);
|
||||
out.total = out.attack + out.hold + out.decay;
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) {
|
||||
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level,
|
||||
bool knot = false) {
|
||||
EnvVertex v;
|
||||
v.node = node;
|
||||
v.x = timeToX(area, totalSeconds, t);
|
||||
v.y = levelToY(area, level);
|
||||
v.level = level;
|
||||
v.knot = knot;
|
||||
return v;
|
||||
}
|
||||
|
||||
// Gate works in px space (timed px + the fixed sustain-plateau reserve) rather than the plain
|
||||
// timeToX map; clamps in double space before the int cast for the same overflow reason as above.
|
||||
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
|
||||
// The AHDSR schematic works in px space rather than the plain timeToX map; clamps in double
|
||||
// space before the int cast for the same overflow reason as timeToX.
|
||||
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool knot = false) {
|
||||
const int w = std::max(1, area.width);
|
||||
if (px < 0.0) px = 0.0;
|
||||
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
|
||||
@@ -72,10 +85,26 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
|
||||
v.x = area.x + static_cast<int>(px + 0.5);
|
||||
v.y = levelToY(area, level);
|
||||
v.level = level;
|
||||
v.knot = knot;
|
||||
return v;
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
|
||||
// The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's
|
||||
// pixel midpoint, its level read through curve_law.h's own law (the knot/dial pairing's home).
|
||||
EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel,
|
||||
double endLevel, double exponent) {
|
||||
const double u = curveMidLevel(exponent);
|
||||
const double level = startLevel + (endLevel - startLevel) * u;
|
||||
EnvVertex v;
|
||||
v.node = node;
|
||||
v.x = (x0 + x1) / 2;
|
||||
v.y = levelToY(area, level);
|
||||
v.level = level;
|
||||
v.knot = true;
|
||||
return v;
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> gatePolyline(const StageEnvelope& env, const Rect& area) {
|
||||
// Clamp defensively — a stored negative duration would be an upstream bug.
|
||||
const double a = std::max(0.0, env.attackSeconds);
|
||||
const double h = std::max(0.0, env.holdSeconds);
|
||||
@@ -83,73 +112,93 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
|
||||
const double r = std::max(0.0, env.releaseSeconds);
|
||||
const double sus = clamp01(env.sustainLevel);
|
||||
|
||||
// A/H/D/R map onto the timed region at the param-domain scale, each segment getting a
|
||||
// kGateNodeSepPx base so nodes never coincide even at the tier-0 zero-hold/zero-decay
|
||||
// defaults. The sustain plateau is the fixed reserve between DecayEnd and ReleaseStart.
|
||||
const int W = std::max(1, area.width);
|
||||
const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
|
||||
const double sep = static_cast<double>(kGateNodeSepPx);
|
||||
const double pps = gatePxPerSecond(area);
|
||||
|
||||
double xAttack = sep + a * pps; // AttackEnd
|
||||
double xHold = xAttack + sep + h * pps; // HoldEnd
|
||||
double xDecay = xHold + sep + d * pps; // DecayEnd (sustain node)
|
||||
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
|
||||
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
|
||||
|
||||
// Overrun beyond the schematic domain compresses from the right, preserving minimum gaps so
|
||||
// trailing nodes stay separated instead of piling on the last column. This re-floor only
|
||||
// bites when the canvas is too narrow to hold the gaps at all — gateVtx's clamp wins then.
|
||||
const double xMax = static_cast<double>(W - 1);
|
||||
if (xRelease > xMax) {
|
||||
xRelease = xMax;
|
||||
xPlateau = std::min(xPlateau, xRelease - sep);
|
||||
xDecay = std::min(xDecay, xPlateau - sustainPx);
|
||||
|
||||
// The release ANCHORS to the right edge: ReleaseEnd is the canvas edge and ReleaseStart —
|
||||
// the sustain->release join, and the node the user drags — sits a release-length to its
|
||||
// left. Everything the release does not take is the sustain plateau, so a zero release
|
||||
// leaves the plateau running to within one separation of the edge.
|
||||
double xAttack = sep + a * pps;
|
||||
double xHold = xAttack + sep + h * pps;
|
||||
double xDecay = xHold + sep + d * pps;
|
||||
double xPlateau = xMax - sep - r * pps;
|
||||
const double xRelease = xMax;
|
||||
|
||||
// Keep every node separated when the four stages together would overrun the canvas: the
|
||||
// plateau holds its minimum gap from the edge, then the A/H/D chain compresses from the
|
||||
// right and re-floors from the left. This only bites at the domain's extremes; gateVtx's
|
||||
// own clamp wins on a canvas too narrow to hold the gaps at all.
|
||||
if (xPlateau < xDecay + sep) {
|
||||
if (xPlateau < 4.0 * sep) xPlateau = 4.0 * sep;
|
||||
xDecay = std::min(xDecay, xPlateau - sep);
|
||||
xHold = std::min(xHold, xDecay - sep);
|
||||
xAttack = std::min(xAttack, xHold - sep);
|
||||
xAttack = std::max(xAttack, sep);
|
||||
xHold = std::max(xHold, xAttack + sep);
|
||||
xDecay = std::max(xDecay, xHold + sep);
|
||||
xPlateau = std::max(xPlateau, xDecay + sustainPx);
|
||||
xRelease = std::max(xRelease, xPlateau + sep);
|
||||
xPlateau = std::max(xPlateau, xDecay + sep);
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> pts;
|
||||
pts.reserve(6);
|
||||
pts.reserve(9);
|
||||
pts.push_back(gateVtx(EnvNode::Origin, area, 0.0, 0.0));
|
||||
pts.push_back(gateVtx(EnvNode::AttackEnd, area, xAttack, 1.0));
|
||||
pts.push_back(gateVtx(EnvNode::HoldEnd, area, xHold, 1.0));
|
||||
pts.push_back(gateVtx(EnvNode::DecayEnd, area, xDecay, sus)); // sustain node
|
||||
pts.push_back(gateVtx(EnvNode::ReleaseStart, area, xPlateau, sus)); // plateau end
|
||||
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0));
|
||||
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0)); // anchored
|
||||
|
||||
// Knots ride only SLOPED stages that actually have a duration — a zero-length stage has no
|
||||
// interior to place a handle in, and one there would collide with its own endpoints.
|
||||
if (a > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
|
||||
env.attackCurve));
|
||||
}
|
||||
if (d > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, sus,
|
||||
env.decayCurve));
|
||||
}
|
||||
if (r > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::ReleaseCurve, area, pts[4].x, pts[5].x, sus, 0.0,
|
||||
env.releaseCurve));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
|
||||
std::vector<EnvVertex> ahdPolyline(const StageEnvelope& env, const Rect& area,
|
||||
double totalSeconds) {
|
||||
// Played span is lengthFraction of the whole sample; fades are fractions of that span.
|
||||
const double len = clamp01(env.lengthFraction);
|
||||
double fadeIn = clamp01(env.fadeInFraction);
|
||||
double fadeOut = clamp01(env.fadeOutFraction);
|
||||
// Fades cannot overlap; trim fade-out first, matching the engine's TriggerParams clamp.
|
||||
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
|
||||
|
||||
const double playSeconds = len * totalSeconds;
|
||||
const double tFadeInEnd = fadeIn * playSeconds;
|
||||
const double tFadeOutStart = playSeconds - fadeOut * playSeconds; // where fade-out begins
|
||||
const AhdSplit s = splitAhdSeconds(env);
|
||||
const double t0 = std::max(0.0, env.originSeconds);
|
||||
|
||||
std::vector<EnvVertex> pts;
|
||||
pts.reserve(4);
|
||||
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, 0.0, 0.0));
|
||||
pts.push_back(vtx(EnvNode::FadeInEnd, area, totalSeconds, tFadeInEnd, 1.0));
|
||||
pts.push_back(vtx(EnvNode::FadeOutStart, area, totalSeconds, tFadeOutStart, 1.0)); // unity end
|
||||
pts.push_back(vtx(EnvNode::LengthEnd, area, totalSeconds, playSeconds, 0.0)); // playEnd
|
||||
pts.reserve(6);
|
||||
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, t0, 0.0));
|
||||
pts.push_back(vtx(EnvNode::AttackEnd, area, totalSeconds, t0 + s.attack, 1.0));
|
||||
pts.push_back(vtx(EnvNode::HoldEnd, area, totalSeconds, t0 + s.attack + s.hold, 1.0));
|
||||
// DecayEnd is drawn at t0 + total, which coincides with HoldEnd exactly when decay ~ 0 —
|
||||
// independent of holdFraction (total = attack + hold + decay always). Left at its true
|
||||
// instant rather than nudged: the 1:1 axis this policy exists to keep honest must hold even
|
||||
// at a shared instant, including the Trigger default's abrupt (zero-decay) cutoff.
|
||||
// envelope_edit's nodeAtPoint handles the coincidence instead, by dropping DecayEnd from the
|
||||
// grabbable set when it also cannot move (holdFraction == 1.0).
|
||||
pts.push_back(vtx(EnvNode::DecayEnd, area, totalSeconds, t0 + s.total, 0.0));
|
||||
if (s.attack > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
|
||||
env.attackCurve));
|
||||
}
|
||||
if (s.decay > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, 0.0,
|
||||
env.decayCurve));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const StageEnvelope& env, const OverlayArea& area,
|
||||
double totalSeconds) {
|
||||
const Rect& rect = area.rect;
|
||||
if (rect.width <= 0 || rect.height <= 0 || totalSeconds <= 0.0) {
|
||||
@@ -157,8 +206,8 @@ std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Overl
|
||||
return {vtx(EnvNode::Origin, rect, 1.0, 0.0, 0.0),
|
||||
vtx(EnvNode::ReleaseEnd, rect, 1.0, 1.0, 0.0)};
|
||||
}
|
||||
return env.mode == EnvMode::Gate ? gatePolyline(env, rect)
|
||||
: triggerPolyline(env, rect, totalSeconds);
|
||||
return env.kind == EnvKind::Ahdsr ? gatePolyline(env, rect)
|
||||
: ahdPolyline(env, rect, totalSeconds);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
|
||||
// envelope_overlay.h — staged-envelope -> polyline geometry for the Sample-view overlay.
|
||||
// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
|
||||
// param_slider. The shell packs the one parameter set's AdsrSeconds/TriggerParams into
|
||||
// AmpEnvelope and draws the polyline plus a handle at each node (envelope_edit does the
|
||||
// hit-test).
|
||||
// param_slider. The shell packs whichever envelope is overlay-active into StageEnvelope and
|
||||
// draws the polyline plus a handle at each node (envelope_edit does the hit-test).
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -10,92 +9,97 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
|
||||
#include "core/util/curve_law.h" // the ONE per-segment curve law
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free.
|
||||
enum class EnvMode { Gate, Trigger };
|
||||
// Which LAYOUT POLICY an envelope takes — see core/instrument/CLAUDE.md's "envelope overlay"
|
||||
// section for why (decided by sustain-stage presence, not by which processor it modulates).
|
||||
// Ahdsr right-anchors its release; Ahd maps 1:1 onto the waveform's own time axis.
|
||||
enum class EnvKind { Ahdsr, Ahd };
|
||||
|
||||
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
|
||||
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd).
|
||||
// Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map).
|
||||
// AHD nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd.
|
||||
// The three *Curve nodes are the round mid-segment knots whose vertical drag sets that
|
||||
// segment's curve exponent. Shared by envelope_overlay (forward/draw) and envelope_edit
|
||||
// (inverse/edit).
|
||||
enum class EnvNode {
|
||||
Origin, // t=0, level 0 — not draggable
|
||||
AttackEnd, // Gate: attack ramp top — sets attackSeconds
|
||||
HoldEnd, // Gate: hold plateau end — sets holdSeconds
|
||||
DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y)
|
||||
ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable
|
||||
ReleaseEnd, // Gate: release tail end — sets releaseSeconds
|
||||
FadeInEnd, // Trigger: fade-in top — sets fadeInFraction
|
||||
FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction
|
||||
LengthEnd, // Trigger: playEnd terminal — sets lengthFraction
|
||||
AttackEnd, // attack ramp top — sets attackSeconds
|
||||
HoldEnd, // hold plateau end — AHDSR: holdSeconds; AHD: holdFraction
|
||||
DecayEnd, // AHDSR: decay settles to sustain (X = decay, Y = sustain); AHD: decay end
|
||||
ReleaseStart, // AHDSR: sustain plateau end — sets releaseSeconds (drags on X, inverted)
|
||||
ReleaseEnd, // AHDSR: the envelope's end point — ANCHORED to the right edge, not draggable
|
||||
AttackCurve, // mid-attack knot — sets attackCurve
|
||||
DecayCurve, // mid-decay knot — sets decayCurve
|
||||
ReleaseCurve, // mid-release knot — sets releaseCurve (AHDSR only)
|
||||
};
|
||||
|
||||
// Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from
|
||||
// TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in
|
||||
// core/instrument/CLAUDE.md.
|
||||
struct AmpEnvelope {
|
||||
EnvMode mode = EnvMode::Gate;
|
||||
// The envelope the overlay draws. One struct for both policies: `kind` selects which fields
|
||||
// are read, so a single pack/unpack pair serves the amp, pitch, and filter envelopes.
|
||||
struct StageEnvelope {
|
||||
EnvKind kind = EnvKind::Ahdsr;
|
||||
|
||||
// Gate (AHDSR): seconds, plus a dimensionless sustain level.
|
||||
// AHDSR: seconds at the schematic param-domain scale, plus a dimensionless sustain level.
|
||||
double attackSeconds = 0.003;
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060;
|
||||
|
||||
// Trigger: fractions of the played span.
|
||||
double lengthFraction = 1.0;
|
||||
double fadeInFraction = 0.0;
|
||||
double fadeOutFraction = 0.0;
|
||||
// AHD: attack/decay seconds plus the Hold FRACTION of the span left after them, laid over
|
||||
// [originSeconds, originSeconds + spanSeconds) of the waveform's own time axis.
|
||||
double holdFraction = 1.0;
|
||||
double originSeconds = 0.0;
|
||||
double spanSeconds = 0.0;
|
||||
|
||||
// Per-segment curve exponents (release is AHDSR-only). curve_law.h owns the domain.
|
||||
double attackCurve = util::kCurveNeutral;
|
||||
double decayCurve = util::kCurveNeutral;
|
||||
double releaseCurve = util::kCurveNeutral;
|
||||
};
|
||||
|
||||
// One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for
|
||||
// inspection.
|
||||
// One polyline vertex: pixel point plus which node it is. `level` is redundant with y, carried
|
||||
// for inspection. `knot` marks the round mid-segment curve handles, which draw differently and
|
||||
// are not part of the traced line.
|
||||
struct EnvVertex {
|
||||
EnvNode node = EnvNode::Origin;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
double level = 0.0;
|
||||
bool knot = false;
|
||||
|
||||
bool operator==(const EnvVertex& o) const {
|
||||
return node == o.node && x == o.x && y == o.y && level == o.level;
|
||||
return node == o.node && x == o.x && y == o.y && level == o.level && knot == o.knot;
|
||||
}
|
||||
};
|
||||
|
||||
// Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width
|
||||
// carries A/H/D/R at the param-domain scale. Shared with envelope_edit.
|
||||
inline constexpr double kGateSustainDisplayFraction = 0.15;
|
||||
|
||||
// Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0
|
||||
// Minimum pixel separation between consecutive AHDSR nodes, so zero-duration stages (tier-0
|
||||
// defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab
|
||||
// radius (6) so a click can never tie between neighbours.
|
||||
inline constexpr int kGateNodeSepPx = 8;
|
||||
|
||||
// Gate schematic's per-stage time domain (seconds) — the timed region represents four stages
|
||||
// end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider
|
||||
// lands exactly at the canvas edge.
|
||||
// The AHDSR schematic's per-stage time domain (seconds) — the four timed stages A/H/D/R each
|
||||
// span at most this. Must match the shell's stage-knob ceiling so a maxed knob lands exactly at
|
||||
// the canvas edge (at which point the sustain plateau has shrunk to nothing).
|
||||
inline constexpr double kGateStageMaxSeconds = 2.0;
|
||||
|
||||
// Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for
|
||||
// a non-empty area; 0 for a zero/negative-width area.
|
||||
int gateTimedWidth(const Rect& area);
|
||||
|
||||
// Pixels per second of the Gate timed region, independent of the sample's actual duration.
|
||||
// Pixels per second of the AHDSR schematic, independent of the sample's actual duration.
|
||||
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
|
||||
// the cursor 1:1.
|
||||
double gatePxPerSecond(const Rect& area);
|
||||
|
||||
// Maps an amp envelope to polyline vertices inside `area` over a sample of `totalSeconds`
|
||||
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); vertices
|
||||
// are in draw order, Origin first.
|
||||
// Maps a staged envelope to polyline vertices inside `area` over a sample of `totalSeconds`
|
||||
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); the
|
||||
// traced vertices come first in draw order (Origin first), then the curve knots.
|
||||
//
|
||||
// Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
|
||||
// waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
|
||||
// the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
|
||||
// or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
|
||||
// The AHDSR x-axis is a bounded schematic independent of totalSeconds (it does NOT line up with
|
||||
// the waveform under it) with its ReleaseEnd anchored to the right edge; the AHD x-axis is
|
||||
// wall-clock, 1:1 with the waveform. Every vertex is clamped inside the canvas: x in
|
||||
// [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area or
|
||||
// totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
|
||||
// waveform overlay (not a lane) — see waveform_view.h's overlay contract.
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const StageEnvelope& env, const OverlayArea& area,
|
||||
double totalSeconds);
|
||||
|
||||
// Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
|
||||
@@ -106,4 +110,16 @@ int timeToX(const Rect& area, double totalSeconds, double t);
|
||||
// clamped. Shared with envelope_edit's node hit-test.
|
||||
int levelToY(const Rect& area, double level);
|
||||
|
||||
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
|
||||
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
|
||||
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push
|
||||
// the sum past the span; there is no clamp on the sum because none is possible.
|
||||
struct AhdSplit {
|
||||
double attack = 0.0;
|
||||
double hold = 0.0;
|
||||
double decay = 0.0;
|
||||
double total = 0.0;
|
||||
};
|
||||
AhdSplit splitAhdSeconds(const StageEnvelope& env);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -20,10 +20,12 @@ int knobRowWidth(const DeckGroupDesc& g) {
|
||||
return w;
|
||||
}
|
||||
|
||||
// The caption-row width: the caption reserve plus the optional caption toggle.
|
||||
// The caption-row width: the caption reserve plus the optional caption toggle and radio.
|
||||
int captionRowWidth(const DeckGroupDesc& g) {
|
||||
int w = g.captionWidth;
|
||||
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
|
||||
if (g.captionToggle2.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle2.segWidth;
|
||||
if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize;
|
||||
return w;
|
||||
}
|
||||
|
||||
@@ -37,38 +39,68 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
const int innerLeft = box.x + kDeckGroupPadX;
|
||||
const int innerRight = box.right() - kDeckGroupPadX;
|
||||
|
||||
// Caption row: text left, compact toggle right-anchored.
|
||||
// Caption row: text left, then the compact toggle, then the corner radio at the far edge.
|
||||
out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
|
||||
if (g.captionToggle.id >= 0) {
|
||||
const int segW = g.captionToggle.segWidth;
|
||||
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
|
||||
const Rect seg1 = Rect::ltrb(innerRight - segW, togTop, innerRight, togTop + kDeckToggleH);
|
||||
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
|
||||
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
|
||||
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
|
||||
out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x;
|
||||
int captionRight = innerRight;
|
||||
if (g.captionRadio.id >= 0) {
|
||||
const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2;
|
||||
out.captionRadio = DeckRadioLayout{
|
||||
g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight,
|
||||
radioTop + kDeckRadioSize)};
|
||||
captionRight = out.captionRadio.box.x - kDeckToggleGap;
|
||||
out.caption.width = captionRight - out.caption.x;
|
||||
}
|
||||
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
|
||||
const auto placeToggle = [&](const DeckToggleDesc& d, DeckToggleLayout& into) {
|
||||
if (d.id < 0) return;
|
||||
const int segW = d.segWidth;
|
||||
const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight,
|
||||
togTop + kDeckToggleH);
|
||||
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
|
||||
into = DeckToggleLayout{d.id, seg0, seg1};
|
||||
captionRight = seg0.x - kDeckToggleGap;
|
||||
// Caption text stops at the leftmost toggle: pull the right edge in (XYWH: width).
|
||||
out.caption.width = captionRight - out.caption.x;
|
||||
};
|
||||
placeToggle(g.captionToggle, out.captionToggle);
|
||||
placeToggle(g.captionToggle2, out.captionToggle2);
|
||||
|
||||
// Knob row: fixed cells left-to-right, then the optional row toggle.
|
||||
// Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared
|
||||
// id, reserves included). Integer division puts an indivisible residue in symmetric end
|
||||
// margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule.
|
||||
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
|
||||
int x = innerLeft;
|
||||
const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
|
||||
int presentCells = 0;
|
||||
for (int id : g.cellIds) {
|
||||
if (id >= 0) ++presentCells;
|
||||
}
|
||||
const int cellW = presentCells > 0 ? runWidth / presentCells : 0;
|
||||
int x = innerLeft + (runWidth - presentCells * cellW) / 2;
|
||||
for (int id : g.cellIds) {
|
||||
if (id < 0) continue;
|
||||
DeckCellLayout c;
|
||||
c.id = id;
|
||||
c.cell = Rect::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH);
|
||||
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2;
|
||||
c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH);
|
||||
const int knobLeft = x + (cellW - kDeckKnobSize) / 2;
|
||||
const int knobTop = cellTop + 4;
|
||||
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
|
||||
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
|
||||
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
|
||||
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
|
||||
innerTopPx + kDeckInnerDialSize);
|
||||
const int labelTop = knobTop + kDeckKnobSize + 4;
|
||||
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
|
||||
out.cells.push_back(c);
|
||||
x += kDeckCellW;
|
||||
x += cellW;
|
||||
}
|
||||
if (g.rowToggle.id >= 0) {
|
||||
if (!g.cellIds.empty()) x += kDeckToggleGap;
|
||||
// Anchored past the whole reserved run, not past the last cell, so a residue margin
|
||||
// cannot shift it.
|
||||
int tx = innerLeft + runWidth;
|
||||
if (!g.cellIds.empty()) tx += kDeckToggleGap;
|
||||
const int segW = g.rowToggle.segWidth;
|
||||
const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2;
|
||||
const Rect seg0 = Rect::ltrb(x, togTop, x + segW, togTop + kDeckToggleH);
|
||||
const Rect seg0 = Rect::ltrb(tx, togTop, tx + segW, togTop + kDeckToggleH);
|
||||
const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH);
|
||||
out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1};
|
||||
}
|
||||
@@ -133,11 +165,13 @@ DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int to
|
||||
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
|
||||
for (const DeckGroupLayout& g : layout.groups) {
|
||||
if (!contains(g.box, x, y)) continue;
|
||||
if (g.captionToggle.id >= 0) {
|
||||
if (contains(g.captionToggle.seg0, x, y))
|
||||
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0};
|
||||
if (contains(g.captionToggle.seg1, x, y))
|
||||
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 1};
|
||||
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
|
||||
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
|
||||
}
|
||||
for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
|
||||
if (t->id < 0) continue;
|
||||
if (contains(t->seg0, x, y)) return {DeckHitKind::CaptionToggle, t->id, 0};
|
||||
if (contains(t->seg1, x, y)) return {DeckHitKind::CaptionToggle, t->id, 1};
|
||||
}
|
||||
if (g.rowToggle.id >= 0) {
|
||||
if (contains(g.rowToggle.seg0, x, y))
|
||||
@@ -146,9 +180,33 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
|
||||
return {DeckHitKind::RowToggle, g.rowToggle.id, 1};
|
||||
}
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
if (c.id >= 0 && contains(c.cell, x, y)) return {DeckHitKind::Knob, c.id, -1};
|
||||
// Every entry here already has a real id — a reserve yields no DeckCellLayout at all.
|
||||
if (contains(c.cell, x, y)) {
|
||||
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
|
||||
}
|
||||
return {}; // inside the box but on fence/padding/blank — a miss (groups never overlap)
|
||||
}
|
||||
return {}; // inside the box but on fence/padding — a miss (groups never overlap)
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool inKnobFace(const Rect& knob, int x, int y) {
|
||||
const double dx = x - (knob.x + knob.width / 2.0);
|
||||
const double dy = y - (knob.y + knob.height / 2.0);
|
||||
// Matches computeKnob's radius rule (param_slider.cpp) so the hit rule never claims more
|
||||
// circle than is actually drawn when a caller's rect is non-square (e.g. a squashed chrome row).
|
||||
const double r = (std::min)(knob.width, knob.height) / 2.0;
|
||||
return dx * dx + dy * dy < r * r;
|
||||
}
|
||||
|
||||
DeckFaceHit hitTestKnobFace(const DeckLayout& layout, int x, int y) {
|
||||
for (const DeckGroupLayout& g : layout.groups) {
|
||||
if (!contains(g.box, x, y)) continue;
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
if (!inKnobFace(c.knob, x, y)) continue;
|
||||
return {c.id, inKnobFace(c.inner, x, y)};
|
||||
}
|
||||
return {}; // inside the group but off every dial
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
//
|
||||
// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
|
||||
// caption row (caption left, the group's compact mode toggle right-anchored) over a knob
|
||||
// row of fixed cells (knob centered, label band beneath). A group may also place one
|
||||
// row of equal-width cells (knob centered, label band beneath). A group may also place one
|
||||
// two-segment toggle in the knob row after its cells. Groups that must keep stable
|
||||
// geometry across a mode flip reserve blank cells (id -1) so a mode flip never reflows
|
||||
// geometry across a mode flip reserve cell width (id -1) so a mode flip never reflows
|
||||
// neighbouring groups.
|
||||
//
|
||||
// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
|
||||
@@ -22,11 +22,13 @@
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Fixed deck metrics, exposed so the shell and tests agree.
|
||||
inline constexpr int kDeckCellW = 48; // one knob cell
|
||||
inline constexpr int kDeckCellH = 58;
|
||||
inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell
|
||||
inline constexpr int kDeckCellLabelH = 12; // the Micro label band under the knob
|
||||
// Fixed deck metrics, exposed so the shell and tests agree. The cell/knob/label sizes were
|
||||
// raised together for legibility at high pixel densities; the editor's floor width
|
||||
// (sample_bands) is what absorbs the wider cells, so the two move as a pair.
|
||||
inline constexpr int kDeckCellW = 60; // one knob cell
|
||||
inline constexpr int kDeckCellH = 74;
|
||||
inline constexpr int kDeckKnobSize = 40; // knob diameter inside the cell
|
||||
inline constexpr int kDeckCellLabelH = 16; // the label band under the knob
|
||||
inline constexpr int kDeckCaptionH = 20; // the group caption row
|
||||
inline constexpr int kDeckToggleH = 18; // compact toggle segment height
|
||||
inline constexpr int kDeckGroupPadX = 6; // group box horizontal inner padding
|
||||
@@ -35,6 +37,11 @@ inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap
|
||||
inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
|
||||
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
|
||||
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
|
||||
inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square
|
||||
// The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while
|
||||
// the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is
|
||||
// deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob.
|
||||
inline constexpr int kDeckInnerDialSize = 20;
|
||||
// One group box: padding + caption + gap + cell row + padding.
|
||||
inline constexpr int kDeckGroupH =
|
||||
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
|
||||
@@ -45,14 +52,26 @@ struct DeckToggleDesc {
|
||||
int segWidth = 44; // px per segment
|
||||
};
|
||||
|
||||
// A single-square corner radio (an exclusive selector across groups, so the group itself
|
||||
// carries no state). id -1 = absent.
|
||||
struct DeckRadioDesc {
|
||||
int id = -1;
|
||||
};
|
||||
|
||||
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
|
||||
// is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell
|
||||
// reserves one cell's WIDTH without a cell, and the cells present divide the whole run —
|
||||
// see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell
|
||||
// reserves for the caption text (this module does not measure text).
|
||||
struct DeckGroupDesc {
|
||||
int id = 0; // shell group id (opaque here)
|
||||
int captionWidth = 60;
|
||||
DeckToggleDesc captionToggle; // right-anchored in the caption row; id -1 = none
|
||||
std::vector<int> cellIds; // knob cells; -1 = blank reserve
|
||||
DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none
|
||||
DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none
|
||||
// A second caption toggle, placed immediately left of the first (or in its place when the
|
||||
// first is absent) — why this exists rather than a rowToggle is recorded once, at this
|
||||
// module's CLAUDE.md bullet.
|
||||
DeckToggleDesc captionToggle2;
|
||||
std::vector<int> cellIds; // knob cells; -1 reserves width only, no cell (see above)
|
||||
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
|
||||
};
|
||||
|
||||
@@ -64,10 +83,16 @@ struct DeckToggleLayout {
|
||||
Rect seg1; // right segment
|
||||
};
|
||||
|
||||
struct DeckRadioLayout {
|
||||
int id = -1;
|
||||
Rect box;
|
||||
};
|
||||
|
||||
struct DeckCellLayout {
|
||||
int id = -1;
|
||||
Rect cell; // the full 48x58 cell
|
||||
Rect cell; // the whole cell; width is the group's reserved run divided by its cell count
|
||||
Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it)
|
||||
Rect inner; // the concentric kDeckInnerDialSize square inside `knob`
|
||||
Rect label; // the 12px label band beneath the knob
|
||||
};
|
||||
|
||||
@@ -75,7 +100,9 @@ struct DeckGroupLayout {
|
||||
int id = 0;
|
||||
Rect box; // the fenced group box
|
||||
Rect caption; // caption text rect (left part of the caption row)
|
||||
DeckRadioLayout captionRadio; // id -1 when absent (rect empty)
|
||||
DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
|
||||
DeckToggleLayout captionToggle2;
|
||||
std::vector<DeckCellLayout> cells;
|
||||
DeckToggleLayout rowToggle; // id -1 when absent
|
||||
};
|
||||
@@ -105,17 +132,41 @@ DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int to
|
||||
|
||||
// --- Hit-test --------------------------------------------------------------------------
|
||||
|
||||
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle };
|
||||
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio };
|
||||
|
||||
struct DeckHit {
|
||||
DeckHitKind kind = DeckHitKind::None;
|
||||
int id = -1; // the control id of the hit element (cell id / toggle id)
|
||||
int id = -1; // the control id of the hit element (cell id / toggle id / radio id)
|
||||
int segment = -1; // 0/1 for a toggle hit; -1 otherwise
|
||||
bool inner = false; // Knob hits only: the grab landed on the cell's inner dial
|
||||
};
|
||||
|
||||
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
|
||||
// circle — the shell anchors the vertical drag wherever the grab lands), a caption-toggle
|
||||
// segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
|
||||
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
|
||||
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or
|
||||
// the caption-row corner radio. Everything else — fence, padding, outside — misses.
|
||||
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
|
||||
|
||||
// The knob FACE a point lands on. id -1 is a miss.
|
||||
struct DeckFaceHit {
|
||||
int id = -1;
|
||||
bool inner = false; // inside the concentric inner disc
|
||||
};
|
||||
|
||||
// A point inside the circle inscribed in `knob` — radius is min(width, height)/2, same rule as
|
||||
// computeKnob's draw-side circle, so a non-square rect can never claim a hit past the drawn disc
|
||||
// — boundary-EXCLUSIVE. THE target rule for the reset gesture wherever a radial knob is drawn —
|
||||
// the deck's own faces and the chrome's preview-velocity dial both resolve through it, so one
|
||||
// gesture cannot grow two target rules.
|
||||
bool inKnobFace(const Rect& knob, int x, int y);
|
||||
|
||||
// Resolved against the drawn CIRCLES, not the cell: a reset is aimed at a dial, so the label
|
||||
// band and the cell margins must miss where a drag grab deliberately does not. One rule for
|
||||
// both rings — a point exactly on the inner radius is an outer-ring hit, one exactly on the
|
||||
// outer radius is a miss. Whether a cell actually carries an inner value is deck_groups' call,
|
||||
// exactly as with DeckHit::inner. Unlike hitTestDeck this runs NO toggle/radio precedence pass
|
||||
// first, which is only correct while no toggle rect overlaps a knob circle — a layout change
|
||||
// that lets them overlap has to give this the same precedence order.
|
||||
DeckFaceHit hitTestKnobFace(const DeckLayout& layout, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -16,13 +16,13 @@ inline constexpr int kPad = 8;
|
||||
// exactly this, and there is no scroll, so anything smaller pushes the deck band off the
|
||||
// window bottom (computeSampleBands' waveform-floor-wins degrade). Growing is fine — the
|
||||
// waveform band is the elastic one. Both the enforced minimum and the opening rect read this.
|
||||
inline constexpr int kEditorMinWidth = 840;
|
||||
inline constexpr int kEditorMinHeight = 620;
|
||||
inline constexpr int kEditorMinWidth = 980;
|
||||
inline constexpr int kEditorMinHeight = 680;
|
||||
|
||||
// Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip,
|
||||
// preview, velocity knob, curve button, channel toggle). sample_chrome partitions it.
|
||||
// preview, velocity knob, channel toggle). sample_chrome partitions it.
|
||||
inline constexpr int kTitleHeight = 26;
|
||||
inline constexpr int kChromeRowHeight = 52;
|
||||
inline constexpr int kChromeRowHeight = 64;
|
||||
|
||||
// Waveform band floor: two stacked lanes plus the seam between them. The band never shrinks
|
||||
// below this — a window too short for it clips the bands beneath instead, so the waveform
|
||||
|
||||
@@ -13,18 +13,22 @@ namespace {
|
||||
// The toolbar row carries the whole control run, so it is taller than the Browse modal's
|
||||
// plain kTitleHeight bar — the velocity knob cell (knob over label) sets the floor. Both
|
||||
// rows still fit the band the allocator hands out (kTitleHeight + kChromeRowHeight).
|
||||
constexpr int kToolbarHeight = 44;
|
||||
constexpr int kToolbarHeight = 58;
|
||||
constexpr int kStripBandHeight = 30;
|
||||
|
||||
constexpr int kRunGap = 6; // between adjacent items of the toolbar run
|
||||
constexpr int kChanSegW = 52;
|
||||
constexpr int kChanSegH = 18;
|
||||
constexpr int kCurveBtnSize = 24;
|
||||
constexpr int kVelCellW = 44;
|
||||
constexpr int kVelLabelH = 12;
|
||||
constexpr int kVelCellW = 56;
|
||||
constexpr int kHoldCellW = 56; // the bake Hold cell, same grammar as the velocity cell
|
||||
constexpr int kVelLabelH = 16;
|
||||
constexpr int kPreviewBtnW = 64;
|
||||
constexpr int kRunButtonH = 24; // Browse and Preview
|
||||
|
||||
constexpr int kPreviewGlyphMinH = 6;
|
||||
constexpr int kPreviewGlyphMaxH = 14;
|
||||
constexpr int kPreviewGlyphPad = 4; // clearance between the glyph and the button edge
|
||||
|
||||
} // namespace
|
||||
|
||||
ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||
@@ -39,7 +43,8 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||
const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; };
|
||||
const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); };
|
||||
|
||||
// The fixed run, right to left: Browse, Mono|Stereo, curve, velocity cell, preview.
|
||||
// The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview, bake, hold.
|
||||
// The velocity-curve button that used to sit here now lives in the deck's VELOCITY group.
|
||||
const int navH = std::min(kRunButtonH, row.height);
|
||||
const int navTop = topFor(navH);
|
||||
const int navRight = std::max(row.x, row.right() - kPad);
|
||||
@@ -53,14 +58,9 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||
r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x,
|
||||
chanTop + kChanSegH);
|
||||
|
||||
const int curveTop = topFor(kCurveBtnSize);
|
||||
const int curveRight = leftOf(r.chanMono.x, kRunGap);
|
||||
r.curveBtn = Rect::ltrb(leftOf(curveRight, kCurveBtnSize), curveTop, curveRight,
|
||||
curveTop + kCurveBtnSize);
|
||||
|
||||
const int cellH = std::min(row.height, knobSize + kVelLabelH);
|
||||
const int cellTop = topFor(cellH);
|
||||
const int cellRight = leftOf(r.curveBtn.x, kRunGap);
|
||||
const int cellRight = leftOf(r.chanMono.x, kRunGap);
|
||||
r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH);
|
||||
const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2;
|
||||
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
||||
@@ -73,9 +73,23 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||
r.preview = Rect::ltrb(leftOf(prevRight, kPreviewBtnW), prevTop, prevRight,
|
||||
prevTop + std::min(kRunButtonH, row.height));
|
||||
|
||||
const int bakeRight = leftOf(r.preview.x, kRunGap);
|
||||
r.bake = Rect::ltrb(leftOf(bakeRight, kBakeButtonWidth), prevTop, bakeRight,
|
||||
prevTop + std::min(kRunButtonH, row.height));
|
||||
|
||||
// Hold: the same knob-cell grammar as the velocity cell, immediately left of Bake.
|
||||
const int holdRight = leftOf(r.bake.x, kRunGap);
|
||||
r.holdCell = Rect::ltrb(leftOf(holdRight, kHoldCellW), cellTop, holdRight,
|
||||
cellTop + cellH);
|
||||
const int holdKnobLeft = r.holdCell.x + (r.holdCell.width - knobSize) / 2;
|
||||
r.holdKnob = Rect::ltrb(holdKnobLeft, r.holdCell.y, holdKnobLeft + knobSize,
|
||||
r.holdCell.y + std::min(knobSize, cellH));
|
||||
r.holdLabel = Rect::ltrb(r.holdCell.x, r.holdKnob.bottom(), r.holdCell.right(),
|
||||
r.holdCell.bottom());
|
||||
|
||||
// The title takes what the run leaves; clamped so a narrow window collapses it rather
|
||||
// than inverting it.
|
||||
r.title = Rect::ltrb(row.x + kPad, row.y, std::max(row.x + kPad, r.preview.x - kRunGap),
|
||||
r.title = Rect::ltrb(row.x + kPad, row.y, std::max(row.x + kPad, r.holdCell.x - kRunGap),
|
||||
row.bottom());
|
||||
|
||||
if (r.controls.empty()) return r;
|
||||
@@ -89,4 +103,24 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||
return r;
|
||||
}
|
||||
|
||||
PreviewGlyph previewGlyph(const Rect& button) {
|
||||
PreviewGlyph g;
|
||||
if (button.empty()) return g;
|
||||
// Even height so the apex lands exactly on the button's horizontal centre line rather
|
||||
// than a half-pixel off it.
|
||||
int h = std::min({kPreviewGlyphMaxH, button.height - 2 * kPreviewGlyphPad,
|
||||
button.width - 2 * kPreviewGlyphPad});
|
||||
h -= h % 2;
|
||||
if (h < kPreviewGlyphMinH) return g;
|
||||
const int w = std::max(2, (h * 7) / 8); // ~equilateral: the classic transport triangle
|
||||
const int cx = button.x + button.width / 2;
|
||||
const int cy = button.y + button.height / 2;
|
||||
g.leftX = cx - w / 2;
|
||||
g.apexX = g.leftX + w;
|
||||
g.topY = cy - h / 2;
|
||||
g.bottomY = g.topY + h;
|
||||
g.apexY = cy;
|
||||
return g;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button
|
||||
inline constexpr int kBakeButtonWidth = 54; // the resample-bake trigger
|
||||
|
||||
// Every interactive rect inside the chrome band, in one pass so draw and hit-test cannot
|
||||
// derive them differently. The toolbar's fixed run is right-anchored and the title takes
|
||||
@@ -17,11 +18,20 @@ inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button
|
||||
struct ChromeRects {
|
||||
Rect toolbar; // full-width top row
|
||||
Rect title; // the title text slot: the toolbar left of the control run
|
||||
Rect preview; // ---- the right-anchored run, left to right ----
|
||||
// The bake Hold cell. Its width is RESERVED unconditionally even though the control is
|
||||
// only drawn for a looped Gate sound (bake_plan.h's bakeWindowNeedsHold). It is the
|
||||
// LEFTMOST member of the right-anchored run, so no other member moves either way — what
|
||||
// the reservation buys is a title slot that holds still: dialling a loop in or out would
|
||||
// otherwise re-measure and re-ellipsize the capture name. The cost is ~62 px of dead
|
||||
// toolbar in the common non-looped case.
|
||||
Rect holdCell; // ---- the right-anchored run, left to right ----
|
||||
Rect holdKnob;
|
||||
Rect holdLabel;
|
||||
Rect bake;
|
||||
Rect preview;
|
||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob;
|
||||
Rect velLabel;
|
||||
Rect curveBtn; // opens the velocity-curve popup
|
||||
Rect chanMono;
|
||||
Rect chanStereo;
|
||||
Rect navBrowse;
|
||||
@@ -32,4 +42,20 @@ struct ChromeRects {
|
||||
// `knobSize` is the deck knob square, passed in so this module does not depend on knob_deck.
|
||||
ChromeRects chromeRects(const Rect& chrome, int knobSize);
|
||||
|
||||
// A right-pointing play triangle centered in the preview button: the button's whole label.
|
||||
// Three vertices, handed straight to one filled-triangle draw. Drawn rather than embedded as a
|
||||
// bitmap so it inherits the palette role of whatever interaction state the button is in.
|
||||
struct PreviewGlyph {
|
||||
int leftX = 0; // the vertical edge
|
||||
int topY = 0;
|
||||
int bottomY = 0;
|
||||
int apexX = 0; // the point, on the button's vertical centre line
|
||||
int apexY = 0;
|
||||
bool empty() const { return apexX <= leftX || bottomY <= topY; }
|
||||
};
|
||||
|
||||
// Sized off the button's shorter dimension and clamped, so the glyph stays legible in a squat
|
||||
// button and never outgrows a generous one. An unusably small button yields an empty glyph.
|
||||
PreviewGlyph previewGlyph(const Rect& button);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// spline_edit.cpp — see spline_edit.h. Pure decision logic; no host types.
|
||||
|
||||
#include "core/instrument/ui/spline_edit.h"
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Box& box,
|
||||
SplineGesture gesture, int x, int y) {
|
||||
if (box.width <= 0 || box.height <= 1) return {};
|
||||
const int idx = curve.pointAtPixel(box, x, y);
|
||||
switch (gesture) {
|
||||
case SplineGesture::kRight:
|
||||
return idx >= 0 ? SplineEdit{SplineEditKind::kDelete, idx} : SplineEdit{};
|
||||
case SplineGesture::kControlLeft:
|
||||
return idx >= 0 ? SplineEdit{SplineEditKind::kToggleHard, idx} : SplineEdit{};
|
||||
case SplineGesture::kLeft:
|
||||
break;
|
||||
}
|
||||
if (idx >= 0) return {SplineEditKind::kGrab, idx};
|
||||
const bool inBox = (x >= box.left && x < box.left + box.width && y >= box.top &&
|
||||
y < box.top + box.height);
|
||||
return inBox ? SplineEdit{SplineEditKind::kAdd, -1} : SplineEdit{};
|
||||
}
|
||||
|
||||
VelocityCurve::Box splineOverlayBox(const OverlayArea& area) {
|
||||
return VelocityCurve::Box{area.rect.x, area.rect.y, area.rect.width, area.rect.height};
|
||||
}
|
||||
|
||||
WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab,
|
||||
const WaveformClaim& marker, SplineGesture gesture) {
|
||||
if (gesture == SplineGesture::kControlLeft && node.hit) return WaveformClaimant::kNode;
|
||||
if (node.hit && (!tab.hit || node.area <= tab.area) && (!marker.hit || node.area <= marker.area))
|
||||
return WaveformClaimant::kNode;
|
||||
if (tab.hit && (!marker.hit || tab.area <= marker.area)) return WaveformClaimant::kTab;
|
||||
if (marker.hit) return WaveformClaimant::kMarker;
|
||||
return WaveformClaimant::kNone;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,80 @@
|
||||
// spline_edit.h — the point-editing grammar's CLICK resolution: add/grab/delete/toggle from a
|
||||
// single (x, y), plus resolveWaveformClaim, the waveform overlay's cross-affordance arbitration
|
||||
// (node vs. crossfade tab vs. marker). Both spline consumers — the velocity-curve popup and the
|
||||
// spline EG overlay — route their mouse-down through the click grammar, so the two cannot drift
|
||||
// apart. Decision logic only, no host types, no drawing; mirror of envelope_edit otherwise.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect / OverlayArea
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using engine::VelocityCurve;
|
||||
|
||||
// The gesture, in the pure module's own vocabulary (the shell maps its modifier state onto it).
|
||||
enum class SplineGesture { kLeft, kRight, kControlLeft };
|
||||
|
||||
// What the gesture resolves to. Left-click adds a point in empty space and grabs an existing
|
||||
// one; right-click deletes; control-click toggles hard/smooth. Points are smooth by default.
|
||||
enum class SplineEditKind { kNone, kGrab, kAdd, kDelete, kToggleHard };
|
||||
|
||||
struct SplineEdit {
|
||||
SplineEditKind kind = SplineEditKind::kNone;
|
||||
int index = -1; // the point the action targets; -1 for kAdd (it has none yet) and kNone
|
||||
};
|
||||
|
||||
// Resolves a click at (x, y) over `box` into an edit. The endpoint and point-count rules are
|
||||
// NOT re-stated here — kDelete on an endpoint and kAdd at the ceiling are refused by
|
||||
// VelocityCurve::deletePoint / addPoint, which the caller applies, so there is exactly one home
|
||||
// for each. A click outside the mapping box resolves to kNone unless it lands on a node's pick
|
||||
// radius: the drawn inset ring must not ADD (the new point would clamp onto an endpoint's x and
|
||||
// stack an undeletable duplicate) but must still be able to grab.
|
||||
SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Box& box,
|
||||
SplineGesture gesture, int x, int y);
|
||||
|
||||
// The contour's mapping box inside the waveform overlay: the FULL area, so the drawn contour
|
||||
// spans the whole sample width 1:1 with its time axis. No inset — unlike the popup's box, which
|
||||
// insets to keep endpoint handles clear of the sheet border, this one must stay 1:1 with the
|
||||
// waveform beneath it. Takes the overlay (not a lane) — see waveform_view.h's overlay contract.
|
||||
VelocityCurve::Box splineOverlayBox(const OverlayArea& area);
|
||||
|
||||
// Two more rules complete the grammar. Both are enforced in the shell — mouse-tracking / drag
|
||||
// state has no home in a pure module — and recorded here as their one home rather than restated
|
||||
// at each call site:
|
||||
// - DRAG-OFF DELETE: releasing a grabbed node well outside its box deletes it (endpoints exempt,
|
||||
// per deletePoint's own refusal) — editor_input.cpp's onMouseUp, shared verbatim by the popup
|
||||
// and the overlay.
|
||||
// - THE OVERLAY'S OUTSIDE-BOX EXCEPTION: resolveSplineEdit's own outside-box grab/toggle/delete
|
||||
// allowance (above) is meant for the popup's inset ring; the overlay narrows it back to
|
||||
// strictly in-box, since splineOverlayBox has no inset — editor_input_waveform.cpp's
|
||||
// splineOverlayClick.
|
||||
|
||||
// One arbitration candidate: whether the affordance was hit under the cursor, and its own
|
||||
// pick-target area (nominal, per its own module's constants — not the actual clipped pixel
|
||||
// count; see resolveWaveformClaim).
|
||||
struct WaveformClaim {
|
||||
bool hit = false;
|
||||
std::int64_t area = 0;
|
||||
};
|
||||
|
||||
// Which affordance a waveform-overlay click claims.
|
||||
enum class WaveformClaimant { kNone, kNode, kTab, kMarker };
|
||||
|
||||
// The overlay's cross-affordance arbitration: a contour node (or, mutually exclusively, a
|
||||
// staged envelope's drag node — both feed the same `node` slot), the loop crossfade tab, and a
|
||||
// marker's full-height column can all claim the same pixel. Hit gates a candidate out
|
||||
// entirely; among the ones that hit, the SMALLEST nominal area wins — the marker column is the
|
||||
// odd one out (its target is the whole overlay height), so it only wins where nothing narrower
|
||||
// also claims the click. Ties go to whichever is checked first: node, then tab, then marker —
|
||||
// no live geometry produces a tie except tab-vs-marker, which the tab correctly wins (see
|
||||
// editor_input_waveform.cpp's mouseDownWaveform for the live constants). A control-click has no
|
||||
// tab/marker meaning (they answer plain grabs only), so it resolves to the node whenever the
|
||||
// node is in the running, regardless of area.
|
||||
WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab,
|
||||
const WaveformClaim& marker, SplineGesture gesture);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -67,6 +67,18 @@ std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x) {
|
||||
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
|
||||
}
|
||||
|
||||
Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame) {
|
||||
const Rect& r = area.rect;
|
||||
if (r.empty()) return Rect{};
|
||||
const int mx = frameToX(area, frameCount, frame);
|
||||
// Clip into the area: a marker at the last frame maps to right(), whose unclipped tab
|
||||
// would claim pixels outside the band the caller already hit-tested.
|
||||
const int left = std::max(r.x, mx - kMarkerHandleHalfWidth);
|
||||
const int right = std::min(r.right(), mx + kMarkerHandleHalfWidth + 1);
|
||||
if (right <= left) return Rect{};
|
||||
return Rect{left, r.y, right - left, std::min(kMarkerHandleHeight, r.height)};
|
||||
}
|
||||
|
||||
int markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y) {
|
||||
if (count <= 0 || frames == nullptr) return -1;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user