Compare commits
141 Commits
be37192fe9
...
1.4.0
| 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 | |||
| 7bd911d58b | |||
| 413967a205 | |||
| 9b1a49c640 | |||
| 0cfd9b6236 | |||
| 39389c1183 | |||
| 67215509cb | |||
| c9c708a338 | |||
| 4bef71b05a | |||
| 5420550ff3 | |||
| 68765da031 | |||
| d04045be69 | |||
| 25c63fb807 | |||
| b9d85161e5 | |||
| 1aea481688 | |||
| 3cb6b9de21 | |||
| d2364eb5ac | |||
| f12700c997 | |||
| 902030bfba | |||
| 7d42d7ed29 | |||
| 7af3c0c630 | |||
| 78e112d1f9 | |||
| e89568c1a8 | |||
| 9812690b96 | |||
| ae23ee0882 | |||
| ea52b14f2a | |||
| dfed1c77bb | |||
| fb12c53522 | |||
| 99fab6a4b6 | |||
| 2a0d10fab5 | |||
| 81f5861ba4 | |||
| 98c07d768f | |||
| e304f2b031 | |||
| 8d4ccbf841 | |||
| c91bf03ef4 | |||
| 875d5b4632 | |||
| 0800760833 | |||
| cd704cb0ad | |||
| 8c620f88e4 | |||
| a689fb75eb |
+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 nineteen 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 ninet
|
||||
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
|
||||
|
||||
@@ -42,7 +43,21 @@ Vendors three submodules (see `.gitmodules`):
|
||||
cmake --build build
|
||||
ctest --test-dir build
|
||||
|
||||
Every pure module has a corresponding `<module>_tests` executable target that runs without REAPER or a DAW. `CMakeLists.txt` is the authoritative target list. 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).
|
||||
On a multi-config generator (Visual Studio, Xcode) the bare `ctest` command above
|
||||
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
|
||||
|
||||
@@ -61,25 +76,29 @@ The VST3 target forks identically: `REASAMPLER_CHANNEL=beta` produces `reasample
|
||||
|
||||
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.
|
||||
Add the generated file to the appropriate `APPLE` / Linux `target_sources` block in `src/app/CMakeLists.txt`. The SWS extension build is the canonical reference for this step.
|
||||
|
||||
### 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 nineteen 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 |
|
||||
@@ -98,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.
|
||||
|
||||
@@ -163,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
|
||||
|
||||
@@ -187,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).
|
||||
|
||||
+50
-1294
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# The two shapes that repeat across src/: a pure static library and its CTest target.
|
||||
# Both are thin pass-throughs — LINK is forwarded to target_link_libraries verbatim, so
|
||||
# PUBLIC/PRIVATE keywords and link order stay visible at the call site rather than being
|
||||
# invented by the helper. Targets that genuinely deviate are written out longhand.
|
||||
|
||||
# Every pure library carries src/ as a PUBLIC include dir: headers are included rooted
|
||||
# there ("core/json/json.h"), so a consumer needs only the link edge.
|
||||
function(reasampler_pure_library name)
|
||||
cmake_parse_arguments(ARG "" "" "SOURCES;LINK" ${ARGN})
|
||||
add_library(${name} STATIC ${ARG_SOURCES})
|
||||
target_include_directories(${name} PUBLIC ${REASAMPLER_SRC_DIR})
|
||||
if(ARG_LINK)
|
||||
target_link_libraries(${name} ${ARG_LINK})
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
# Test naming is exceptionless: target <name>_tests is built from tests/test_<name>.cpp
|
||||
# and registered under its own target name.
|
||||
function(reasampler_test name)
|
||||
cmake_parse_arguments(ARG "" "" "LINK" ${ARGN})
|
||||
add_executable(${name}_tests ${REASAMPLER_TESTS_DIR}/test_${name}.cpp)
|
||||
target_link_libraries(${name}_tests PRIVATE ${ARG_LINK})
|
||||
add_test(NAME ${name}_tests COMMAND ${name}_tests)
|
||||
endfunction()
|
||||
@@ -18,3 +18,871 @@ intentional exception (Daniel-approved) is two user-facing error strings in
|
||||
findings — all content cut that should have survived — and all ten were
|
||||
remediated and re-gated before merge. Driver: Daniel's instruction — *"Brief
|
||||
concise engineering comments. A little why, and maybe context, never WHAT."*
|
||||
|
||||
### CMake build-system split (ad-hoc, Daniel's request)
|
||||
|
||||
Split the 1423-line root `CMakeLists.txt` into a 91-line root plus 18 per-directory
|
||||
`CMakeLists.txt` files under `src/`, with two shared declaration helpers
|
||||
(`reasampler_pure_library`, `reasampler_test`) factored into a new
|
||||
`cmake/reasampler_targets.cmake`. The root now keeps only repo-global concerns:
|
||||
version/channel single-source-of-truth, `configure_file`, vendor path vars,
|
||||
`LICE_SRC`, `enable_testing()`, and the `add_subdirectory` calls. Comments throughout
|
||||
were rewritten to the project's comment conventions — phase/wave/ticket IDs removed,
|
||||
module semantics already owned by `src/**/CLAUDE.md` deleted, load-bearing build
|
||||
facts kept.
|
||||
|
||||
Also fixed two duplicate-object-code defects surfaced by the split: 18 `core/`
|
||||
translation units were previously compiled directly into the `reaper_reasampler`
|
||||
module *while also* being linked in as static libraries — those 18 source entries
|
||||
were removed from the module's source list and 3 missing link edges
|
||||
(`view_tree`, `guid_diff`, `lane_keys`) added so every `core/` TU now enters through
|
||||
exactly one static-library link edge. A dead `bridge_marshal` link edge was also
|
||||
dropped from `reaper_reasampler`, and two inaccurate comments in
|
||||
`src/app/CMakeLists.txt` were corrected.
|
||||
|
||||
No `.cpp`, `.h`, `tests/`, or `vendor/` file was touched. Behaviour is unchanged and
|
||||
was verified mechanically: same 130 targets, same 65 tests all passing,
|
||||
`reaper_reasampler.dll` byte-identical at 3,477,504 bytes, both channels
|
||||
(stable/beta) building to the same artifact names and locations as before.
|
||||
|
||||
### Θ-W1-T1 — zone-retirement
|
||||
|
||||
ReaSampler 9000's zone-mapping system is retired: one loaded capture, one parameter
|
||||
set, playing across the full keyboard repitched from root with key-tracking — no zones,
|
||||
no per-zone divergence, no keymap of captures. The dedicated zone-editing face and its
|
||||
authoring affordances (add/delete zone, per-zone parameter panel, Low/High/Root zone
|
||||
legend) are gone; the root note survives as a first-class parameter. `sampler_core`
|
||||
split along the note-routing/per-voice-render responsibility seam (no virtual `tick()`
|
||||
on the per-voice path), and the Sample face split into chrome/waveform/decks bands with
|
||||
a shared band-stack allocator, discharging the wave's two structural deliverables.
|
||||
Migration adopts a saved multi-zone instance's first zone; single-zone instances lift
|
||||
losslessly.
|
||||
|
||||
**Deviations from spec:**
|
||||
- The key-range open question (**[propose]**) is answered outright rather than left
|
||||
open: no key-range concept survives at all — `KeyZone`/`lowNote`/`highNote` are gone
|
||||
from the engine, the write format, and the strip. A low/high pair remains re-addable
|
||||
later as two ordinary parameters. Θ-W2-T3 consumes this decision.
|
||||
- **"Which zone is first" (**[verify]**) confirmed:** `PerformanceMap::zones` was an
|
||||
ordered vector and `Keymap::resolve` was first-match-in-order, so index 0 was the
|
||||
audible zone. Migration adopts index 0, and that zone's `sampleId` supersedes the
|
||||
envelope's stored `selectionId`.
|
||||
- The control row (root strip, preview, velocity knob, curve button, Mono|Stereo) moved
|
||||
into the chrome band, directly under the title rather than above the deck —
|
||||
user-visible and deliberate; it's what makes Θ-W2's band-disjointness real.
|
||||
- Loading a capture now clears the three capture-anchored overrides (root, loop span,
|
||||
start frame) while keeping the shaping parameters — strictly less destructive than the
|
||||
previous whole-zone drop.
|
||||
- The embed strip became a read-only readout; it lost its click handler since with no
|
||||
zones there is nothing to select.
|
||||
- **Migration side-effect:** a previously-zoned instance with implicit channel mode and
|
||||
a stereo capture persisted as Mono will reopen as **Stereo**. It converges on the
|
||||
documented rule and is reachable only for old blobs, but "sounds identical" was an
|
||||
acceptance bar, so it's a real deviation.
|
||||
- `sampler_core.{h,cpp}`'s 956-line documented hot-path exception is retired, not
|
||||
relocated — the tree now carries no over-ceiling exception at all.
|
||||
- `note_entry` was deleted as dead code (its only consumer was the removed zone editor).
|
||||
- **Still unverified:** a real pre-change project reopening through REAPER's `setState`
|
||||
has not been exercised in the DAW; migration is proven only in the pure domain against
|
||||
hand-laid legacy bytes.
|
||||
|
||||
### Θ-W1-T2 — capture-handoff-bugs
|
||||
|
||||
Fixed both extension-side capture-handoff defects: drag-out now delivers the capture's
|
||||
audio at the drop target every time (previously intermittent, retry-fixable); dropping a
|
||||
capture onto an FX container now loads the instrument with the capture, matching the
|
||||
FX-button drop path.
|
||||
|
||||
**Deviations from spec:**
|
||||
- Item 5's root cause was three defects, not one: non-atomic COM refcounts racing a drop
|
||||
target's background copy; an unverified assumption that REAPER had already called
|
||||
`OleInitialize` on the calling thread; and a teardown-before-payload-check ordering bug
|
||||
that let an unresolvable payload consume the gesture.
|
||||
- Item 6's fix rests on an unconfirmed hypothesis — that a bare `instantiate = -1` left
|
||||
placement to REAPER's ambient FX-chain insert point, which a container-focused chain
|
||||
window moves. It is now pinned to an explicit top-level position; nobody could confirm
|
||||
the mechanism without the DAW.
|
||||
- The opportunistic rider was partly taken: `src/ingest.{h,cpp}` re-homed to
|
||||
`src/shell/actions/`. `ext_keys.h` was declined (most consumers sit in another track's
|
||||
exclusive surface); `resource.h` was declined (it's a build input paired with
|
||||
`src/resource.rc` and the SWELL resgen step).
|
||||
- **Neither acceptance criterion has actually been met yet.** Item 5's gate is
|
||||
explicitly a soak ("a single pass is not a gate") and item 6 needs a live container
|
||||
drop; both require REAPER and are outstanding. The code is merged; the acceptance
|
||||
gates are not closed.
|
||||
|
||||
### Θ-W1-T3 — filter-dsp-port
|
||||
|
||||
Lands the per-voice resonant filter as a standalone pure module
|
||||
(`core/instrument/engine/filter/`, five files: `filter_params`, `filter_coeffs`,
|
||||
`filter_morph`, `filter_saturate`, `voice_filter`) — concrete `VoiceFilter` type, no
|
||||
vtable, no allocation in `process()`. **No call site**; Θ-W2-T1 wires it into the voice
|
||||
path.
|
||||
|
||||
**Deviations from spec — the spec itself changed mid-flight, headline first:**
|
||||
- **The Cortex-M4 biquad port was superseded entirely by a TPT/SVF topology, Daniel's
|
||||
call.** Measurement found the firmware's high-pass resonance feedback tap vestigial:
|
||||
its stated rationale was inverted (the HP numerator approaches 1 as cutoff falls, not
|
||||
zero — it's the LP numerator that collapses), it *reduced* HP resonance everywhere it
|
||||
ran, and it carried unwanted sample-rate and input-level dependence. It was a Q15
|
||||
fixed-point workaround for a ~17-bit cancellation float32 doesn't suffer. Daniel's
|
||||
ruling on the resulting level-dependent resonance bloom: *"was a feature on the
|
||||
hardware (one knob colorful HP for master FX), wrong choice for this approach."*
|
||||
- **Two discrete modes (HP/LP) became a continuous morph, with two selectable morph
|
||||
laws** — HP→BP→LP (default) and HP→notch→LP (Oberheim SEM) — chosen at `prepare()`
|
||||
via a `MorphLaw` enum on `FilterSettings`. Zero per-sample cost, verified by diffing
|
||||
emitted assembly (byte-identical between laws).
|
||||
- **A configurable drive stage was added:** an in-loop soft limiter on the band-pass
|
||||
integrator state, normalized 0..1, with a radial dial planned for Θ-W2. Drive at 0 is
|
||||
bit-exact linear.
|
||||
- **`Biquad1PoleLP` was struck (Daniel's call) and never ported.**
|
||||
- **Q spans the full 0.1–10 with √2 at the control centre**, replacing the firmware's
|
||||
0.707-floored mapping — as originally specified.
|
||||
- **No reference sample rate exists anywhere in the module** — rate reaches the DSP
|
||||
only via `g = tan(π·fc/sr)`. An interim fix that anchored a feedback tap to a
|
||||
`1/48000` constant was superseded by the rewrite.
|
||||
- **The rewrite fixed a float32 conditioning defect the biquad carried:** Direct Form I
|
||||
measured −27% peak error at 20 Hz / 192 kHz; TPT measures +0.034%.
|
||||
- **Still open, deliberately:** drive's maximum depth (4.0, set by measurement — at 64
|
||||
the resonant peak inverted below passband) and the absence of makeup gain both await
|
||||
an ear pass against the real dial.
|
||||
- **The module has no call site** — integration is Θ-W2-T1, which also carries three
|
||||
recorded decisions of its own: the envelope-order choice (drive is level-dependent, so
|
||||
pre- vs post-envelope placement is sound-defining), the drive dial's calibration, and
|
||||
the fact that drive authority varies ~11 dB across the morph sweep.
|
||||
|
||||
### Θ-W2-T1 — filter-voice-path
|
||||
|
||||
Wires the pure filter module into ReaSampler 9000's per-voice signal path as a new fixed
|
||||
processing point between the pitch envelope and the amp stage, gives it its own
|
||||
knob-deck group, and relays the deck row in signal-flow order (pitch → filter → amp).
|
||||
Each `Voice` owns its own `VoiceFilter` and a second `AdsrEnvelope` instance — per-voice,
|
||||
never shared — and neither adds allocation or virtual dispatch to the per-sample path.
|
||||
Parameters — morph position, cutoff, Q, drive, mod amount (bipolar ±100%, targeting
|
||||
cutoff), velocity and key-tracking modulation, then AHDSR — live in the one parameter
|
||||
set; `FilterParams` stores the filter module's own `FilterSettings` by value rather than
|
||||
a parallel copy of the normalized positions. The filter is off by default and bit-exact
|
||||
off — a project saved before the change reopens sounding identical, pinned by a
|
||||
bit-equality test. `ComponentState`'s params payload moves v8 → v9, appending the filter
|
||||
tail; a v8 blob is a strict prefix and lifts to the off/neutral filter default, with
|
||||
non-finite filter fields falling back to neutral, pinned by a golden byte-literal
|
||||
fixture. Deck composition was extracted into a new pure module
|
||||
`core/instrument/ui/deck_groups`, with the pitch → filter → amp row order pinned by
|
||||
test; the filter's AHDSR ships as its own `FILTER ENV` group sibling to `FILTER`,
|
||||
mirroring the existing `PITCH` / `PITCH ENV` split, and a morph-law toggle (`Band` |
|
||||
`Notch`) ships as the FILTER group's row toggle.
|
||||
|
||||
**Deviations from spec:**
|
||||
- **An initial mod-quantizer was added, then rejected and removed.** A
|
||||
`kFilterModSteps = 2048` step gate on the coefficient re-solve stair-stepped the
|
||||
corner (~5.8 cents per step); Daniel rejected it. Replaced by a cutoff-only re-solve
|
||||
(`VoiceFilter::setCutoffNorm`) that re-derives only `g = tan(π·fc/sr)` — Q's parabola,
|
||||
the morph's cos/sin, and the folded mix are all cutoff-independent and stay cached
|
||||
from `prepare()`; `filter_params` hoists its constant logs. Measured at 48 kHz,
|
||||
Release, net of the sweep generator: kernel alone 2.8 ns/frame, full `prepare()` 56.9
|
||||
ns, cutoff-only re-solve 15.5 ns — about 1.2% of a core for 16 continuously-swept
|
||||
voices. The corner now sweeps continuously rather than stair-stepping.
|
||||
- **The editor floor was raised to 840×620**, which is also its new default size, up
|
||||
from a 560×460 floor at which the grown deck wrapped to four rows and pushed
|
||||
`FILTER ENV` / `AMP ENVELOPE` / `VOICE` / `MASTER` off-screen with no scroll.
|
||||
`kEditorMinWidth`/`kEditorMinHeight` now live in `sample_bands`, read by both the
|
||||
shell's `checkSizeConstraint` and the opening `ViewRect`. Consequence worth
|
||||
recording: a host with a saved editor rect below 840×620 is clamped up on reopen.
|
||||
|
||||
### Θ-W2-T2 — stereo-waveform-lanes
|
||||
|
||||
Delivered as specified: two stacked lanes, L above R, in stereo mode; one lane in mono;
|
||||
overlays draw once at full stacked height.
|
||||
|
||||
**Deviations from spec:**
|
||||
- **A mono source under stereo mode draws one lane**, not two — lane count keys off
|
||||
`stereoMode && sourceChannels >= 2`, not channel mode alone, since a mono capture in
|
||||
stereo mode is dual-mono and a second lane would be the redundant duplicate the spec
|
||||
forbids. Review confirmed this is the only reading consistent with the decode path.
|
||||
- The full-height overlay contract Θ-W3 and Θ-W4 consume is **type-enforced, not
|
||||
merely documented**: an `OverlayArea` wrapper type that lane rects cannot satisfy.
|
||||
- A single-slot per-channel PCM cache was added to the editor session, invalidating
|
||||
alongside the existing PCM cache.
|
||||
|
||||
### Θ-W2-T3 — toolbar-and-piano-strip
|
||||
|
||||
Delivered as specified: one toolbar font, no zone-count label, full-width piano strip,
|
||||
uniform key widths, note-name tooltips, root displayed and settable.
|
||||
|
||||
**Deviations from spec:**
|
||||
- **The non-uniform key widths were integer quantisation, not aliasing** — the old
|
||||
`keyEdgeToX` truncated an exact rational, alternating 6px/7px. Θ-W6's general
|
||||
antialiasing audit inherits nothing on key *widths* as a result, though key *edges*
|
||||
are still drawn unantialiased.
|
||||
- **Uniform integer key widths and gap-free edge-to-edge tiling are mutually
|
||||
exclusive** — 75 white keys do not divide an arbitrary width. The residue now lands
|
||||
in symmetric end gutters, 37px each side at the shipped 840px default — the maximum
|
||||
of a sawtooth with period 75px of window width. Daniel accepted this provisionally,
|
||||
pending how it looks in REAPER.
|
||||
- The whole control run moved into the toolbar row, not just preview and mono/stereo —
|
||||
the full-width strip left the velocity knob and curve button nowhere else to go.
|
||||
- Root drag became absolute-tracking rather than pixel-delta, since black keys
|
||||
overlaying white give no single pixels-per-semitone rate.
|
||||
- **Width uniformity is guaranteed in client pixels only.** Nothing in the instrument
|
||||
consumes a DPI scale factor, so host-side scaling is unverified — Θ-W6 already
|
||||
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.
|
||||
|
||||
+2418
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,14 @@
|
||||
# TODO-1.0
|
||||
|
||||
> **`docs/PLAN.md` is now the roadmap.** All seventeen items below have been
|
||||
> consolidated into areas and sequenced into the Phase → Wave → Track hierarchy in
|
||||
> `docs/PLAN.md`; that file is what implementation specialists are dispatched against,
|
||||
> and each of its tracks is self-sufficient for a brief. **This file is retained as the
|
||||
> verbatim-provenance spec appendix** — Daniel's raw asks and every answer round,
|
||||
> unedited, are the source of truth behind PLAN.md's compressed behavior bullets. Its
|
||||
> traceability table maps each item number below onto the track that owns it. Nothing in
|
||||
> this file changes as work lands; PLAN.md points move to `docs/COMPLETED.md`.
|
||||
|
||||
Post-1.0 queue for ReaSampler — chiefly the 9000 instrument, plus two
|
||||
extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering
|
||||
(2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day);
|
||||
|
||||
+575
-1
@@ -2,6 +2,38 @@
|
||||
|
||||
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
|
||||
|
||||
## The per-voice filter is solved against the WAV's sample rate, not the render rate
|
||||
|
||||
**Context (what shipped — Θ-W2-T1, the filter in the voice path).** `Voice::start` sets `filterRate_ = sample.sampleRate` — the rate read off the **decoded WAV header** — and hands it to `VoiceFilter::prepare()` and every later `setCutoffNorm()`. But the voice emits exactly one frame per **host** frame, so the rate the corner should be solved against is the project/render rate the processor already latches in `setupProcessing` (`ReaSamplerProcessor::sampleRate_`), not the file's. The rate enters the DSP only through `g = tan(pi*fc/sr)` (`engine/filter/CLAUDE.md`), so a wrong `sr` scales the realized corner by exactly the ratio of the two rates.
|
||||
|
||||
**The wart.** When capture rate ≠ project rate, the corner lands at the wrong frequency, by that ratio. A 44.1 kHz capture in a 48 kHz project puts the corner roughly **1.5 semitones sharp** (48000/44100 ≈ 1.088×); the same capture in a 96 kHz project is roughly **13.5 semitones off**. The Nyquist clamp (`kFilterNyquistFraction`) measures against the wrong Nyquist for the same reason. This falsifies the guarantee `filter_params.h` states in its own words — that the persisted value is a normalized knob position precisely so one preset does not sound different at 44.1k and 96k. The control law honors that; the solve defeats it.
|
||||
|
||||
**Intended fix.** Thread the host render rate onto `SampleData` and set `filterRate_` from it. The processor already holds `sampleRate_` from `setupProcessing` and already guards on it being non-zero before building, so the value is available at exactly the point `SampleData` is constructed — this is a plumbing change, not a new mechanism.
|
||||
|
||||
**The constraint the fix MUST handle.** The engine **already conflates the two rates everywhere** — `sample_map` resolves the AHDSR's stored seconds at the WAV's own rate, and nothing resamples the source — so a cross-rate capture already plays back sharp *and* short by the same ratio. This is an inherited assumption, not a defect introduced by the filter; the filter is simply the first module where it lands as an audible **frequency** error rather than a timing one. A fix that corrects only the filter leaves the filter rate-correct while envelope timing stays rate-wrong. That is strictly less wrong and defensible, but it splits one assumption into two, and the split must be a deliberate choice rather than a side effect of fixing the loudest symptom. Second constraint: `filterRate_ <= 0` must keep meaning **bypass** — the filter module forbids a reference, calibration, or fallback rate anywhere in itself, and a plumbing fix must not smuggle one in as a default.
|
||||
|
||||
**Priority / risk.** Deferred by ruling — Daniel, 2026-07-30: *"record this and proceed."* Inaudible whenever capture rate == project rate, which is the common case for captures this tool made in the project they belong to. Audible and large on an imported or cross-rate capture, and worse the further the two rates diverge.
|
||||
|
||||
**Done looks like.** The realized filter corner matches `filterCutoffHzFromNorm(pos)` within measurement tolerance at every combination of capture rate and project rate; the Nyquist clamp measures against the render rate; and the decision about whether envelope timing follows the same correction is recorded rather than left implicit.
|
||||
|
||||
## Filter ring-out is truncated on the source-exhaustion path
|
||||
|
||||
**Context (what shipped — Θ-W2-T1).** The per-voice filter runs between the pitch stage and the amp multiply. When `readPos_` runs past the end of the sample with no usable loop, `Voice::advanceFrame` latches `active_ = false` and returns 0 — the voice stops feeding, and whatever energy remains in the filter's two integrators is discarded rather than rung out.
|
||||
|
||||
**The wart.** The filter's tail is cut at source exhaustion instead of decaying to the filter's own denormal floor.
|
||||
|
||||
**Why the common case is unaffected.** A released Gate note's filter tail is shaped to silence by the **amp release** before the read head reaches the end — that is the pipeline ordering (pitch → filter → amp) working exactly as designed. Trigger's fade-out has already taken the amp to ~0 at `playEnd`, so the discarded state is multiplied by ~0 regardless. The exposed case is a voice that reaches source exhaustion with the amp envelope still open.
|
||||
|
||||
**Intended fix.** Let a voice keep rendering the filter past source exhaustion — zero input, filter ringing — until `VoiceFilter::isSilent()`.
|
||||
|
||||
**The constraint the fix MUST handle (why deferred).** Extending a voice past source exhaustion changes `active()` and `soundingNote()`, and those two predicates feed `VoiceEngine`'s oldest-first stealing policy and the Preserve-voice tally. A ring-out voice would hold an allocation slot and could suppress or be stolen by a note-on that today would be routed differently — a materially larger blast radius than the track that found the defect, which is why it is deferred rather than patched at the call site. The existing takeover declick already carves out an `active() && !soundingNote()` ring-out state; a filter ring-out would be a second occupant of that state and must compose with it rather than fight it.
|
||||
|
||||
**The caveat both reviewers recorded.** The discarded state can be roughly `2Q` larger than the source that produced it, so at high Q the cut **amplifies** the step that already existed at source exhaustion rather than merely preserving it. The defect gets worse the more resonance is dialled in — it is not a uniformly small residual.
|
||||
|
||||
**Priority / risk.** Low / deferred. Recorded during Θ-W2-T1 review and left for a track that can own the voice-lifetime predicates.
|
||||
|
||||
**Done looks like.** A high-Q filtered voice that reaches source exhaustion with the amp envelope still open decays to the filter's denormal floor rather than cutting, with no change to voice-stealing behavior, the Preserve tally, or the takeover-declick ring-out state.
|
||||
|
||||
## Persist ReaSampler 9000 instance identity to let prune reclaim de-referenced captures after reopen
|
||||
|
||||
**Context (what shipped — Phase S usage-detection).** Each ReaSampler 9000 instance publishes the captures it holds to project ext-state (`rsusage_<guid>` keys, ComponentState v11). The extension's prune reads those records and unions every live instance's held captures into the referenced-set, so a capture any live instance holds can never be pruned. Fail-safe: unreadable/ambiguous usage state aborts prune (deletes nothing). Airtight on safety.
|
||||
@@ -14,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
|
||||
|
||||
@@ -118,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.
|
||||
@@ -131,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).
|
||||
@@ -0,0 +1,98 @@
|
||||
# The REAPER extension — a loadable module REAPER dlopen()s, never linked against. Paths
|
||||
# below are rooted at src/, not relative to this directory.
|
||||
# No core/ TU is ever compiled into this source list; every core/ TU enters through a link edge
|
||||
# instead. Compiling one here too would give it its own copy, built under this target's own
|
||||
# compile definitions and include dirs — free to diverge from the library copy every other
|
||||
# consumer (the <module>_tests targets, reasampler_vst) links, with nothing to detect it.
|
||||
|
||||
add_library(reaper_reasampler MODULE
|
||||
${REASAMPLER_SRC_DIR}/app/main.cpp
|
||||
${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
|
||||
${REASAMPLER_SRC_DIR}/shell/persist/session.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/persist/ext_state_io.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/persist/prune_fs.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/bank_ops/bank_ops.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_bank_ops.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_render.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_thumbnails.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_window.cpp
|
||||
# draw_kit is compiled into each module rather than being a static library — see root
|
||||
# CMakeLists.txt's LICE_SRC comment for why.
|
||||
${REASAMPLER_SRC_DIR}/shell/panel/draw_kit.cpp
|
||||
${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
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/action_registry.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/design_view_actions.cpp
|
||||
${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 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
|
||||
# configs, since REAPER dlopen's any reaper_* module and the two channels' artifacts load
|
||||
# side-by-side. LIBRARY_OUTPUT_DIRECTORY pins the module to the top of the build tree even
|
||||
# though this target is declared in a subdirectory — the install step copies it from there.
|
||||
# ARCHIVE_OUTPUT_DIRECTORY pins the same for MODULE targets: CMake emits an import-lib
|
||||
# sidecar (.lib/.exp on MSVC) keyed off ARCHIVE_OUTPUT_DIRECTORY, not LIBRARY_OUTPUT_DIRECTORY,
|
||||
# so it needs pinning too even though nothing links against this import lib.
|
||||
set_target_properties(reaper_reasampler PROPERTIES
|
||||
PREFIX ""
|
||||
OUTPUT_NAME "${REASAMPLER_OUTPUT_NAME}"
|
||||
LIBRARY_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}"
|
||||
ARCHIVE_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}")
|
||||
|
||||
if(WIN32)
|
||||
# Native Win32; REAPER provides nothing extra to link. The bank-panel dialog template
|
||||
# is compiled from resource.rc by the platform RC compiler.
|
||||
target_sources(reaper_reasampler PRIVATE ${REASAMPLER_SRC_DIR}/resource.rc)
|
||||
|
||||
elseif(APPLE)
|
||||
# Use REAPER's OWN SWELL at runtime via the modstub. Do NOT build full SWELL —
|
||||
# SWELL_PROVIDED_BY_APP routes calls to the host.
|
||||
target_sources(reaper_reasampler PRIVATE ${SWELL}/swell-modstub.mm)
|
||||
target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP)
|
||||
target_link_libraries(reaper_reasampler PRIVATE "-framework AppKit")
|
||||
set_target_properties(reaper_reasampler PROPERTIES SUFFIX ".dylib")
|
||||
# SWELL can't read a Win32 .rc directly. Run resgen once to turn resource.rc into a
|
||||
# C++ source, then add it here:
|
||||
# php ${WDL_INC}/swell/mac_resgen.php src/resource.rc
|
||||
# target_sources(reaper_reasampler PRIVATE ${REASAMPLER_SRC_DIR}/resource.rc_mac_dlg.h)
|
||||
|
||||
else()
|
||||
# Linux: REAPER's libSwell.so is used at runtime via the generic modstub. With
|
||||
# SWELL_PROVIDED_BY_APP you can drop pkg-config / -lX11 entirely.
|
||||
target_sources(reaper_reasampler PRIVATE ${SWELL}/swell-modstub-generic.cpp)
|
||||
target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP)
|
||||
set_target_properties(reaper_reasampler PROPERTIES SUFFIX ".so")
|
||||
# Reuse the macOS resgen output (see CLAUDE.md, SWELL dialog resources), then add the
|
||||
# generated source:
|
||||
# php ${WDL_INC}/swell/mac_resgen.php src/resource.rc
|
||||
# target_sources(reaper_reasampler PRIVATE ${REASAMPLER_SRC_DIR}/resource.rc_mac_dlg.h)
|
||||
endif()
|
||||
+24
-3
@@ -22,10 +22,12 @@
|
||||
|
||||
#include "core/capture/render_settings.h" // captureActionTable
|
||||
#include "core/version/app_version.h" // appVersion
|
||||
#include "ingest.h"
|
||||
#include "shell/actions/ingest.h"
|
||||
#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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# The pure substrate — see root CLAUDE.md's architecture section for the core/ purity invariant.
|
||||
#
|
||||
# Declaration order below runs base-first so the file reads as a dependency ladder; CMake
|
||||
# itself does not require it (link names resolve at generate time), and a few edges do run
|
||||
# backwards — core/wire's instrument_drop reuses the instrument's own state codec.
|
||||
|
||||
add_subdirectory(json)
|
||||
add_subdirectory(util)
|
||||
add_subdirectory(wire)
|
||||
add_subdirectory(audio)
|
||||
add_subdirectory(model)
|
||||
add_subdirectory(capture)
|
||||
add_subdirectory(tracking)
|
||||
add_subdirectory(reclaim)
|
||||
add_subdirectory(version)
|
||||
add_subdirectory(view)
|
||||
add_subdirectory(ui)
|
||||
add_subdirectory(instrument)
|
||||
@@ -0,0 +1,2 @@
|
||||
reasampler_pure_library(peaks SOURCES peaks.cpp)
|
||||
reasampler_test(peaks LINK peaks)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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)
|
||||
|
||||
reasampler_pure_library(tail_control
|
||||
SOURCES tail_control.cpp
|
||||
LINK PUBLIC render_settings PRIVATE json)
|
||||
reasampler_test(tail_control LINK tail_control)
|
||||
|
||||
reasampler_pure_library(capture_realtime SOURCES capture_realtime.cpp LINK PUBLIC bank_model)
|
||||
reasampler_test(capture_realtime LINK capture_realtime)
|
||||
|
||||
reasampler_pure_library(wav_codec SOURCES wav_codec.cpp LINK PUBLIC peaks)
|
||||
reasampler_test(wav_codec LINK wav_codec)
|
||||
@@ -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
|
||||
|
||||
+211
-71
@@ -1,20 +1,27 @@
|
||||
# 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, per-zone play params, pitch shifting,
|
||||
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
|
||||
velocity curve, and master-gain taper math.
|
||||
- **`map/`** — the zone/keymap payload, 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).
|
||||
- **`ui/`** — pure editor geometry/hit-test modules (layout, waveform, keyboard strip,
|
||||
capture browser, param controls, envelope overlay/edit). These are geometry-and-math
|
||||
only; the LICE draw + REAPER/VST3 plumbing is the `shell/instrument` editor shell,
|
||||
**out of scope for this file** (owned by a parallel dispatch), along with the VST3
|
||||
processor, `reaper_bridge`, `reasampler_embed`, and `vst_entry`.
|
||||
- **`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, 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
|
||||
the `shell/instrument` editor shell, along with the VST3 processor, `reaper_bridge`,
|
||||
`reasampler_embed`, and `vst_entry`.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -39,6 +46,21 @@ subdirectories:
|
||||
audio — the bank index, the mapping, which project is active — the instrument reads
|
||||
the live `"reasampler"` ext-state via the bridge.
|
||||
|
||||
### One capture = one parameter set
|
||||
|
||||
The instrument holds ONE loaded capture and ONE set of playback parameters governing it
|
||||
across the whole keyboard. There are no zones, no per-zone divergence, and no keymap of
|
||||
captures: every playback parameter edits in exactly one place, and no gesture can express
|
||||
per-zone divergence. The root note survives as a first-class parameter of that one set.
|
||||
|
||||
- **No key-range concept.** The loaded capture answers every note 0..127, repitched from
|
||||
its root, with key-tracking applied. A user-settable low/high playable range is
|
||||
re-addable later as two ordinary parameters if it is ever missed.
|
||||
- **Migration is adopt-the-first-zone.** A saved multi-zone instance lifts by taking zone
|
||||
one's capture and zone one's parameters; the rest drop, touching no file and no bank
|
||||
entry. Single-zone instances lift losslessly. The sounds-identical bar is deliberately
|
||||
relaxed for a genuinely multi-zone instance.
|
||||
|
||||
### The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26)
|
||||
|
||||
The split model is the settled answer, mirroring the capture/placement separation:
|
||||
@@ -47,20 +69,18 @@ The split model is the settled answer, mirroring the capture/placement separatio
|
||||
MIDI note the sample was recorded at) and loop points (sustain-loop start/end for held
|
||||
notes) are facts about the file, added as an additive field extension (same shape as
|
||||
`provenance`).
|
||||
- **The performance map (a creative arrangement) lives in the instrument.** Key zones,
|
||||
velocity layers, round-robin groups, amplitude envelopes, and per-sample tuning/gain
|
||||
trim are a performance choice, not a fact about a file — they belong to the instrument,
|
||||
not the bank. This "who owns which field" rule (D-B) governs every performance-map
|
||||
field added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode
|
||||
and pitch envelope (S16), key-tracking, preview velocity, and the velocity curve
|
||||
(S-VIEW) — all are per-instance/per-zone `ComponentState`, never written to `Sample` or
|
||||
the bank.
|
||||
- **Performance choices live in the instrument.** Amplitude envelopes and per-sample
|
||||
tuning/gain trim are a performance choice, not a fact about a file — they belong to the
|
||||
instrument, not the bank. This "who owns which field" rule (D-B) governs every parameter
|
||||
added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode and pitch
|
||||
envelope (S16), key-tracking, preview velocity, and the velocity curve (S-VIEW) — all are
|
||||
per-instance `ComponentState`, never written to `Sample` or the bank.
|
||||
|
||||
### The pure core (D3 — the load-bearing split)
|
||||
|
||||
The sampler's voice engine, envelope math, key/velocity mapping, repitch/interpolation,
|
||||
and keymap resolution are a pure, REAPER-free, DAW-free, unit-tested module — the mirror
|
||||
of `bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
|
||||
The sampler's voice engine, envelope math, velocity mapping, and repitch/interpolation are
|
||||
a pure, REAPER-free, DAW-free, unit-tested module — the mirror of
|
||||
`bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
|
||||
`SingleComponentEffect` subclass, bus setup, `process` marshalling, the `IPlugView` LICE
|
||||
editor, and the bridge calls) is the thin shell — the only part that touches VST3 or
|
||||
REAPER at all. Any VST3 or REAPER type leaking into this core is a bug.
|
||||
@@ -98,21 +118,26 @@ 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 (per-zone toggle, S16).** Varispeed (current/
|
||||
- **Pitch engine — Varispeed vs Preserve (S16).** Varispeed (current/
|
||||
classic path): `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_` with linear
|
||||
interp — resampling that couples pitch and duration; cheap, zero-latency, musically
|
||||
right for drums/one-shots. Preserve (duration-preserving): the read advances at the
|
||||
@@ -121,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
|
||||
@@ -140,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
|
||||
@@ -151,73 +209,155 @@ 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 zone envelope fields, 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.
|
||||
|
||||
### New performance-map parameters — ownership and persistence (D-B)
|
||||
**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.
|
||||
|
||||
- **Key-tracking** — per-zone, additive/version-bumped component state, default 100%
|
||||
**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)
|
||||
|
||||
- **Key-tracking** — additive/version-bumped component state, default 100%
|
||||
(absent field on an older blob lifts to 100%, bit-identical playback).
|
||||
- **Preview velocity** — a per-instance utility setting for the Sample view's
|
||||
preview-trigger button (not a musical parameter of the capture); **persists across
|
||||
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** — per-zone; the one non-back-compat surface in S-VIEW: an
|
||||
already-saved zone 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/`
|
||||
|
||||
- `sampler_core` — polyphonic voice engine with bounded stealing, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato toggle), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots); per-zone `ZonePlayParams` (Gate/Trigger, AHDSR, pitch engine Varispeed/Preserve, AD pitch mod envelope), repitch/interpolation with loop-point-aware sustain. 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.
|
||||
- `zone_params.h` (`core/instrument/engine`) is the sibling header split out of `sampler_core.h` (T4-14/T4-17): the per-zone play-parameter value structs (`ZonePlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`) and the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`) the engine, the codec, and the editor all share.
|
||||
- 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. 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` — zone payload: zones keyed by note range. **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). JSON round-trip.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + zones-payload binary codec (envelope v1…v11, zones-payload v1…v7), 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 (`sampler_core`/`pitch_shift`) 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.
|
||||
- `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`.
|
||||
- `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.
|
||||
- `note_entry` — parses a raw string into a clamped MIDI note [0,127]; accepts plain decimal integers or note names (C4==60, DAW convention).
|
||||
- `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`) — VST3 editor layout: aliases the shared `core::ui::Rect` (+ `contains()`) rather than defining its own; owns `EditorLayout`/`layoutEditor(w,h)`, the Tier-0/Tier-1 sample-list and keymap-editor row layout/hit-test, and — hoisted here off the former `reasampler_editor.cpp` god-TU (Q-W2v, T2-06) — the r11 Sample-face band layout (`SampleBands`/`ClusterRects`/`channelToggleRects`) and the Zone-face content/legend/deck layout, so the editor shell only draws + routes.
|
||||
- `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, zone highlight overlay geometry.
|
||||
- `waveform_view` — waveform/marker geometry: maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
|
||||
- `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 — 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, 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.
|
||||
- `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.
|
||||
- **`param_slider`'s linear slider rows are retired on the Zone panel** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (`editor_geometry`/knob deck grammar) is now the only live consumer of that half of `param_slider` on the Zone face. Don't assume `param_slider`'s SLIDER row type is still drawn there.
|
||||
- **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.
|
||||
- **Two superseded designs are called out in Invariants above**: the earlier
|
||||
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
|
||||
scaling of the plugin window — which would resample the uniform integer key widths at the
|
||||
physical-pixel level — is unverified.
|
||||
|
||||
@@ -0,0 +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
|
||||
@@ -0,0 +1,50 @@
|
||||
# The shifter is hand-rolled rather than WDL_SimplePitchShifter because that header drags
|
||||
# <windows.h> in via wdltypes.h, which cannot enter the pure engine.
|
||||
reasampler_pure_library(pitch_shift SOURCES pitch_shift.cpp LINK PUBLIC peaks)
|
||||
# Links only pitch_shift: linking more would break the plain-data-boundary proof — and
|
||||
# specifically the compile-time proof it does not drag in the WDL <windows.h> chain.
|
||||
reasampler_test(pitch_shift LINK pitch_shift)
|
||||
|
||||
reasampler_pure_library(velocity_curve SOURCES velocity_curve.cpp)
|
||||
# Links only velocity_curve, deliberately not editor_geometry: the proof the engine can
|
||||
# depend on the curve without inheriting the editor's layout types.
|
||||
reasampler_test(velocity_curve LINK velocity_curve)
|
||||
|
||||
reasampler_pure_library(master_gain SOURCES master_gain.cpp)
|
||||
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 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)
|
||||
|
||||
# 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)
|
||||
@@ -0,0 +1,485 @@
|
||||
#pragma once
|
||||
// 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).
|
||||
|
||||
#include <cmath>
|
||||
#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.
|
||||
//
|
||||
// Segment math:
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// 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 };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.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. 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;
|
||||
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:
|
||||
level_ = 0.0;
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
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;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at
|
||||
// 1.0 beyond what Attack already emitted) so a zero-length hold emits no
|
||||
// extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general
|
||||
// recursion). Re-enters the STAGE evaluator, never tick(), so a running
|
||||
// smoother is applied exactly once per frame.
|
||||
return tickStage();
|
||||
}
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.holdFrames)) {
|
||||
stage_ = Stage::Decay;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.decayFrames)) {
|
||||
stage_ = Stage::Sustain;
|
||||
stagePos_ = 0.0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = stageLevel(params_);
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.releaseFrames)) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
double stagePos_ = 0.0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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 >= static_cast<double>(fit_.total)) {
|
||||
if (sourceOffset >= static_cast<double>(fit_.total)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
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:
|
||||
// `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 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:
|
||||
// `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 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 span_ = 0;
|
||||
AhdSpan fit_;
|
||||
double pos_ = 0.0;
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,263 @@
|
||||
# src/core/instrument/engine/filter — the per-voice resonant filter
|
||||
|
||||
## Scope
|
||||
|
||||
The pure per-voice filter a sounding voice runs: a Zavalishin TPT/SVF with a continuous
|
||||
morph under one of two laws — HP→BP→LP or HP→notch→LP — and a drive stage. No REAPER, no
|
||||
VST3, no allocation, no I/O. Everything
|
||||
here lives in `reasampler::instrument::engine::filter`, nested per the
|
||||
directory-mirrors-namespace convention, which keeps `FilterSettings` and friends out of
|
||||
`reasampler::instrument::engine` proper where `play_params.h` lives — and `play_params.h`
|
||||
now stores a `FilterSettings` by value, so that separation is load-bearing rather than
|
||||
merely tidy. Five files, one responsibility each:
|
||||
|
||||
- `filter_params` — the control domain: normalized [0,1] knob position → cutoff Hz, Q, and
|
||||
drive depth, plus the exact inverses for cutoff and Q.
|
||||
- `filter_coeffs` — the DSP domain: `SvfCoeffs` and the TPT coefficient solve from
|
||||
(cutoff Hz, Q, sample rate).
|
||||
- `filter_morph` — the morph domain: `MorphLaw`, normalized position → per-tap weights under
|
||||
the selected law, and the fold of those weights into the three multipliers the kernel
|
||||
applies.
|
||||
- `filter_saturate` — `softLimit`, the drive stage's shaper. Header-only inline; it sits
|
||||
inside the per-sample recursion.
|
||||
- `voice_filter` — `FilterSettings` and `VoiceFilter`, the concrete per-voice type.
|
||||
`process()` is defined in the header.
|
||||
|
||||
### The cutoff is the only control that re-solves per frame, and it re-solves alone
|
||||
|
||||
`prepare()` is the full solve; `setCutoffNorm()` is the per-frame one. The split exists because
|
||||
**a modulated corner must move continuously** — Daniel's ruling, replacing a retired 2048-step
|
||||
quantizer that staircased the sweep in ~5.8-cent jumps — and a full `prepare()` per frame is the
|
||||
wasteful way to buy that. Only `g = tan(pi*fc/sr)` depends on cutoff: Q's parabola, the morph's
|
||||
`cos`/`sin`, and the folded mix (a function of the weights and `k` alone) do not, so
|
||||
`setCutoffNorm` re-derives none of them and reuses the `q_` cached at `prepare()`.
|
||||
|
||||
Measured at 48 kHz, MSVC `/O2`, net of the sweep generator and the kernel:
|
||||
|
||||
| per frame | ns |
|
||||
|---|---|
|
||||
| kernel alone, no re-solve | 2.8 |
|
||||
| full `prepare()` | 56.9 |
|
||||
| `setCutoffNorm`, constant logs recomputed | 22.8 |
|
||||
| `setCutoffNorm`, constant logs hoisted (shipped) | 15.5 |
|
||||
|
||||
The last row is 16 voices of continuously-swept filter at ~1.2% of one core — affordable, which
|
||||
is why nothing approximates `tan` here. The hoist is in `filter_params.cpp`: the sweep endpoints
|
||||
and the Q parabola are functions of compile-time constants, and recomputing those five
|
||||
logarithms per frame cost more than the solve they fed. **Do not put a quantizer back on the
|
||||
control value to save the solve** — make the solve cheaper instead.
|
||||
|
||||
## Invariants
|
||||
|
||||
### No vtable on the per-sample path
|
||||
|
||||
The Cortex-M4 source this began as was a virtual hierarchy (`FilterBase` → `Filter` →
|
||||
`Biquad` → `{BiquadHP, BiquadLP}`) whose base class routed the channel loop through
|
||||
pure-virtual `process_channel_frame` / `filter` / `update_feedback` so a `FilterDecorator`
|
||||
chain could wrap it. **None of that came across, and none of it may come back.**
|
||||
`VoiceFilter` is concrete, `process()` is inlined, and there is no `IFilter`, no decorator
|
||||
seam, no virtual `tick()`, and no allocation in `process()` — root `CLAUDE.md`'s structural
|
||||
heuristic 3 names this class of dispatch blowout directly.
|
||||
|
||||
### The rate enters ONLY through `g = tan(pi*fc/sr)`
|
||||
|
||||
There is no reference sample rate, calibration rate, or fallback rate anywhere in this
|
||||
module, and introducing one is the specific regression to guard against. An earlier design
|
||||
carried a `kFilterFeedbackDelaySeconds = 1/48000` tuning constant for a feedback tap; that
|
||||
tap, its ring buffer, and the constant are all deleted. A non-positive rate yields `g == 0`
|
||||
and a bypass mix (signal passes through) — never an invented rate.
|
||||
|
||||
### Why the high-pass feedback tap was right on Q15 hardware and wrong here
|
||||
|
||||
The ported firmware fed a saturated share of an earlier output back into the high-pass
|
||||
input. Its stated rationale — that the HP numerator collapses toward zero at low cutoff,
|
||||
taking the resonance with it — is **inverted**, and the comment asserting it has been
|
||||
removed rather than carried forward. Measurement: the HP `b0` approaches **1** as cutoff
|
||||
falls (0.99987 at 20 Hz); it is the **low-pass** `b0` that collapses (1.7e−06 at 20 Hz).
|
||||
|
||||
The tap was a Q15 fixed-point workaround. At 16-bit fixed point the low-cutoff biquad loses
|
||||
a ~17-bit cancellation and the resonance really does die; the feedback injected it back by
|
||||
another route. float32 survives that cancellation with 7 bits to spare, so on this target
|
||||
the tap did not restore character — it *reduced* it (HP landed 0.4% off the analytic RBJ
|
||||
target with the tap disabled, and 25% off with it enabled), and it introduced both level
|
||||
dependence and rate dependence.
|
||||
|
||||
Daniel's ruling on the level-dependent resonance bloom it produced: *"was a feature on the
|
||||
hardware (one knob colorful HP for master FX), wrong choice for this approach."* Drive is
|
||||
now an explicit user-controlled stage instead of an emergent side effect.
|
||||
|
||||
### The morph is a blend of taps, never a coefficient switch
|
||||
|
||||
An SVF produces high, band, and low from the same state, which is the reason this topology
|
||||
was chosen. `FilterMode` as a discrete enum is retired. HP at 0.0, LP at 1.0, continuous
|
||||
throughout, and both endpoints are exact under either law — only the centre differs.
|
||||
|
||||
The crossfade is **equal-power** in both laws, and that is forced by the topology rather
|
||||
than picked by ear. At the corner the taps are `HP = jQ`, `BP = Q`, `LP = -jQ` — adjacent
|
||||
taps in exact quadrature and HP/LP in exact antiphase, relationships the bilinear transform
|
||||
preserves exactly at the prewarped corner. A `cos`/`sin` pair therefore holds the crossfaded
|
||||
power at unity across the whole sweep; a linear crossfade of a quadrature pair would sag to
|
||||
`1/sqrt(2)` mid-leg, a 3 dB hole that reads as a defect rather than as character.
|
||||
|
||||
### The two morph laws, and why only one of them has a flat corner
|
||||
|
||||
`MorphLaw` is a two-value selector on `FilterSettings`, **defaulting to `HighBandLow`** —
|
||||
that is the reviewed-and-measured law, and it is enumerator 0 so a zero-initialized or absent
|
||||
persisted field lands on it rather than on the SEM leg.
|
||||
|
||||
- **`HighBandLow` (HP→BP→LP, the default).** Two equal-power legs crossfading **adjacent taps
|
||||
only**, BP at the centre. Because adjacent taps are in quadrature, the corner magnitude is
|
||||
algebraically `Q*sqrt(cos² + sin²) = Q` at every position — measured flat to 4e-6 across 65
|
||||
positions. **That flatness guarantee is specific to this law.** Do not weaken the assertion
|
||||
that pins it in order to accommodate the other law.
|
||||
- **`HighNotchLow` (HP→notch→LP, the Oberheim SEM).** One equal-power crossfade weighting HP
|
||||
and LP **together** across the whole sweep, `bp == 0` throughout. The notch is not tuned in:
|
||||
HP and LP sit at exactly +90° and −90° at the corner, so equal weights cancel there by
|
||||
construction. Here the corner magnitude deliberately goes to **zero** at the centre —
|
||||
measured worst case −88 dB on the shipped `{250, 1000, 4000}` Hz cutoff grid, typically −110 to
|
||||
−145 dB. Over the full control range (20 Hz – 20 kHz, Q 0.1 – 10) the worst residual is
|
||||
shallower — −69.8 dB at 192 kHz / 30 Hz / Q=10 — from float conditioning in the folded
|
||||
`x − k·v1` term as `fc/sr → 1e-4` at high Q; it is Q-dependent (Q=0.1 holds −110 dB everywhere)
|
||||
and still an excellent notch, not a broadband defect. `test_filter.cpp`'s null test covers this
|
||||
full range with a Q-scaled threshold rather than the flat −74 dB the shipped grid alone would
|
||||
justify. The fold makes the centre's cancellation structural rather than a runtime near-miss:
|
||||
`m2 = lp - hp` is **exactly** `0.0f` at the centre, because `cos` and `sin` of π/4 differ by
|
||||
about an ulp of *double*, nine orders below float's spacing there, so they narrow to one float.
|
||||
|
||||
SEM's zero is at the **notch frequency**, not a broadband level sag — off the corner the pair
|
||||
is still equal-power, so neither law's legs dip. Measuring that requires dividing by each
|
||||
tap's own analytic response first: at `Q = 0.1` a 2-pole approaches its passband so slowly
|
||||
that the pure low tap still reads 0.896 at 50 Hz, and a raw reading would report a 20% "sag"
|
||||
that is the Q, not the morph.
|
||||
|
||||
**The toggle is free on the hot path, and must stay that way.** `morphWeights` runs at
|
||||
`prepare()` cadence; the law is consumed there and nowhere else. The kernel, `svfCoeffs`, and
|
||||
`morphMix`'s fold are identical between the laws — all a law selects is three floats the
|
||||
kernel was already multiplying by. Verified at the machine-code level, not by inspection: the
|
||||
same TU compiled `/O2` against the pre-toggle and post-toggle headers emits byte-identical
|
||||
assembly for `process()` and `processFrame()`. `VoiceFilter` gained no member and `process()`
|
||||
gained no branch. A design that puts the law selector inside the per-sample path is wrong —
|
||||
rework it rather than paying for it.
|
||||
|
||||
### Drive is a contraction inside the loop, which is what makes it unconditionally stable
|
||||
|
||||
`softLimit(u, depth) = u / sqrt(1 + (depth*u)²)` shapes the **band-pass integrator state**.
|
||||
Three properties carry the design:
|
||||
|
||||
- `depth == 0` makes it algebraically the identity (`x / sqrt(1) == x`, exact in IEEE), so
|
||||
drive 0 is **bit-exact** linear whether or not `softLimit` is actually called. The test
|
||||
asserts bit-identity against the same kernel with the limiter deleted.
|
||||
- `process()` gates the call on `driven_` (`driveDepth_ != 0`, cached at `prepare()`) rather
|
||||
than calling `softLimit` unconditionally. `sqrt`/div sit on the per-sample recursive
|
||||
dependency chain, so out-of-order execution can't hide their latency, and at drive 0 that
|
||||
cost buys nothing. Measured: 11.2 ns/sample unconditional vs 4.1 ns gated — the gated form
|
||||
lands at the limiter-removed floor. `driven_` only changes at `prepare()`, so the branch
|
||||
predicts perfectly. The gate is a perf optimization on top of the bit-identity above, not a
|
||||
substitute for it — deleting the gate would still be correct, just 2.7x slower at rest.
|
||||
- `|softLimit(u, d)| <= |u|` for every depth, so the state update can only shrink the state.
|
||||
The filter cannot gain energy from the drive stage: stability at any Q and any cutoff is
|
||||
structural, and self-oscillation is impossible. This is why the shaper must keep unit slope
|
||||
at the origin — a shaper with gain above 1 there turns the resonator into an oscillator.
|
||||
- It shapes the **state**, not the zero-delay loop. A nonlinearity inside the loop would
|
||||
break the closed-form `a1`/`a2`/`a3` solve and need per-sample Newton iteration.
|
||||
|
||||
Placement is the resonance path because that is where the firmware's character came from,
|
||||
and because the band-pass state sits at zero in the passband and at DC — so drive colours
|
||||
the resonance and leaves the passband transparent (measured 0.98 at max drive). It is not a
|
||||
distortion box in series with the signal; a caller wanting that has every other plugin.
|
||||
|
||||
**Drive × resonance interact by design.** What reaches the shaper is the resonance state,
|
||||
already multiplied by roughly `2*Q`, so the same drive setting bites harder the more
|
||||
resonance is dialled in — and harder on a hotter input. That level dependence is the
|
||||
*point* of an explicit drive control; what Daniel rejected was level dependence nobody
|
||||
asked for. At drive 0 there is none, to 0.0004% over a 1000:1 level range.
|
||||
|
||||
`kFilterDriveDepthMax` (4.0) was set against measurement, not feel: at max drive, full-scale
|
||||
input and max resonance the resonant peak lands ~10 dB under the passband — plainly
|
||||
crushed, which is the asked-for "extreme". Raising it further inverts the filter's shape
|
||||
(21 dB under passband at depth 64), turning the peak the user dialled in into a notch.
|
||||
There is deliberately **no makeup gain** — any law for it would be invented rather than
|
||||
derived, and drive is due an ear pass against the radial dial.
|
||||
|
||||
### The cutoff control is sample-rate-free; the clamp is not
|
||||
|
||||
`filterCutoffHzFromNorm` sweeps a fixed 20 Hz – 20 kHz (three exact decades, so norm 1/3 is
|
||||
200 Hz and 2/3 is 2 kHz) and takes no sample rate. The persisted value is the normalized
|
||||
knob position, so a rate-derived endpoint would make one preset sound different at 44.1k and
|
||||
96k. The Nyquist clamp (`kFilterNyquistFraction`, 0.48) is a property of the bilinear
|
||||
transform — `tan(pi*fc/sr)` diverges at Nyquist — so it lives in `svfCoeffs` where the rate
|
||||
is already a parameter. 20 kHz is under 0.48·sr at 44.1k and above, so the clamp never eats
|
||||
live knob travel there; the source's hardcoded 23 kHz endpoint did exactly that at 44.1k.
|
||||
|
||||
### Q spans 0.1 → 10 with √2 at the center
|
||||
|
||||
Settled by Daniel. The source's `Q = M_SQRT1_2 + resonance` mapping (floored at 0.707, no
|
||||
center anchor) was **rewritten, not ported**. The curve is quadratic in log Q through the
|
||||
three anchors rather than two spliced log segments — same anchors either way, but no slope
|
||||
kink at the center detent. The quadratic term is nonzero only because √2 is not the
|
||||
geometric mean of 0.1 and 10; `filterNormFromQ` divides by it. The SVF consumes it as
|
||||
`k = 1/Q`.
|
||||
|
||||
### Denormal flushing: why conjunctive, honestly
|
||||
|
||||
`process()` flushes **both** integrators to exact zero once both are below
|
||||
`kFilterDenormalFloor` (1e-30). The honest reason is narrower than it sounds: `isSilent()`
|
||||
means "both integrators are exactly zero," so both have to reach zero for that check to mean
|
||||
anything, and the conjunctive test is the cheapest way to guarantee it.
|
||||
|
||||
The stronger claim — that a per-variable flush limit-cycles at the floor — does **not**
|
||||
reproduce on this topology. Measured (Q=10, fc=1kHz, 48k): shipped conjunctive goes silent at
|
||||
sample 10783 with 0 subnormals; a per-variable independent flush goes silent ~180 samples
|
||||
earlier and an either-below-zero-both flush ~970 samples earlier, both also 0 subnormals, no
|
||||
limit cycle, and the same excited RMS. That claim WAS real on the retired Direct Form I state,
|
||||
where the flushed variables (`y1`/`y2`) were the actual filter OUTPUT, so zeroing one injected
|
||||
a discontinuity the resonance then amplified. Here `ic1`/`ic2` are integrator STATE, not
|
||||
output: zeroing one only removes energy, a contraction rather than an injection, so the hazard
|
||||
is structurally absent. The only demonstrable hazard is no flush at all, which never reaches
|
||||
exact zero and grinds through subnormals for thousands of samples on a released voice.
|
||||
|
||||
Keep the conjunctive test regardless — it costs nothing extra and is the right guarantee for
|
||||
`isSilent()` — but don't cite the limit-cycle rationale for TPT; it belongs to the retired
|
||||
topology.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **TPT is what fixed the low-cutoff conditioning defect** — this is a topology change, not
|
||||
a relocation. Direct Form I encoded pole proximity in `a1 → -2`, `a2 → +1` and cancelled
|
||||
them against each other every sample; at `fc/sr ≈ 1e-4` that ~17-bit cancellation moved the
|
||||
measured 20 Hz / 192 kHz LP peak by **-27% on a true-peak scan, -57% measured at the
|
||||
analytic peak frequency** (the degraded pole itself moves, so the two methods diverge), and
|
||||
the error is non-monotone with rate rather than a fixed percentage (+5% high at 96 kHz).
|
||||
TPT encodes the same proximity in `a1`'s small deviation from 1, which float32 resolves:
|
||||
checked against an exact-double evaluation of the same difference equation (which matches
|
||||
the analytic target to within measurement noise), TPT's float32-narrowed coefficients are
|
||||
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
|
||||
the state at all, so a stale nonzero `ic1`/`ic2` would otherwise latch `isSilent()` false
|
||||
forever with no audible effect either way — `prepare()` clears state on that path only,
|
||||
which costs nothing audibly since bypass ignores it.
|
||||
- **The morph endpoints are asserted on the folded mix, exactly.** `morphWeights` snaps the
|
||||
leg endpoints instead of trusting `cos`/`sin` to land on 0 and 1, which they miss by ~1e-17
|
||||
— enough to leave a -324 dB neighbour tap in what is specified as a pure response.
|
||||
- **A NaN morph position falls back per law, not to one shared value.** Every comparison
|
||||
against NaN is false, so it clamps to neither endpoint: `HighBandLow` lands on pure
|
||||
band-pass, `HighNotchLow` on pure high-pass, since it has no band tap to land on.
|
||||
- **Measuring a null needs a ring-time-adequate settle window.** At `Q = 10` the leftover
|
||||
transient alone reads as −52 dB after 0.15 s and would be mistaken for the noise floor.
|
||||
- **The call site is `Voice::advanceFrame`**, between the pitch stage and the amp multiply.
|
||||
It re-solves the corner on **every frame the modulated cutoff actually moves — unquantized**,
|
||||
so the corner glides rather than staircasing.
|
||||
- **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
|
||||
budget expressed in samples is therefore itself a rate assumption — a fixed 20000 samples
|
||||
is ample at 48k and expires mid-decay at 96k and above.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
|
||||
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
|
||||
reasampler_pure_library(filter SOURCES
|
||||
filter_params.cpp
|
||||
filter_coeffs.cpp
|
||||
filter_morph.cpp
|
||||
voice_filter.cpp)
|
||||
|
||||
# Four test targets along the module's own seams so each asserts one domain. filter_tests
|
||||
# alone owns the analytic reference and the steady-state gain measurement — a forked copy of
|
||||
# a measurement reference is a worse defect than a long file.
|
||||
reasampler_test(filter_params LINK filter)
|
||||
reasampler_test(filter_morph LINK filter)
|
||||
reasampler_test(filter_state LINK filter)
|
||||
reasampler_test(filter LINK filter)
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "core/instrument/engine/filter/filter_coeffs.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
namespace {
|
||||
|
||||
// M_PI is not standard C++ and is absent on MSVC without _USE_MATH_DEFINES.
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
double clampd(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
} // namespace
|
||||
|
||||
SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate) {
|
||||
const double qq = clampd(q, kFilterQMin, kFilterQMax);
|
||||
const double k = 1.0 / qq;
|
||||
|
||||
double g = 0.0;
|
||||
if (sampleRate > 0.0) {
|
||||
const double fc = clampd(cutoffHz, kFilterCutoffMinHz, kFilterNyquistFraction * sampleRate);
|
||||
g = std::tan(kPi * fc / sampleRate);
|
||||
}
|
||||
|
||||
// Solved in double and narrowed once. The intermediate g*(g+k) is the term that carries the
|
||||
// pole proximity, so forming it in float would throw away the conditioning TPT just bought.
|
||||
const double a1 = 1.0 / (1.0 + g * (g + k));
|
||||
const double a2 = g * a1;
|
||||
const double a3 = g * a2;
|
||||
|
||||
SvfCoeffs c;
|
||||
c.g = static_cast<float>(g);
|
||||
c.k = static_cast<float>(k);
|
||||
c.a1 = static_cast<float>(a1);
|
||||
c.a2 = static_cast<float>(a2);
|
||||
c.a3 = static_cast<float>(a3);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,42 @@
|
||||
// filter_coeffs.h — Zavalishin topology-preserving-transform state-variable coefficients.
|
||||
// The rate enters ONLY through g = tan(pi*fc/sr); there is no reference or calibration rate
|
||||
// anywhere in this module, and reintroducing one would restore the rate-dependent resonance
|
||||
// the TPT rewrite exists to remove.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// The two-integrator SVF's per-sample constants. a1/a2/a3 are the algebraic solution of the
|
||||
// zero-delay feedback loop, so the kernel needs no iteration.
|
||||
struct SvfCoeffs {
|
||||
float g = 0.0f; // tan(pi*fc/sr) — the ONLY place the sample rate appears
|
||||
float k = 1.0f; // 1/Q, the damping term
|
||||
float a1 = 1.0f;
|
||||
float a2 = 0.0f;
|
||||
float a3 = 0.0f;
|
||||
};
|
||||
|
||||
// Highest fraction of the sample rate the pre-warp stays well-conditioned at: tan() diverges
|
||||
// as fc approaches sr/2.
|
||||
inline constexpr double kFilterNyquistFraction = 0.48;
|
||||
|
||||
// cutoffHz is clamped into [kFilterCutoffMinHz, kFilterNyquistFraction*sampleRate] and q into
|
||||
// [kFilterQMin, kFilterQMax]. A non-positive sampleRate yields g == 0 — we refuse to invent a
|
||||
// rate rather than assume 44.1k.
|
||||
//
|
||||
// Float storage is safe HERE in a way it was not for the retired Direct Form I path. DF1 encoded
|
||||
// pole proximity in a1 -> -2, a2 -> +1 and cancelled them against each other every sample; at
|
||||
// fc/sr ~ 1e-4 that ~17-bit cancellation moved the measured 20 Hz/192 kHz LP peak by -27%
|
||||
// (true-peak scan) to -57% (point measurement at the analytic peak frequency, since the
|
||||
// degraded pole itself moves) -- and the error is non-monotone with rate, not a fixed percentage
|
||||
// (+5% high at 96 kHz). TPT encodes the same proximity in a1's small DEVIATION from 1, which
|
||||
// float resolves: measured against an exact-double evaluation of the same difference equation
|
||||
// (which matches the analytic target to within measurement noise), TPT's float32-narrowed
|
||||
// coefficients land genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz -- both
|
||||
// comfortably inside the test's 0.4% tolerance.
|
||||
SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate);
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,64 @@
|
||||
#include "core/instrument/engine/filter/filter_morph.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
namespace {
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
struct Pair {
|
||||
double a, b;
|
||||
};
|
||||
|
||||
// Equal-power crossfade, EXACT at both ends by construction rather than by rounding: cos and sin
|
||||
// of the leg's quarter turn are only 1e-17 from 0/1 at the endpoints, and the endpoints have to
|
||||
// be pure taps, not a pure tap plus a -324 dB neighbour.
|
||||
Pair equalPower(double t) {
|
||||
if (!(t > 0.0)) return {1.0, 0.0};
|
||||
if (t >= 1.0) return {0.0, 1.0};
|
||||
const double theta = 0.5 * kPi * t;
|
||||
return {std::cos(theta), std::sin(theta)};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MorphWeights morphWeights(float norm, MorphLaw law) {
|
||||
const double n = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : static_cast<double>(norm));
|
||||
|
||||
MorphWeights w;
|
||||
if (law == MorphLaw::HighNotchLow) {
|
||||
// ONE crossfade across the whole sweep rather than two legs, so HP and LP carry weight
|
||||
// together everywhere between the endpoints and are equal at the centre.
|
||||
const Pair p = equalPower(n);
|
||||
w.hp = static_cast<float>(p.a);
|
||||
w.bp = 0.0f;
|
||||
w.lp = static_cast<float>(p.b);
|
||||
return w;
|
||||
}
|
||||
|
||||
if (n <= 0.5) {
|
||||
const Pair p = equalPower(2.0 * n); // HP -> BP
|
||||
w.hp = static_cast<float>(p.a);
|
||||
w.bp = static_cast<float>(p.b);
|
||||
w.lp = 0.0f;
|
||||
} else {
|
||||
const Pair p = equalPower(2.0 * n - 1.0); // BP -> LP
|
||||
w.hp = 0.0f;
|
||||
w.bp = static_cast<float>(p.a);
|
||||
w.lp = static_cast<float>(p.b);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
MorphMix morphMix(const MorphWeights& w, float k) {
|
||||
MorphMix m;
|
||||
m.m0 = w.hp;
|
||||
m.m1 = w.bp - w.hp * k;
|
||||
m.m2 = w.lp - w.hp;
|
||||
return m;
|
||||
}
|
||||
|
||||
MorphMix bypassMix() { return MorphMix{1.0f, 0.0f, 0.0f}; }
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,70 @@
|
||||
// filter_morph.h — the continuous morph: normalized position to tap weights under one of two
|
||||
// laws, and the fold of those weights into the three multipliers the kernel actually applies.
|
||||
// An SVF produces all three taps from one state, so the morph is a blend, never a coefficient
|
||||
// switch. Weights are computed at prepare() cadence; the law never reaches the per-sample path.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// Which shape the sweep traces between its two fixed endpoints. This selects CHARACTER, not
|
||||
// topology — same SVF, same coefficients, same kernel under either law; only the centre differs.
|
||||
//
|
||||
// HighBandLow is enumerator 0 deliberately: a zero-initialized or absent persisted field then
|
||||
// lands on the default rather than on the SEM leg.
|
||||
enum class MorphLaw {
|
||||
// HP -> BP -> LP. Crossfades ADJACENT taps only, so the corner magnitude is flat at Q the
|
||||
// whole way across. The default.
|
||||
HighBandLow,
|
||||
// HP -> notch -> LP, the Oberheim SEM. One crossfade weighting HP and LP together, bp == 0
|
||||
// throughout; the notch falls out of the antiphase cancellation rather than being tuned in.
|
||||
HighNotchLow,
|
||||
};
|
||||
|
||||
// Weight on each SVF tap. Under HighBandLow exactly one of hp/lp is nonzero at a time — that law
|
||||
// crossfades adjacent taps only, never HP against LP. Under HighNotchLow bp is always zero and
|
||||
// hp/lp carry weight together, which is precisely what cuts the notch.
|
||||
struct MorphWeights {
|
||||
float hp = 0.0f;
|
||||
float bp = 0.0f;
|
||||
float lp = 1.0f;
|
||||
};
|
||||
|
||||
// HP at 0.0, LP at 1.0 under BOTH laws; the centre is a band-pass under HighBandLow and a notch
|
||||
// under HighNotchLow. Out-of-range norm clamps to the endpoints; NaN clamps to neither (every
|
||||
// comparison against it is false) and lands on the law's degenerate — pure band-pass under
|
||||
// HighBandLow, pure high-pass under HighNotchLow, which has no band tap to land on.
|
||||
//
|
||||
// Equal-power (cos/sin) in both laws rather than linear, and that choice is forced by the
|
||||
// topology rather than picked by ear. At the corner frequency the three taps are HP = jQ,
|
||||
// BP = Q, LP = -jQ, so ADJACENT taps are in exact QUADRATURE there (and the bilinear transform
|
||||
// preserves that exactly at the prewarped corner). Under HighBandLow's cos/sin pair the corner
|
||||
// magnitude is therefore Q*sqrt(cos^2 + sin^2) = Q at every morph position — algebraically flat
|
||||
// across the whole sweep. A linear crossfade of the same quadrature pair would sag to Q/sqrt(2),
|
||||
// a 3 dB hole mid-leg.
|
||||
//
|
||||
// HP and LP are exactly ANTIPHASE at the corner (+90 and -90 degrees), so a law giving both
|
||||
// simultaneous weight cancels there. HighBandLow avoids that by staying adjacent; HighNotchLow
|
||||
// uses it — one equal-power crossfade of HP against LP over the whole sweep puts equal weights
|
||||
// at the centre and the null is exact by construction, not tuned. That is why the corner-flat-at-Q
|
||||
// guarantee is specific to HighBandLow: on the SEM leg the corner magnitude deliberately goes to
|
||||
// zero at the centre. Equal power still holds off the notch frequency, so neither law's legs sag.
|
||||
MorphWeights morphWeights(float norm, MorphLaw law);
|
||||
|
||||
// The kernel applies out = m0*v0 + m1*v1 + m2*v2, where v0 is the input and v1/v2 are the SVF's
|
||||
// band and low outputs. Folding hp = v0 - k*v1 - v2 into the weights here keeps the per-sample
|
||||
// path at three multiplies and spares it ever forming the high tap.
|
||||
struct MorphMix {
|
||||
float m0 = 0.0f;
|
||||
float m1 = 0.0f;
|
||||
float m2 = 1.0f;
|
||||
};
|
||||
|
||||
MorphMix morphMix(const MorphWeights& w, float k);
|
||||
|
||||
// Passes the input through untouched, whatever the morph position asks for. Reserved for a
|
||||
// sample rate we cannot form a filter from: silencing an instrument is a worse failure than
|
||||
// ignoring the morph, and at g == 0 a low-pass tap is analytically silent.
|
||||
MorphMix bypassMix();
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,68 @@
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
namespace {
|
||||
|
||||
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||
|
||||
// log Q = A + B*n + C*n^2, solved from the three anchor points. C is nonzero precisely
|
||||
// because the center anchor sqrt(2) is not the geometric mean of the endpoints (which is 1);
|
||||
// were they equal the curve would degenerate to a plain log sweep and the inverse below
|
||||
// would divide by zero.
|
||||
struct QCurve {
|
||||
double a, b, c;
|
||||
};
|
||||
|
||||
QCurve solveQCurve() {
|
||||
const double lo = std::log(static_cast<double>(kFilterQMin));
|
||||
const double mid = std::log(static_cast<double>(kFilterQCenter));
|
||||
const double hi = std::log(static_cast<double>(kFilterQMax));
|
||||
return {lo, 4.0 * mid - 3.0 * lo - hi, 2.0 * lo + 2.0 * hi - 4.0 * mid};
|
||||
}
|
||||
|
||||
// Functions of compile-time constants alone, so they resolve once at static init rather than
|
||||
// per call. Load-bearing rather than tidy: a modulated cutoff re-solves EVERY FRAME, and
|
||||
// recomputing these logarithms of literals cost more than the solve they feed.
|
||||
const double kLogCutoffMin = std::log(static_cast<double>(kFilterCutoffMinHz));
|
||||
const double kLogCutoffSpan =
|
||||
std::log(static_cast<double>(kFilterCutoffMaxHz)) - kLogCutoffMin;
|
||||
const QCurve kQCurve = solveQCurve();
|
||||
|
||||
} // namespace
|
||||
|
||||
float filterCutoffHzFromNorm(float norm) {
|
||||
return static_cast<float>(std::exp(kLogCutoffMin + clamp01(norm) * kLogCutoffSpan));
|
||||
}
|
||||
|
||||
float filterNormFromCutoffHz(float hz) {
|
||||
if (!(hz > 0.0f)) return 0.0f;
|
||||
return static_cast<float>(
|
||||
clamp01((std::log(static_cast<double>(hz)) - kLogCutoffMin) / kLogCutoffSpan));
|
||||
}
|
||||
|
||||
float filterQFromNorm(float norm) {
|
||||
const double n = clamp01(norm);
|
||||
return static_cast<float>(std::exp(kQCurve.a + n * (kQCurve.b + kQCurve.c * n)));
|
||||
}
|
||||
|
||||
float filterDriveDepthFromNorm(float norm) {
|
||||
const double n = clamp01(norm);
|
||||
return static_cast<float>(kFilterDriveDepthMax * n * n);
|
||||
}
|
||||
|
||||
float filterNormFromQ(float q) {
|
||||
if (!(q > kFilterQMin)) return 0.0f;
|
||||
if (q >= kFilterQMax) return 1.0f;
|
||||
// Clamping first is load-bearing, not just tidy: the parabola peaks at log Q well below
|
||||
// an arbitrarily large q, so an unclamped out-of-range value has no real root at all.
|
||||
const QCurve& k = kQCurve;
|
||||
const double d = k.b * k.b - 4.0 * k.c * (k.a - std::log(static_cast<double>(q)));
|
||||
if (!(d >= 0.0)) return 0.0f;
|
||||
// Of the two roots only this one lies on the rising branch inside [0,1]; the parabola's
|
||||
// vertex sits well above 1 for the settled anchors.
|
||||
return static_cast<float>(clamp01((-k.b + std::sqrt(d)) / (2.0 * k.c)));
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,52 @@
|
||||
// filter_params.h — control-domain mapping for the voice filter: normalized [0,1] knob
|
||||
// positions to cutoff Hz, Q, and drive depth. Deliberately sample-rate-free — the Nyquist
|
||||
// clamp is a property of the bilinear transform and lives in filter_coeffs, so the persisted
|
||||
// normalized cutoff means the same frequency at every project rate.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// The audio band the cutoff control sweeps: three exact decades, so norm 1/3 is 200 Hz and
|
||||
// norm 2/3 is 2 kHz. NOT derived from the sample rate — a rate-dependent endpoint would make
|
||||
// one saved preset sound different at 44.1k and 96k, and at 44.1k the top of the travel would
|
||||
// be dead against the Nyquist clamp (the ported firmware's 23 kHz endpoint had exactly that
|
||||
// defect). 20 kHz sits under 0.48*sr at 44.1 kHz and above; below that (e.g. 32 kHz, 22.05 kHz)
|
||||
// the clamp still handles it correctly, it just eats the top of the knob travel at those rates.
|
||||
inline constexpr float kFilterCutoffMinHz = 20.0f;
|
||||
inline constexpr float kFilterCutoffMaxHz = 20000.0f;
|
||||
|
||||
// Q spans the full range with Butterworth (sqrt(2)) at the control's center detent.
|
||||
inline constexpr float kFilterQMin = 0.1f;
|
||||
inline constexpr float kFilterQMax = 10.0f;
|
||||
inline constexpr float kFilterQCenter = 1.41421356f;
|
||||
|
||||
// Depth at the top of the drive control. The limiter's knee is at 1/depth, and the resonance
|
||||
// swings the state to roughly 2*Q*level, so this is the range over which drive bites. Chosen
|
||||
// against measurement rather than by feel: at max drive, full-scale input and max resonance the
|
||||
// resonant peak lands ~10 dB under the passband — plainly crushed, which is the asked-for
|
||||
// "extreme". Raising it further inverts the filter's shape (measured 21 dB under passband at
|
||||
// depth 64), turning the peak the user dialled in into a notch.
|
||||
inline constexpr float kFilterDriveDepthMax = 4.0f;
|
||||
|
||||
// Out-of-range norm clamps to the endpoints.
|
||||
float filterCutoffHzFromNorm(float norm);
|
||||
|
||||
// Exact inverse of filterCutoffHzFromNorm over the band; out-of-band Hz clamps to 0 or 1.
|
||||
float filterNormFromCutoffHz(float hz);
|
||||
|
||||
// A single smooth curve — quadratic in log Q — through (0, kFilterQMin),
|
||||
// (0.5, kFilterQCenter), (1, kFilterQMax), rather than two spliced log segments. Same three
|
||||
// anchors either way, but the single curve has no slope kink at the center detent.
|
||||
float filterQFromNorm(float norm);
|
||||
|
||||
// Exact inverse of filterQFromNorm; out-of-range Q clamps to 0 or 1.
|
||||
float filterNormFromQ(float q);
|
||||
|
||||
// Drive depth for the in-loop limiter. Square law, not linear: the knee is 1/depth, so a linear
|
||||
// depth would spend most of the audible travel in the first tenth of the knob. Exactly 0 at
|
||||
// norm 0 — the limiter is then algebraically the identity, which is what makes drive=0 bit-exact
|
||||
// linear rather than merely close.
|
||||
float filterDriveDepthFromNorm(float norm);
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,34 @@
|
||||
// filter_saturate.h — the drive stage's soft limiter. Header-inline: it sits inside the
|
||||
// per-voice per-sample recursion.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// Odd, smooth, strictly monotone, bounded by 1/depth, with unit slope at the origin.
|
||||
//
|
||||
// Three properties are load-bearing and none of them are tuning:
|
||||
// - depth == 0 makes this ALGEBRAICALLY the identity (x / sqrt(1) == x, exact in IEEE), so
|
||||
// drive = 0 is bit-exact linear whether or not the caller special-cases it. (voice_filter.h
|
||||
// gates the call on drive != 0 anyway, but as a perf optimization, not because correctness
|
||||
// needs it.)
|
||||
// - |softLimit(x, d)| <= |x| for every d, so dropping it into the resonance state update can
|
||||
// only ever shrink the state. The filter therefore cannot gain energy from the drive stage:
|
||||
// stability at any Q and any cutoff is structural, not a tuned margin, and it can never
|
||||
// self-oscillate.
|
||||
// - Unit slope at the origin, so the shaper adds no gain of its own at any depth. What reaches
|
||||
// it is the resonance state, already multiplied by roughly 2*Q, which is why drive and
|
||||
// resonance interact: the same drive setting bites harder the more resonance is dialled in.
|
||||
//
|
||||
// The retired feedbackSaturate() is deliberately not carried forward: it had 0.75 slope at the
|
||||
// origin, a fixed +/-2.0 threshold calibrated for firmware excursion levels, and turned over
|
||||
// (non-monotone) past x = 6. That absolute threshold is the origin of the level-dependent
|
||||
// resonance this rewrite removes — do not reintroduce it.
|
||||
inline float softLimit(float x, float depth) {
|
||||
const float s = depth * x;
|
||||
return x / std::sqrt(1.0f + s * s);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,37 @@
|
||||
#include "core/instrument/engine/filter/voice_filter.h"
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
|
||||
q_ = filterQFromNorm(settings.resonanceNorm);
|
||||
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(settings.cutoffNorm), q_, sampleRate);
|
||||
if (sampleRate > 0.0) {
|
||||
mix_ = morphMix(morphWeights(settings.morphNorm, settings.morphLaw), coeffs_.k);
|
||||
} else {
|
||||
// Bypass: a1=1, a2=a3=0 makes both state updates the exact identity, and bypassMix()
|
||||
// reads only the input, never the state -- so clearing here is audibly free (the state
|
||||
// was already going to be ignored) and prevents a stale nonzero ic1/ic2 from latching
|
||||
// isSilent() false forever, which prepare() otherwise deliberately never does.
|
||||
mix_ = bypassMix();
|
||||
for (State& s : state_) s = State{};
|
||||
}
|
||||
driveDepth_ = filterDriveDepthFromNorm(settings.driveNorm);
|
||||
driven_ = driveDepth_ != 0.0f;
|
||||
}
|
||||
|
||||
void VoiceFilter::setCutoffNorm(float cutoffNorm, double sampleRate) {
|
||||
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(cutoffNorm), q_, sampleRate);
|
||||
}
|
||||
|
||||
void VoiceFilter::reset() {
|
||||
for (State& s : state_) s = State{};
|
||||
}
|
||||
|
||||
bool VoiceFilter::isSilent() const {
|
||||
for (const State& s : state_) {
|
||||
if (s.ic1 != 0.0f || s.ic2 != 0.0f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,135 @@
|
||||
// voice_filter.h — per-voice TPT state-variable filter with a continuous HP->BP->LP morph and
|
||||
// an in-loop drive stage. Concrete type, no vtable: this sits on the per-voice per-sample path,
|
||||
// so process() is header-inline. No allocation, no virtual dispatch, no I/O in process().
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cassert>
|
||||
#include <type_traits>
|
||||
|
||||
#include "core/instrument/engine/filter/filter_coeffs.h"
|
||||
#include "core/instrument/engine/filter/filter_morph.h"
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
#include "core/instrument/engine/filter/filter_saturate.h"
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// Normalized control positions, as the editor moves them and the persisted state carries them.
|
||||
// morphLaw is the one discrete control here — a two-value selector, not a normalized position —
|
||||
// because its two values are characters to choose between, not points on a continuum.
|
||||
struct FilterSettings {
|
||||
float cutoffNorm = 1.0f;
|
||||
float resonanceNorm = 0.0f;
|
||||
float morphNorm = 1.0f; // 0 = high-pass, 1 = low-pass; the centre is set by morphLaw
|
||||
float driveNorm = 0.0f;
|
||||
MorphLaw morphLaw = MorphLaw::HighBandLow;
|
||||
};
|
||||
|
||||
// Below this the recursion has decayed past -600 dB. Flushing keeps the state out of the
|
||||
// subnormal range, where a ringing-out voice would otherwise stall the FPU for thousands of
|
||||
// samples. Chosen well above FLT_MIN so a flushed state can never re-enter that range.
|
||||
inline constexpr float kFilterDenormalFloor = 1e-30f;
|
||||
|
||||
class VoiceFilter {
|
||||
public:
|
||||
// The instrument's output bus is permanently stereo; one integrator pair per channel.
|
||||
static constexpr int kMaxChannels = 2;
|
||||
|
||||
struct State {
|
||||
float ic1 = 0.0f; // band-pass integrator
|
||||
float ic2 = 0.0f; // low-pass integrator
|
||||
};
|
||||
|
||||
// Recomputes coefficients from the control positions. State is deliberately preserved so a
|
||||
// live parameter move glides instead of clicking; call reset() at note-on.
|
||||
void prepare(const FilterSettings& settings, double sampleRate);
|
||||
|
||||
// Re-solves ONLY the cutoff-dependent coefficients, for a cutoff that moves per frame under
|
||||
// envelope modulation — cheap enough that the corner never needs quantizing (see
|
||||
// filter/CLAUDE.md for the numbers). Neither Q's parabola nor the morph's cos/sin enters
|
||||
// `g`, and the folded mix is a function of the weights and k alone, so a cutoff move
|
||||
// re-derives none of them. State preserved, exactly as prepare(). `sampleRate` must be the
|
||||
// one the last prepare() ran at: prepare() owns the non-positive-rate bypass mix, and this
|
||||
// deliberately leaves that mix alone.
|
||||
void setCutoffNorm(float cutoffNorm, double sampleRate);
|
||||
|
||||
void reset();
|
||||
|
||||
// Hot path. `channel` must be in [0, kMaxChannels).
|
||||
float process(int channel, float x) {
|
||||
assert(channel >= 0 && channel < kMaxChannels);
|
||||
State& s = state_[channel];
|
||||
|
||||
const float v3 = x - s.ic2;
|
||||
const float v1 = coeffs_.a1 * s.ic1 + coeffs_.a2 * v3;
|
||||
const float v2 = s.ic2 + coeffs_.a2 * s.ic1 + coeffs_.a3 * v3;
|
||||
|
||||
// The drive stage, and the only nonlinearity. It shapes the BAND-PASS integrator state
|
||||
// rather than the input because that state IS the resonance: in the passband and at DC
|
||||
// it sits at zero, so drive colours the resonance and leaves the passband transparent.
|
||||
// Placing it on the state rather than inside the zero-delay loop keeps a1/a2/a3 an exact
|
||||
// algebraic solve — a nonlinearity inside the loop would need per-sample Newton
|
||||
// iteration. softLimit is a contraction, so this cannot destabilize the filter.
|
||||
//
|
||||
// Gated on driven_ rather than called unconditionally: sqrt and div sit on this
|
||||
// recursive dependency chain, so out-of-order execution can't hide them, and at drive 0
|
||||
// (the default) that cost buys nothing — softLimit(x, 0) == x algebraically. Measured:
|
||||
// 11.2 ns/sample unconditional vs 4.1 ns gated, matching the limiter-removed floor.
|
||||
// driven_ only changes at prepare(), so the branch predicts perfectly. Bit-identity at
|
||||
// drive 0 holds either way, by algebra — the gate is a perf optimization, not what makes
|
||||
// it exact.
|
||||
const float u = 2.0f * v1 - s.ic1;
|
||||
s.ic1 = driven_ ? softLimit(u, driveDepth_) : u;
|
||||
s.ic2 = 2.0f * v2 - s.ic2;
|
||||
|
||||
// Snap the state once the whole resonator has decayed past -600 dB. isSilent() means
|
||||
// "both integrators are exactly zero," so both must reach zero for that check to be
|
||||
// meaningful — the conjunctive test is the cheapest guarantee of that, not a defense
|
||||
// against a demonstrated limit cycle on this topology (measured: a per-variable flush
|
||||
// and an either-below-zero-both flush both go silent here too, no limit cycle, no
|
||||
// subnormals). That risk was real on the retired Direct Form I state, where a per-sample
|
||||
// flush zeroed y1/y2 — the actual OUTPUT — injecting a step the resonance then amplified.
|
||||
// ic1/ic2 are integrator STATE, not output; zeroing one only removes energy, a
|
||||
// contraction rather than an injection. The only demonstrable hazard here is no flush at
|
||||
// all, which never reaches exact zero and stalls in subnormals for thousands of samples.
|
||||
if (s.ic1 > -kFilterDenormalFloor && s.ic1 < kFilterDenormalFloor &&
|
||||
s.ic2 > -kFilterDenormalFloor && s.ic2 < kFilterDenormalFloor) {
|
||||
s.ic1 = 0.0f;
|
||||
s.ic2 = 0.0f;
|
||||
}
|
||||
|
||||
return mix_.m0 * x + mix_.m1 * v1 + mix_.m2 * v2;
|
||||
}
|
||||
|
||||
void processFrame(float* samples, int channelCount) {
|
||||
assert(channelCount >= 0 && channelCount <= kMaxChannels);
|
||||
for (int c = 0; c < channelCount; ++c) samples[c] = process(c, samples[c]);
|
||||
}
|
||||
|
||||
// True once every integrator has flushed to exact zero — the voice's filter has stopped
|
||||
// ringing and cannot contribute further output.
|
||||
bool isSilent() const;
|
||||
|
||||
const State& state(int channel) const {
|
||||
assert(channel >= 0 && channel < kMaxChannels);
|
||||
return state_[channel];
|
||||
}
|
||||
const SvfCoeffs& coeffs() const { return coeffs_; }
|
||||
const MorphMix& mix() const { return mix_; }
|
||||
|
||||
private:
|
||||
SvfCoeffs coeffs_{};
|
||||
MorphMix mix_{};
|
||||
float q_ = 1.0f; // cached at prepare() so setCutoffNorm need not re-solve Q's parabola
|
||||
float driveDepth_ = 0.0f;
|
||||
bool driven_ = false; // driveDepth_ != 0, cached so process() branches on a bool, not a float compare
|
||||
State state_[kMaxChannels]{};
|
||||
};
|
||||
|
||||
// The port's whole point, enforced by the compiler rather than by review: the source was a
|
||||
// virtual hierarchy dispatching per channel per sample, and this type must never grow one
|
||||
// back. Trivially copyable also means nothing here is heap-owned.
|
||||
static_assert(!std::is_polymorphic_v<VoiceFilter>, "no vtable on the per-sample path");
|
||||
static_assert(std::is_trivially_copyable_v<VoiceFilter>, "state is plain values, never owned");
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -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
|
||||
@@ -0,0 +1,288 @@
|
||||
#pragma once
|
||||
// play_params.h — the instrument's one set of playback-parameter value structs plus the
|
||||
// per-instance mode enums, shared by the engine, sample_map, the ComponentState codec, and
|
||||
// the editor. Split out of the engine headers so a UI/codec TU reading a param struct
|
||||
// doesn't recompile when a Voice/VoiceEngine member changes. The per-frame evaluators live
|
||||
// in envelopes.h; the engine in voice.h / voice_engine.h.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#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;
|
||||
|
||||
// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
|
||||
// stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
|
||||
enum class ChannelMode { Mono, Stereo };
|
||||
|
||||
// POLY is the fixed-pool engine with bounded stealing; MONO is a single voice with last-note
|
||||
// priority over a held-note stack (a new note takes over; releasing the top note falls back to
|
||||
// the most-recent still-held one). Never a bank fact. Default Poly.
|
||||
enum class VoiceMode { Poly, Mono };
|
||||
|
||||
// How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
|
||||
// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
|
||||
// re-attack). With one loaded capture every takeover is same-sample, so Legato always glides.
|
||||
enum class MonoTrigger { Retrigger, Legato };
|
||||
|
||||
// Shared range so the engine, the component-state codec, and the editor control can't drift.
|
||||
inline constexpr int kMinVoiceCount = 1;
|
||||
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 the AHD. Both honor
|
||||
// the start point. Default Gate so an instrument with no params set plays as before.
|
||||
enum class PlayMode { Gate, Trigger };
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
// 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).
|
||||
enum class PitchEngine { Varispeed, Preserve };
|
||||
|
||||
// Product default is Preserve, but applied at the state boundary (the codec's read path /
|
||||
// the editor's default params), NOT here: PlayParams.pitchEngine itself defaults to Varispeed
|
||||
// so "no params == the bare engine" holds for the core's own regression tests (an octave up
|
||||
// still halves duration with no params set).
|
||||
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
|
||||
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
|
||||
// on big transpositions. Onset latency is zero — start() primes the ring with the first window
|
||||
// of real source, so output frame 0 is source frame 0 regardless of window size.
|
||||
inline constexpr double kPreserveWindowMs = 50.0;
|
||||
|
||||
// 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;
|
||||
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
|
||||
// entirely -> bit-identical to the un-filtered engine). Holds the filter module's OWN
|
||||
// normalized control positions verbatim rather than a parallel set, so no control range is
|
||||
// re-derived here; `filter_params.h` owns every law that maps them to Hz/Q/depth.
|
||||
//
|
||||
// The three modulation depths below land in that same normalized cutoff domain and sum
|
||||
// before a single clamp; all three are zero/neutral by default.
|
||||
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], scales velocityCurve's output
|
||||
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
|
||||
// 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, 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; // 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 {
|
||||
bool hasLoop = false;
|
||||
std::int64_t start = 0;
|
||||
std::int64_t end = 0;
|
||||
};
|
||||
|
||||
// The one loaded capture the core plays: decoded PCM plus every parameter governing playback.
|
||||
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
||||
//
|
||||
// Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
|
||||
// (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
|
||||
// `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
|
||||
// channels share the read head / rootNote / loop, so repitch and loop stay per-frame identical
|
||||
// across channels. `rootNote` is the MIDI note the file was recorded at — unity ratio there.
|
||||
struct SampleData {
|
||||
std::vector<AudioSample> frames;
|
||||
std::vector<AudioSample> framesR; // empty for a mono sample
|
||||
int sampleRate = 0; // ratio math is note-relative, so rate cancels for
|
||||
// repitch; still, 0 is invalid — every consumer must
|
||||
// receive a real rate before use.
|
||||
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;
|
||||
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
|
||||
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
|
||||
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
|
||||
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 {
|
||||
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
|
||||
}
|
||||
|
||||
// Nothing decoded -> nothing to play; the engine refuses a note-on rather than starting a
|
||||
// voice on an empty read span.
|
||||
bool playable() const { return !frames.empty(); }
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,956 +0,0 @@
|
||||
// sampler_core — pure sampler engine implementation. See sampler_core.h for the contract.
|
||||
//
|
||||
// Documented hot-path exception to the ~600-line file ceiling: this TU deliberately stays
|
||||
// whole. AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, called per-sample from VoiceEngine::render
|
||||
// — same-TU definition is what lets the compiler inline that stack (no LTO configured). A
|
||||
// by-class TU split would put the hottest inner loop across TU boundaries. Do not split
|
||||
// this file further; the header is split instead (zone_params.h carries the value structs).
|
||||
|
||||
#include "core/instrument/engine/sampler_core.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pitchRatio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double pitchRatio(int note, int rootNote) {
|
||||
// Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0.
|
||||
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||
}
|
||||
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
// keyTrack == 1.0 yields (note-root)*1.0, exact in IEEE-754 for an integer-valued double,
|
||||
// so the argument to std::pow is bit-identical to pitchRatio(note, rootNote).
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keymap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ZoneResolution Keymap::resolve(int note, int velocity) const {
|
||||
(void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1.
|
||||
for (std::size_t i = 0; i < zones.size(); ++i) {
|
||||
const KeyZone& z = zones[i];
|
||||
if (note >= z.lowNote && note <= z.highNote) {
|
||||
return ZoneResolution{true, i};
|
||||
}
|
||||
}
|
||||
return ZoneResolution{false, 0};
|
||||
}
|
||||
|
||||
Keymap Keymap::singleSampleChromatic(SampleData sample) {
|
||||
const int root = sample.rootNote;
|
||||
Keymap km;
|
||||
km.samples.push_back(std::move(sample));
|
||||
KeyZone zone;
|
||||
zone.lowNote = 0;
|
||||
zone.highNote = 127;
|
||||
zone.rootNote = root;
|
||||
zone.sampleIndex = 0;
|
||||
km.zones.push_back(zone);
|
||||
return km;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdsrEnvelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AdsrEnvelope::noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
void AdsrEnvelope::noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished ||
|
||||
stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
// Release from the CURRENT level — release-before-sustain releases from the
|
||||
// partial attack/decay level, not from sustainLevel.
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
double AdsrEnvelope::tick() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
level_ = 0.0;
|
||||
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;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at 1.0
|
||||
// beyond what Attack already emitted) so a zero-length hold emits no extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general recursion).
|
||||
return tick();
|
||||
}
|
||||
level_ = 1.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 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;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = params_.sustainLevel;
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
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;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TriggerEnvelope — a time-boxed fade-in/hold/fade-out amplitude function.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve) {
|
||||
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;
|
||||
}
|
||||
|
||||
double TriggerEnvelope::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;
|
||||
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): 0->1 constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): 1->0 constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PitchEnvelope — AD pitch offset in semitones, off when disabled.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double PitchEnvelope::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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
|
||||
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
|
||||
// prime scratch is sized here for the same reason: start() assembles the first window
|
||||
// of the upcoming source into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
bool Voice::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());
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack, const VelocityCurve& velocityCurve,
|
||||
bool declickTakeover) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
// the difference between this reference and the new voice's raw output that frame
|
||||
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
|
||||
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
|
||||
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
|
||||
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
|
||||
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
|
||||
// rendered between) must record the same pre-cut reference, not a phantom 0.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickPending_ = true;
|
||||
} else {
|
||||
declickPending_ = false;
|
||||
}
|
||||
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||
declickActive_ = false;
|
||||
declickWeight_ = 0.0;
|
||||
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = 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, rootNote, keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
const ZonePlayParams& p = sample.play;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
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)
|
||||
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from the zone's play.adsr,
|
||||
// resolved to frames from stored seconds at reload time); Trigger = the time-boxed
|
||||
// fade-in/out over the % play length.
|
||||
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)
|
||||
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
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
|
||||
// has a full window of real history to land in — a silence-warmed ring instead makes
|
||||
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
|
||||
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
|
||||
// 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 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
|
||||
// writer there — but a full window bounded only by frameCount would let a Trigger
|
||||
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
|
||||
// transposed, before the voice freed), and a shorter-than-window sample would get
|
||||
// zero padding declared as valid history (splices landing in silence). So bound the
|
||||
// prime by the same playable span and, when that span is shorter than a window,
|
||||
// freeze the tail immediately after the prime — that machinery then recycles the
|
||||
// real short tail. The sustain-loop path is unbounded by construction (the wrap
|
||||
// keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const std::int64_t primeCount =
|
||||
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||
// Both channels walk identical SOURCE positions (the walk depends only on loop geometry,
|
||||
// not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1.
|
||||
std::int64_t p = start;
|
||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
while (q >= loop.end) q -= loopLen;
|
||||
}
|
||||
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
}
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::retune(int note, int rootNote, double keyTrack) {
|
||||
// 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
|
||||
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a legato
|
||||
// phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_) return;
|
||||
note_ = note;
|
||||
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
}
|
||||
|
||||
void Voice::hardStop() {
|
||||
// Immediate silence regardless of play mode: stops Trigger one-shots that ignore
|
||||
// release(), and short-circuits Gate release tails. RT-safe: no allocation.
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
double Voice::tickAmplitude() {
|
||||
double amp;
|
||||
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;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
void Voice::seedDeclick(double newOutL, double newOutR) {
|
||||
// First frame after a takeover restart: arm the bounded blend. The weight starts at 1.0
|
||||
// so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever
|
||||
// the new envelope's first value. Each subsequent frame adds `w*(ref − outCurrent)` then
|
||||
// decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot
|
||||
// is impossible even if outCurrent rises while the weight is still significant. (An
|
||||
// earlier revision stored the frozen difference (ref − x₀), which could exceed full scale
|
||||
// if outₙ rose while that residue was still large.)
|
||||
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
|
||||
declickPending_ = false;
|
||||
declickWeight_ = 1.0; // one weight for both channels
|
||||
// ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
|
||||
// if ref ≈ 0 there is nothing to blend.
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
||||
// Shared read/advance for the mono and stereo paths: the read-head geometry is computed
|
||||
// once and applied identically to every channel — only the PCM value read differs. The
|
||||
// amplitude + pitch envelopes tick once per frame and scale all channels equally.
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||
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);
|
||||
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger frees once the read head reaches playEnd; the envelope also finishes at the
|
||||
// same count, either latches idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done, except an in-flight
|
||||
// takeover declick rings out here instead of hard-cutting — dropping it would
|
||||
// re-introduce a step on exactly the path the ramp exists for (a restart whose new 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)) {
|
||||
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
|
||||
if (declickActive_) {
|
||||
// Bounded blend at silence: outCurrent == 0, so the blend is w*(ref − 0) == w*ref.
|
||||
// The weight decays by kDeclickDecay each frame, floor-checked on the weight itself.
|
||||
const double l = declickWeight_ * declickRefL_;
|
||||
const double r = declickWeight_ * declickRefR_; // same weight for both channels
|
||||
declickWeight_ *= kDeclickDecay;
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
active_ = false;
|
||||
}
|
||||
lastOutL_ = l;
|
||||
lastOutR_ = stereo ? r : l;
|
||||
if (stereo) outR = static_cast<AudioSample>(r);
|
||||
return static_cast<AudioSample>(l);
|
||||
}
|
||||
active_ = false;
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
|
||||
const double amp = tickAmplitude();
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = 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.
|
||||
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// Feed the shifters the source stream at unity rate (duration held) and transpose the
|
||||
// output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the shift
|
||||
// amount, not the read rate. The feed runs one window ahead of readPos_ (the 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;
|
||||
}
|
||||
// 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
|
||||
// the source is exhausted — feeding the held last sample instead would give the
|
||||
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
|
||||
// cadence, growing toward the note end). Freezing the shifter's writer means no
|
||||
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
|
||||
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
|
||||
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input below is ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||
outL = shiftedL * gain;
|
||||
if (stereo) {
|
||||
if (haveR && shiftR_.configured()) {
|
||||
// Genuine stereo (Q-W0 T1-01, linked lag): channel 1's shifter FOLLOWS channel
|
||||
// 0's splice decisions via processLinked — one correlation search, one lag, one
|
||||
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||
// every splice: stereo image wander at the splice cadence + mono-sum combing.
|
||||
// Each shifter is still processed EXACTLY ONCE per output frame (never twice —
|
||||
// that would advance its heads twice and corrupt the state). Gated on haveR so
|
||||
// a MONO sample never touches shiftR_ — start() only primes it for genuinely
|
||||
// stereo samples, and a stale un-primed ring must not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
|
||||
gain;
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
|
||||
// value from the mono feed; mirror it to R. Do NOT call shiftL_.process again
|
||||
// this frame.
|
||||
outRlocal = shiftedL * gain;
|
||||
}
|
||||
}
|
||||
++feedPos_;
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch
|
||||
// envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the
|
||||
// envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||
//
|
||||
// Linear interpolation between the two bracketing SOURCE frames at the read head. For
|
||||
// the loop case, the second point wraps to loopStart so the seam is continuous.
|
||||
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) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||
outL = srcL * gain;
|
||||
if (stereo) {
|
||||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||
outRlocal = srcR * gain;
|
||||
}
|
||||
ratio_ = baseRatio_ * envFactor;
|
||||
}
|
||||
|
||||
// Takeover declick (Phase S GA fix, rev 2, bounded-blend revision): on the FIRST frame
|
||||
// after a takeover/steal restart, seed the blend weight at 1.0 so this frame's output is
|
||||
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||
// Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
|
||||
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
|
||||
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
|
||||
// [Rev 1 added the frozen difference (ref − x₀) ungated; if outₙ rose while the residue
|
||||
// was still large the sum could exceed ±1 by up to ~+3.8 dB on an extreme retrig.]
|
||||
// Inactive (the common case) costs one branch; the blend itself costs one extra subtract.
|
||||
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
|
||||
if (declickActive_) {
|
||||
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||
outL += addL;
|
||||
if (stereo) outRlocal += addR;
|
||||
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||
|
||||
// Track the value this voice actually contributed THIS frame (post-gain, incl. any running
|
||||
// declick) — a future takeover restart seeds its declick from exactly this. In a mono
|
||||
// render the R track mirrors L (dual-mono semantics, matching the stereo mirror of a mono
|
||||
// sample), so a later stereo takeover still has a sane R seed.
|
||||
lastOutL_ = outL;
|
||||
lastOutR_ = stereo ? outRlocal : outL;
|
||||
|
||||
readPos_ += ratio_;
|
||||
|
||||
// A finished amplitude envelope frees the voice — unless a takeover declick still rings:
|
||||
// the envelope contributes 0 from here on, so the remaining frames are the bare ramp
|
||||
// fading out (bounded: the ramp floors within ~4 ms). Baseline (no declick) unchanged.
|
||||
if (amplitudeDone_ && !declickActive_) {
|
||||
active_ = false;
|
||||
}
|
||||
return static_cast<AudioSample>(outL);
|
||||
}
|
||||
|
||||
AudioSample Voice::renderFrame() {
|
||||
AudioSample discard = 0.0f;
|
||||
return advanceFrame(/*stereo=*/false, discard);
|
||||
}
|
||||
|
||||
void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
r = 0.0f;
|
||||
l = advanceFrame(/*stereo=*/true, r);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames,
|
||||
VoiceMode voiceMode, MonoTrigger monoTrigger,
|
||||
bool takeoverDeclick)
|
||||
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||
keymap_(keymap),
|
||||
preserveVoiceCap_(preserveVoiceCap),
|
||||
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
|
||||
takeoverDeclick_(takeoverDeclick) {
|
||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||
// allocation point for the shifter rings across the engine's lifetime.
|
||||
// MONO: voices_.size() == 1, so the loop below sizes exactly one voice regardless of
|
||||
// maxVoices — the Poly path sizes the whole pool as before.
|
||||
if (preserveWindowFrames > 1) {
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
|
||||
// be dropped (kNoVoice return at :797-800) during the narrow ~4 ms window the ramp lives.
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::allocateVoice() {
|
||||
// 1. A free (idle) voice, lowest index for determinism.
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (!voices_[i].active()) return i;
|
||||
}
|
||||
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||
std::size_t bestReleasing = kNoVoice;
|
||||
std::uint64_t bestReleasingOrder = 0;
|
||||
std::size_t bestOverall = kNoVoice;
|
||||
std::uint64_t bestOverallOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (voices_[i].releasing()) {
|
||||
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||
bestReleasing = i;
|
||||
bestReleasingOrder = order;
|
||||
}
|
||||
}
|
||||
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||
bestOverall = i;
|
||||
bestOverallOrder = order;
|
||||
}
|
||||
}
|
||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||
}
|
||||
|
||||
void VoiceEngine::removeHeld(int note) {
|
||||
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||
// Shift the notes above it down one slot (press order preserved).
|
||||
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||
--heldCount_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||
if (note < 0 || note > 127) return kNoVoice;
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play, never joins the stack.
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) return kNoVoice;
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||
removeHeld(note);
|
||||
if (heldCount_ < heldStack_.size()) {
|
||||
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||
static_cast<std::uint8_t>(vclamped)};
|
||||
}
|
||||
|
||||
Voice& v = voices_[0];
|
||||
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||
// means another note was already physically held — the exact "takeover within a phrase"
|
||||
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER zones:
|
||||
// Voice::release() is a no-op in Trigger, so releasing_ never latches, and a one-shot
|
||||
// still ringing after the last key-up was silently RETUNED in place instead of
|
||||
// re-attacked. NOTE: a one-held-note same-note re-press (heldCount_ becomes 1 after the
|
||||
// removeHeld/re-push above — so heldCount_ < 2) re-attacks rather than retuning, which is
|
||||
// the correct fresh-phrase behavior for that edge case.) Same-sample requirement unchanged.
|
||||
//
|
||||
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
|
||||
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
|
||||
// ramp rather than restarting the new note, producing a silent note on the common
|
||||
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
|
||||
// the new note-on restarts the voice normally (monoNoteOn falls through to start() below).
|
||||
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato &&
|
||||
v.playingSample() == &sample) {
|
||||
v.retune(note, zone.rootNote, zone.keyTrack);
|
||||
return 0;
|
||||
}
|
||||
// RETRIGGER takeover / first note of a phrase / cross-sample legato: (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, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void VoiceEngine::monoNoteOff(int note) {
|
||||
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||
if (note < 0 || note > 127) return;
|
||||
removeHeld(note);
|
||||
Voice& v = voices_[0];
|
||||
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||
// note) changes nothing audible.
|
||||
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||
|
||||
if (heldCount_ == 0) {
|
||||
v.release(); // last finger up: gate off (Trigger zones ignore this and play through).
|
||||
return;
|
||||
}
|
||||
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||
const ZoneResolution res = keymap_.resolve(fb.note, fb.velocity);
|
||||
if (!res.matched || keymap_.zones[res.zoneIndex].sampleIndex >= keymap_.samples.size()) {
|
||||
v.release(); // defensive: only resolving notes are pushed, so this shouldn't happen.
|
||||
return;
|
||||
}
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
if (monoTrigger_ == MonoTrigger::Legato && v.playingSample() == &sample) {
|
||||
v.retune(fb.note, zone.rootNote, zone.keyTrack); // glide back, no re-attack
|
||||
return;
|
||||
}
|
||||
// Retrigger (or cross-sample) 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, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play.
|
||||
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) {
|
||||
return kNoVoice; // zone points at a missing sample — refuse rather than UB.
|
||||
}
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice
|
||||
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||
// rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated).
|
||||
// Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only
|
||||
// NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch").
|
||||
if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve &&
|
||||
activePreserveVoices() >= preserveVoiceCap_) {
|
||||
return kNoVoice;
|
||||
}
|
||||
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
// The takeover declick rides the STEAL restart too (GA fix): start() self-gates on the
|
||||
// voice being 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, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
void VoiceEngine::noteOff(int note) {
|
||||
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||
std::size_t target = kNoVoice;
|
||||
std::uint64_t bestOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||
voices_[i].note() == note) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (target == kNoVoice || order > bestOrder) {
|
||||
target = i;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::allNotesOff() {
|
||||
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
if (v.active()) v.release();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::allSoundsOff() {
|
||||
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||
// bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
v.hardStop();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap (S4 real-time discipline).
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||
// 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;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
voice.renderFrameStereo(l, r);
|
||||
left[f] += l;
|
||||
right[f] += r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active()) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,485 +0,0 @@
|
||||
#pragma once
|
||||
// sampler_core — the polyphonic voice engine: bounded-stealing allocation, an ADSR
|
||||
// amplitude envelope, a key/velocity keymap resolving (note, velocity) -> zone, and
|
||||
// repitch/interpolation from a root note with loop-point-aware sustain.
|
||||
//
|
||||
// Shares the `AudioSample` float alias from peaks. Seam fields (root note, loop points)
|
||||
// enter as plain int/frame-index inputs; the core does no file I/O.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/zone_params.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::PitchShifter;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// Keymap — the performance map. A note+velocity resolves to at most one zone; a zone
|
||||
// names which SampleData to play and the root note to repitch from. Tier-0 degenerate
|
||||
// case: a single zone spanning [0,127] with the sample's own root. Tier-1: several
|
||||
// zones, each a key range with its own root.
|
||||
//
|
||||
// Tier-2 extension (velocity layers/round-robin) — designed for, not built: a zone
|
||||
// today owns one sampleIndex; Tier 2 would make it own a list of (velocity-range,
|
||||
// sampleIndex) layers, and resolve() would gain the velocity dimension it already
|
||||
// receives but currently ignores for selection — no signature change needed.
|
||||
|
||||
// A key range [lowNote, highNote] (inclusive) mapping to one sample, with the root
|
||||
// note to repitch from (defaults to the sample's own root, overridable per zone).
|
||||
// velocityLow/High reserved for Tier-2 layers; today a zone accepts the full 1..127
|
||||
// velocity range (0 is note-off by MIDI convention).
|
||||
struct KeyZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // repitch reference for this zone
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 =
|
||||
// no tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root)
|
||||
// semitone offset in keyTrackedRatio; rides both engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
std::size_t sampleIndex = 0; // index into Keymap::samples
|
||||
};
|
||||
|
||||
// `matched == false` means the note falls in no zone — a defined no-play result, not an
|
||||
// error and not voice 0.
|
||||
struct ZoneResolution {
|
||||
bool matched = false;
|
||||
std::size_t zoneIndex = 0; // valid only when matched
|
||||
};
|
||||
|
||||
// Decoded samples plus the zones that map keys onto them. Zones are tested first-match
|
||||
// in order, so an earlier zone wins an overlap (deterministic, documented).
|
||||
struct Keymap {
|
||||
std::vector<SampleData> samples;
|
||||
std::vector<KeyZone> zones;
|
||||
|
||||
// First zone (in order) whose [low,high] contains `note` wins. velocity is accepted
|
||||
// (Tier-2 seam) but doesn't affect zone choice at Tier 0-1.
|
||||
ZoneResolution resolve(int note, int velocity) const;
|
||||
|
||||
// The Tier-0 degenerate keymap: one sample mapped chromatically across the whole
|
||||
// keyboard from its own root note.
|
||||
static Keymap singleSampleChromatic(SampleData sample);
|
||||
};
|
||||
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal-temperament; no
|
||||
// reference-frequency needed.
|
||||
double pitchRatio(int note, int rootNote);
|
||||
|
||||
// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before
|
||||
// the ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
|
||||
// ((note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call); 0.0 means every
|
||||
// key plays the root pitch; 2.0 doubles the tracking rate. At the root note the offset is
|
||||
// 0 regardless of keyTrack. Both repitch engines derive from it via the voice's baseRatio_.
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack);
|
||||
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
// Segment math:
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// 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.
|
||||
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn();
|
||||
// Gate off: enter Release from the current level.
|
||||
void noteOff();
|
||||
|
||||
// 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.
|
||||
double tick();
|
||||
|
||||
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 releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// 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.
|
||||
double amplitudeAt(double sourceOffset);
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// 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).
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
double tick();
|
||||
|
||||
private:
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback, a
|
||||
// cross-sample legato restart, or a poly at-cap steal) hard-cuts the old tone in one
|
||||
// frame — a step discontinuity that clicks. When the caller opts in (start()'s
|
||||
// declickTakeover), start() records the last rendered output as a pre-cut reference, and
|
||||
// the first frame after the restart seeds a compensation equal to
|
||||
// (reference - that frame's raw new output), summed in ungated and decaying by
|
||||
// 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
|
||||
// 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)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A single voice: one active note playing one repitched, enveloped sample. 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Voice {
|
||||
public:
|
||||
// Plays `sample` (a stable reference the caller must keep alive — the Keymap owns it),
|
||||
// repitched from `rootNote`. AHDSR/play-mode/pitch-engine params are read from
|
||||
// sample.play (frames, resolved from stored seconds at keymap build). Preserve shifters
|
||||
// must already be pre-sized (presizePreserveShifters, off-thread) — start() only
|
||||
// reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio thread
|
||||
// inside process(); the warm silence pass settles the OLA taps before the first output
|
||||
// frame. Byte-identical to the bare engine when sample.play is default.
|
||||
// `keyTrack` scales the (note-root) semitone offset feeding the repitch ratio; 1.0 is
|
||||
// standard 12-tone-ET. `velocityCurve` maps note-on velocity to amp gain, evaluated once
|
||||
// here (off the per-frame path); defaults to flat y=1. `declickTakeover`: when true and
|
||||
// this voice is currently active (a takeover/steal restart, not a fresh start), arms the
|
||||
// difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack = 1.0,
|
||||
const VelocityCurve& velocityCurve = VelocityCurve::flat(),
|
||||
bool declickTakeover = false);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
// engines pick the new baseRatio_ up on the next frame. No-op on an idle voice. Caller
|
||||
// guarantees the voice is playing the same SampleData the resolved zone names — a
|
||||
// cross-sample takeover must restart the voice instead.
|
||||
void retune(int note, int rootNote, double keyTrack = 1.0);
|
||||
|
||||
// Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
|
||||
// ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
|
||||
// no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
|
||||
// RT-safe: no allocation, no lock.
|
||||
void hardStop();
|
||||
|
||||
// True while producing (or about to produce) sound, including any declick ring-out
|
||||
// tail past the note's playable span.
|
||||
bool active() const { return active_; }
|
||||
// True while sounding a playable note — active and the amplitude envelope hasn't
|
||||
// finished. A voice ringing out a declick tail past note end is active() but not
|
||||
// soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
|
||||
// ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
|
||||
bool soundingNote() const { return active_ && !amplitudeDone_; }
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
// Identity only, never mutated through; the engine's mono legato path compares it
|
||||
// against the new note's resolved sample to decide retune vs. restart.
|
||||
const SampleData* playingSample() const { return sample_; }
|
||||
|
||||
// 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.
|
||||
// Idempotent: a re-presize to the same window is a cheap no-op.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one output
|
||||
// frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
|
||||
// with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
|
||||
// Mono path (channel 0 only).
|
||||
AudioSample renderFrame();
|
||||
|
||||
// Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
|
||||
// envelope by exactly one frame (the envelope ticks once per frame, shared across both
|
||||
// channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
|
||||
// on the same conditions as the mono path, writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r);
|
||||
|
||||
private:
|
||||
// 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
|
||||
// whether the second channel is read (into `outR`). Returns the channel-0 value.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR);
|
||||
|
||||
// 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.
|
||||
double tickAmplitude();
|
||||
|
||||
// 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;
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
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
|
||||
// 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
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
//
|
||||
// The shifter rings are primed at start() with the first window of the actual upcoming
|
||||
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
|
||||
// silence, and splices always land in real history. feedPos_ is the integer source frame
|
||||
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
|
||||
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
|
||||
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
|
||||
// primeBuf_ is the presized scratch the prime stream is assembled into.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
std::int64_t feedPos_ = 0;
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// 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.
|
||||
void seedDeclick(double newOutL, double newOutR);
|
||||
|
||||
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
|
||||
// restart calls seedDeclick to arm the bounded blend:
|
||||
// outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
|
||||
// L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
|
||||
// Algebraically outₙ + w*(ref − outₙ), so the boundary frame (w=1) is exactly `ref` and
|
||||
// every subsequent output is bounded by max(|ref|, |outₙ|) — mid-ramp overshoot is
|
||||
// impossible regardless of outₙ rising. (An earlier revision stored the frozen difference
|
||||
// (ref − x₀); when outₙ rose while that residue was still large, the sum could exceed
|
||||
// full scale by several dB.)
|
||||
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
|
||||
// state is cleared on a fresh (non-takeover) start.
|
||||
bool declickPending_ = false;
|
||||
bool declickActive_ = false;
|
||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||
double declickRefR_ = 0.0;
|
||||
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||
double lastOutL_ = 0.0;
|
||||
double lastOutR_ = 0.0;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with bounded
|
||||
// voice stealing, note-off routing, and block rendering (sum of voices).
|
||||
//
|
||||
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
|
||||
// note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
|
||||
// hardware-sampler policy.
|
||||
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices playing from `keymap` (must outlive the
|
||||
// engine — held by reference, never copies PCM). Play params ride on each zone's
|
||||
// SampleData::play; the engine holds no instrument-wide ADSR.
|
||||
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
|
||||
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are
|
||||
// pre-sized to at construction (off the audio thread), so note-on never allocates; 0
|
||||
// leaves them pass-through. The processor derives it from the host sample rate.
|
||||
//
|
||||
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
|
||||
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a
|
||||
// same-sample takeover without a re-attack). The engine's config is immutable — a
|
||||
// mode/count change rebuilds the engine off-thread through the processor's drain-slot
|
||||
// reload, so ringing tails survive the swap.
|
||||
//
|
||||
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
|
||||
// takeover/fallback, cross-sample legato restart, poly at-cap steal) seeds the
|
||||
// per-voice declick ramp (see kDeclickDecay) so the hard cut doesn't click. start()
|
||||
// self-gates on the voice being active, so a fresh start never ramps. Default false
|
||||
// keeps the bare core byte-identical to the pre-fix engine; the processor shell opts in.
|
||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
||||
bool takeoverDeclick = false);
|
||||
|
||||
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
||||
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
||||
// voice, or steals one per the policy above. Returns the index of the voice used,
|
||||
// or kNoVoice for an out-of-zone (unplayed) note.
|
||||
std::size_t noteOn(int note, int velocity);
|
||||
|
||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
|
||||
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
|
||||
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
|
||||
// resurrected by the fallback and sustain forever with no key held. RT-safe.
|
||||
void allNotesOff();
|
||||
|
||||
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
|
||||
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
|
||||
// the softer "let gates release." RT-safe, callable from the audio thread.
|
||||
void allSoundsOff();
|
||||
|
||||
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
|
||||
// to whatever is there — never allocates (the audio-thread entry point; the VST3
|
||||
// process callback passes the host's own output buffer). Voices that finish mid-block
|
||||
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
|
||||
// sample plays dual-mono (same value both channels); a stereo sample plays its two
|
||||
// channels. Mono and stereo render are independent output shapes over the same voice
|
||||
// pool — the active channel mode picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
|
||||
// do not call on the audio thread). Delegates to the real-time overload after sizing
|
||||
// the buffer. Does not clear existing contents — appends.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
std::size_t activeVoiceCount() const;
|
||||
|
||||
std::size_t maxVoices() const { return voices_.size(); }
|
||||
|
||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||
|
||||
private:
|
||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// 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;
|
||||
|
||||
// Mono mode: last-note priority over a held-note stack. The stack holds every
|
||||
// currently-held, zone-resolving note in press order (top = most recent = the sounding
|
||||
// note). An out-of-zone note never joins (it cannot sound, so it must not later take
|
||||
// the voice back on a fallback). Re-pressing a held note moves it to the top.
|
||||
// Fixed-capacity (128 distinct MIDI notes) — no allocation on the audio thread.
|
||||
// Velocity is kept per held note so a retrigger fallback re-strikes at its original
|
||||
// velocity.
|
||||
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||
|
||||
// Push to the stack and take the voice over (legato retune on a same-sample takeover,
|
||||
// else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone or
|
||||
// out-of-range (rejected before the stack, which stores uint8). The Preserve cap is
|
||||
// not applied in mono — a single voice runs at most one shifter, inherently within any
|
||||
// cap; applying it would wrongly drop a Preserve->Preserve takeover.
|
||||
std::size_t monoNoteOn(int note, int velocity);
|
||||
// Pop from the stack; if the released note was sounding, fall back to the most-recent
|
||||
// still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
void monoNoteOff(int note);
|
||||
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||
void removeHeld(int note);
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const Keymap& keymap_;
|
||||
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
|
||||
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||
std::size_t heldCount_ = 0;
|
||||
};
|
||||
|
||||
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
|
||||
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
|
||||
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
|
||||
// There is no dedicated preview voice isolated from the MIDI pool.
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
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_);
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
// voice.cpp — the PER-NOTE half of Voice: note-on setup (including the Preserve ring
|
||||
// prime), legato retune, gate-off, and the off-thread shifter presize. The per-sample
|
||||
// render half is inline in voice.h by RT constraint — see that file's header.
|
||||
|
||||
#include "core/instrument/engine/voice.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
|
||||
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
|
||||
// prime scratch is sized here for the same reason: start() assembles the first window
|
||||
// of the upcoming source into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
// the difference between this reference and the new voice's raw output that frame
|
||||
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
|
||||
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
|
||||
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
|
||||
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
|
||||
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
|
||||
// rendered between) must record the same pre-cut reference, not a phantom 0.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickPending_ = true;
|
||||
} else {
|
||||
declickPending_ = false;
|
||||
}
|
||||
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||
declickActive_ = false;
|
||||
declickWeight_ = 0.0;
|
||||
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
|
||||
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;
|
||||
|
||||
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
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 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(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;
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(postStart) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > postStart) playLen = postStart;
|
||||
playEnd_ = start + playLen;
|
||||
trigSpan = playLen;
|
||||
ampAhd_.configure(playLen, p.trigAhd);
|
||||
}
|
||||
|
||||
// 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;
|
||||
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 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_);
|
||||
filterSolvedCutoff_ = filterBaseCutoff_;
|
||||
filterSolved_ = true;
|
||||
}
|
||||
|
||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
|
||||
// has a full window of real history to land in — a silence-warmed ring instead makes
|
||||
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
|
||||
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
|
||||
// no per-frame shifter cost.
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
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
|
||||
// writer there — but a full window bounded only by frameCount would let a Trigger
|
||||
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
|
||||
// transposed, before the voice freed), and a shorter-than-window sample would get
|
||||
// zero padding declared as valid history (splices landing in silence). So bound the
|
||||
// prime by the same playable span and, when that span is shorter than a window,
|
||||
// freeze the tail immediately after the prime — that machinery then recycles the
|
||||
// real short tail. The sustain-loop path is unbounded by construction (the wrap
|
||||
// keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const std::int64_t primeCount =
|
||||
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||
// Both channels walk identical SOURCE positions (the walk depends only on loop
|
||||
// geometry, not on channel PCM values) — compute `p` once for channel 0, reuse for 1.
|
||||
std::int64_t p = start;
|
||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
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. 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)
|
||||
? crossfadedSource(pcmCh, loop_, q, crossfadeWeight(loop_,
|
||||
static_cast<double>(q)))
|
||||
: 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
}
|
||||
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
|
||||
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a
|
||||
// legato phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_ || sample_ == nullptr) return;
|
||||
note_ = note;
|
||||
// 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);
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
filterEnv_.noteOff();
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,711 @@
|
||||
#pragma once
|
||||
// voice.h — one sounding voice: a repitched, enveloped read over the loaded capture.
|
||||
//
|
||||
// The PER-SAMPLE render half (advanceFrame and everything it calls) is defined INLINE here
|
||||
// on purpose: VoiceEngine::render's inner loop lives in another TU, and with no LTO
|
||||
// configured an out-of-line render would put a call — and the envelope ticks behind it —
|
||||
// across a TU boundary on the hottest path in the program. The per-NOTE half (start /
|
||||
// retune / release / hardStop / presize) is cold enough to live in voice.cpp.
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#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"
|
||||
|
||||
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.
|
||||
inline double pitchRatio(int note, int rootNote) {
|
||||
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||
}
|
||||
|
||||
// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before the
|
||||
// ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
|
||||
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
|
||||
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
|
||||
// At the root note the offset is 0 regardless of keyTrack.
|
||||
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
const double semis = static_cast<double>(note - rootNote) * 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.
|
||||
inline double filterNormPerOctave() {
|
||||
namespace flt = instrument::engine::filter;
|
||||
return static_cast<double>(flt::filterNormFromCutoffHz(2.0f * flt::kFilterCutoffMinHz) -
|
||||
flt::filterNormFromCutoffHz(flt::kFilterCutoffMinHz));
|
||||
}
|
||||
|
||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a
|
||||
// poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks.
|
||||
// When the caller opts in (start()'s declickTakeover), start() records the last rendered
|
||||
// output as a pre-cut reference, and the first frame after the restart seeds a compensation
|
||||
// equal to (reference - that frame's raw new output), summed in ungated and decaying by
|
||||
// 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 (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.
|
||||
class Voice {
|
||||
public:
|
||||
// Plays `sample` (a stable reference the caller must keep alive — the engine's loaded
|
||||
// instrument owns it), repitched from its root by `sample.keyTrack`. Play-mode /
|
||||
// AHDSR / pitch-engine params are read from sample.play (frames, resolved from stored
|
||||
// seconds at load). Preserve shifters must already be pre-sized
|
||||
// (presizePreserveShifters, off-thread) — start() only reset()s + warm()s them (RT-safe,
|
||||
// no allocation) since it runs on the audio thread inside process(). Byte-identical to
|
||||
// the bare engine when sample.play is default. `velocityCurve` maps note-on velocity to
|
||||
// amp gain, evaluated once here (off the per-frame path). `declickTakeover`: when true
|
||||
// and this voice is currently active (a takeover/steal restart, not a fresh start), arms
|
||||
// the difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
// engines pick the new baseRatio_ up on the next frame. No-op on an idle voice.
|
||||
void retune(int note);
|
||||
|
||||
// Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
|
||||
// ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
|
||||
// no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
|
||||
// RT-safe: no allocation, no lock.
|
||||
void hardStop() { active_ = false; }
|
||||
|
||||
// True while producing (or about to produce) sound, including any declick ring-out
|
||||
// tail past the note's playable span.
|
||||
bool active() const { return active_; }
|
||||
// True while sounding a playable note — active and the amplitude envelope hasn't
|
||||
// finished. A voice ringing out a declick tail past note end is active() but not
|
||||
// soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
|
||||
// ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
|
||||
bool soundingNote() const { return active_ && !amplitudeDone_; }
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// 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.
|
||||
// Idempotent: a re-presize to the same window is a cheap no-op.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one output
|
||||
// frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
|
||||
// with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
|
||||
// Mono path (channel 0 only).
|
||||
AudioSample renderFrame() {
|
||||
AudioSample discard = 0.0f;
|
||||
return advanceFrame(/*stereo=*/false, discard);
|
||||
}
|
||||
|
||||
// Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
|
||||
// envelope by exactly one frame (the envelope ticks once per frame, shared across both
|
||||
// channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
|
||||
// on the same conditions as the mono path, writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
r = 0.0f;
|
||||
l = advanceFrame(/*stereo=*/true, r);
|
||||
}
|
||||
|
||||
private:
|
||||
// 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. 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;
|
||||
// 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 {
|
||||
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
|
||||
// contract (voice_filter.h / filter/CLAUDE.md). Do not reintroduce a step quantizer on the
|
||||
// control value to save the solve — setCutoffNorm exists to make the solve cheap instead.
|
||||
//
|
||||
// Two exact skips, neither of which rounds the control: filterModAmount_ is fixed for the
|
||||
// note's lifetime, so a zero depth can only ever re-derive the cutoff already solved; and a
|
||||
// held envelope (sustain, or finished) reproduces the previous position bit-for-bit. Both
|
||||
// compare the value itself, so they can never suppress a move the ear would hear.
|
||||
// filterSolved_ == false (forced by start()/retune() via updateFilterCutoffBase) falls
|
||||
// through both so a moved base always re-solves.
|
||||
void tickFilterCutoff() {
|
||||
if (filterModAmount_ == 0.0 && filterSolved_) return;
|
||||
// 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);
|
||||
if (filterSolved_ && cutNorm == filterSolvedCutoff_) return;
|
||||
filterSolvedCutoff_ = cutNorm;
|
||||
filterSolved_ = true;
|
||||
filter_.setCutoffNorm(cutNorm, filterRate_);
|
||||
}
|
||||
|
||||
// The cutoff position before the envelope: the stored knob position plus this note's
|
||||
// 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_ *
|
||||
(static_cast<double>(note - sample_->rootNote) / 12.0) *
|
||||
filterNormPerOctave();
|
||||
}
|
||||
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.
|
||||
void seedDeclick() {
|
||||
// The weight starts at 1.0 so this frame's output is `out*(1-1) + ref*1 == ref` —
|
||||
// exact boundary identity whatever the new envelope's first value. Each subsequent
|
||||
// frame adds `w*(ref − outCurrent)` then decays w, so output is provably bounded by
|
||||
// max(|ref|, |outCurrent|) — mid-ramp overshoot is impossible even if outCurrent
|
||||
// rises while the weight is still significant. (An earlier revision stored the frozen
|
||||
// difference (ref − x₀), which could exceed full scale if outₙ rose while that
|
||||
// residue was still large.)
|
||||
declickPending_ = false;
|
||||
declickWeight_ = 1.0; // one weight for both channels
|
||||
// ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
|
||||
// if ref ≈ 0 there is nothing to blend.
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
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
|
||||
// whether the second channel is read (into `outR`). Returns the channel-0 value.
|
||||
//
|
||||
// INLINE BY CONSTRAINT — see the file header.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR) {
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||
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). 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.
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger frees once the read head reaches playEnd; the envelope also finishes at the
|
||||
// same count, either latches idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done, except an in-flight
|
||||
// takeover declick rings out here instead of hard-cutting — dropping it would
|
||||
// re-introduce a step on exactly the path the ramp exists for (a restart whose new
|
||||
// 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,
|
||||
// floor-checked on the weight itself.
|
||||
const double l = declickWeight_ * declickRefL_;
|
||||
const double r = declickWeight_ * declickRefR_; // same weight both channels
|
||||
declickWeight_ *= kDeclickDecay;
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
active_ = false;
|
||||
}
|
||||
lastOutL_ = l;
|
||||
lastOutR_ = stereo ? r : l;
|
||||
if (stereo) outR = static_cast<AudioSample>(r);
|
||||
return static_cast<AudioSample>(l);
|
||||
}
|
||||
active_ = false;
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// 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 = 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.
|
||||
const double envFactor =
|
||||
(pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
// Both pitch branches leave the UNENVELOPED post-pitch signal here; the filter acts on
|
||||
// it and the amp gain is applied afterwards, so the pipeline is pitch -> filter -> amp
|
||||
// and the amp envelope shapes the filtered result (drive included).
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// Feed the shifters the source stream at unity rate (duration held) and transpose
|
||||
// the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the
|
||||
// shift amount, not the read rate. The feed runs one window ahead of readPos_ (the
|
||||
// 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 (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
|
||||
// the source is exhausted — feeding the held last sample instead would give the
|
||||
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
|
||||
// cadence, growing toward the note end). Freezing the shifter's writer means no
|
||||
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
|
||||
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
|
||||
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
// 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));
|
||||
outL = shiftedL;
|
||||
if (stereo) {
|
||||
if (haveR && shiftR_.configured()) {
|
||||
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
|
||||
// splice decisions via processLinked — one correlation search, one lag, one
|
||||
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||
// every splice: stereo image wander at the splice cadence + mono-sum
|
||||
// combing. Each shifter is still processed EXACTLY ONCE per output frame
|
||||
// (never twice — that would advance its heads twice and corrupt the state).
|
||||
// Gated on haveR so a MONO sample never touches shiftR_ — start() only
|
||||
// primes it for genuinely stereo samples, and a stale un-primed ring must
|
||||
// not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR =
|
||||
feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
|
||||
// shifted value from the mono feed; mirror it to R. Do NOT call
|
||||
// shiftL_.process again this frame.
|
||||
outRlocal = shiftedL;
|
||||
}
|
||||
}
|
||||
++feedPos_;
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
|
||||
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
|
||||
// the envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||
//
|
||||
// Linear interpolation between the two bracketing SOURCE frames at the read head.
|
||||
// For the loop case, the second point wraps to loopStart so the seam is continuous.
|
||||
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 (loop.active && i1 >= loop.end) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||
outL = srcL;
|
||||
if (stereo) {
|
||||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||
(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
|
||||
// filtered result is exactly what a second identical filter would produce — one
|
||||
// less kernel pass per frame for the same samples.
|
||||
if (stereo) {
|
||||
outRlocal = haveR
|
||||
? static_cast<double>(filter_.process(1, static_cast<float>(outRlocal)))
|
||||
: outL;
|
||||
}
|
||||
}
|
||||
|
||||
outL *= gain;
|
||||
if (stereo) outRlocal *= gain;
|
||||
|
||||
// Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal
|
||||
// restart, seed the blend weight at 1.0 so this frame's output is
|
||||
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||
// Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
|
||||
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
|
||||
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
|
||||
// [An earlier revision added the frozen difference (ref − x₀) ungated; if outₙ rose
|
||||
// while the residue was still large the sum could exceed ±1 by up to ~+3.8 dB on an
|
||||
// extreme retrig.] Inactive (the common case) costs one branch; the blend itself costs
|
||||
// one extra subtract.
|
||||
if (declickPending_) seedDeclick();
|
||||
if (declickActive_) {
|
||||
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||
outL += addL;
|
||||
if (stereo) outRlocal += addR;
|
||||
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||
|
||||
// Track the value this voice actually contributed THIS frame (post-gain, incl. any
|
||||
// running declick) — a future takeover restart seeds its declick from exactly this. In
|
||||
// a mono render the R track mirrors L (dual-mono semantics, matching the stereo mirror
|
||||
// of a mono sample), so a later stereo takeover still has a sane R seed.
|
||||
lastOutL_ = outL;
|
||||
lastOutR_ = stereo ? outRlocal : outL;
|
||||
|
||||
readPos_ += ratio_;
|
||||
|
||||
// A finished amplitude envelope frees the voice — unless a takeover declick still
|
||||
// rings: the envelope contributes 0 from here on, so the remaining frames are the bare
|
||||
// ramp fading out (bounded: the ramp floors within ~4 ms). Baseline unchanged.
|
||||
if (amplitudeDone_ && !declickActive_) {
|
||||
active_ = false;
|
||||
}
|
||||
return static_cast<AudioSample>(outL);
|
||||
}
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
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 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_;
|
||||
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_; // Gate
|
||||
AhdEnvelope filterAhd_; // Trigger
|
||||
bool filterOn_ = false;
|
||||
double filterRate_ = 0.0;
|
||||
double filterCutoffNorm_ = 1.0;
|
||||
double filterModAmount_ = 0.0;
|
||||
// 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.
|
||||
//
|
||||
// The shifter rings are primed at start() with the first window of the actual upcoming
|
||||
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
|
||||
// silence, and splices always land in real history. feedPos_ is the integer source frame
|
||||
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
|
||||
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
|
||||
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
|
||||
// primeBuf_ is the presized scratch the prime stream is assembled into.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
std::int64_t feedPos_ = 0;
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
|
||||
// restart calls seedDeclick to arm the bounded blend:
|
||||
// outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
|
||||
// L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
|
||||
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
|
||||
// state is cleared on a fresh (non-takeover) start.
|
||||
bool declickPending_ = false;
|
||||
bool declickActive_ = false;
|
||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||
double declickRefR_ = 0.0;
|
||||
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||
double lastOutL_ = 0.0;
|
||||
double lastOutR_ = 0.0;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,297 @@
|
||||
// voice_engine.cpp — note routing, allocation/stealing, the mono held stack, panic, and the
|
||||
// block render loops. See voice_engine.h for the contract.
|
||||
//
|
||||
// The render loops below call Voice::renderFrame / renderFrameStereo, which are inline in
|
||||
// voice.h precisely so this TU boundary costs nothing on the per-sample path.
|
||||
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames,
|
||||
VoiceMode voiceMode, MonoTrigger monoTrigger,
|
||||
bool takeoverDeclick)
|
||||
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||
sample_(sample),
|
||||
preserveVoiceCap_(preserveVoiceCap),
|
||||
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
|
||||
takeoverDeclick_(takeoverDeclick) {
|
||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||
// allocation point for the shifter rings across the engine's lifetime.
|
||||
if (preserveWindowFrames > 1) {
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
|
||||
// be dropped during the narrow ~4 ms window the ramp lives.
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::allocateVoice() {
|
||||
// 1. A free (idle) voice, lowest index for determinism.
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (!voices_[i].active()) return i;
|
||||
}
|
||||
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||
std::size_t bestReleasing = kNoVoice;
|
||||
std::uint64_t bestReleasingOrder = 0;
|
||||
std::size_t bestOverall = kNoVoice;
|
||||
std::uint64_t bestOverallOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (voices_[i].releasing()) {
|
||||
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||
bestReleasing = i;
|
||||
bestReleasingOrder = order;
|
||||
}
|
||||
}
|
||||
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||
bestOverall = i;
|
||||
bestOverallOrder = order;
|
||||
}
|
||||
}
|
||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||
}
|
||||
|
||||
void VoiceEngine::removeHeld(int note) {
|
||||
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||
// Shift the notes above it down one slot (press order preserved).
|
||||
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||
--heldCount_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||
if (note < 0 || note > 127) return kNoVoice;
|
||||
// Nothing decoded: a defined no-play, and the note must not join the stack (it cannot
|
||||
// sound, so it must not later take the voice back on a fallback).
|
||||
if (!sample_.playable()) return kNoVoice;
|
||||
|
||||
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||
removeHeld(note);
|
||||
if (heldCount_ < heldStack_.size()) {
|
||||
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||
static_cast<std::uint8_t>(vclamped)};
|
||||
}
|
||||
|
||||
Voice& v = voices_[0];
|
||||
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||
// means another note was already physically held — the exact "takeover within a phrase"
|
||||
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER: release() is
|
||||
// a no-op there, so releasing_ never latches and a one-shot still ringing after the last
|
||||
// key-up was silently RETUNED in place instead of re-attacked. NOTE: a one-held-note
|
||||
// same-note re-press (heldCount_ becomes 1 after the removeHeld/re-push above — so
|
||||
// heldCount_ < 2) re-attacks rather than retuning, the correct fresh-phrase behavior.)
|
||||
//
|
||||
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
|
||||
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
|
||||
// ramp rather than restarting the new note, producing a silent note on the common
|
||||
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
|
||||
// the new note-on restarts the voice normally (falls through to start() below).
|
||||
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato) {
|
||||
v.retune(note);
|
||||
return 0;
|
||||
}
|
||||
// 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.
|
||||
startVoice(v, note, velocity);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void VoiceEngine::monoNoteOff(int note) {
|
||||
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||
if (note < 0 || note > 127) return;
|
||||
removeHeld(note);
|
||||
Voice& v = voices_[0];
|
||||
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||
// note) changes nothing audible.
|
||||
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||
|
||||
if (heldCount_ == 0) {
|
||||
v.release(); // last finger up: gate off (Trigger ignores this and plays through).
|
||||
return;
|
||||
}
|
||||
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||
if (monoTrigger_ == MonoTrigger::Legato) {
|
||||
v.retune(fb.note); // glide back, no re-attack
|
||||
return;
|
||||
}
|
||||
// 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).
|
||||
startVoice(v, fb.note, fb.velocity);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||
if (!sample_.playable()) return kNoVoice; // nothing decoded: defined no-play.
|
||||
|
||||
// Preserve voice cap: a Preserve voice is materially heavier than Varispeed (a per-voice
|
||||
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||
// rather than glitch (a defined no-play — no shifter is allocated). Varispeed notes are
|
||||
// unaffected. A voice already sounding is never cut by this cap; only NEW Preserve onsets
|
||||
// past the cap are refused.
|
||||
if (preserveVoiceCap_ > 0 && sample_.play.pitchEngine == PitchEngine::Preserve &&
|
||||
activePreserveVoices() >= preserveVoiceCap_) {
|
||||
return kNoVoice;
|
||||
}
|
||||
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
// The takeover declick rides the STEAL restart too: start() self-gates on the voice being
|
||||
// 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();
|
||||
startVoice(voices_[v], note, velocity);
|
||||
return v;
|
||||
}
|
||||
|
||||
void VoiceEngine::noteOff(int note) {
|
||||
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||
std::size_t target = kNoVoice;
|
||||
std::uint64_t bestOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||
voices_[i].note() == note) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (target == kNoVoice || order > bestOrder) {
|
||||
target = i;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::allNotesOff() {
|
||||
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
if (v.active()) v.release();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::allSoundsOff() {
|
||||
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||
// bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
v.hardStop();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// 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) {
|
||||
if (!voice.active()) break;
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||
// 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) {
|
||||
if (!voice.active()) break;
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
voice.renderFrameStereo(l, r);
|
||||
left[f] += l;
|
||||
right[f] += r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active()) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,166 @@
|
||||
#pragma once
|
||||
// voice_engine.h — the COLD half of the sampler engine: note routing, voice allocation and
|
||||
// stealing, the mono held-note stack, the two-tier panic, and the block render loops. The
|
||||
// per-voice per-sample work it drives is inline in voice.h, so render's inner loop keeps its
|
||||
// present inline shape across this seam.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#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"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// The polyphonic voice engine: a fixed pool of voices over ONE loaded capture, note-on
|
||||
// allocation with bounded voice stealing, note-off routing, and block rendering (sum of
|
||||
// voices).
|
||||
//
|
||||
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
|
||||
// note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
|
||||
// hardware-sampler policy.
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices playing `sample` (must outlive the engine —
|
||||
// held by reference, never copies PCM). Every playback parameter rides on the sample; the
|
||||
// engine holds no parameters of its own beyond the voice-system config below.
|
||||
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
|
||||
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are pre-sized
|
||||
// to at construction (off the audio thread), so note-on never allocates; 0 leaves them
|
||||
// pass-through. The processor derives it from the host sample rate.
|
||||
//
|
||||
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
|
||||
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes without a
|
||||
// re-attack). The engine's config is immutable — a mode/count change rebuilds the engine
|
||||
// off-thread through the processor's drain-slot reload, so ringing tails survive the swap.
|
||||
//
|
||||
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
|
||||
// takeover/fallback, poly at-cap steal) seeds the per-voice declick ramp (see
|
||||
// kDeclickDecay) so the hard cut doesn't click. start() self-gates on the voice being
|
||||
// active, so a fresh start never ramps. Default false keeps the bare core byte-identical
|
||||
// to the pre-fix engine; the processor shell opts in.
|
||||
VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
||||
bool takeoverDeclick = false);
|
||||
|
||||
// MIDI note-on. Allocates a free voice, or steals one per the policy above. Returns the
|
||||
// index of the voice used, or kNoVoice when nothing is playable (no decoded PCM, an
|
||||
// out-of-range note, or a Preserve note-on past the cap) — a defined no-play, not an error.
|
||||
std::size_t noteOn(int note, int velocity);
|
||||
|
||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
|
||||
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
|
||||
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
|
||||
// resurrected by the fallback and sustain forever with no key held. RT-safe.
|
||||
void allNotesOff();
|
||||
|
||||
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
|
||||
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
|
||||
// the softer "let gates release." RT-safe, callable from the audio thread.
|
||||
void allSoundsOff();
|
||||
|
||||
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
|
||||
// to whatever is there — never allocates (the audio-thread entry point; the VST3
|
||||
// process callback passes the host's own output buffer). Voices that finish mid-block
|
||||
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
|
||||
// sample plays dual-mono (same value both channels); a stereo sample plays its two
|
||||
// channels. Mono and stereo render are independent output shapes over the same voice
|
||||
// pool — the active channel mode picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
|
||||
// do not call on the audio thread). Delegates to the real-time overload after sizing
|
||||
// the buffer. Does not clear existing contents — appends.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
std::size_t activeVoiceCount() const;
|
||||
|
||||
std::size_t maxVoices() const { return voices_.size(); }
|
||||
|
||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||
|
||||
private:
|
||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||
// 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;
|
||||
|
||||
// Mono mode: last-note priority over a held-note stack. The stack holds every
|
||||
// currently-held, playable note in press order (top = most recent = the sounding note).
|
||||
// Re-pressing a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) —
|
||||
// no allocation on the audio thread. Velocity is kept per held note so a retrigger
|
||||
// fallback re-strikes at its original velocity.
|
||||
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||
|
||||
// Push to the stack and take the voice over (legato retune, else a fresh start). Returns
|
||||
// 0 (the mono voice) or kNoVoice for an unplayable/out-of-range note (rejected before the
|
||||
// stack, which stores uint8). The Preserve cap is not applied in mono — a single voice
|
||||
// runs at most one shifter, inherently within any cap; applying it would wrongly drop a
|
||||
// Preserve->Preserve takeover.
|
||||
std::size_t monoNoteOn(int note, int velocity);
|
||||
// Pop from the stack; if the released note was sounding, fall back to the most-recent
|
||||
// still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
void monoNoteOff(int note);
|
||||
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||
void removeHeld(int note);
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const SampleData& sample_;
|
||||
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
|
||||
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||
std::size_t heldCount_ = 0;
|
||||
};
|
||||
|
||||
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
|
||||
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
|
||||
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
|
||||
// There is no dedicated preview voice isolated from the MIDI pool.
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,142 +0,0 @@
|
||||
#pragma once
|
||||
// zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by
|
||||
// the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h
|
||||
// so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member
|
||||
// changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the
|
||||
// engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
|
||||
// stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
|
||||
enum class ChannelMode { Mono, Stereo };
|
||||
|
||||
// POLY is the fixed-pool engine with bounded stealing; MONO is a single voice with last-note
|
||||
// priority over a held-note stack (a new note takes over; releasing the top note falls back to
|
||||
// the most-recent still-held one). Never a bank fact. Default Poly.
|
||||
enum class VoiceMode { Poly, Mono };
|
||||
|
||||
// How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
|
||||
// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
|
||||
// re-attack) but only for a SAME-SAMPLE takeover — one read head can't glide between two PCM
|
||||
// streams, so crossing into a different sample always restarts the voice. Meaningless in Poly.
|
||||
enum class MonoTrigger { Retrigger, Legato };
|
||||
|
||||
// Shared range so the engine, the component-state codec, and the editor control can't drift.
|
||||
inline constexpr int kMinVoiceCount = 1;
|
||||
inline constexpr int kMaxVoiceCount = 32;
|
||||
inline constexpr int kDefaultVoiceCount = 16;
|
||||
|
||||
// AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
|
||||
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;
|
||||
};
|
||||
|
||||
// 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. Per-zone; default Gate so an instrument with no params set plays
|
||||
// exactly 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.
|
||||
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).
|
||||
enum class PitchEngine { Varispeed, Preserve };
|
||||
|
||||
// Product default is Preserve, but applied at the state boundary (sample_map deserialize /
|
||||
// editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself
|
||||
// defaults to Varispeed so "no params == the bare engine" holds for the core's own regression
|
||||
// tests (an octave up still halves duration with no params set).
|
||||
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
|
||||
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
|
||||
// on big transpositions. Onset latency is zero — start() primes the ring with the first window
|
||||
// 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.
|
||||
struct PitchEnvParams {
|
||||
bool enabled = false;
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||
// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
|
||||
// default is layered on at (de)serialization, see kDefaultPitchEngine.
|
||||
struct ZonePlayParams {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrParams adsr;
|
||||
TriggerParams trigger;
|
||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||
PitchEnvParams pitchEnv;
|
||||
};
|
||||
|
||||
// Sample data the core plays: plain decoded PCM + the bank intrinsics that govern playback.
|
||||
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
||||
|
||||
// [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 {
|
||||
bool hasLoop = false;
|
||||
std::int64_t start = 0;
|
||||
std::int64_t end = 0;
|
||||
};
|
||||
|
||||
// Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
|
||||
// (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
|
||||
// `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
|
||||
// channels share `readPos_`/`rootNote`/`loop`, so repitch/loop stay per-frame identical across
|
||||
// channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there.
|
||||
struct SampleData {
|
||||
std::vector<AudioSample> frames;
|
||||
std::vector<AudioSample> framesR; // empty for a mono sample
|
||||
int sampleRate = 0; // ratio math is note-relative, so rate cancels for
|
||||
// repitch; still, 0 is invalid — every consumer must
|
||||
// receive a real rate before use.
|
||||
int rootNote = 60;
|
||||
SampleLoop loop;
|
||||
|
||||
// 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;
|
||||
|
||||
ZonePlayParams play;
|
||||
|
||||
// A framesR of a different length than frames is treated as absent — a malformed pair
|
||||
// never half-plays.
|
||||
int channelCount() const {
|
||||
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,46 @@
|
||||
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)
|
||||
# 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
|
||||
SOURCES bank_sync.cpp
|
||||
LINK PUBLIC assignment_request PRIVATE wire)
|
||||
# Links only bank_sync (+ its assignment_request dep): linking more would break the
|
||||
# plain-data-boundary proof.
|
||||
reasampler_test(bank_sync LINK bank_sync)
|
||||
|
||||
# The state codec is shared with the extension's preset-blob path, so it must link WITHOUT
|
||||
# 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 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 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,237 +1,38 @@
|
||||
// component_state_io — the ComponentState envelope + zones-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7).
|
||||
// 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/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec, T4-20)
|
||||
#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); }
|
||||
|
||||
// Append the zones payload — the shared body of the performance blob and the component
|
||||
// blob, so both write zones identically. Always emits the CURRENT payload version (marker +
|
||||
// version + extended records: loop/start tail + full play-params tail in SECONDS); the
|
||||
// marker precedes the zone count so any reader can detect record shape independent of the
|
||||
// envelope version (see sample_map.h).
|
||||
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
||||
putLE(out, kZonesFormatMarker);
|
||||
putLE(out, kZonesPayloadVersion);
|
||||
putLE(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putLE(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
||||
out.push_back(z.rootOverride ? 1 : 0);
|
||||
if (z.rootOverride) {
|
||||
putLE(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||
}
|
||||
// loop override (hasLoop flag + start/end), then start point.
|
||||
out.push_back(z.loopOverride ? 1 : 0);
|
||||
if (z.loopOverride) {
|
||||
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(z.loopOverride->start));
|
||||
putLE(out, asU64(z.loopOverride->end));
|
||||
}
|
||||
out.push_back(z.startPoint ? 1 : 0);
|
||||
if (z.startPoint) putLE(out, asU64(*z.startPoint));
|
||||
|
||||
// Play params (PAYLOAD v5): always present. Wall-clock times are SECONDS (doubles);
|
||||
// trigger %-length + fades stay source frames/fraction. Order matches the header's
|
||||
// v5 record spec.
|
||||
const ZonePlaySeconds& pp = z.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));
|
||||
// PAYLOAD v6: the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(z.keyTrack));
|
||||
// PAYLOAD v7: the per-zone velocity->amp transfer curve, appended last. 4-byte LE
|
||||
// control-point count, then per point velocity + amp as doubles (endpoints included).
|
||||
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& p : pts) {
|
||||
putLE(out, doubleToBits(p.velocity));
|
||||
putLE(out, doubleToBits(p.amp));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read a zones payload from `r` into `map`. Shared by the performance parse and the
|
||||
// component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with
|
||||
// the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean
|
||||
// back-compat lift, overrides default absent). A truncated mid-zone read keeps the zones
|
||||
// that parsed cleanly and drops the rest.
|
||||
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock
|
||||
// frame counts (holdFrames, pitchEnv A/D) to seconds at the read boundary: seconds = frames
|
||||
// / projectRate. Must be > 0 (callers guard). v5+ blobs carry seconds directly; no rate needed.
|
||||
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
bool extended = false; // v2+: the loop/start tail is present
|
||||
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
|
||||
if (r.peekU32() == kZonesFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
extended = (pv >= 2); // v2+ carries the loop/start tail
|
||||
}
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in 44.1k frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// z.play defaults to the product defaults (Gate + Preserve + tier-0 AHDSR seconds).
|
||||
// A v1/v2 payload (no play tail) lifts every zone to those defaults.
|
||||
PerformanceZone z;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
z.sampleId = r.str(idLen);
|
||||
z.lowNote = r.i32();
|
||||
z.highNote = r.i32();
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) z.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();
|
||||
z.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) z.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail (Daniel's beta projects). 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 -> leave the seconds defaults on z.play.adsr.
|
||||
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} else if (secondsPlay) {
|
||||
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
|
||||
// frames; read in the emit order.
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
// PAYLOAD v6: key-tracking scalar, appended after the v5 play tail. A pre-v6 payload
|
||||
// (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
|
||||
// already-saved instance repitches BIT-IDENTICALLY.
|
||||
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
|
||||
// PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7
|
||||
// payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(),
|
||||
// Daniel-approved), the deliberate NON-back-compat behavior change for already-saved
|
||||
// zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated
|
||||
// read leaves the flat default and the mid-zone break below drops the rest.
|
||||
if (curveTail) {
|
||||
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 p = 0; p < ptCount && r.ok; ++p) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) z.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
// 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) {
|
||||
out.params = std::move(read.params);
|
||||
if (!read.adoptedSampleId.empty()) out.selectionId = std::move(read.adoptedSampleId);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putLE(out, kPerformanceStateVersion);
|
||||
putZonesPayload(out, map);
|
||||
return out;
|
||||
}
|
||||
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
|
||||
// v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate.
|
||||
PerformanceMap map;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return map; // no version tag -> empty
|
||||
|
||||
// BACK-COMPAT: a v1 blob is the original single-selection format (version 1 + id bytes,
|
||||
// no length prefix). Lift it to one full-keyboard zone playing that id.
|
||||
if (version == kSelectionStateVersion) {
|
||||
const std::string id = deserializeSelection(bytes);
|
||||
if (!id.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = id;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
||||
|
||||
readZonesPayload(r, map, projectRate);
|
||||
return map;
|
||||
}
|
||||
|
||||
// --- Combined component state --------------------------------------
|
||||
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
@@ -268,7 +69,7 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
// 1 = user deliberately toggled the mode (never fought).
|
||||
out.push_back(state.channelModeExplicit ? 1 : 0);
|
||||
// v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up
|
||||
// to here. Wire shape per kSelectionZonesRefsV10Version: entry count, then per entry id
|
||||
// to here. Wire shape per kSelectionRefsV10Version: entry count, then per entry id
|
||||
// + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written),
|
||||
// channelCount, displayName (length-prefixed; display-only).
|
||||
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
||||
@@ -286,75 +87,65 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
putLE(out, static_cast<std::uint32_t>(e.displayName.size()));
|
||||
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
||||
}
|
||||
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
|
||||
// v11 envelope addition (usage instance identity): the minted per-instance guid,
|
||||
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
|
||||
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
|
||||
putLE(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
|
||||
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
|
||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||
// Length-prefixed selection id (it precedes the params payload, so it MUST be framed —
|
||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||
putLE(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
|
||||
putZonesPayload(out, state.map);
|
||||
putParamsPayload(out, state.params);
|
||||
return out;
|
||||
}
|
||||
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
|
||||
// v5+. See readZonesPayload for the guard.
|
||||
// projectRate is only consumed for a LEGACY v3 payload; unused for v5+.
|
||||
ComponentState out;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return out; // no version tag -> empty (the silent empty state)
|
||||
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
|
||||
// * v1 (original single-selection: version 1 + id-to-end): restore {id, one
|
||||
// full-keyboard zone} so the old pick survives as BOTH the selection and a one-zone map.
|
||||
// * v2 (zones-only): restore {"", zones} — that instance had zones but no separate
|
||||
// single-capture selection.
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, params} split.
|
||||
// * v1 (original single-selection: version 1 + id-to-end): restore the id as the
|
||||
// loaded capture with default parameters.
|
||||
// * v2 (zones-only): the adopted first zone supplies BOTH the capture and the params.
|
||||
if (version == kSelectionStateVersion) {
|
||||
out.selectionId = deserializeSelection(bytes);
|
||||
if (!out.selectionId.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = out.selectionId;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
out.map.zones.push_back(std::move(z));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (version == kPerformanceStateVersion) {
|
||||
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
|
||||
return out; // channelMode stays Mono
|
||||
applyPayload(out, readParamsPayload(r, projectRate)); // body starts after the tag
|
||||
return out; // channelMode stays Mono
|
||||
}
|
||||
// BACK-COMPAT: a v3 blob ({selection, zones}, no channel mode) restores as MONO — the id
|
||||
// length + id + zones body starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionZonesV3Version) {
|
||||
// BACK-COMPAT: a v3 blob ({selection, params}, no channel mode) restores as MONO — the id
|
||||
// length + id + payload starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionV3Version) {
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // channelMode stays Mono, marker stays 0
|
||||
}
|
||||
// BACK-COMPAT: a v4 blob ({mode, selection, zones}, no consumed marker): mode byte, then
|
||||
// the id + zones body — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
|
||||
// BACK-COMPAT: a v4 blob ({mode, selection, params}, no consumed marker): mode byte, then
|
||||
// the id + payload — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
|
||||
// a first assign still applies for a pre-marker instance.
|
||||
if (version == kSelectionZonesModeV4Version) {
|
||||
if (version == kSelectionModeV4Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // marker stays 0
|
||||
}
|
||||
// BACK-COMPAT: a v5 blob ({mode, marker, selection, zones}, no preview-velocity byte):
|
||||
// mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
|
||||
// BACK-COMPAT: a v5 blob ({mode, marker, selection, params}, no preview-velocity byte).
|
||||
// previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
|
||||
// already-saved instance restores at the mid default.
|
||||
if (version == kSelectionZonesModeMarkerV5Version) {
|
||||
if (version == kSelectionModeMarkerV5Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
@@ -363,21 +154,21 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // previewVelocity stays at the mid default
|
||||
}
|
||||
if (version != kComponentStateVersion &&
|
||||
version != kSelectionZonesRefsV10Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
|
||||
version != kSelectionZonesModeMarkerVelV6Version) {
|
||||
version != kSelectionRefsV10Version &&
|
||||
version != kSelectionModeMarkerVelVoiceGainExplicitV9Version &&
|
||||
version != kSelectionModeMarkerVelVoiceGainV8Version &&
|
||||
version != kSelectionModeMarkerVelVoiceV7Version &&
|
||||
version != kSelectionModeMarkerVelV6Version) {
|
||||
return out; // unknown -> empty
|
||||
}
|
||||
|
||||
// v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
|
||||
// preview velocity, precede the v3 body. A non-{0,1} mode byte treats as mono
|
||||
// (conservative default) rather than rejected — a corrupt mode never silences the instance.
|
||||
// v6..v11 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
|
||||
// preview velocity. A non-{0,1} mode byte treats as mono (conservative default) rather
|
||||
// than rejected — a corrupt mode never silences the instance.
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
@@ -392,7 +183,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
: kPreviewVelocityDefault;
|
||||
// v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
|
||||
// {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceV7Version) {
|
||||
const std::uint8_t vc = r.u8();
|
||||
const std::uint8_t vm = r.u8();
|
||||
const std::uint8_t mt = r.u8();
|
||||
@@ -408,7 +199,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default
|
||||
// (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather
|
||||
// than silencing/blasting.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceGainV8Version) {
|
||||
const double g = bitsToDouble(r.u64());
|
||||
if (!r.ok) return out; // truncated inside the gain double — out already carries
|
||||
// mode/marker/velocity/voice fields from above; unity holds
|
||||
@@ -420,7 +211,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v9: the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
|
||||
// default (false = implicit) holds, so an already-saved instance's mode is treated as
|
||||
// the untouched default and the shell may auto-default it from the loaded capture.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceGainExplicitV9Version) {
|
||||
const std::uint8_t explicitByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
|
||||
out.channelModeExplicit = (explicitByte == 1);
|
||||
@@ -428,8 +219,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v10: the sample-refs table. A v9-or-older blob skips it — the EMPTY-table default
|
||||
// holds, and the shell lifts the refs once via the bridge-resolve path (then re-saves
|
||||
// self-contained). A truncated mid-entry read keeps the entries that parsed cleanly and
|
||||
// drops the rest (the selection/zones behind it are unreadable anyway).
|
||||
if (version >= kSelectionZonesRefsV10Version) {
|
||||
// drops the rest (the selection/params behind it are unreadable anyway).
|
||||
if (version >= kSelectionRefsV10Version) {
|
||||
const std::uint32_t refCount = r.u32();
|
||||
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
|
||||
SampleRefEntry e;
|
||||
@@ -457,7 +248,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
}
|
||||
// v11: the minted instance guid. A v10-or-older blob skips it — the EMPTY default
|
||||
// holds and the processor mints a fresh identity on first publish.
|
||||
if (version >= kSelectionZonesRefsIdentityV11Version) {
|
||||
if (version >= kSelectionRefsIdentityV11Version) {
|
||||
const std::uint32_t guidLen = r.u32();
|
||||
out.instanceGuid = r.str(guidLen);
|
||||
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
|
||||
@@ -465,7 +256,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,125 +1,203 @@
|
||||
#pragma once
|
||||
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the
|
||||
// component_state_io — the ComponentState ENVELOPE + params-payload binary codec for the
|
||||
// ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
|
||||
// instrument's processor reads/writes it at setState/getState, and the extension's
|
||||
// instrument-drop path serializes the identical bytes into a transient .vstpreset, so the
|
||||
// payload and the instrument's reader can never drift — without the extension having to
|
||||
// link the whole voice engine (sampler_core + pitch_shift) just to serialize one preset
|
||||
// blob. Its own links are velocity_curve + master_gain (wire value validation), never the
|
||||
// engine.
|
||||
// link the whole voice engine (voice/pitch_shift) just to serialize one preset blob. Its
|
||||
// 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, zones
|
||||
// payload v1..v7) must be preserved exactly.
|
||||
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
|
||||
// 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>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core)
|
||||
#include "core/instrument/map/sample_map.h" // InstrumentParams / SampleRefs / SelectedSample
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
||||
// --- The instance's parameter payload ----------------------------------------
|
||||
//
|
||||
// The performance map is the instrument's OWN state, serialized to the VST3 component-state
|
||||
// IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant
|
||||
// of truncation/wrong-version (bounded reads, never throws across the host).
|
||||
// The one parameter set is the instrument's OWN state, serialized to the VST3
|
||||
// component-state IBStream — never written to the "reasampler" bank ext-state. Versioned
|
||||
// binary, tolerant of truncation/wrong-version (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
||||
// ZONES PAYLOAD.
|
||||
// PAYLOAD VERSIONING is self-describing and envelope-independent: the payload carries its
|
||||
// OWN version, so its record can grow without bumping the envelope version. Payload
|
||||
// extensions and envelope-field additions stay on independent axes that can never collide
|
||||
// on one version number.
|
||||
//
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING is self-describing and envelope-independent: the payload
|
||||
// carries its OWN version, so the per-zone record can grow without bumping the envelope
|
||||
// version. Zone-record extensions and envelope-field additions stay on independent axes
|
||||
// that can never collide on one version number.
|
||||
// v1..v7 are the RETIRED per-zone list formats. They are still READ — a saved instance lifts
|
||||
// by adopting its FIRST zone's capture and that zone's parameters; any remaining zones drop
|
||||
// (dropping a zone touches no file and no bank entry). A single-zone instance therefore
|
||||
// lifts losslessly; a genuinely multi-zone one keeps zone one only, the deliberately relaxed
|
||||
// case. Their record shapes, in order:
|
||||
// * v1 (original, no marker): 4-byte LE zone count, then per zone: 4-byte LE id length +
|
||||
// id bytes, 4-byte LE lowNote, 4-byte LE highNote, 1 byte hasRootOverride, 4-byte LE
|
||||
// rootOverride (iff hasRootOverride). A payload starting with a small u32 (zone count)
|
||||
// is v1.
|
||||
// * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can
|
||||
// equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record
|
||||
// after rootOverride: 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE
|
||||
// loop.start + loop.end (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint
|
||||
// (int64). The marker lets the reader detect record shape independent of the envelope.
|
||||
// rootOverride (iff hasRootOverride). A payload starting with a small u32 is v1.
|
||||
// * v2: marker + version (== 2), then the v1 body PLUS, per zone after rootOverride:
|
||||
// 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE loop.start + loop.end
|
||||
// (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint (int64).
|
||||
// * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
|
||||
// a per-zone play-params tail (always present): 1 byte playMode (0 Gate/1 Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE
|
||||
// trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames
|
||||
// (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte
|
||||
// LE pitchEnv.attackFrames + decayFrames (int64, FRAMES 44.1k nom); 8-byte LE
|
||||
// peakSemitones (double). A v1/v2 payload (no v3 tail) lifts each zone to the product
|
||||
// defaults (Gate + Preserve, no fades, pitch env disabled) — deliberate for
|
||||
// already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed.
|
||||
// LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were
|
||||
// always written as nominal frames at a baked-in rate; convert to seconds by dividing by
|
||||
// the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no
|
||||
// baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
|
||||
// absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060).
|
||||
// * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the
|
||||
// full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte
|
||||
// playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE
|
||||
// trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1
|
||||
// byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds +
|
||||
// decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds +
|
||||
// sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is
|
||||
// intentionally not read. Keymap builders resolve stored seconds to frames at the LIVE
|
||||
// sample rate; no rate is baked into storage or the program.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the original single-selection format: version tag 1 + id
|
||||
// bytes) lifts to a single full-keyboard zone playing that id (no override). A
|
||||
// truncated/unknown/empty blob deserializes to an EMPTY map.
|
||||
// a play-params tail: 1 byte playMode (0 Gate/1 Trigger); 8-byte LE adsr.holdFrames
|
||||
// (int64, FRAMES at a nominal rate); 8-byte LE trigger.lengthFraction (double); 8-byte
|
||||
// LE trigger.fadeInFrames + fadeOutFrames (int64); 1 byte pitchEngine (0 Varispeed/1
|
||||
// Preserve); 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackFrames + decayFrames
|
||||
// (int64, nominal FRAMES); 8-byte LE peakSemitones (double). LEGACY-READ CONVERSION: the
|
||||
// v3 wall-clock frame counts convert to seconds by dividing by the PROJECT sample rate
|
||||
// threaded into the v3 lift path at read time (a parameter, no baked constant).
|
||||
// Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R absent in v3 ->
|
||||
// tier-0 seconds defaults (0.003/0/1.0/0.060).
|
||||
// * v5: marker + version (== 5), v2 body PLUS the full play params with WALL-CLOCK TIMES
|
||||
// AS SECONDS (rate-free doubles): 1 byte playMode; 8-byte LE adsr.holdSeconds; 8-byte LE
|
||||
// trigger.lengthFraction; 8-byte LE trigger.fadeInFrames + fadeOutFrames (int64,
|
||||
// unchanged — source-timeline facts); 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||
// 8-byte LE pitchEnv.attackSeconds + decaySeconds + peakSemitones; 8-byte LE
|
||||
// adsr.attackSeconds + decaySeconds + sustainLevel + releaseSeconds. v4 (a branch-only
|
||||
// frames tail) was never shipped and is intentionally not read.
|
||||
// * v6: v5 PLUS 8-byte LE keyTrack (double) per zone (1.0 = 100% ET).
|
||||
// * v7: v6 PLUS the velocity->amp transfer curve per zone: 4-byte LE control-point count
|
||||
// N, then per point 8-byte LE velocity + 8-byte LE amp (doubles), N >= 2. A pre-v7
|
||||
// payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
|
||||
// (soft hits play louder than under the old linear velocity/127 map).
|
||||
//
|
||||
// These two functions serialize the ZONES only; the instrument's full component state is
|
||||
// {single-capture selection id, zones} — see ComponentState / serializeComponentState below.
|
||||
// v8 is the first one-parameter-set record: marker + version (== 8), then a SINGLE record
|
||||
// with no count, no key range and no sample id (the envelope's selection id is the capture):
|
||||
// 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte hasLoopOverride + [1 byte
|
||||
// loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set); 1 byte hasStartPoint + 8-byte LE
|
||||
// startPoint (iff set); the v5 play tail verbatim (SECONDS); 8-byte LE keyTrack; then the
|
||||
// velocity curve (count + points) as in v7.
|
||||
//
|
||||
// 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 zones-payload format version and its detection marker. serializePerformance and
|
||||
// serializeComponentState both emit the CURRENT payload version (v7: marker + version +
|
||||
// records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack
|
||||
// scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope.
|
||||
// Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play
|
||||
// tail, wall-clock frame counts) for back-compat, lifting missing fields to defaults. v4 was
|
||||
// never shipped and is not read. The marker is a high sentinel no legitimate zone count
|
||||
// (bounded by 128 MIDI zones, always tiny) can ever collide with.
|
||||
// * PAYLOAD v6: identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail: 8-byte LE keyTrack (double) — the per-zone key-tracking
|
||||
// scalar (1.0 = 100% ET). A v1-v5 payload (no keyTrack) lifts every zone to keyTrack =
|
||||
// 1.0, so already-saved instances are BIT-IDENTICAL — the default reproduces the prior
|
||||
// repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended after the v6 keyTrack field: 4-byte LE control-point count N,
|
||||
// then per point 8-byte LE velocity + 8-byte LE amp (doubles). The two endpoints
|
||||
// (velocity 0 and 127) are always included, so N >= 2. A v1-v6 payload (no
|
||||
// velocity-curve field) lifts every zone to VelocityCurve::flat() (Daniel-approved).
|
||||
// This is a DELIBERATE NON-back-compat behavior change: an already-saved zone's soft
|
||||
// hits play LOUDER than under the old linear velocity/127. A truncated mid-curve record
|
||||
// leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
// 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 = 14; // v13 + the bake Hold division
|
||||
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone 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 keymap build already receives,
|
||||
// so the seconds domain is consistent across both paths. No constant is baked in.
|
||||
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
|
||||
// reads through the legacy walk; everything at or above it shares the v8 record shape and
|
||||
// grows by appending. The reader branches on this, never on kParamsPayloadVersion, so a
|
||||
// future bump does not silently push the previous format back into the zone reader.
|
||||
inline constexpr std::uint32_t kParamsSingleRecordVersion = 8;
|
||||
|
||||
// The performance map serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
// v8 + the per-voice filter tail. Named so the filter branch in readParamsPayload is
|
||||
// self-describing, mirroring the envelope's version constants.
|
||||
inline constexpr std::uint32_t kParamsFilterVersion = 9;
|
||||
|
||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
// v9 + the staged-curve tail (curve exponents, the Trigger AHDs, the pitch Hold fraction).
|
||||
inline constexpr std::uint32_t kParamsCurveVersion = 10;
|
||||
|
||||
// --- Combined component state (VST3 setState/getState, v3+) -------------
|
||||
//
|
||||
// The single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
||||
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
||||
// project restores an instance's pick AND its zones — and an instance with NO pick and NO
|
||||
// zones restores EMPTY (silence + the "pick a capture" empty state), never auto-playing
|
||||
// sample #1.
|
||||
// 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
|
||||
// seconds domain is consistent across both paths. No constant is baked in.
|
||||
|
||||
// --- Combined component state (VST3 setState/getState) -----------------------
|
||||
//
|
||||
// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
|
||||
// mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
|
||||
@@ -129,51 +207,53 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
// 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
|
||||
// toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
|
||||
// path + intrinsics + display name per referenced sample; wire shape at
|
||||
// kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes —
|
||||
// the minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
|
||||
// kSelectionRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes — the
|
||||
// minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
|
||||
// record under, see sample_usage.h); 4-byte LE selection-id length + id bytes; then the
|
||||
// CURRENT zones payload (identical to serializePerformance's body — its own self-describing
|
||||
// version). The instance guid is the only v11 addition over v10, as the refs table was the
|
||||
// only v10 addition over v9 — the envelope grows a field, the zones payload is untouched (a
|
||||
// PARALLEL track owns zone-record extension under its own versioning — the two version
|
||||
// numbers are independent axes; do NOT bump the zones-payload version for an envelope
|
||||
// field). An out-of-range voice byte or a non-finite/out-of-range master-gain double (a
|
||||
// corrupt blob) falls back to the field's default rather than silencing the instance.
|
||||
// CURRENT params payload (its own self-describing version). The envelope grows fields on an
|
||||
// axis INDEPENDENT of the payload version — do NOT bump one for the other.
|
||||
//
|
||||
// An out-of-range voice byte or a non-finite/out-of-range master-gain double (a corrupt
|
||||
// blob) falls back to the field's default rather than silencing the instance.
|
||||
//
|
||||
// BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
|
||||
// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
|
||||
// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
|
||||
// pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
|
||||
// loaded capture, and a user who HAD deliberately chosen a mode re-toggles once and the
|
||||
// choice persists explicit from then on — and an EMPTY sample-refs table, which the shell
|
||||
// lifts once via the bridge-resolve path — and an EMPTY instance guid, which the shell
|
||||
// re-mints on first publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
|
||||
// loaded capture — and an EMPTY sample-refs table, which the shell lifts once via the
|
||||
// bridge-resolve path — and an EMPTY instance guid, which the shell re-mints on first
|
||||
// publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, params} direct.
|
||||
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
|
||||
// * v9 blob -> the v10 fields minus sampleRefs (empty table — bridge-resolve lift).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode.
|
||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain.
|
||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults.
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte.
|
||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker.
|
||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode.
|
||||
// * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the silent empty state).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, params}: implicit mode.
|
||||
// * v7 blob -> unity master gain.
|
||||
// * v6 blob -> voice defaults.
|
||||
// * v5 blob -> no velocity byte.
|
||||
// * v4 blob -> no marker.
|
||||
// * v3 blob -> no channel mode.
|
||||
// * v2 blob -> zones-only, no separate selection: the adopted first zone supplies BOTH.
|
||||
// * v1 blob -> {mono, 0, mid, id, default params}: single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", default params}: EMPTY (the silent empty state).
|
||||
//
|
||||
// WHY THE MARKER PERSISTS. The last-consumed assignment generation stops a re-opened
|
||||
// instance re-applying a stale assign_request the user already got and then manually
|
||||
// changed away from: on re-open the instance re-reads the pending request, and only a
|
||||
// generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// ADOPTION RULE (retired zone payloads only): when a v1..v7 payload carries at least one
|
||||
// zone, its FIRST zone's sampleId REPLACES the envelope's selection id — that zone is what
|
||||
// the old first-match resolve actually played, so adopting it is what keeps a single-capture
|
||||
// instance sounding identical. A payload with no zones leaves the envelope's selection alone.
|
||||
//
|
||||
// WHY THE ASSIGNMENT MARKER PERSISTS. The last-consumed assignment generation stops a
|
||||
// re-opened instance re-applying a stale assign_request the user already got and then
|
||||
// manually changed away from: on re-open the instance re-reads the pending request, and only
|
||||
// a generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first
|
||||
// assign (generation >= 1) still applies. It is the instrument's own state, never written
|
||||
// to the bank — the extension owns the assign_request key; the instrument only tracks what
|
||||
// it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob
|
||||
// with no velocity byte lifts to this, audible-but-not-hot.
|
||||
// assign (generation >= 1) still applies. It is the instrument's own state, never written to
|
||||
// the bank. The preview-trigger velocity default is a mid MIDI velocity: an older blob with
|
||||
// no velocity byte lifts to this, audible-but-not-hot.
|
||||
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
|
||||
|
||||
struct ComponentState {
|
||||
std::string selectionId; // the single-capture pick; "" = no pick
|
||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||
std::string selectionId; // the loaded capture; "" = no pick
|
||||
InstrumentParams params; // the ONE parameter set governing it
|
||||
ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
|
||||
// Whether channelMode was DELIBERATELY set by the user (the editor toggle). While
|
||||
// false (implicit), the shell auto-defaults the mode from the loaded capture's channel
|
||||
@@ -181,26 +261,25 @@ struct ComponentState {
|
||||
// choice is never fought. Pre-v9 blobs lift to false (implicit).
|
||||
bool channelModeExplicit = false;
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
|
||||
// Preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling of
|
||||
// channelMode, NOT per-zone), persisted so the Sample-view preview button retains the
|
||||
// user's chosen strike velocity across saves.
|
||||
// Preview-trigger velocity (MIDI 1..127): a per-instance utility setting, persisted so
|
||||
// the Sample-view preview button retains the user's chosen strike velocity across saves.
|
||||
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||
// Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
||||
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system behavior
|
||||
// exactly, so an older blob lifting to these plays byte-identically.
|
||||
// Voice system: per-instance performance choices. Defaults {16, Poly, Retrigger}
|
||||
// reproduce pre-voice-system behavior exactly, so an older blob lifting to these plays
|
||||
// byte-identically.
|
||||
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
||||
// Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
|
||||
// ~15.849 = +24 dB — master_gain owns the dB taper). PER-INSTANCE output trim applied
|
||||
// by process() AFTER the voice sum — never per voice, never a keymap fact. Default
|
||||
// unity reproduces pre-master-gain output byte-identically.
|
||||
// ~15.849 = +24 dB — master_gain owns the dB taper). Applied by process() AFTER the
|
||||
// voice sum — never per voice. Default unity reproduces pre-master-gain output
|
||||
// byte-identically.
|
||||
double masterGainLinear = 1.0;
|
||||
// Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every
|
||||
// bank sample this instance plays (see the SampleRefs block above). setState decodes
|
||||
// straight from these; NO bridge/extension read is required for playback. A pre-v10
|
||||
// blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve path
|
||||
// once (then re-saves self-contained).
|
||||
// bank sample this instance plays (see the SampleRefs block in sample_map.h). setState
|
||||
// decodes straight from these; NO bridge/extension read is required for playback. A
|
||||
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||
// path once (then re-saves self-contained).
|
||||
SampleRefs sampleRefs;
|
||||
// The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
|
||||
// ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
|
||||
@@ -214,7 +293,7 @@ inline constexpr std::uint32_t kComponentStateVersion = 11;
|
||||
|
||||
// v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the
|
||||
// v10/v9/… series so the version branches in deserializeComponentState stay self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
inline constexpr std::uint32_t kSelectionRefsIdentityV11Version = 11;
|
||||
|
||||
// v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after
|
||||
// the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry:
|
||||
@@ -222,36 +301,36 @@ inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
// (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written
|
||||
// regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
|
||||
// length + bytes (display-only; the editor label's extension-absent fallback).
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
||||
inline constexpr std::uint32_t kSelectionRefsV10Version = 10;
|
||||
|
||||
// Everything through the master gain, no channel-mode explicit flag. Retained so
|
||||
// deserializeComponentState can lift a v8 blob to implicit mode.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainV8Version = 8;
|
||||
|
||||
// v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
|
||||
// in deserializeComponentState is self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker + preview velocity + voice system, no
|
||||
// Selection + params + channel mode + consumed marker + preview velocity + voice system, no
|
||||
// master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceV7Version = 7;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker + preview velocity, no voice-system
|
||||
// Selection + params + channel mode + consumed marker + preview velocity, no voice-system
|
||||
// fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
|
||||
// {16, Poly, Retrigger}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelV6Version = 6;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker, no preview velocity. Retained so
|
||||
// Selection + params + channel mode + consumed marker, no preview velocity. Retained so
|
||||
// deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerV5Version = 5;
|
||||
|
||||
// Selection + zones + channel mode, no consumed marker. Retained so
|
||||
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
||||
// Selection + params + channel mode, no consumed marker. Retained so
|
||||
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, params}.
|
||||
inline constexpr std::uint32_t kSelectionModeV4Version = 4;
|
||||
|
||||
// Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3
|
||||
// blob to {mono, selection, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
|
||||
// Selection + params, no channel mode. Retained so deserializeComponentState can lift a v3
|
||||
// blob to {mono, selection, params}.
|
||||
inline constexpr std::uint32_t kSelectionV3Version = 3;
|
||||
|
||||
// The full instance state serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
|
||||
@@ -266,16 +345,13 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
|
||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (a performance choice, held by
|
||||
// the instrument, never written back to the bank) — a single string id. serialize/
|
||||
// deserialize keep the on-the-wire form explicit and versioned so it can be extended
|
||||
// without breaking already-saved instances.
|
||||
// The original v1 instance state was which bank sample it plays — a single string id.
|
||||
//
|
||||
// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
|
||||
// needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
|
||||
// version/empty blob by returning "" (no selection is SILENCE + the "pick a capture" empty
|
||||
// state, not the bank's first sample), never throwing across the host boundary. Retained
|
||||
// for the v1->v3 back-compat lift in deserializeComponentState.
|
||||
// for the v1 back-compat lift in deserializeComponentState.
|
||||
|
||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||
|
||||
@@ -286,5 +362,4 @@ std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
|
||||
// too-short, or empty -> "" (graceful no-selection).
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
// note_entry.cpp — see note_entry.h.
|
||||
|
||||
#include "core/instrument/map/note_entry.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
namespace {
|
||||
char asciiUpper(char c) {
|
||||
return static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
}
|
||||
|
||||
std::string trim(const std::string& s) {
|
||||
std::size_t a = 0;
|
||||
std::size_t b = s.size();
|
||||
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
||||
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
||||
return s.substr(a, b - a);
|
||||
}
|
||||
|
||||
int clampNote(long long n) {
|
||||
if (n < 0) return 0;
|
||||
if (n > 127) return 127;
|
||||
return static_cast<int>(n);
|
||||
}
|
||||
|
||||
// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter.
|
||||
int letterSemitone(char up) {
|
||||
switch (up) {
|
||||
case 'C': return 0;
|
||||
case 'D': return 2;
|
||||
case 'E': return 4;
|
||||
case 'F': return 5;
|
||||
case 'G': return 7;
|
||||
case 'A': return 9;
|
||||
case 'B': return 11;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive, DAW convention:
|
||||
// MIDI 0 == C-1, 60 == C4). Returns nullopt if it is not a note name.
|
||||
std::optional<int> parseNoteName(const std::string& s) {
|
||||
if (s.empty()) return std::nullopt;
|
||||
std::size_t i = 0;
|
||||
const int base = letterSemitone(asciiUpper(s[i]));
|
||||
if (base < 0) return std::nullopt; // not a letter -> not a note name
|
||||
++i;
|
||||
int semitone = base;
|
||||
// Optional accidental(s): # / b only (not 's'/'f').
|
||||
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
|
||||
if (s[i] == '#') ++semitone;
|
||||
else --semitone;
|
||||
++i;
|
||||
}
|
||||
// The octave: an optional sign then digits, running to the end.
|
||||
if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous)
|
||||
bool neg = false;
|
||||
if (s[i] == '+' || s[i] == '-') {
|
||||
neg = (s[i] == '-');
|
||||
++i;
|
||||
}
|
||||
if (i >= s.size()) return std::nullopt;
|
||||
int octave = 0;
|
||||
bool anyDigit = false;
|
||||
for (; i < s.size(); ++i) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
||||
octave = octave * 10 + (s[i] - '0');
|
||||
anyDigit = true;
|
||||
}
|
||||
if (!anyDigit) return std::nullopt;
|
||||
if (neg) octave = -octave;
|
||||
// MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60).
|
||||
const long long note = static_cast<long long>(octave + 1) * 12 + semitone;
|
||||
return clampNote(note);
|
||||
}
|
||||
|
||||
std::optional<int> parseInteger(const std::string& s) {
|
||||
if (s.empty()) return std::nullopt;
|
||||
std::size_t i = 0;
|
||||
bool neg = false;
|
||||
if (s[i] == '+' || s[i] == '-') {
|
||||
neg = (s[i] == '-');
|
||||
++i;
|
||||
}
|
||||
if (i >= s.size()) return std::nullopt;
|
||||
long long v = 0;
|
||||
for (; i < s.size(); ++i) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
||||
v = v * 10 + (s[i] - '0');
|
||||
if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway
|
||||
}
|
||||
if (neg) v = -v;
|
||||
return clampNote(v);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<int> parseNoteEntry(const std::string& text) {
|
||||
const std::string s = trim(text);
|
||||
if (s.empty()) return std::nullopt;
|
||||
// Try a plain integer first (the common MIDI-number case); fall back to a note name.
|
||||
if (std::isdigit(static_cast<unsigned char>(s[0])) || s[0] == '+' ||
|
||||
(s[0] == '-' && s.size() > 1 && std::isdigit(static_cast<unsigned char>(s[1])))) {
|
||||
if (auto n = parseInteger(s)) return n;
|
||||
}
|
||||
return parseNoteName(s);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -1,18 +0,0 @@
|
||||
// note_entry — parse + clamp for direct numeric/note-name entry of a zone's low/high/root
|
||||
// MIDI note (a drag on the keyboard strip can't hit a precise note reliably).
|
||||
//
|
||||
// Accepts a plain decimal integer ("60", "+5") or a note name ("C4", "f#3", "Bb-1", DAW
|
||||
// convention: MIDI 0 == C-1, 60 == C4). Out-of-range CLAMPS to [0,127] rather than
|
||||
// rejecting; unparseable input returns nullopt (shell keeps the old value).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// Leading/trailing whitespace ignored. Empty or unparseable input returns nullopt.
|
||||
std::optional<int> parseNoteEntry(const std::string& text);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -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
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
#include "core/instrument/map/sample_map.h"
|
||||
|
||||
#include <algorithm> // std::min
|
||||
#include <algorithm> // std::remove_if
|
||||
#include <cassert> // assert
|
||||
#include <utility> // std::move
|
||||
|
||||
@@ -34,25 +34,6 @@ SelectedSample distill(const Sample& s) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The ONE override-beats-intrinsic fold shared by resolvePerformance and
|
||||
// resolvePerformanceFromRefs, so the two resolution paths cannot drift.
|
||||
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
|
||||
ResolvedZone rz;
|
||||
rz.relativePath = ref.relativePath;
|
||||
rz.lowNote = z.lowNote;
|
||||
rz.highNote = z.highNote;
|
||||
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
|
||||
// Key tracking + velocity curve are instrument state — carried straight through.
|
||||
rz.keyTrack = z.keyTrack;
|
||||
rz.velocityCurve = z.velocityCurve;
|
||||
// Per-zone override wins over the intrinsic; absent -> intrinsic (loop) / frame 0
|
||||
// (start). The bank is never mutated.
|
||||
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
|
||||
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
||||
rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames
|
||||
return rz;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
@@ -90,18 +71,9 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map) {
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId) {
|
||||
std::vector<std::string> ids;
|
||||
const auto addUnique = [&ids](const std::string& id) {
|
||||
if (id.empty()) return;
|
||||
for (const std::string& have : ids) {
|
||||
if (have == id) return;
|
||||
}
|
||||
ids.push_back(id);
|
||||
};
|
||||
addUnique(selectionId);
|
||||
for (const PerformanceZone& z : map.zones) addUnique(z.sampleId);
|
||||
if (!selectionId.empty()) ids.push_back(selectionId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -214,10 +186,10 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
|
||||
return out;
|
||||
}
|
||||
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
|
||||
DecodedZonePcm out;
|
||||
DecodedPcm out;
|
||||
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
|
||||
out.sampleRate = sampleRate;
|
||||
if (mode == ChannelMode::Mono) {
|
||||
@@ -230,9 +202,9 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
return out;
|
||||
}
|
||||
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
|
||||
// fades) carry through untouched, already frames/fractions.
|
||||
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
// 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) {
|
||||
@@ -240,146 +212,120 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
ZonePlayParams out;
|
||||
// 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);
|
||||
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
|
||||
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;
|
||||
out.filter.settings = stored.filter.settings;
|
||||
out.filter.modAmount = stored.filter.modAmount;
|
||||
out.filter.velAmount = stored.filter.velAmount;
|
||||
out.filter.keyTrack = stored.filter.keyTrack;
|
||||
out.filter.velocityCurve = stored.filter.velocityCurve;
|
||||
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
|
||||
out.filter.env.holdFrames = secToFrames(stored.filter.env.holdSeconds);
|
||||
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;
|
||||
}
|
||||
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
|
||||
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
|
||||
// --- The one parameter set ----------------------------------------------------
|
||||
|
||||
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params) {
|
||||
ResolvedCapture rs;
|
||||
rs.relativePath = ref.relativePath;
|
||||
rs.rootNote = params.rootOverride ? *params.rootOverride : ref.rootNote;
|
||||
// Key tracking + velocity curve are instrument state — carried straight through.
|
||||
rs.keyTrack = params.keyTrack;
|
||||
rs.velocityCurve = params.velocityCurve;
|
||||
// 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;
|
||||
}
|
||||
|
||||
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params) {
|
||||
const std::optional<SelectedSample> sel = selectSample(banksJson, selectionId);
|
||||
if (!sel) return std::nullopt;
|
||||
return resolveCapture(*sel, params);
|
||||
}
|
||||
|
||||
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params) {
|
||||
const SelectedSample* ref = findRef(refs, selectionId);
|
||||
if (ref == nullptr) return std::nullopt;
|
||||
return resolveCapture(*ref, params);
|
||||
}
|
||||
|
||||
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) {
|
||||
SampleData data;
|
||||
data.frames = std::move(frames);
|
||||
// A second channel only counts when it length-matches channel 0 (else the sample stays
|
||||
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
|
||||
if (!framesR.empty() && framesR.size() == data.frames.size()) {
|
||||
data.framesR = std::move(framesR);
|
||||
if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence
|
||||
assert(decoded.sampleRate > 0 &&
|
||||
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
|
||||
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
|
||||
data.frames = std::move(decoded.monoFrames);
|
||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||
if (!decoded.framesR.empty() && decoded.framesR.size() == data.frames.size()) {
|
||||
data.framesR = std::move(decoded.framesR);
|
||||
}
|
||||
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
|
||||
data.sampleRate = sampleRate;
|
||||
data.rootNote = rootNote;
|
||||
data.loop = loop;
|
||||
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
|
||||
data.play = resolvePlay(play, data.sampleRate);
|
||||
|
||||
return Keymap::singleSampleChromatic(std::move(data));
|
||||
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;
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(resolved.play, data.sampleRate);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Performance map ---------------------------------------------------------
|
||||
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0)
|
||||
if (banksJson.empty()) return out; // no bank -> nothing resolves
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out; // malformed -> nothing (never throw)
|
||||
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// A sample lives in exactly one bank, so first hit wins.
|
||||
const Sample* found = nullptr;
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(z.sampleId)) {
|
||||
found = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report
|
||||
continue;
|
||||
}
|
||||
// Distill to the same intrinsics shape the refs table carries, then run the SHARED
|
||||
// fold — so the bank path and refs path resolve identically.
|
||||
out.zones.push_back(foldZone(z, distill(*found)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
|
||||
out.zones.push_back(foldZone(z, *r));
|
||||
} else {
|
||||
// No ref for this id: drop + report, same shape as the bank path's stale-id policy.
|
||||
out.droppedSampleIds.push_back(z.sampleId);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) {
|
||||
if (selectedId.empty() || map.zones.empty()) return false;
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// An authored key range marks Zone-view intent — first-match order is load-bearing
|
||||
// there, so the map is left exactly as authored.
|
||||
if (z.lowNote != 0 || z.highNote != 127) return false;
|
||||
}
|
||||
// Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first
|
||||
// zone bound to the selection (preserving its params); drop the stale shadowers.
|
||||
// Decide BEFORE mutating so the no-change path leaves the map bit-identical.
|
||||
std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection
|
||||
for (std::size_t i = 0; i < map.zones.size(); ++i) {
|
||||
if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; }
|
||||
}
|
||||
const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u;
|
||||
if (keptCount == map.zones.size()) return false; // one zone, already the selection's
|
||||
if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]);
|
||||
map.zones.resize(keptCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded) {
|
||||
Keymap km;
|
||||
const std::size_t n = std::min(zones.size(), decoded.size());
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
// An unreadable/empty WAV drops just this zone (not the whole map).
|
||||
if (decoded[i].monoFrames.empty()) continue;
|
||||
SampleData data;
|
||||
data.frames = decoded[i].monoFrames;
|
||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||
if (!decoded[i].framesR.empty() &&
|
||||
decoded[i].framesR.size() == data.frames.size()) {
|
||||
data.framesR = decoded[i].framesR;
|
||||
}
|
||||
assert(decoded[i].sampleRate > 0 &&
|
||||
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
|
||||
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
|
||||
data.sampleRate = decoded[i].sampleRate;
|
||||
data.rootNote = zones[i].rootNote;
|
||||
data.loop = zones[i].loop;
|
||||
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(zones[i].play, data.sampleRate);
|
||||
const std::size_t sampleIndex = km.samples.size();
|
||||
km.samples.push_back(std::move(data));
|
||||
KeyZone zone;
|
||||
zone.lowNote = zones[i].lowNote;
|
||||
zone.highNote = zones[i].highNote;
|
||||
zone.rootNote = zones[i].rootNote;
|
||||
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time
|
||||
zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start
|
||||
zone.sampleIndex = sampleIndex;
|
||||
km.zones.push_back(zone);
|
||||
}
|
||||
return km; // empty zones in -> empty Keymap (silence)
|
||||
}
|
||||
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#pragma once
|
||||
// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
|
||||
// data the sampler core plays, and (de)serializes the instance's zone/selection state.
|
||||
// data the sampler core plays, and resolves the instance's one capture + one parameter set.
|
||||
// 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, mono downmix, keymap build) is pure and unit-tested
|
||||
// here. Links bank_book, wav_codec, and sampler_core (all pure).
|
||||
// bank_book JSON path, sample pick, channel policy, SampleData build) is pure and
|
||||
// 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>
|
||||
@@ -12,7 +13,9 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
||||
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
|
||||
#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 {
|
||||
@@ -29,7 +32,7 @@ struct SelectedSample {
|
||||
int rootNote = 60; // defaults to middle C when the bank left it empty
|
||||
SampleLoop loop; // hasLoop=false when the bank left it empty
|
||||
int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) —
|
||||
// the GA channel-mode auto-default skips it
|
||||
// the channel-mode auto-default skips it
|
||||
};
|
||||
|
||||
// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
|
||||
@@ -60,8 +63,6 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
|
||||
// Consequence: a sample deleted from the bank no longer silences an instance that carries
|
||||
// its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
|
||||
// the file yields the defined no-play).
|
||||
struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
|
||||
|
||||
struct SampleRefEntry {
|
||||
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
|
||||
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
|
||||
@@ -74,10 +75,9 @@ using SampleRefs = std::vector<SampleRefEntry>;
|
||||
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
|
||||
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
|
||||
|
||||
// Every bank sample id this instance plays: the selection (when set) + each zone's
|
||||
// sampleId, de-duplicated, selection first then map order.
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map);
|
||||
// Every bank sample id this instance plays. One capture = at most one id; the list form is
|
||||
// kept because the refs-table helpers below are id-set operations.
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId);
|
||||
|
||||
// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
|
||||
// name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
|
||||
@@ -123,10 +123,10 @@ struct BankChoice {
|
||||
};
|
||||
std::vector<BankChoice> listBanks(const std::string& banksJson);
|
||||
|
||||
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract
|
||||
// by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not
|
||||
// "take L", not summing: a centered mono source stays unity, a hard-panned source is
|
||||
// attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
|
||||
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to ONE channel by AVERAGING
|
||||
// channels per frame (`channelCount` is the interleave stride, >= 1) — not "take L", not
|
||||
// summing: a centered mono source stays unity, a hard-panned source is attenuated rather
|
||||
// than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
|
||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount);
|
||||
|
||||
@@ -136,85 +136,38 @@ 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) per-zone 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
|
||||
// keymap 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
|
||||
};
|
||||
// --- Seconds -> frames --------------------------------------------------------
|
||||
|
||||
// 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 per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities
|
||||
// in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing —
|
||||
// distinct from sampler_core's engine-facing ZonePlayParams (frames).
|
||||
struct ZonePlaySeconds {
|
||||
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
|
||||
};
|
||||
|
||||
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
|
||||
// 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
|
||||
// unchanged. `sampleRate` must be > 0 (the caller guards this).
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
|
||||
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate);
|
||||
|
||||
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
|
||||
// keyboard, repitched from `rootNote`, looped per `loop` (Keymap::singleSampleChromatic).
|
||||
// `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono
|
||||
// sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a
|
||||
// bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play
|
||||
// params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a
|
||||
// picked single capture plays under the same default engine as a zone would. Resolves the
|
||||
// wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR = {},
|
||||
const ZonePlaySeconds& play = ZonePlaySeconds{});
|
||||
|
||||
// --- Performance map (the instrument's OWN state) ---------------
|
||||
// --- The instrument's ONE parameter set (its OWN state) -----------------------
|
||||
//
|
||||
// The performance map is the keymap the user authors IN the instrument: several bank
|
||||
// samples zoned across the keyboard, each with a key range and a root note. A performance
|
||||
// choice, so it lives in the instrument (VST3 component state), never written back to the
|
||||
// bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the
|
||||
// shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames
|
||||
// + this map into a sampler_core Keymap.
|
||||
|
||||
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range.
|
||||
// rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C
|
||||
// when empty). loopOverride/startPoint mirror rootOverride: the sustain loop and initial
|
||||
// read position are facts about the file, but the instrument may override them per zone
|
||||
// without writing back to the bank (loopOverride wins when set; startPoint sets the voice's
|
||||
// initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into
|
||||
// the effective ResolvedZone.
|
||||
struct PerformanceZone {
|
||||
std::string sampleId; // bank sample id this zone plays
|
||||
int lowNote = 0; // inclusive
|
||||
int highNote = 127; // inclusive
|
||||
// One loaded capture, one set of playback parameters governing it across the whole
|
||||
// keyboard. A performance choice, so it lives in the instrument (VST3 component state),
|
||||
// never written back to the bank. Pure value type: names no sample (the ComponentState's
|
||||
// selection id is the capture) and holds no PCM — the shell resolves + decodes the WAV, and
|
||||
// the pure build stitches the decoded frames + this set into one SampleData.
|
||||
//
|
||||
// rootOverride absent -> repitch from the capture's own rootNote intrinsic (or middle C when
|
||||
// the bank left it empty). loopOverride/startPoint mirror it: the sustain loop and initial
|
||||
// read position are facts about the file, but the instrument may override them without
|
||||
// writing back to the bank (loopOverride wins when set; startPoint sets the voice's initial
|
||||
// read frame, absent -> 0). resolveCapture folds override-beats-intrinsic into the effective
|
||||
// ResolvedCapture.
|
||||
struct InstrumentParams {
|
||||
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
|
||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
|
||||
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
|
||||
@@ -223,116 +176,87 @@ struct PerformanceZone {
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
|
||||
// replacing the old fixed linear velocity/127. Per-zone. Default = flat y=1 (Daniel-
|
||||
// approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change —
|
||||
// a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits
|
||||
// play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd
|
||||
// in Voice::start.
|
||||
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
|
||||
// every velocity plays at unity. DELIBERATE non-back-compat behavior change — a blob
|
||||
// predating this field lifts to flat y=1, so an already-saved instance's soft hits play
|
||||
// LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd in
|
||||
// Voice::start.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
|
||||
// Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine +
|
||||
// AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in
|
||||
// SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults
|
||||
// for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no
|
||||
// fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts
|
||||
// to exactly these defaults on read.
|
||||
ZonePlaySeconds play;
|
||||
// Play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine + AD pitch
|
||||
// envelope). Instrument-owned, never a bank fact. Wall-clock times stored in SECONDS
|
||||
// (rate-free); the build resolves to frames at the live sample rate. Defaults: Gate,
|
||||
// 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 instrument's performance map: an ordered list of zones. Order is authoritative for
|
||||
// overlap resolution — first zone in order wins (mirrors the core's first-match
|
||||
// Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction.
|
||||
struct PerformanceMap {
|
||||
std::vector<PerformanceZone> zones;
|
||||
|
||||
bool empty() const { return zones.empty(); }
|
||||
};
|
||||
|
||||
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix.
|
||||
//
|
||||
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
|
||||
// control edit. Loading a different sample used to change only the selection id, leaving
|
||||
// the previous sample's full-range zone in the map — and since zone resolution is
|
||||
// first-match in order, that stale zone shadowed every later one forever: the engine kept
|
||||
// playing the old sample while the editor drew the new one's zone. This function is called
|
||||
// at every selection-change site so the zone the editor draws is the zone the engine plays.
|
||||
//
|
||||
// Rules (order-preserving where it matters):
|
||||
// * empty `selectedId` or empty map -> untouched, false.
|
||||
// * ANY zone with an authored key range (not full [0,127]) -> Zone-view authorship,
|
||||
// first-match order is load-bearing there — untouched, false (the Sample face never
|
||||
// creates a narrow zone, so a narrow zone proves deliberate multi-zone intent).
|
||||
// * else (every zone full-range) -> keep only the first zone bound to `selectedId`
|
||||
// (params preserved); drop the rest. A selection with no zone yet empties the map.
|
||||
// Returns true iff the map changed (the caller republishes + reloads on true).
|
||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
|
||||
|
||||
// One resolved zone ready for the shell to decode + the pure build to stitch: project-
|
||||
// relative WAV path (file seam), effective root note (override beats bank intrinsic beats
|
||||
// middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names
|
||||
// an id) — this is the id resolved against the live bank.
|
||||
struct ResolvedZone {
|
||||
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
|
||||
// plus the effective values after override-beats-intrinsic. Distinct from InstrumentParams
|
||||
// (which holds optional overrides) — this is the parameter set folded against the capture.
|
||||
struct ResolvedCapture {
|
||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
|
||||
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
|
||||
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
||||
PlaySeconds play; // stored SECONDS; resolved to frames at build
|
||||
};
|
||||
|
||||
// `zones` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order
|
||||
// preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped
|
||||
// cleanly — not an error, not silence for the whole map — and reported here so the editor
|
||||
// can flag/prune it.
|
||||
struct ResolvedPerformance {
|
||||
std::vector<ResolvedZone> zones;
|
||||
std::vector<std::string> droppedSampleIds;
|
||||
};
|
||||
// The ONE override-beats-intrinsic fold, shared by both resolve paths below so they cannot
|
||||
// drift.
|
||||
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params);
|
||||
|
||||
// Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId
|
||||
// is looked up across every bank; a hit yields a ResolvedZone with the effective root note
|
||||
// and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
|
||||
// -> empty result.
|
||||
// Resolve the selection against the live "banks" ext-state blob. Empty/malformed blob, an
|
||||
// empty selection, or a stale id -> nullopt.
|
||||
//
|
||||
// NOT the live load path — reloadInstrument resolves via resolvePerformanceFromRefs (the
|
||||
// instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against
|
||||
// (both share foldZone, so the drift test keeps the shared fold honest).
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map);
|
||||
// NOT the live load path — reloadInstrument resolves via resolveFromRefs (the instance-owned
|
||||
// refs). Retained as the TESTED REFERENCE the refs path is verified against (both share
|
||||
// resolveCapture, so the drift test keeps the shared fold honest).
|
||||
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params);
|
||||
|
||||
// The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table —
|
||||
// shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone
|
||||
// whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path).
|
||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
const PerformanceMap& map);
|
||||
// The bank-free mirror, against the INSTANCE-OWNED refs table — shares the same fold, so the
|
||||
// two paths cannot drift. A selection with no ref -> nullopt (the defined no-play).
|
||||
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params);
|
||||
|
||||
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches
|
||||
// `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is
|
||||
// decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved
|
||||
// so first-match overlap resolution matches authored order. A zone whose decoded frames are
|
||||
// empty is SKIPPED (an unreadable WAV drops the zone, not the map).
|
||||
struct DecodedZonePcm {
|
||||
// Freshly-decoded PCM under the instance's channel policy, ready for the SampleData build.
|
||||
struct DecodedPcm {
|
||||
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
|
||||
int sampleRate = 0; // 0 is explicitly invalid
|
||||
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
|
||||
};
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded);
|
||||
|
||||
// Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or
|
||||
// 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's float
|
||||
// frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
|
||||
// 2-channel DecodedPcm the build consumes. `interleaved` is the WAV's float frames (stride =
|
||||
// `sourceChannels`); `mode` is the instance's channel mode.
|
||||
// * MONO mode -> downmix to one channel (average all source channels).
|
||||
// * STEREO mode, mono src -> dual-mono: channel 0 duplicated into channel 1 (centered).
|
||||
// * STEREO mode, stereo+ src -> channels 0 and 1 as-is (no surround fold on >2 channels).
|
||||
// Empty/zero-channel input -> empty frames (caller drops the zone or plays silence).
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||
// Empty/zero-channel input -> empty frames (caller plays silence).
|
||||
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||
|
||||
// The ComponentState envelope + zones-payload binary codec lives in component_state_io.h:
|
||||
// Stitch the resolved parameter set + the decoded PCM into the one SampleData the engine
|
||||
// plays across the whole keyboard, repitched from the effective root. A second channel is
|
||||
// carried only when it length-matches channel 0 (SampleData::channelCount() enforces the
|
||||
// same rule, so a bad pair never half-plays). Resolves the stored wall-clock SECONDS to
|
||||
// frames against the DECODE's actual rate. Empty PCM or a non-positive rate yields an
|
||||
// unplayable SampleData (silence, never a crash).
|
||||
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded);
|
||||
|
||||
// The ComponentState envelope + params-payload binary codec lives in component_state_io.h:
|
||||
// it grows on every envelope bump and is consumed by the extension's preset-blob path too,
|
||||
// so both artifacts share the codec while only the VST links the voice engine.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,90 @@
|
||||
# The geometry vocabulary every instrument UI module speaks (the Rect alias + contains())
|
||||
# is header-only, hence INTERFACE.
|
||||
add_library(editor_geometry INTERFACE)
|
||||
target_include_directories(editor_geometry INTERFACE ${REASAMPLER_SRC_DIR})
|
||||
|
||||
reasampler_pure_library(sample_bands SOURCES sample_bands.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(sample_bands LINK sample_bands)
|
||||
|
||||
reasampler_pure_library(sample_chrome SOURCES sample_chrome.cpp LINK PUBLIC sample_bands)
|
||||
# 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)
|
||||
|
||||
reasampler_pure_library(capture_browser SOURCES capture_browser.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(capture_browser LINK capture_browser)
|
||||
|
||||
reasampler_pure_library(keyboard_strip SOURCES keyboard_strip.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome)
|
||||
|
||||
# sample_bands is PRIVATE: the lane split is used internally and nothing in the public
|
||||
# header needs it.
|
||||
reasampler_pure_library(waveform_view
|
||||
SOURCES waveform_view.cpp
|
||||
LINK PUBLIC editor_geometry peaks PRIVATE sample_bands)
|
||||
# sample_bands is linked directly here because the test exercises the lane metrics that
|
||||
# waveform_view does not re-export.
|
||||
reasampler_test(waveform_view LINK waveform_view sample_bands)
|
||||
|
||||
reasampler_pure_library(browser_scroll
|
||||
SOURCES browser_scroll.cpp
|
||||
LINK PUBLIC capture_browser sample_chrome)
|
||||
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 curve_law)
|
||||
reasampler_test(envelope_overlay LINK envelope_overlay)
|
||||
|
||||
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
|
||||
reasampler_test(envelope_edit LINK envelope_edit)
|
||||
|
||||
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(knob_deck LINK knob_deck)
|
||||
|
||||
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see
|
||||
# core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads
|
||||
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
|
||||
# AudioSample dependency. play_params.h also drags in filter/'s headers (FilterSettings,
|
||||
# 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 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)
|
||||
# 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user