Compare commits
211 Commits
363c091308
..
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 | |||
| be37192fe9 | |||
| 949dc3af7c | |||
| 47ccadf726 | |||
| 9d037fb233 | |||
| 58c6d49261 | |||
| 8dac5b4a54 | |||
| 80df142605 | |||
| 12ffe377e5 | |||
| 65ca1e1f9d | |||
| 3d3415f943 | |||
| 54f5f24506 | |||
| cc36dd59c7 | |||
| d4d29146c7 | |||
| ccd9968be1 | |||
| 354192ae27 | |||
| 3599d97836 | |||
| 85cc340e58 | |||
| 1f24c4b095 | |||
| b34a543b81 | |||
| 1a62e10467 | |||
| 8a9e2a1194 | |||
| f529554673 | |||
| 4d2316b77c | |||
| d1202b3174 | |||
| cd12b97631 | |||
| f3be4d8cce | |||
| 4831e0e172 | |||
| 0c39a716a5 | |||
| 75aa93f913 | |||
| bbbb69ee55 | |||
| 4f587258b2 | |||
| 430e117620 | |||
| 5232227323 | |||
| 3bcd4dcc7b | |||
| 6108673c84 | |||
| 8bc5aa1257 | |||
| 09f7173db2 | |||
| d7d7f7e084 | |||
| f0f91f7698 | |||
| ea86f540b8 | |||
| 9d5783453c | |||
| 19b12186ac | |||
| 30a4ffd01b | |||
| b5788c82f6 | |||
| d8651fb7a7 | |||
| b8e8bb4c75 | |||
| 9dd3440b75 | |||
| 0ce3f4894a | |||
| 2d59bbe35d | |||
| 847936f813 | |||
| 67a41728f3 | |||
| 88e7765ee5 | |||
| 32785606d4 | |||
| b2013f2056 | |||
| 587032ffa4 | |||
| 113d268553 | |||
| 546927ee43 | |||
| 16b2a1b8ca | |||
| 15d293b42f | |||
| 35f02cece2 | |||
| 678274c19b | |||
| 78a214b247 | |||
| a4571cc074 | |||
| b06b226182 | |||
| 57e509c64f | |||
| 3eaa0b886e | |||
| 2dbefb8c01 | |||
| ca086f4009 | |||
| dfe6ccbddd | |||
| f0d5d23171 |
+3
-1
@@ -1,10 +1,12 @@
|
|||||||
/build/
|
/build/
|
||||||
/build-beta/
|
/build-beta/
|
||||||
/.claude/worktrees/
|
/.claude/worktrees/
|
||||||
/.idea/ctestState.xml
|
/.idea/
|
||||||
*.dll
|
*.dll
|
||||||
*.dylib
|
*.dylib
|
||||||
*.so
|
*.so
|
||||||
*.o
|
*.o
|
||||||
*.obj
|
*.obj
|
||||||
.DS_Store
|
.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>
|
|
||||||
+51
-1174
File diff suppressed because it is too large
Load Diff
-2885
File diff suppressed because it is too large
Load Diff
-1228
File diff suppressed because it is too large
Load Diff
-2636
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,531 +0,0 @@
|
|||||||
# PLAN.md — ReaSampler milestone roadmap
|
|
||||||
|
|
||||||
Living milestone roadmap for ReaSampler. Derived from CONTEXT.md's 11-step build
|
|
||||||
order; CONTEXT.md remains the authoritative spec — this file is the tickable
|
|
||||||
checklist, not a re-statement of the spec. When a point lands, doc-keeper removes
|
|
||||||
it here and appends it to `COMPLETED.md`.
|
|
||||||
|
|
||||||
**Conventions**
|
|
||||||
- One checkbox `- [ ]` = one discrete, independently-landable point.
|
|
||||||
- Each milestone opens with a **Goal** (one line) and a **Verify** criterion
|
|
||||||
(the acceptance gate; precision invariants pulled in where one applies).
|
|
||||||
- Verify-in-DAW points require a manual REAPER run; pure points are gated by CTest.
|
|
||||||
- "See CONTEXT.md §…" points at the authoritative detail — do not duplicate it here.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open questions to resolve during build
|
|
||||||
Carried from CONTEXT.md §Open questions — keep visible until each is closed by a
|
|
||||||
landed milestone.
|
|
||||||
|
|
||||||
- **`parseInt` narrowing hardening:** `src/bank_model.cpp` `parseInt` casts
|
|
||||||
`int64_t → int` via `static_cast` without a range check; integers that fit
|
|
||||||
in int64 but exceed `INT_MAX` are implementation-defined. Hardening candidate
|
|
||||||
— add bounds check before the cast when integer-field validation is in scope.
|
|
||||||
- **Capture send/routing isolation (TODO):** The FX-scope capture neutralizes out-of-scope FX, gain, and pan — but NOT aux **sends**. So a downstream coloring send (e.g. a folder → reverb-track send) still routes and blends the reverb into an item/track capture, past the intended isolation point. A true item-level capture should be taken at the isolated graph point — the target scope's output before out-of-scope track FX/gain/pan **and** before out-of-scope aux/parallel sends. The hard part: distinguish **source routing that must be preserved** (e.g. a MIDI send T1→T2 where T2's synth is where a MIDI item's audio is actually produced — the "item level" for that MIDI item is T2's synth output) from **coloring sends that must be excluded** (folder→reverb). Repro: folder F1; T1 (MIDI) sends MIDI to T2 (synth); T1+T2 → F1; F1 sends to reverb T3; capturing the MIDI item on T1 currently includes the reverb, should be isolated to T2's synth output pre-F1 with the MIDI send preserved and the reverb send excluded. Likely approach: snapshot + mute out-of-scope tracks' aux sends during the render while preserving the main/source signal path — needs a rule for which sends are load-bearing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase D2 — Two-canvas (item-level mode projection; additive to D1)
|
|
||||||
|
|
||||||
> **Phase D2 is functionally complete** — D2-W1, D2-W2, D2-W3-A, D2-W3-B all landed; see `COMPLETED.md`.
|
|
||||||
>
|
|
||||||
> **Deferred:** panel UI indicator for per-track lane/mode state (a per-track
|
|
||||||
> lane-split marker). The mode switch already shows the active mode; no natural
|
|
||||||
> cheap home for a per-track indicator was found in the bank panel. Explicitly
|
|
||||||
> deferred — not silently dropped. Can be picked up later if wanted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase S — MIDI-playback instrument (native VST3 sampler; a second build artifact)
|
|
||||||
|
|
||||||
> **Landed on dev (merged 2026-07-27); DAW verification pending Daniel's smoke test.**
|
|
||||||
> S1–S18 and the product-name/binary-rename work are all on dev. The cross-artifact
|
|
||||||
> ingest relay (S13 bullet) was explicitly DEGRADED and remains deferred — see below.
|
|
||||||
> Completed material archived in `COMPLETED.md`. Authoritative spec: **CONTEXT.md
|
|
||||||
> §MIDI-playback instrument — additive phase spec (Phase S)**. Product framing:
|
|
||||||
> `docs/product/midi-playback.md`.
|
|
||||||
>
|
|
||||||
## S13 — cross-artifact ingest relay (deferred)
|
|
||||||
|
|
||||||
> **SPIKE VERDICT (ps-w12, 2026-07-27): DEGRADED — relay deferred.** The instrument's
|
|
||||||
> REAPER bridge (`reaper_bridge`) is deliberately READ-ONLY; a relay would need a new
|
|
||||||
> instrument WRITE seam into ext-state and an extension-side timer poller servicing a
|
|
||||||
> drop-ingest inbox key with a claim/clear nonce — the same cross-process handshake race
|
|
||||||
> the S17 spec rejected for alternative (A). Both the read-only-instrument boundary and
|
|
||||||
> the new poller are load-bearing design calls, so the relay is deferred to a future wave.
|
|
||||||
> The shipped ingest gesture stays drop-onto-docked-panel (S8). The degrade path (editor
|
|
||||||
> shows a "drop files onto the ReaSampler bank panel to add them" affordance) landed as
|
|
||||||
> part of Phase S. See `COMPLETED.md` §S13 for full context.
|
|
||||||
|
|
||||||
- [ ] Cross-artifact ingest relay: the editor hands the dropped path + this instance's
|
|
||||||
identity to the extension as a bank-ingest request over an agreed seam. DEFERRED —
|
|
||||||
relay mechanism proved load-bearing to redesign; drop-onto-panel (S8) is the shipped
|
|
||||||
ingest path. Requires (a) a new instrument WRITE seam into ext-state and (b) an
|
|
||||||
extension-side timer poller + claim/clear nonce. A future wave when the design is ready.
|
|
||||||
|
|
||||||
## Phase S — product name (ReaSampler 9000)
|
|
||||||
The MIDI-playback instrument's product name is **ReaSampler 9000** (Daniel, 2026-07-26,
|
|
||||||
on DAW-testing the S1–S6 instrument). The extension remains **ReaSampler**; the instrument
|
|
||||||
is **ReaSampler 9000**. Framing + propagation surfaces:
|
|
||||||
`docs/product/midi-playback.md` §Product name.
|
|
||||||
|
|
||||||
- [ ] **Compat verification (must-DAW-verify before shipping the rename):** the working
|
|
||||||
assumption is that REAPER **rebinds a saved instance by its VST3 class UID, not by the
|
|
||||||
module filename**, so a filename rename with an unchanged UID keeps saved projects working
|
|
||||||
(existing instances still resolve). **This is not yet confirmed from source** — a web
|
|
||||||
check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's binding is more nuanced
|
|
||||||
than "UID only" (it can involve an FXID match), so treat UID-rebind as **to-verify, not
|
|
||||||
asserted fact**. **DAW-verify:** save a project with a ReaSampler 9000 instance under the
|
|
||||||
old filename, rename the module, reopen — confirm the instance rebinds and restores its
|
|
||||||
state. If REAPER does key partly on filename, fall back to keeping the current filename
|
|
||||||
(display-strings-only) and record that as the shipped choice.
|
|
||||||
|
|
||||||
## Phase S — held and optional-forever (noted, not specified)
|
|
||||||
|
|
||||||
- **Tier 2 — "expressive" (HELD).** Velocity layers, round-robin (anti-machine-gun),
|
|
||||||
full ADSR, per-sample tuning/gain trim, sustain loops. The next depth increment once
|
|
||||||
Tier 0–1 proves the instrument belongs — **its points are not drawn up here.**
|
|
||||||
- **Tier 3 — "instrument polish" (optional-forever).** Filters, filter/pitch
|
|
||||||
envelopes, LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave
|
|
||||||
room for, never a commitment. **Note:** S16 lands the *pitch* envelope + the Varispeed/
|
|
||||||
Preserve pitch-engine mode early (Daniel's directive) — the Tier-3 "filter/pitch envelopes"
|
|
||||||
line now means the *filter* envelope + LFOs remainder.
|
|
||||||
- **Sinc Varispeed-quality upgrade (HELD — WDL_Resampler).** `WDL_Resampler`'s sinc mode
|
|
||||||
beats the core's 2-point linear interp for **Varispeed** base-repitch quality (see the S16
|
|
||||||
WDL finding). An optional per-voice quality toggle (linear default / sinc), RT-suitable but
|
|
||||||
heavier. Held as a Tier-2/3 quality option — not needed for S15/S16, not scheduled. (A
|
|
||||||
resampler couples duration, so it is a Varispeed-quality option only, **not** a Preserve
|
|
||||||
engine.)
|
|
||||||
- **WDL_SimplePitchShifter swap (HELD — fork S16-F2 route a).** `WDL_SimplePitchShifter`
|
|
||||||
as a drop-in swap for the `pitch_shift` pure module if the hand-rolled OLA onset latency
|
|
||||||
or warble proves musically unacceptable. Same `PitchEngine::Preserve` contract behind the
|
|
||||||
seam. WDL excluded from the shipped build by include-chain (windows.h); held as the
|
|
||||||
quality/latency alternative.
|
|
||||||
- **Trigger choke-on-note-off (HELD — fork S15-F1).** A future option for Trigger mode to
|
|
||||||
*cut* (choke) on note-off or on a same-group re-trigger (hi-hat open/closed). Deliberately
|
|
||||||
out of S15 scope (Trigger ignores note-off entirely there); a Tier-3 choke-group direction.
|
|
||||||
|
|
||||||
## Phase S — editor view-model redesign (three views: Sample / Browse / Zone)
|
|
||||||
|
|
||||||
> **Additive Phase S sub-phase (S-VIEW; Daniel, 2026-07-27, r9).** Re-partitions the ReaSampler
|
|
||||||
> 9000 editor from a two-view toggle into a **three-view model where the loaded sample is the
|
|
||||||
> home** — Sample (default face), Browse (modal picker over Sample), Zone (dedicated keymap
|
|
||||||
> surface). Adds three performance parameters (key-tracking, preview velocity, and the r10
|
|
||||||
> velocity→amp transfer curve) and three visual components (envelope overlay, real piano-key
|
|
||||||
> pattern, and the r10 velocity-curve editor), and frames two engineering prerequisites
|
|
||||||
> (drop-to-FX bug, default window size). An **editor** redesign — the S3 voice engine, keymap
|
|
||||||
> resolution, and read-only-over-bank contract are **unchanged**; component state extends
|
|
||||||
> additively for key-tracking; VST3 class UID unchanged. Authoritative spec: **CONTEXT.md §Phase
|
|
||||||
> S — editor view-model redesign (S-VIEW)**. Product framing: `docs/product/midi-playback.md`
|
|
||||||
> §Addendum r9. When a point lands, doc-keeper moves it to `COMPLETED.md`.
|
|
||||||
>
|
|
||||||
> **Depends on Phase S being on dev** (this redesigns Phase S editor shells —
|
|
||||||
> `reasampler_editor.cpp` + the pure `src/vst/` geometry modules). **Phase L L3 is FINISHED and
|
|
||||||
> merged** (2026-07-27, commit `c53683e` — `reasampler_editor.cpp` + `reasampler_embed.cpp`
|
|
||||||
> restyled through the L1 kit). S-VIEW builds the new three-view layout **directly on the current
|
|
||||||
> L3 look-and-feel as its baseline — one implementation pass, styled correctly from the start.**
|
|
||||||
> There is **no two-pass "land S-VIEW then restyle through L3"** — L3 already happened; every new
|
|
||||||
> S-VIEW surface (Sample face, Browse modal, Zone surface, envelope overlay + node handles,
|
|
||||||
> piano-key strip, preview cluster) is drawn through the L1 kit at build time, inheriting the L3
|
|
||||||
> palette and component-draw grammar. Restyle-after is not a concern.
|
|
||||||
|
|
||||||
**Goal:** A three-view editor that makes "pick a capture, tune it, play it" fast, easy, and fun —
|
|
||||||
Sample is home, Browse is an easy-to-summon modal picker, Zone is the deliberate keymap surface.
|
|
||||||
**Verify (in DAW):** open on a 1080p screen → full Sample face (hero waveform + envelope overlay +
|
|
||||||
fenced root + preview-trigger + control strip) with no scroll; Browse opens as a modal over
|
|
||||||
Sample, select+confirm loads a new capture; Zone opens on its own button, key-tracking + the
|
|
||||||
piano-key pattern work; drop-a-capture-onto-FX instantiates a playing instance.
|
|
||||||
|
|
||||||
### Phase S editor Wave B — Sample-face recomposition (r11; Daniel, 2026-07-27)
|
|
||||||
|
|
||||||
> Daniel's post-landing DAW pass on the S-VIEW editor: **all linear sliders → small radial
|
|
||||||
> knobs** grouped into a fenced knob deck (envelope controls grouped as a unit); the **mode
|
|
||||||
> toggles compact**, not full-width; the **inline velocity-curve box → a miniature curve
|
|
||||||
> preview button + full-size popup editor** (right-click deletes a node in the popup); the
|
|
||||||
> **hero waveform full-width**. Authoritative layout spec: **CONTEXT.md §S-VIEW → "The
|
|
||||||
> Sample-face recomposition (r11)"** (band order, group taxonomy, cell metrics, popup
|
|
||||||
> geometry, inventory contract — nothing silently dropped). Product framing:
|
|
||||||
> `docs/product/midi-playback.md` §Addendum r11. All drawing through the L1 kit; all
|
|
||||||
> layout/hit-test in pure geometry modules.
|
|
||||||
>
|
|
||||||
> **S-VIEW-11, S-VIEW-12, and S-VIEW-13 have all landed** — FB1 (merged 2026-07-27, suite
|
|
||||||
> 55/55) brought the knob deck + master gain + curve popup + full-width hero; FB2 (merged
|
|
||||||
> 2026-07-28, suite 55/55) brought Zone-panel parity. **Phase S editor Wave B (r11) is
|
|
||||||
> complete.** See `COMPLETED.md` for both FB entries.
|
|
||||||
|
|
||||||
**Open forks** — **R11-F1** (hero height vs. default window) SETTLED at FB1 build: elastic
|
|
||||||
hero, 840×620 default kept. **R11-F2** (Zone-panel parity) SETTLED at FB2 build: knob deck
|
|
||||||
+ curve popup adopted on the Zone panel; `param_slider` slider rows retired on that surface.
|
|
||||||
|
|
||||||
### Phase S editor redesign — forks (Daniel's to call)
|
|
||||||
*(r9/r10 forks all settled: S-VIEW-F1 and S-VIEW-F2 SETTLED 2026-07-27 — folded into S-VIEW-4 /
|
|
||||||
S-VIEW-3. F1: preview velocity persists via envelope-v6 `ComponentState`. F2: envelope nodes are
|
|
||||||
draggable via the pure `envelope_edit` module. R10-F1 SETTLED 2026-07-27 — Option A flat y=1,
|
|
||||||
folded into S-VIEW-9. S-VIEW-F3 SETTLED — full-window overlay, implemented as Browse renders as a
|
|
||||||
full-window modal over Sample (landed in S-VIEW-1/S-VIEW-5). **r11 forks all settled: R11-F1
|
|
||||||
SETTLED at FB1 build (elastic hero, 840×620 default kept); R11-F2 SETTLED at FB2 build (Zone
|
|
||||||
panel adopts knob deck + curve popup, `param_slider` slider rows retired on that surface).**)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase Q — Quality (structural reorganization; zero-runtime-cost)
|
|
||||||
|
|
||||||
> **New pillar, own lettered namespace, and — uniquely — the LAST structural pillar.** Phase
|
|
||||||
> Q is a **pure structural refactor**: it reorganizes `src/` into a healthier shape (more
|
|
||||||
> encapsulation, granular namespaces, `core/`/`shell/`/`app/` subdirectories) against a stated
|
|
||||||
> quality bar — *"mtytel Vital is my code reference for quality"* — to bring the codebase
|
|
||||||
> "into the realm of something I can stand to look at." It ships **no feature and changes no
|
|
||||||
> behavior**: the test suite passing unchanged is the proof of correctness. Namespaced **`Q`
|
|
||||||
> (Quality)** — M/D/B/R/V/S/L are all taken; `Q` names the *end* (the quality bar), the reorg
|
|
||||||
> being the *means*. Authoritative spec: **CONTEXT.md §Phase Q — structural reorganization
|
|
||||||
> (reorg spec)**. Product framing, the Vital-grounded target shape, the grep-verified SOLID
|
|
||||||
> audit that is the evidence base, and the settled/recommended fork record (Q-1..Q-6):
|
|
||||||
> `docs/product/code-organization.md`. When a point lands, doc-keeper moves it to
|
|
||||||
> `COMPLETED.md`.
|
|
||||||
>
|
|
||||||
> **THE GATE (load-bearing — state first; reconciled to reality 2026-07-27).** Phase Q is
|
|
||||||
> **gated on the tree being otherwise quiescent.** Daniel's plain readiness target: **"when
|
|
||||||
> Phase S and L3 are finished."** **As of 2026-07-27 both gate conditions are satisfied:**
|
|
||||||
> Phase S merged to dev (2026-07-27); Phase L L3 (the VST restyle) merged to dev
|
|
||||||
> (2026-07-27, commit `c53683e`) — **Phase L is complete** (L1/L2/L3/L4/L5/L6/L7 all
|
|
||||||
> landed, see `COMPLETED.md`). **D2** is functionally complete (D2-W1..W3-B landed; the
|
|
||||||
> lone open item, a per-track lane-split panel indicator, is *explicitly deferred*, not a
|
|
||||||
> blocking residual). **M9** (slots) is **abandoned** (Daniel, 2026-07-27) — will not be
|
|
||||||
> built. D2 is named in the gate only so that *reactivating* its deferred panel indicator
|
|
||||||
> re-arms the quiescence condition; neither D2 nor M9 blocks the gate today. *Why the gate:* Phase Q
|
|
||||||
> touches **nearly every file in `src/`** (relocate into subdirectories, re-namespace every
|
|
||||||
> header, split the four largest TUs, plus the §2b renames). Every large in-flight branch
|
|
||||||
> (Phase S on its worktree, and L3 once it lands) is diffed against the *current flat
|
|
||||||
> layout*; landing a rename-and-relocate-everything reorg mid-flight forces every open
|
|
||||||
> branch through the worst conflict class (every hunk moved, every qualified reference
|
|
||||||
> changed) — a combinatorial re-resolution, not a linear one. Phase Q is *last* precisely
|
|
||||||
> because it reshapes the ground every other pillar stands on. Landing it early taxes every
|
|
||||||
> subsequent phase; landing it last taxes nothing. **The gate is now satisfied — Phase Q
|
|
||||||
> may begin, entering at the Q-W0 audit wave** (the structural waves Q-W1+ do not begin until
|
|
||||||
> Q-W0's triage closes and Daniel signs off — see the Q-W0 sub-gate above).
|
|
||||||
>
|
|
||||||
> **M9 disposition — resolved (Daniel, 2026-07-27): abandoned.** M9 is out; it will not be
|
|
||||||
> reactivated. The gate remains satisfied; no re-arm condition applies.
|
|
||||||
>
|
|
||||||
> **PRE-RESTRUCTURE AUDIT WAVE (Q-W0 — added 2026-07-27, Daniel's ask; runs FIRST).** Before any
|
|
||||||
> structural point (Q-W1+) begins, Phase Q now opens with a **functional + DSP quality audit**
|
|
||||||
> (`Q-W0`) — a thorough static analysis of the code from a *functional-correctness* and
|
|
||||||
> *algorithm-quality* point of view, complementary to (not a repeat of) the grep-verified
|
|
||||||
> SOLID/naming audit that already grounds Q-W1..Q-W6. Q-W0 hunts DSP smells (close eye on the pitch
|
|
||||||
> engine), reinvented wheels, duplicate code, poor-quality algorithms, leaky pure/shell boundaries,
|
|
||||||
> and domain-modeling smells around env-coupled constants; it produces a written, triaged findings
|
|
||||||
> report. **The gate to begin Q-W1 is that Q-W0's triage is complete and Daniel has signed off on
|
|
||||||
> each finding's disposition** (fix-now vs. document-and-defer). Q-W0's findings may add or reshape
|
|
||||||
> downstream Q-W1..Q-W6 points; fixes that Q-W0 classifies fix-now are remediated in Q-W0 (or folded
|
|
||||||
> into the wave that already touches the file), **not** deferred silently into the structural waves.
|
|
||||||
>
|
|
||||||
> **Settled (Q-1, this-doc):** the phase is **`Q` (Quality)**; point-id family `Q1..Qn`, wave
|
|
||||||
> prefixes `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg).
|
|
||||||
> **Settled (Q-10/Q-11, Daniel 2026-07-27):** Q-10 audit-report home = a **committed doc**
|
|
||||||
> (`docs/product/code-quality-audit.md`, not a tracked issue list); Q-11 pitch-remediation depth =
|
|
||||||
> **defer to findings** (default document-and-defer; weigh a bounded OLA fix before a technique
|
|
||||||
> replacement; a technique replacement is a Daniel decision at triage time, not an automatic Q-W0
|
|
||||||
> action).
|
|
||||||
> **Recommended, Daniel's to call (Q-2..Q-9, see
|
|
||||||
> `docs/product/code-organization.md` §6):** Q-2 JSON extraction in scope + first (rec: yes);
|
|
||||||
> Q-3 directory shape `core/`/`shell/`/`app/` top-split with subsystem dirs beneath (rec: this
|
|
||||||
> over pure-Vital subsystem-first — it makes the pure/shell invariant *structural*); Q-4
|
|
||||||
> sub-namespace to match sub-directory (rec: both); Q-5 split god-modules to the audit's named
|
|
||||||
> seams, no finer (rec: yes); Q-6 OCP registration-table as the final wave (rec: in, last);
|
|
||||||
> **Q-7 naming rides the relocation waves, no dedicated naming wave (rec: yes — forced once
|
|
||||||
> Q-3/Q-4 settle); Q-8 class/module renames — fix the two that actively mislead (`BankIndex`→
|
|
||||||
> `BankModel`; the JSON `Parser`→`json::Reader`/`Writer`), leave the merely-quirky (rec);
|
|
||||||
> Q-9 align the `capture_realtime`/`realtime_record` shell↔core word order during W3 (rec: yes).**
|
|
||||||
>
|
|
||||||
> **HARD CONSTRAINT — performance (see CONTEXT.md §Phase Q, `docs/product/code-organization.md`
|
|
||||||
> §3).** The reorg must cost **zero runtime.** On the three hot paths — `peaks` envelope
|
|
||||||
> compute, audition/preview, the realtime-capture tick — **no added virtual dispatch, no
|
|
||||||
> header→TU indirection, no changed call/inline or branch shape.** `computeEnvelope` stays a
|
|
||||||
> free function on `const std::vector<float>&`; audition split stays a direct call-through;
|
|
||||||
> the realtime idle tick stays a single pointer test; `FxBypassGuard` stays stack RAII. This is
|
|
||||||
> an acceptance criterion on every point: *a split that would add a hot-path indirection is out
|
|
||||||
> of scope — rework it or drop it.*
|
|
||||||
>
|
|
||||||
> **NAMING dimension (added 2026-07-27; grep-verified audit in `docs/product/code-organization.md`
|
|
||||||
> §2b).** Beyond giving symbols a directory + namespace *home* (Q-3/Q-4), Phase Q also gives
|
|
||||||
> poorly/inconsistently-named symbols a consistent *name*, against the same Vital bar. The audit
|
|
||||||
> found: four `class Parser` copies collapsing to one `json::Parser` (Q-W1); shared pure-UI rect
|
|
||||||
> types (`FooterRect`/`ButtonRect`) that the codebase already hand-checks for collision
|
|
||||||
> (`footer_bar.h`'s "NAME NOTE") — resolved by the Q-4 sub-namespaces for free; the
|
|
||||||
> `bank_model`/`BankIndex` file↔class word-mismatch (Q-8); and the `capture_realtime`/
|
|
||||||
> `realtime_record` shell↔core word-order inversion (Q-9). **Renames ride the wave that already
|
|
||||||
> relocates/splits the file — no dedicated naming wave (Q-7);** the geometry-mirror
|
|
||||||
> `compute*`/`hitTest*` verb vocabulary and the `_tests` suffix are already consistent and are
|
|
||||||
> preserved verbatim. Naming changes are zero-behavior-change like the rest of Phase Q, and the
|
|
||||||
> FOREVER-STABLE contract strings (command ids, action names, ext-state namespace/keys, VST3 UID)
|
|
||||||
> are **not** C++ symbols and are never renamed.
|
|
||||||
>
|
|
||||||
> **Every point is independently landable and CTest-green at EVERY step.** The CMake
|
|
||||||
> per-module static-lib + per-module test-executable seams already draw the module boundaries;
|
|
||||||
> a file move + namespace change is mechanically verifiable — `ctest --test-dir build` is green
|
|
||||||
> or it isn't. **Green-CTest-at-every-point is an acceptance criterion.** Big-bang is rejected;
|
|
||||||
> the reorg is risk-ordered waves (W1 safe opener → W2–W5 god-module splits → W6 OCP finish).
|
|
||||||
|
|
||||||
## Q-W0 — pre-restructure functional + DSP quality audit (runs FIRST; gates Q-W1)
|
|
||||||
**Goal:** Before a single structural point moves, perform a **thorough static/functional audit** of
|
|
||||||
the codebase and produce a **written, triaged findings report**. This is the *functional-correctness
|
|
||||||
and algorithm-quality* complement to the grep-verified SOLID/naming audit that already grounds
|
|
||||||
Q-W1..Q-W6 (§2/§2b of `docs/product/code-organization.md`) — it goes deeper on *does the code do the
|
|
||||||
right thing well*, not *where does the responsibility live*. It hunts, across the whole `src/` tree:
|
|
||||||
duplicate code, reinvented wheels, poor-quality algorithms (**close eye on the pitch engine**),
|
|
||||||
numerical-robustness and artifact hazards in the DSP paths, leaky pure/shell boundaries, and
|
|
||||||
**domain-modeling smells around env-coupled constants** (values stored in a frame/rate/DPI/tick-coupled
|
|
||||||
domain that should be stored rate-free and resolved at use). Every finding is **classified for
|
|
||||||
disposition**: *eliminate-before-restructure* (fix-now) vs. *document-and-defer* (with rationale).
|
|
||||||
Nothing structural (Q-W1+) begins until this wave closes and Daniel signs off on the disposition of
|
|
||||||
every finding. CONTEXT.md §Phase Q (Q-W0 audit scope + triage). See
|
|
||||||
`docs/product/code-organization.md` §2c.
|
|
||||||
**Verify:** A findings report exists and is complete over the named audit surfaces (DSP/audio incl.
|
|
||||||
pitch; architecture smells; env-coupled-constant domain-modeling smells). **Every finding is
|
|
||||||
triaged** (fix-now vs. documented-and-deferred, each with a one-line rationale). Fix-now findings are
|
|
||||||
either remediated in this wave or explicitly assigned to the downstream wave that already touches the
|
|
||||||
file (recorded per finding); no fix-now finding is left implicit. Any behavior-changing remediation
|
|
||||||
lands with its module's CTest executable green and (where a DSP path changes audibly) a stated
|
|
||||||
before/after listening or null check. **The gate to Q-W1 is: triage complete + Daniel signed off.**
|
|
||||||
**Depends on:** the GATE (tree quiescent). Precedes every other Q wave — Q-W1 depends on Q-W0.
|
|
||||||
|
|
||||||
- [ ] **DSP / audio audit — close eye on pitch.** Assess *algorithm quality* (correctness,
|
|
||||||
artifacts, numerical robustness, interpolation quality, reinvented-wheel vs. established DSP
|
|
||||||
technique) across: `src/vst/pitch_shift` (hand-rolled OLA pitch-preserve — window/overlap choice,
|
|
||||||
phase handling, transient/formant behavior, buffer edges), `sampler_core` (repitch ratio math,
|
|
||||||
interpolation order/quality, loop-point crossfade, voice-stealing correctness/click behavior),
|
|
||||||
`peaks` (envelope min/max binning), `wav_trim` (decay-scan threshold + truncate plan), and the
|
|
||||||
capture/tail paths. **Pitch-remediation depth (Q-11 SETTLED, Daniel 2026-07-27): defer to
|
|
||||||
findings** — default document-and-defer; only if the audit surfaces artifacts that matter, weigh a
|
|
||||||
bounded OLA fix (window/overlap/edge tuning) **before** a technique replacement. A technique
|
|
||||||
replacement (phase-vocoder / WSOLA) reshapes `pitch_shift`, spills a downstream Q-wave point, and is
|
|
||||||
a **Daniel decision at triage time, not an automatic Q-W0 action** — Q-W0 surfaces the
|
|
||||||
recommendation, Daniel calls it.
|
|
||||||
- [ ] **Architecture-smell audit.** Duplicate code, reinvented wheels, poor abstractions, and any
|
|
||||||
leaky pure/shell boundary (a `core/` module reaching a REAPER/host type, or geometry/algorithm
|
|
||||||
math living untestable in a shell). Cross-check against — but do not merely restate — the §2/§2b
|
|
||||||
SOLID/naming findings; Q-W0's contribution is the *functional* smells those audits didn't target.
|
|
||||||
- [ ] **Env-coupled-constant domain-modeling audit (explicit smell category).** Flag ANY value
|
|
||||||
stored in a frame/rate/DPI/tick-coupled domain that should be stored **rate-free and resolved at
|
|
||||||
the point of use** — per the load-bearing invariant that wall-clock times are rate-free SECONDS
|
|
||||||
resolved against the live project rate (`sample_map`), with NO hardcoded sample rates in `src/`.
|
|
||||||
Treat this as **domain-modeling** (store rate-free, resolve at use), **not** "rescale by rate."
|
|
||||||
There was a prior incident here — envelope times stored in the frame domain — so this is a
|
|
||||||
first-class category, not a footnote. Sweep envelope times, loop points, fade lengths, tail
|
|
||||||
lengths, and any UI geometry constant that silently assumes a DPI/rate.
|
|
||||||
- [ ] **Triage + report.** Write the findings report as a **committed doc,
|
|
||||||
`docs/product/code-quality-audit.md`** (Q-10 SETTLED, Daniel 2026-07-27 — beside the SOLID/naming
|
|
||||||
audit, not a tracked issue list); classify each finding fix-now vs. document-and-defer with a
|
|
||||||
one-line rationale; for each fix-now, note whether it is remediated in Q-W0 or assigned to the
|
|
||||||
downstream wave that already opens the file. Deferred findings carry a documented rationale so they
|
|
||||||
are a decision, not an omission.
|
|
||||||
- [ ] **Sign-off gate.** Daniel reviews the triage and signs off on each disposition. Q-W1 does not
|
|
||||||
begin until this is done; fold any new/reshaped downstream points the audit surfaces into
|
|
||||||
Q-W1..Q-W6 before starting them.
|
|
||||||
|
|
||||||
## Q-W1 — safe opener: extract `core/json` + impose the directory/namespace layout on clean modules
|
|
||||||
**Goal:** The zero-god-module-risk opener. Two moves: (1) extract a pure **`core/json`** module
|
|
||||||
(parser + serializer) and **delete the four hand-rolled `Parser`s** in `bank_model` /
|
|
||||||
`bank_book` / `view_mode_model` / `owned_manifest` (the single largest DRY+SRP violation, and
|
|
||||||
entirely off the hot paths); (2) impose the settled `core/`/`shell/`/`app/` directory layout +
|
|
||||||
sub-namespaces (`reasampler::model`/`view`/`capture`/`audio`/`ui`/`reclaim`/`version`/`json`) on
|
|
||||||
the **30 clean pure libs + the clean shells that need no splitting** — pure relocation, no logic
|
|
||||||
change. Proves the wave discipline (relocate + encapsulate, CTest-green) before any god-module
|
|
||||||
surgery. CONTEXT.md §Phase Q (json extraction; directory + namespace map).
|
|
||||||
**Verify:** CTest green at every commit. The four duplicate `Parser`s are gone, replaced by one
|
|
||||||
`core/json` consumed by all four models; round-trip serialization is byte-identical to before
|
|
||||||
(no format change — a *structural* dedupe, not a behavior change). Every relocated clean module
|
|
||||||
compiles and its test executable passes unmoved. `Sample` (model) vs `AudioSample` (audio) vs
|
|
||||||
unified `Parser` (json) do not collide once sub-namespaced. No REAPER type crosses into any
|
|
||||||
`core/` file; the CMake pure/shell enforcement still holds.
|
|
||||||
**Depends on:** the GATE (tree quiescent) **and Q-W0 closed** (audit triaged + Daniel signed off;
|
|
||||||
any fix-now findings the audit assigned to Q-W1 folded in). First structural wave.
|
|
||||||
|
|
||||||
- [ ] Extract `core/json` (pure parser + serializer: parseString/parseInt/parseKey/skipValue +
|
|
||||||
escape, plus emit helpers); unify under `reasampler::json`; guard the `Parser` name against
|
|
||||||
cross-lib collision. Off all hot paths — safe to abstract freely.
|
|
||||||
- [ ] Rewire `bank_model`, `bank_book`, `view_mode_model`, `owned_manifest` onto `core/json`;
|
|
||||||
**delete the four duplicate `Parser`s.** Round-trip output byte-identical (dedupe, not
|
|
||||||
reformat).
|
|
||||||
- [ ] Relocate the 30 clean pure libs into `core/{model,view,capture,audio,ui,reclaim,version,
|
|
||||||
json}/` and the clean shells into `shell/{capture,panel,view,persist,actions}/`; move
|
|
||||||
`main.cpp` to `app/`. Update `CMakeLists.txt` `src/` paths only (no target-graph change).
|
|
||||||
- [ ] Apply sub-namespaces matching the directories on every relocated *clean* module (the
|
|
||||||
god-modules re-namespace their own new TUs as they split, W2–W5). Resolve `Sample`/
|
|
||||||
`AudioSample`/`Parser` homes. **This alone resolves the naming *collisions*** (§2b.2): the
|
|
||||||
shared pure-UI rect types (`FooterRect`/`ButtonRect`/`Selection`/`CellRect`) get one `ui::`
|
|
||||||
owner — retire the hand-collision "NAME NOTE" in `footer_bar.h`.
|
|
||||||
- [ ] **Naming riders (Q-8, if settled):** rename the survivor JSON parser to `json::Parser`
|
|
||||||
(or `json::Reader`/`json::Writer`); if Daniel takes the `BankIndex`→`BankModel` rename, land
|
|
||||||
it here (mechanical class rename, verified by `bank_model_tests`). No rename on a file this
|
|
||||||
wave isn't already relocating (Q-7).
|
|
||||||
- [ ] Confirm CTest green + no hot-path change: `peaks`/audition/realtime-tick untouched by this
|
|
||||||
wave (pure relocation of clean modules; `peaks` stays a free function).
|
|
||||||
|
|
||||||
## Q-W2 — split `bank_panel.cpp` (the biggest god-module, 2424 LOC)
|
|
||||||
**Goal:** Split the largest god-module (8+ responsibilities) along the audit's named seams:
|
|
||||||
`panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` /
|
|
||||||
`panel_window`. Split the fat `bank_panel.h` alongside (Interface Segregation). **Preserve the
|
|
||||||
audition hot path as a direct call-through, never virtual.** `panel_bank_ops` becomes the single
|
|
||||||
home for the bank-CRUD verbs that W4 will dedupe `actions.cpp` against. CONTEXT.md §Phase Q
|
|
||||||
(bank_panel split seams; hot-path audition guardrail). See `docs/product/code-organization.md`
|
|
||||||
§2.1, §5.
|
|
||||||
**Verify:** CTest green at every commit. Each seam is its own TU under `shell/panel/`; the panel
|
|
||||||
draws, thumbnails, auditions, handles input, does bank ops, and manages its window exactly as
|
|
||||||
before (no behavior change — verify in DAW that the panel is visually and interactively
|
|
||||||
unchanged). Audition/preview call path stays a **direct call-through** (no virtual dispatch, no
|
|
||||||
added header→TU indirection on the preview path). The ~20-function public API is now segmented
|
|
||||||
across the split headers.
|
|
||||||
**Depends on:** Q-W1 (directory/namespace layout established). Independently landable.
|
|
||||||
|
|
||||||
- [ ] Split rendering (`draw*`/`paint*`) → `panel_render`; thumbnail compute+cache →
|
|
||||||
`panel_thumbnails`.
|
|
||||||
- [ ] Split the audio audition/preview engine → `panel_audition` — **direct call-through, not
|
|
||||||
virtual; preview idle path unchanged.**
|
|
||||||
- [ ] Split input handling (mouse/key/wheel) + new-content detection → `panel_input`; window
|
|
||||||
lifecycle + OS drag-out/drop-target → `panel_window`.
|
|
||||||
- [ ] Extract bank-CRUD verbs → `panel_bank_ops` (the future single owner; W4 dedupes
|
|
||||||
`actions.cpp` against it). Split `bank_panel.h` into per-seam headers (I).
|
|
||||||
- [ ] Verify in DAW: panel unchanged; CTest green; no hot-path indirection added.
|
|
||||||
|
|
||||||
## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch)
|
|
||||||
**Goal:** Reduce `main.cpp` (1762 LOC) to its actual job — API pointers + `ReaperPluginEntry` +
|
|
||||||
dispatch (~the owns-pointers ~120 lines) — by hoisting: `capture_orchestrator` (`RunCapture` /
|
|
||||||
`captureAndIndexOne` / `renderOffline` / batch/recapture/realtime `Run*`), `scope_resolve`
|
|
||||||
(`resolveRange`/`resolveRazorRange`/`collectSelectedTracks` + provenance assembly inputs), and
|
|
||||||
`realtime_lifecycle` (the realtime-capture state machine + globals + selection guards).
|
|
||||||
**`FxBypassGuard` moves out but stays a stack RAII object (precision-critical); the realtime idle
|
|
||||||
tick stays a single pointer test.** CONTEXT.md §Phase Q (main split seams; FxBypassGuard +
|
|
||||||
realtime-tick guardrails). See `docs/product/code-organization.md` §2.1, §3.
|
|
||||||
**Verify:** CTest green at every commit. Capture (offline + realtime + batch + recapture) behaves
|
|
||||||
identically in DAW; the null test still nulls, bit-identical repeats still match (the precision
|
|
||||||
invariants `FxBypassGuard` protects are unchanged); capture ≠ placement holds (no hoisted `Run*`
|
|
||||||
path gains an `InsertMedia` call). The realtime idle fast-path is still a single pointer test.
|
|
||||||
`main.cpp` is now pointers + entry + dispatch only.
|
|
||||||
**Depends on:** Q-W1. Independent of Q-W2.
|
|
||||||
|
|
||||||
- [ ] Hoist capture orchestration → `capture_orchestrator` (`shell/capture/`); keep
|
|
||||||
`FxBypassGuard` a **stack RAII** object as it moves (precision-invariant-critical).
|
|
||||||
- [ ] Hoist scope/source resolution + provenance assembly inputs → `scope_resolve`.
|
|
||||||
- [ ] Hoist the realtime-capture lifecycle state machine + globals + the two RAII selection
|
|
||||||
guards → `realtime_lifecycle`; **idle tick stays a single pointer test.**
|
|
||||||
- [ ] Leave `main.cpp` = API-pointer ownership + `ReaperPluginEntry` + dispatch; move to `app/`.
|
|
||||||
- [ ] **Naming rider (Q-9, if settled):** align the `capture_realtime` (shell) / `realtime_record`
|
|
||||||
(pure) word-order inversion to the house shell↔core convention (rec: stem `capture_realtime`,
|
|
||||||
shell suffixed) — a free rider since W3 already hoists the realtime lifecycle. No rename on a
|
|
||||||
file this wave isn't already touching (Q-7).
|
|
||||||
- [ ] Verify in DAW: null test nulls, bit-identical repeats match, capture≠placement holds;
|
|
||||||
CTest green; no realtime-tick branch-shape change.
|
|
||||||
|
|
||||||
## Q-W4 — split `actions.cpp` + dedupe bank verbs against `panel_bank_ops`
|
|
||||||
**Goal:** Split the two unrelated command-id families in one TU (981 LOC) into
|
|
||||||
`design_view_actions` / `bank_actions` / `prune_action`, and **dedupe** `actions.cpp`'s own
|
|
||||||
`promptText`/`mintBankId` and bank verbs against the `panel_bank_ops` single-owner established in
|
|
||||||
Q-W2. `prune_action` keeps the `doBankPruneFolder` deletion authority contract intact (routes to
|
|
||||||
`persist`'s `prune_fs` after W5). CONTEXT.md §Phase Q (actions split seams; bank-verb dedupe).
|
|
||||||
See `docs/product/code-organization.md` §2.1, §2.4.
|
|
||||||
**Verify:** CTest green at every commit. Every action fires identically in DAW (Design View
|
|
||||||
family; multi-bank create/rename/reorder/delete/evacuate/activate/move/copy/remove; prune). The
|
|
||||||
bank-CRUD verbs have **one** implementation home (no `bank_panel`/`actions` duplication). Each
|
|
||||||
bank verb still wraps its mutation in one batched undo point; the prune action still writes no
|
|
||||||
ext state and opens no undo point. Command-id strings are **unchanged** (FOREVER-STABLE
|
|
||||||
contract — a reorg must not touch a shipped command id).
|
|
||||||
**Depends on:** Q-W2 (`panel_bank_ops` is the dedupe target). Independent of Q-W3.
|
|
||||||
|
|
||||||
- [ ] Split → `design_view_actions` (toggle/activate/tag/untag/showBoth/moveItems),
|
|
||||||
`bank_actions` (bank CRUD family), `prune_action` (`doBankPruneFolder` — the single
|
|
||||||
file-deletion action).
|
|
||||||
- [ ] Dedupe `actions.cpp`'s `promptText`/`mintBankId` + bank verbs against `panel_bank_ops`
|
|
||||||
(one owner); do **not** change any command-id string.
|
|
||||||
- [ ] Verify in DAW: all action families fire unchanged; one bank op = one Ctrl-Z; prune still
|
|
||||||
no-undo/no-ext-state; CTest green.
|
|
||||||
|
|
||||||
## Q-W5 — split `persist.cpp` (isolate the single file-deletion authority into `prune_fs`)
|
|
||||||
**Goal:** Split `persist.cpp` (766 LOC, 5 responsibilities) into `session` (lifecycle+poll,
|
|
||||||
`BeginLoadProjectState` reload hook), `ext_state_io` (the ext-state ↔ JSON serialization bridge +
|
|
||||||
GUID minting + folder relocation), and **`prune_fs`** (prune scanning + `deleteOrphanFile` via
|
|
||||||
`SHFileOperationW`). The split **concentrates** the byte-deleting authority into one obvious
|
|
||||||
module — it must never spread it. CONTEXT.md §Phase Q (persist split seams; deletion-authority
|
|
||||||
isolation). See `docs/product/code-organization.md` §2.1, §7.
|
|
||||||
**Verify:** CTest green at every commit. Session save/load/undo-reload, ext-state round-trip,
|
|
||||||
folder relocation, and prune deletion all behave identically in DAW. **File deletion lives in
|
|
||||||
exactly one module (`prune_fs`)** — the single-file-deletion-authority invariant is *improved*
|
|
||||||
(concentrated), never diluted. Relative-paths-only persistence is unchanged.
|
|
||||||
**Depends on:** Q-W1. Best after Q-W4 (so `prune_action` routes cleanly to `prune_fs`), but
|
|
||||||
independently landable.
|
|
||||||
|
|
||||||
- [ ] Split → `session` (lifecycle/poll + `projectconfig` reload hook), `ext_state_io`
|
|
||||||
(serialization bridge + GUID minting + folder relocation).
|
|
||||||
- [ ] Isolate prune scanning + `deleteOrphanFile` (`SHFileOperationW`) → **`prune_fs`** — the
|
|
||||||
one file-deletion module; nothing else may delete bytes.
|
|
||||||
- [ ] Verify in DAW: save/load/undo-reload/relocation/prune unchanged; deletion authority is one
|
|
||||||
module; relative-paths-only holds; CTest green.
|
|
||||||
|
|
||||||
## Q-W6 — OCP registration-table + residual fat-header (I) splits
|
|
||||||
**Goal:** Close the last SOLID wart: replace the ~350-line hand-written **non-table** action
|
|
||||||
registration blocks (now isolated in `app/main.cpp` after Q-W3) with a **registration table**, so
|
|
||||||
adding an action edits one place, not four parallel ones (OCP). Split any remaining fat headers
|
|
||||||
(`capture.h`/`persist.h`) not already resolved by their TU splits (I). CONTEXT.md §Phase Q (OCP
|
|
||||||
registration-table). See `docs/product/code-organization.md` §2.3, §6 (Q-6).
|
|
||||||
**Verify:** CTest green at every commit. Every action still registers, appears in the Actions
|
|
||||||
list, and fires via `hookcommand` exactly as before; command-id + display strings unchanged
|
|
||||||
(FOREVER-STABLE, per-channel); unload still mirror-unregisters everything. Adding a hypothetical
|
|
||||||
new action now touches the table only (demonstrated in review, not shipped). Remaining fat
|
|
||||||
headers are segmented.
|
|
||||||
**Depends on:** Q-W3 (registration code must be isolated first). Sequenced last; the most
|
|
||||||
droppable point if the phase needs narrowing (Q-6).
|
|
||||||
|
|
||||||
- [ ] Convert the hand-written `Register("command_id"/"gaccel"/"hookcommand")` blocks to a
|
|
||||||
data-driven registration table; unload mirror-unregisters from the same table.
|
|
||||||
- [ ] Split residual fat headers (`capture.h`/`persist.h` and any other) alongside their TUs (I).
|
|
||||||
- [ ] Verify: all actions register/fire/unregister unchanged; command-id strings untouched; CTest
|
|
||||||
green.
|
|
||||||
|
|
||||||
## Phase Q — sequencing
|
|
||||||
```
|
|
||||||
GATE: Phase S + Phase L L3 merged to dev (D2 complete, M9 abandoned) — tree quiescent
|
|
||||||
("when Phase S and L3 are finished" — L1/L2/L3/L4–L7 all landed — GATE SATISFIED)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Q-W0 (pre-restructure functional + DSP quality audit — findings report + triage)
|
|
||||||
│ ── SUB-GATE: triage complete + Daniel signed off on every disposition ──
|
|
||||||
▼ (fix-now findings remediated/assigned; downstream Q-W1..W6 reshaped as needed)
|
|
||||||
Q-W1 (safe opener: core/json extract + directory/namespace layout on clean modules)
|
|
||||||
├─► Q-W2 (split bank_panel) ──► Q-W4 (split actions + dedupe bank verbs vs panel_bank_ops)
|
|
||||||
├─► Q-W3 (split main.cpp; hoist orchestration) ──► Q-W6 (OCP registration-table + I splits)
|
|
||||||
└─► Q-W5 (split persist; isolate prune_fs) [best after Q-W4]
|
|
||||||
```
|
|
||||||
Q-W0 is the **entry point** — the functional/DSP audit runs FIRST and gates Q-W1 (no structural
|
|
||||||
point begins until its triage closes and Daniel signs off). W1 is then the safe, high-leverage
|
|
||||||
structural opener (all later waves assume the layout it establishes). The four god-module splits
|
|
||||||
(W2–W5) are risk-ordered and mostly parallel-safe; W4 depends on W2's `panel_bank_ops`, W6 depends
|
|
||||||
on W3's isolated registration code. Big-bang is rejected — every wave is independently landable and
|
|
||||||
CTest-green.
|
|
||||||
|
|
||||||
## Phase Q — must-verify-before-build
|
|
||||||
- **Q-W0 closed before any structural point** — the functional/DSP audit's findings report exists,
|
|
||||||
every finding is triaged (fix-now vs. document-and-defer, each with rationale), fix-now findings
|
|
||||||
are remediated or assigned to the wave that opens their file, and **Daniel has signed off on every
|
|
||||||
disposition.** Q-W1 does not begin otherwise. (CONTEXT.md §Phase Q Q-W0; naming/DSP smell
|
|
||||||
categories §2c of `docs/product/code-organization.md`.)
|
|
||||||
- **Hot-path call/inline shape** — before landing each split, confirm no virtual dispatch and no
|
|
||||||
header→TU indirection was added on `peaks` envelope compute, audition/preview, or the realtime
|
|
||||||
tick. `computeEnvelope` stays a free function on `const std::vector<float>&`;
|
|
||||||
audition stays a direct call-through; the idle tick stays a single pointer test. (CONTEXT.md
|
|
||||||
§Phase Q, `docs/product/code-organization.md` §3.)
|
|
||||||
- **Command-id + display strings are FOREVER-STABLE** — a reorg must not change a shipped
|
|
||||||
`command_id` string, action display name, ext-state namespace, or VST3 class UID. Re-namespacing
|
|
||||||
C++ symbols is orthogonal to these on-the-wire/on-disk contract strings; keep them byte-identical
|
|
||||||
(per-channel, per the Phase V V4 contract). (CONTEXT.md §Phase Q.)
|
|
||||||
- **Name-collision sweep on sub-namespacing** — `Sample` (model) vs `AudioSample` (audio) vs the
|
|
||||||
unified `Parser` (json), plus the shared pure-UI rect types `FooterRect`/`ButtonRect` (already
|
|
||||||
hand-collision-checked in `footer_bar.h`), and any other cross-lib name that collides once
|
|
||||||
flattened into granular namespaces. Resolve by each symbol's new subsystem home before landing
|
|
||||||
W1. (naming audit §2b.2; `docs/product/code-organization.md` §6 Q-4/Q-8.)
|
|
||||||
- **Naming stays zero-behavior-change and off the wire** — the §2b renames touch C++ symbols only;
|
|
||||||
no `command_id` string, action display name, ext-state namespace/key, `reasampler:` lane prefix,
|
|
||||||
or VST3 class UID is renamed (naming audit §2b.5). A rename that would alter a shipped contract
|
|
||||||
literal is out of scope, full stop.
|
|
||||||
- **The GATE** — do not begin any Q point until **Phase S + Phase L L3** are merged to dev and the
|
|
||||||
tree is quiescent (D2 complete, M9 abandoned). Re-confirm quiescence against dev before W1.
|
|
||||||
@@ -1,20 +1,38 @@
|
|||||||
# ReaSampler
|
# ReaSampler
|
||||||
|
|
||||||
A native C++ REAPER extension that captures any arbitrary audio source into a
|
Version 1.4.0 · License: GNU AGPL v3 (see `LICENSE`)
|
||||||
per-project **sample bank** (cached files + a docked grid), decoupled from the
|
|
||||||
arrange view, with keyboard/MIDI-bindable capture and placement. Built as a
|
A per-project audio sample-bank capture tool for REAPER, built as two artifacts: a
|
||||||
precision tool: deterministic, non-destructive, no clutter.
|
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
|
## The two artifacts
|
||||||
|
|
||||||
**`reaper_reasampler`** — the REAPER extension. Captures audio into a named,
|
**`reaper_reasampler`** — the REAPER extension. Captures audio into a named,
|
||||||
per-project sample bank, displays it in a docked panel, and provides bindable
|
per-project sample bank and provides bindable actions across the workflow: offline
|
||||||
actions for capture and placement.
|
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
|
**ReaSampler 9000** (`reasampler_9000.vst3`) — a Windows-only VST3 sampler
|
||||||
instrument that plays bank captures back across a MIDI keyboard. The VST3 target
|
instrument that plays bank captures back across a MIDI keyboard. Since
|
||||||
(`reasampler_vst`) is gated on the vendored `vendor/vst3sdk` slice; configure
|
`ComponentState` v10 it is self-contained: it decodes samples from its own
|
||||||
quietly omits it if the slice is absent.
|
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
|
A **beta channel** build is available via `-DREASAMPLER_CHANNEL=beta` at configure
|
||||||
time, producing `reaper_reasampler_beta` and `reasampler_9000_beta.vst3`. The two
|
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
|
is a distinct, on-demand action. Any code path that auto-inserts a capture into the
|
||||||
timeline violates the purpose of the tool.
|
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
|
## 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
|
git submodule update --init
|
||||||
|
|
||||||
Vendors three submodules:
|
Vendors three submodules:
|
||||||
@@ -43,10 +88,25 @@ Vendors three submodules:
|
|||||||
|
|
||||||
cmake -B build -S .
|
cmake -B build -S .
|
||||||
cmake --build build
|
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.
|
On a multi-config generator (Visual Studio, Xcode), `cmake --build build` with no
|
||||||
`CMakeLists.txt` is the authoritative list of all targets.
|
`--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
|
### 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
|
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
|
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
|
## Install
|
||||||
|
|
||||||
Copy the built binary into REAPER's `UserPlugins/` folder
|
There is no hot reload; REAPER loads extensions at startup only.
|
||||||
(Options → Show REAPER resource path), then **restart REAPER**. Extensions load at
|
|
||||||
startup only; there is no hot reload.
|
**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
|
## Repo layout
|
||||||
|
|
||||||
The codebase is organized around one discipline: **pure, REAPER-free testable core
|
The codebase is organized around one discipline: **pure, REAPER/VST3-SDK-free
|
||||||
split from REAPER-facing shells**.
|
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
|
- `src/app/` — the REAPER extension's entry point (`main.cpp` only)
|
||||||
REAPER-facing shells (extension entry point, panel, actions, capture backends, etc.)
|
- `src/core/` — pure modules, no REAPER or VST3 SDK types, each with a
|
||||||
- `src/vst/` — the VST3 instrument: pure voice engine + zone payload + editor UI
|
`<module>_tests` target: `audio/`, `capture/`, `instrument/` (further split into
|
||||||
pure modules, and the VST3 shells
|
`bake/`, `engine/filter/`, `engine/loop/`, `map/`, `note/`, `ui/`), `json/`,
|
||||||
- `tests/` — unit tests for the pure core modules
|
`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
|
- `vendor/` — git submodules
|
||||||
|
|
||||||
See `CLAUDE.md` for the full module inventory, architectural contracts, and the
|
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
|
## Further reading
|
||||||
|
|
||||||
- `CLAUDE.md` — architecture, module inventory, and build/API contracts
|
- `CLAUDE.md` — architecture, module inventory, and build/API contracts
|
||||||
- `CONTEXT.md` — the authoritative spec (large: ~186k — grep the relevant section rather than reading whole)
|
- `docs/PLAN.md` — the active roadmap
|
||||||
- `CONTEXT-ARCHIVE.md` — build detail for landed work
|
- `docs/COMPLETED.md` — landed milestones for the current (1.x) cycle
|
||||||
- `PLAN.md` — roadmap
|
- `docs/TODO.md` — deferred follow-ups, with the reason each was deferred
|
||||||
- `COMPLETED.md` — landed milestones
|
- `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
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
**The wart.** The per-instance identity token is minted fresh each incarnation and is NOT persisted. After save→reopen, an instance cannot recognize its OWN prior-session usage record — it looks foreign, so the instance defensively unions and marks the record append-only (poisoned). Net effect: after any reopen, prune stops reclaiming captures an instance once loaded but no longer uses. Safe (never deletes a used capture), but the bank folder grows without bound.
|
|
||||||
|
|
||||||
**Intended fix.** Persist the instance identity in ComponentState so an instance recognizes its own last-session record and does a clean-replace instead of union/poison → prune reclaims de-referenced instance-touched captures normally.
|
|
||||||
|
|
||||||
**The constraint the fix MUST handle (why deferred).** VST3 provides no stable per-instance identity, and Ctrl+D / in-place FX duplication clones plugin state. A persisted identity is inherited by an in-place duplicate → two live instances in one project share one `rsusage_<guid>` key. Harmless while both hold the same capture; the risk is a divergent clone — the copies load DIFFERENT captures, and last-writer-wins drops the other's held capture from the record, exposing it to prune. The fix must detect a genuine live same-identity collision and protect the union in that case, WITHOUT reintroducing the sibling-drop bug the fresh-per-session token was originally added to prevent. (Whole-project copies are a non-issue — bank files are cloned with the project and ext-state is per-project.)
|
|
||||||
|
|
||||||
**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.
|
|
||||||
@@ -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()
|
||||||
+1065
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,888 @@
|
|||||||
|
# COMPLETED.md — ReaSampler landed milestones
|
||||||
|
|
||||||
|
Completed milestone entries removed from `PLAN.md`. Each entry preserves its
|
||||||
|
original Goal, Verify, and checklist points with boxes marked done.
|
||||||
|
|
||||||
|
This file holds the current (1.x) cycle's landed milestones only. For all
|
||||||
|
pre-1.0 (version-0) history, see `docs/ARCHIVE.md`.
|
||||||
|
|
||||||
|
### Comment-reduction pass (tree-wide, twelve parallel tracks)
|
||||||
|
|
||||||
|
Cut source comment volume tree-wide: 209 files changed, net **−6,493** lines.
|
||||||
|
Comment-only lines went from 15,073 to ~8,671, a **~42% cut** — before the
|
||||||
|
pass, 37% of all source lines were comment-only. **Zero code drift**, verified
|
||||||
|
across all 209 files by comparing comment-stripped hashes; the sole
|
||||||
|
intentional exception (Daniel-approved) is two user-facing error strings in
|
||||||
|
`src/shell/capture/capture.cpp` that lost internal milestone IDs (`M8`, `M3`,
|
||||||
|
`M7+`). Build clean, 61/61 ctest pass. Code review surfaced 2 Major + 8 Minor
|
||||||
|
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
+1682
File diff suppressed because it is too large
Load Diff
+707
@@ -0,0 +1,707 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**The wart.** The per-instance identity token is minted fresh each incarnation and is NOT persisted. After save→reopen, an instance cannot recognize its OWN prior-session usage record — it looks foreign, so the instance defensively unions and marks the record append-only (poisoned). Net effect: after any reopen, prune stops reclaiming captures an instance once loaded but no longer uses. Safe (never deletes a used capture), but the bank folder grows without bound.
|
||||||
|
|
||||||
|
**Intended fix.** Persist the instance identity in ComponentState so an instance recognizes its own last-session record and does a clean-replace instead of union/poison → prune reclaims de-referenced instance-touched captures normally.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle (why deferred).** VST3 provides no stable per-instance identity, and Ctrl+D / in-place FX duplication clones plugin state. A persisted identity is inherited by an in-place duplicate → two live instances in one project share one `rsusage_<guid>` key. Harmless while both hold the same capture; the risk is a divergent clone — the copies load DIFFERENT captures, and last-writer-wins drops the other's held capture from the record, exposing it to prune. The fix must detect a genuine live same-identity collision and protect the union in that case, WITHOUT reintroducing the sibling-drop bug the fresh-per-session token was originally added to prevent. (Whole-project copies are a non-issue — bank files are cloned with the project and ext-state is per-project.)
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Context (carried from PLAN.md's "Open questions to resolve during build").** The FX-scope capture neutralizes out-of-scope FX, gain, and pan for both item and track scope — root `CLAUDE.md`'s "Capture FX scope" precision invariant states this precisely: 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. **Aux/parallel sends are conspicuously absent from that enumerated list** — the invariant as currently written does not cover them, which is the gap this item exists to close.
|
||||||
|
|
||||||
|
**The wart.** A downstream coloring send (e.g. a folder → reverb-track send) still routes and blends into an item/track capture, past the intended isolation point. Repro from PLAN.md: folder F1; T1 (MIDI) sends MIDI to T2 (synth); T1+T2 → F1; F1 sends to reverb T3; capturing the MIDI item on T1 currently includes the reverb, but should be isolated to T2's synth output pre-F1 with the MIDI send preserved and the reverb send excluded.
|
||||||
|
|
||||||
|
**Intended fix.** Likely approach (PLAN.md): snapshot + mute out-of-scope tracks' aux sends during the render while preserving the main/source signal path.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** Distinguish **source routing that must be preserved** (e.g. a MIDI send T1→T2 where T2's synth is where a MIDI item's audio is actually produced — the "item level" for that MIDI item is T2's synth output) from **coloring sends that must be excluded** (folder→reverb). PLAN.md notes this is "the hard part" and that a rule is needed for which sends are load-bearing.
|
||||||
|
|
||||||
|
**Settled rule (Daniel, 2026-07-29).** The classification rule for which sends are load-bearing: (1) **ancestor sends are excluded** from the capture — the folder parent is *up* the folder tree but *downstream* in signal flow, so this item deliberately says "ancestor," not "upstream," which would read backwards to anyone applying signal-flow convention; (2) **sibling sends are preserved and captured** — a sibling is a track sharing the capture scope's parent; (3) **if the send's destination has a different parent, ignore it in the capture** — it isn't a sibling, so it isn't captured. Applied to the repro above: the T1→T2 MIDI send is a sibling send (T1 and T2 share parent F1) and is preserved — T2's synth is where the item's audio is actually produced; the F1→T3 reverb send is an ancestor send and is excluded. This resolves the repro.
|
||||||
|
|
||||||
|
**Still open.** The rule above settles *which sends to mute*, but the repro is only fully resolved together with a second, unspecified lever — *where the capture taps*. PLAN.md's own framing of this open question says a true item-level capture should be taken "at the isolated graph point — the target scope's output before out-of-scope track FX/gain/pan and before out-of-scope aux/parallel sends." How the mute-rule above and the tap-point interact is not yet specified.
|
||||||
|
|
||||||
|
**Priority / risk.** Not stated in PLAN.md (recorded there as an open `(TODO)` question, not yet triaged to a priority). Flagged here as mattering more than the other four carried-over items because it exposes a live gap in a stated precision invariant, not just a deferred feature.
|
||||||
|
|
||||||
|
**Done looks like.** Capturing the MIDI item on T1 in the repro above is isolated to T2's synth output pre-F1 — the T1→T2 MIDI send is preserved and the F1→reverb send is excluded — and the CLAUDE.md "Capture FX scope" invariant's neutralization list is extended to name sends explicitly.
|
||||||
|
|
||||||
|
## Confirm REAPER's VST3 UID-vs-filename instance rebind behavior (Phase S compat verification)
|
||||||
|
|
||||||
|
**Context.** PLAN.md, under "Phase S — product name (ReaSampler 9000)": the working assumption is that REAPER rebinds a saved instance by its VST3 class UID, not by the module filename, so a filename rename with an unchanged UID keeps saved projects working (existing instances still resolve).
|
||||||
|
|
||||||
|
**The wart.** This is not yet confirmed from source — PLAN.md records that a web check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's binding is more nuanced than "UID only" (it can involve an FXID match), so UID-rebind is to-verify, not asserted fact.
|
||||||
|
|
||||||
|
**Intended fix / required check.** DAW-verify: save a project with a ReaSampler 9000 instance under the old filename, rename the module, reopen — confirm the instance rebinds and restores its state.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** If REAPER does key partly on filename, the fallback is to keep the current filename (display-strings-only) and record that as the shipped choice.
|
||||||
|
|
||||||
|
**Priority / risk.** Marked "must-DAW-verify before shipping the rename" in PLAN.md; no explicit priority level stated beyond that.
|
||||||
|
|
||||||
|
**Done looks like.** The save→rename→reopen DAW check is performed and its result (rebind confirmed, or filename-revert fallback taken) is recorded.
|
||||||
|
|
||||||
|
## S13 — cross-artifact ingest relay (deferred, spike verdict DEGRADED)
|
||||||
|
|
||||||
|
**Context.** PLAN.md records the ps-w12 (2026-07-27) spike verdict: DEGRADED — relay deferred. The instrument's REAPER bridge (`reaper_bridge`) is deliberately READ-ONLY; a relay would need a new instrument WRITE seam into ext-state and an extension-side timer poller servicing a drop-ingest inbox key with a claim/clear nonce — the same cross-process handshake race the S17 spec rejected for alternative (A). The shipped ingest gesture stays drop-onto-docked-panel (S8); the editor shows a "drop files onto the ReaSampler bank panel to add them" affordance as the degrade path.
|
||||||
|
|
||||||
|
**The wart.** Dropping a file directly onto the editor/instrument does not ingest it into the bank — only drop-onto-docked-panel does.
|
||||||
|
|
||||||
|
**Intended fix.** The editor hands the dropped path + this instance's identity to the extension as a bank-ingest request over an agreed seam.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** Requires (a) a new instrument WRITE seam into ext-state (breaking the current read-only-bridge invariant) and (b) an extension-side timer poller + claim/clear nonce — both are load-bearing design calls that need to be made deliberately, not as a call-site patch. Both the read-only-instrument boundary and the new poller were judged load-bearing enough that the relay is deferred to a future wave rather than pushed through now.
|
||||||
|
|
||||||
|
**Priority / risk.** PLAN.md marks this DEFERRED, awaiting a future wave, with no priority assigned.
|
||||||
|
|
||||||
|
**Done looks like.** Not stated in PLAN.md beyond "a future wave when the design is ready."
|
||||||
|
|
||||||
|
## Phase D2 — per-track lane/mode-state panel indicator (deferred)
|
||||||
|
|
||||||
|
**Context.** PLAN.md: Phase D2 is functionally complete (D2-W1, D2-W2, D2-W3-A, D2-W3-B all landed). One item was deferred out of that completion: a panel UI indicator for per-track lane/mode state (a per-track lane-split marker).
|
||||||
|
|
||||||
|
**The wart.** The mode switch already shows the active mode, but there is no per-track indicator; PLAN.md records that no natural cheap home for one was found in the bank panel.
|
||||||
|
|
||||||
|
**Intended fix.** Not specified in PLAN.md beyond the goal (a per-track lane-split marker in the bank panel) — the design is unresolved, which is part of why it was deferred rather than built.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** Finding a home for the indicator in the bank panel's existing layout, which PLAN.md notes doesn't currently have a natural cheap spot for it.
|
||||||
|
|
||||||
|
**Priority / risk.** Not stated in PLAN.md. PLAN.md's own framing: "Explicitly deferred — not silently dropped. Can be picked up later if wanted."
|
||||||
|
|
||||||
|
**Done looks like.** Not stated in PLAN.md.
|
||||||
|
|
||||||
|
## FX-GUID keying for `restoreFxOffline` (Design View park/restore)
|
||||||
|
|
||||||
|
**Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section): the bulk of reconcile residuals shipped (`ViewModeModel::reconcile(liveGuids)` prunes orphaned snapshots on every toggle/load; folder restructure is self-healing because the tree is rebuilt each toggle; membership is intentionally kept so undo-delete preserves the tag). Two sub-items were left deferred out of that; this is the first.
|
||||||
|
|
||||||
|
**The wart.** `restoreFxOffline` currently restores per-FX offline state by slot index. If the FX chain is reshuffled while a track is parked, restore lands on whatever plugin now occupies that slot rather than the plugin it was originally captured from.
|
||||||
|
|
||||||
|
**Intended fix.** FX-GUID keying — key the per-FX offline snapshot entries by FX identity rather than slot index.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** The keying change requires a snapshot-schema migration; CONTEXT.md names this alongside the keying change as the reason the fix was deferred rather than folded into the reconcile-residuals work.
|
||||||
|
|
||||||
|
**Priority / risk.** Not stated in the source.
|
||||||
|
|
||||||
|
**Done looks like.** Not stated in the source beyond the fix description above.
|
||||||
|
|
||||||
|
## Dormant membership entries in persisted `view_state`
|
||||||
|
|
||||||
|
**Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section), the second of the two sub-items left deferred after the reconcile-residuals ship described above.
|
||||||
|
|
||||||
|
**The wart.** Truly-deleted tracks accumulate stale entries in persisted `view_state`.
|
||||||
|
|
||||||
|
**Intended fix.** A future user-initiated "compact" action to remove the stale entries.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** Must NOT be automatic pruning — automatic pruning would reintroduce the undo-delete tag-loss that the deliberate membership-retention was designed to prevent.
|
||||||
|
|
||||||
|
**Priority / risk.** Not stated as a priority level; the source characterizes the wart itself as "harmless and bounded."
|
||||||
|
|
||||||
|
**Done looks like.** Not stated in the source beyond "a future user-initiated 'compact' action."
|
||||||
|
|
||||||
|
## Confirm no fight between Design View flags and screenset recall
|
||||||
|
|
||||||
|
**Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section): Design View drives the same track flags a screenset recall would drive, and last writer wins between the two.
|
||||||
|
|
||||||
|
**The wart.** Not a defect — this is a verification task, not a code change. The open concern is confirming there is no surprising interaction between Design View's flag-driving and an active screenset recall.
|
||||||
|
|
||||||
|
**Intended fix.** N/A — no fix is proposed; the task is to confirm no surprising fight between the two mechanisms.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** N/A — verification only.
|
||||||
|
|
||||||
|
**Priority / risk.** Not stated in the source.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**The wart.** No placement chosen yet among three candidates: per-region headers, a single header readout, or a lit-tab treatment.
|
||||||
|
|
||||||
|
**Intended fix.** Not stated in the source beyond the three candidate placements above — the choice among them is the open item. This is explicitly a panel-polish detail.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** Not stated in the source.
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# Q-W0 Track 1 — DSP / audio algorithm-quality audit (findings)
|
||||||
|
|
||||||
|
Static analysis only; no code changed. Surfaces per the track brief: `src/vst/pitch_shift.{h,cpp}`
|
||||||
|
(highest priority — GA correlation-aligned SOLA rewrite + GA2 prime + GA3 freeze, never audited),
|
||||||
|
`src/vst/sampler_core.{h,cpp}`, `src/peaks.*`, `src/wav_trim.*`, the capture/tail paths
|
||||||
|
(`src/capture.cpp`, `src/capture_realtime.cpp`, `src/realtime_record.h`), `src/vst/master_gain.*`,
|
||||||
|
`src/vst/velocity_curve.*`.
|
||||||
|
|
||||||
|
Dispositions follow Q-11 (SETTLED): default document-and-defer; a bounded SOLA fix is weighed
|
||||||
|
before any technique replacement; technique replacement is Daniel's decision at triage. Note on
|
||||||
|
"assigned wave": **no structural wave Q-W1..Q-W6 opens the `src/vst/` DSP files** (they target
|
||||||
|
`core/json`, `bank_panel`, `main`, `actions`, `persist`, registration tables) — so any DSP finding
|
||||||
|
triaged fix-now is remediated in Q-W0 itself or folded in as a new point before Q-W1 begins.
|
||||||
|
|
||||||
|
I cannot listen; every artifact below is stated as mechanism + predicted audible consequence.
|
||||||
|
Perceptual materiality is Daniel's call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overall verdict on the pitch engine (Q-11 framing)
|
||||||
|
|
||||||
|
The correlation-aligned SOLA in `pitch_shift` is **not a reinvented wheel in the pejorative
|
||||||
|
sense** — single-tap SOLA with normalized cross-correlation splice alignment, parabolic sub-sample
|
||||||
|
peak refinement, and amplitude-complementary raised-cosine fades *is* an established time-domain
|
||||||
|
technique family (SOLA/TD-PSOLA lineage), and the implementation is unusually well-defended:
|
||||||
|
normalized (not raw) correlation, ratio-scaled fade lengths with a drain-headroom derivation,
|
||||||
|
prime-with-real-content onset, frozen-writer tail, filled-span clamping, and double-before-int64
|
||||||
|
clamps at the overflow-prone spots. The RT discipline holds throughout: `process()` does no
|
||||||
|
allocation, no locks; the splice burst is bounded and fires once per splice cadence, not per frame.
|
||||||
|
**No technique replacement (phase-vocoder / WSOLA) is warranted on this evidence.** The findings
|
||||||
|
below are bounded-fix candidates and documented limits within the existing approach, exactly the
|
||||||
|
Q-11 escalation ladder's first rung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### T1-01 — Stereo Preserve: per-channel independent splice alignment decorrelates L/R
|
||||||
|
- **Location:** `src/vst/sampler_core.cpp` `Voice::advanceFrame` (Preserve branch, ~577–589) +
|
||||||
|
`src/vst/pitch_shift.cpp` `PitchShifter::splice`.
|
||||||
|
- **Mechanism:** a stereo Preserve voice owns two `PitchShifter`s, each running its **own**
|
||||||
|
correlation search on its own channel's PCM. `bestLag + frac` differ per channel at every splice,
|
||||||
|
so after the first splice the two read taps sit at different ring positions — an inter-channel
|
||||||
|
time offset of up to ±`maxLag` (= window/4 ≈ **12.5 ms** at the 50 ms window), re-drawn at every
|
||||||
|
splice (cadence ≈ window/|ratio−1| frames). The splice *schedules* also diverge (delay drift
|
||||||
|
depends on tap position), so L and R fade at different times.
|
||||||
|
- **Predicted artifact:** on genuinely stereo captures played through Preserve off-root: stereo
|
||||||
|
image wander / widening that changes at the splice cadence, and comb-filter coloration on any
|
||||||
|
mono sum. Correlated stereo content (the common case for a captured bus) is the worst case.
|
||||||
|
Dual-mono (mono sample in stereo bus) is unaffected — the code correctly mirrors one shifter.
|
||||||
|
- **Severity:** **High** (Preserve is the product-default pitch engine and the output bus is
|
||||||
|
permanently stereo with channel mode auto-defaulting from the capture — this hits the flagship
|
||||||
|
path on stereo material).
|
||||||
|
- **Disposition proposal:** this is the strongest candidate for a **bounded SOLA fix** (Q-11 rung
|
||||||
|
1): link the channels — run the correlation search once (on the L+R mid signal, or on L as
|
||||||
|
master) and apply the same `bestLag + frac` and splice schedule to both channels. Standard
|
||||||
|
practice for stereo SOLA. It reshapes the `PitchShifter` seam slightly (splice decision must be
|
||||||
|
computable once and applied to two rings — e.g. a lag-provider hook or a two-channel shifter),
|
||||||
|
but no technique change and no new dependency. Recommend **fix-now in Q-W0** pending Daniel's
|
||||||
|
triage call; if deferred, record it as the known stereo-Preserve limitation.
|
||||||
|
|
||||||
|
### T1-02 — Ratio slew mid-fade can drain the outgoing tap past the writer
|
||||||
|
- **Location:** `src/vst/pitch_shift.cpp` `splice()` fade-length cap (~301–311) + `process()` tap
|
||||||
|
advance (~360–366).
|
||||||
|
- **Mechanism:** `fadeLen_` is capped from the drain headroom **at splice time** using the
|
||||||
|
then-current `ratio_`. The pitch envelope legitimately slews the ratio per frame
|
||||||
|
(`setShiftRatio` mid-fade). A ratio that **rises** after the splice (pitch-env attack toward a
|
||||||
|
positive peak, or attack from a negative dip back to base) drains tap B faster than the cap
|
||||||
|
assumed; the code comment claims the 2-frame margin covers "any realistic per-frame bias", but
|
||||||
|
the margin is absolute, not slew-proportional: e.g. a splice at ratio ≈ 1 sets
|
||||||
|
`fadeLen_ = window/4` with no cap (drainRate ≈ 0), and a pitch-env attack ramping to +24 st
|
||||||
|
(ratio 4) within those ~12 ms drains tap B ≈ 3·window/4 — far past the `dLow` ≈ window/4
|
||||||
|
headroom. Tap B laps the parked/advancing writer and reads ring-length-stale content at up to
|
||||||
|
~half fade gain.
|
||||||
|
- **Predicted artifact:** a periodic click/garble burst at the splice cadence during fast upward
|
||||||
|
pitch-envelope ramps on Preserve voices. Only reachable with the AD pitch envelope enabled and
|
||||||
|
steep; base transpositions (constant ratio) are correctly covered by the existing cap.
|
||||||
|
- **Severity:** Med.
|
||||||
|
- **Disposition proposal:** document-and-defer (needs pitch-env + Preserve + steep attack to
|
||||||
|
trigger), with a cheap bounded fix noted for whenever the file is opened: re-tighten `fadeLen_`
|
||||||
|
when the ratio increases mid-fade (the `freezeTail()` re-anchor block is the exact pattern to
|
||||||
|
reuse), or clamp tap B's delay to ≥ 2 during a fade.
|
||||||
|
|
||||||
|
### T1-03 — Preserve prime ignores the Trigger play-end bound (short-span rings hold cut content)
|
||||||
|
- **Location:** `src/vst/sampler_core.cpp` `Voice::start` prime block (~375–400) vs. the GA3
|
||||||
|
`feedBound` in `advanceFrame` (~568–572).
|
||||||
|
- **Mechanism:** the per-frame feed treats `playEnd_` (Trigger) / `frameCount` as source
|
||||||
|
exhaustion and freezes the writer so "no padding ever enters the ring" (GA3). But `start()`
|
||||||
|
primes a **full window** from `pcm[q]` bounded only by `frameCount` — not by `playEnd_` — and
|
||||||
|
pads with zeros past the sample end while declaring the whole window `filled_`. Two
|
||||||
|
consequences for spans shorter than the 50 ms window: (a) a Trigger zone's ring holds real PCM
|
||||||
|
**past the user's chosen stop**, which an up-shifted tap can reach and play (transposed) before
|
||||||
|
`readPos_ ≥ playEnd_` frees the voice; (b) a sample shorter than the window gets zero padding
|
||||||
|
inside the ring as declared-valid history, so splices can land in silence — a bounded re-entry
|
||||||
|
of exactly the burst/gap onset artifact GA2/GA3 eliminated, scoped to sub-50 ms material (short
|
||||||
|
drum one-shots are realistic content).
|
||||||
|
- **Severity:** Med (bounded to short spans / short samples in Preserve; inaudible for spans ≥ one
|
||||||
|
window).
|
||||||
|
- **Disposition proposal:** bounded fix candidate — prime `min(window, span-to-feedBound)` frames
|
||||||
|
and call `freezeTail()` immediately after prime when the span is shorter than a window (the GA3
|
||||||
|
machinery then recycles the real short tail, which is its designed behavior). Small and
|
||||||
|
contained in `Voice::start`. Recommend fix-now in Q-W0 if Daniel agrees the short-one-shot case
|
||||||
|
matters; else document-and-defer with this note as the record.
|
||||||
|
|
||||||
|
### T1-04 — No sustain-loop crossfade (hard loop seam)
|
||||||
|
- **Location:** `src/vst/sampler_core.cpp` `Voice::advanceFrame` loop wrap (~493–498, 610–612).
|
||||||
|
- **Mechanism:** the sustain loop wraps by subtracting the loop length (phase-preserving) and the
|
||||||
|
interpolation partner wraps `i1 → loop.start`, giving one-sample continuity only. There is no
|
||||||
|
crossfade region: unless the user's loop points sit at amplitude/slope-matched positions, every
|
||||||
|
loop pass produces a step discontinuity — a click at the loop rate. `waveform_view`'s
|
||||||
|
zero-crossing snap on the loop markers mitigates but does not remove it (zero crossings with
|
||||||
|
mismatched slopes still click). Established samplers crossfade the loop seam (equal-power over a
|
||||||
|
user- or fixed-length region).
|
||||||
|
- **Severity:** Med (musically prominent when it hits, fully user-avoidable with careful loop
|
||||||
|
placement).
|
||||||
|
- **Disposition proposal:** document-and-defer — a loop-crossfade is a *feature* (needs a
|
||||||
|
crossfade-length parameter and UI surface), not a bug fix; wrong scope for a reorg phase. Record
|
||||||
|
as a known limitation beside the zone-loop spec.
|
||||||
|
|
||||||
|
### T1-05 — Linear interpolation + no band-limiting on repitch (both engines)
|
||||||
|
- **Location:** `src/vst/sampler_core.cpp` Varispeed read (~607–624); `src/vst/pitch_shift.cpp`
|
||||||
|
`readTap` (~172–185).
|
||||||
|
- **Mechanism:** all fractional reads are first-order (linear). Linear interpolation's frequency
|
||||||
|
response rolls off highs and leaks imaging sidebands (the interpolation image spectrum is
|
||||||
|
attenuated only ~12 dB/oct); Varispeed up-shifts additionally alias (reading faster than 1× with
|
||||||
|
no pre-filter folds source content above the post-shift Nyquist back into band). This is classic
|
||||||
|
hardware-sampler behavior — often accepted, sometimes desired — and both engines share it
|
||||||
|
consistently.
|
||||||
|
- **Severity:** Low (quality ceiling, not a defect; deterministic and stable).
|
||||||
|
- **Disposition proposal:** document-and-defer as a recorded trade-off. If a quality bump is ever
|
||||||
|
wanted, a 4-point cubic Hermite read is a drop-in bounded upgrade at both call sites (no
|
||||||
|
structural change); band-limited varispeed is a much bigger lift and not recommended.
|
||||||
|
|
||||||
|
### T1-06 — Correlation search: coarse step 4 can mis-lock on very high fundamentals; maxLag bounds alignment to ≥ ~80 Hz
|
||||||
|
- **Location:** `src/vst/pitch_shift.cpp` `splice()` search loops (~240–257) and `configure()`
|
||||||
|
geometry (~68–72).
|
||||||
|
- **Mechanism:** two documented-by-construction limits. (a) The coarse search samples the
|
||||||
|
correlation every 4 lags and refines ±3 around the coarse best — full integer coverage only
|
||||||
|
*near* the coarse winner. For content whose correlation oscillates with period < ~8 samples
|
||||||
|
(fundamentals above ~5.5 kHz at 44.1k), the coarse grid can alias and lock a non-optimal region;
|
||||||
|
the splice then lands up to half a period misaligned. (b) `maxLag = window/4` (~12.5 ms) cannot
|
||||||
|
span a full period below ~80 Hz, so deep-bass fundamentals cannot be period-aligned and splices
|
||||||
|
degrade toward unaligned OLA there. Both are inherent range/cost trades every SOLA makes; the
|
||||||
|
in-code comment already states (b).
|
||||||
|
- **Severity:** Low (edge content: pure tones > 5 kHz, fundamentals < 80 Hz).
|
||||||
|
- **Disposition proposal:** document-and-defer; record both bounds as the engine's stated
|
||||||
|
operating range. No change recommended — widening either costs splice-burst CPU linearly.
|
||||||
|
|
||||||
|
### T1-07 — `splice()` up-jump clamp comment contradicts the code (margin direction)
|
||||||
|
- **Location:** `src/vst/pitch_shift.cpp` ~196–210.
|
||||||
|
- **Mechanism:** the comment derives the "tight cap" as `filled_ - d - maxLag_ - 2`, then says the
|
||||||
|
code's `- 1` is "one sample of conservative margin" — but `-1` permits a *larger* jump than
|
||||||
|
`-2`, i.e. the code is *less* restrictive than the comment's own derivation; the sentence has
|
||||||
|
the direction backwards. Re-deriving: the deepest probe is the parabola's outer lag at
|
||||||
|
`d + jump + maxLag + 1` (the interpolator's `i1 = i0 + 1` read-ahead moves *younger*, not
|
||||||
|
deeper), so the code's `-1` is exactly tight and the comment's `-2` double-counts the
|
||||||
|
interpolator. No out-of-range read either way; the comment is wrong, not the code.
|
||||||
|
- **Severity:** Low (doc-only; misleads the next maintainer of a safety-critical clamp).
|
||||||
|
- **Disposition proposal:** fix-now (comment rewrite, zero behavior change) — fold into whichever
|
||||||
|
Q-W0 remediation touches `pitch_shift`; if none does, a standalone one-line doc fix in Q-W0.
|
||||||
|
|
||||||
|
### T1-08 — Linear-in-amplitude ADSR decay/release segments
|
||||||
|
- **Location:** `src/vst/sampler_core.cpp` `AdsrEnvelope::tick` (~131–170).
|
||||||
|
- **Mechanism:** decay and release ramp linearly in amplitude. Constant-slope amplitude is
|
||||||
|
constant-dB-rate nowhere: a long release spends most of its wall-clock at perceptually loud
|
||||||
|
levels then collapses abruptly (in dB terms the curve is logarithmic-late). Classic samplers use
|
||||||
|
exponential (constant-ratio) segments for decay/release. The evaluator itself is correct and
|
||||||
|
well-tested (release-from-current-level, hold-0 byte-compat re-dispatch are both right).
|
||||||
|
- **Severity:** Low (character, not correctness; the perceptual judgment is Daniel's).
|
||||||
|
- **Disposition proposal:** document-and-defer. An exponential-segment option is a contained
|
||||||
|
evaluator change but alters every existing instrument's envelope feel — a product decision, not
|
||||||
|
a Q-W0 cleanup.
|
||||||
|
|
||||||
|
### T1-09 — Takeover-declick: `declickR_` is dead state
|
||||||
|
- **Location:** `src/vst/sampler_core.cpp` (~598–599, 639–646, 515–518).
|
||||||
|
- **Mechanism:** both channels deliberately share one blend weight (`declickL_` — commented), but
|
||||||
|
`declickR_` is still seeded and decayed every frame and never read for output. Dead state that
|
||||||
|
invites a future L/R-weight divergence bug. The blend itself audits **clean**: `out' =
|
||||||
|
(1−w)·out + w·ref` is a convex combination for w ∈ [0,1], so `|out'| ≤ max(|out|,|ref|)` — the
|
||||||
|
rev-2 boundedness claim is mathematically sound, the boundary-frame identity holds, and the
|
||||||
|
ring-out path on voice end is handled (the peer-path symmetry is present).
|
||||||
|
- **Severity:** Low (hygiene; no audio effect).
|
||||||
|
- **Disposition proposal:** fix-now-trivial (delete the field or rename the shared weight) — fold
|
||||||
|
into any Q-W0 edit of `sampler_core`; not worth its own change otherwise.
|
||||||
|
|
||||||
|
### T1-10 — `planWavTruncate` silently drops chunks located after `data`
|
||||||
|
- **Location:** `src/wav_trim.cpp` `planWavTruncate` (~151), `capture_realtime.cpp`
|
||||||
|
`trimAutoTailInPlace`.
|
||||||
|
- **Mechanism:** the plan truncates the file at `dataByteOffset + keptDataBytes`. Any RIFF chunk
|
||||||
|
REAPER wrote *after* the data chunk (bext/iXML/smpl orderings vary by writer) is discarded; the
|
||||||
|
RIFF size is patched consistently so the result is a valid WAV, but metadata is lost without a
|
||||||
|
trace. The PCM and the trim boundary math themselves audit clean (file-rate-authoritative frame
|
||||||
|
math, scan confined to the tail region, -72 dB threshold single-sourced from
|
||||||
|
`kAutoTrimThresholdDb`, one-frame-past-last-audible per spec, no-trim fallbacks total).
|
||||||
|
- **Severity:** Low (metadata only; audio unaffected; trim is a convenience path).
|
||||||
|
- **Disposition proposal:** document-and-defer — note the behavior in the header's FORMAT
|
||||||
|
ASSUMPTION block when the file is next touched. Preserving trailing chunks would complicate the
|
||||||
|
single-truncating-write design for no audio benefit.
|
||||||
|
|
||||||
|
### T1-11 — `makeUniqueTag` has one-second resolution (collision window)
|
||||||
|
- **Location:** `src/capture.cpp` (~224–227) and `src/capture_realtime.cpp` (~120–123).
|
||||||
|
- **Mechanism:** the uniqueness tag is `std::time(nullptr)` — 1 s resolution. Two captures of the
|
||||||
|
same `baseName` within the same wall-clock second derive the same file stem: the offline path
|
||||||
|
would overwrite the first render's file and mint two Samples with colliding ids. Reachable in
|
||||||
|
practice via `batch_capture` driving several short renders back-to-back. The realtime path
|
||||||
|
can't self-collide (transport exclusivity) but shares the pattern. DSP-adjacent rather than
|
||||||
|
DSP; recorded here because the capture paths are this track's surface — Track 2 may claim it.
|
||||||
|
- **Severity:** Low-Med (silent data loss on collision; narrow window).
|
||||||
|
- **Disposition proposal:** fix-now candidate, trivial: append a per-session monotonic counter to
|
||||||
|
the tag (both call sites). Belongs wherever Track 2/triage routes capture-path hygiene; Q-W3
|
||||||
|
(main/orchestration split) is the nearest wave that opens the extension capture flow, else Q-W0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surfaces that came back clean
|
||||||
|
|
||||||
|
- **`src/peaks.*` — clean.** The bin partition `[b·frames/binCount, (b+1)·frames/binCount)` is
|
||||||
|
exact integer math, remainder-distributing, no dropped tail; overflow guarded; short-buffer
|
||||||
|
clamped; per-channel with no fold (invariant honored). `columnMinMax` mirrors the partition with
|
||||||
|
64-bit products and the enclosing-bin fallback. `lastFrameAboveThreshold` scans backward with a
|
||||||
|
correct strictly-greater test and no wrap hazard.
|
||||||
|
- **`src/wav_trim.*` — clean** except T1-10 (metadata note). Chunk walk is bounds-checked and
|
||||||
|
total; even-byte padding honored; extensible-format float discrimination via the SubFormat GUID
|
||||||
|
leading tag is correct; LE reads via `memcpy` (no aliasing UB); truncate plan never grows.
|
||||||
|
- **`src/capture.cpp` (offline) — clean** from the algorithm-quality lens except T1-11. Exact
|
||||||
|
unrounded bounds, dither forced off (bit-identical repeats), float32-only with ground-truth
|
||||||
|
format blob, surgical trim-end normalize only in Auto, full snapshot/restore RAII. The
|
||||||
|
precision-invariant plumbing is disciplined.
|
||||||
|
- **`src/capture_realtime.cpp` + `src/realtime_record.h` — clean** except T1-11 (shared) and the
|
||||||
|
already-in-code DAW-verify flags (take/frame-0 alignment assumption for the trim; abort()'s
|
||||||
|
best-effort finalize racing the flush — both explicitly documented in place, correctly scoped).
|
||||||
|
The record state machine's decisions are pure and ceiling-bounded; the trim is best-effort and
|
||||||
|
never eats the range body.
|
||||||
|
- **`src/vst/master_gain.*` — clean.** Taper endpoints single-sourced; norm-0 true-zero detent
|
||||||
|
with the finite −60 dB floor; unity at ≈ 0.714 as documented; inverse collapses sub-floor values
|
||||||
|
to the detent (documented); non-finite input clamped. The dB↔linear math is correct.
|
||||||
|
- **`src/vst/velocity_curve.*` — clean.** The Fritsch–Carlson tangent is the standard
|
||||||
|
weighted-harmonic-mean form (w₁ = 2h₂ + h₁, w₂ = h₂ + 2h₁), which bounds m ≤ 3·min(d₁,d₂) —
|
||||||
|
monotonicity and no-overshoot inside [0,1] hold as claimed; sign-change/flat neighbors pin to 0;
|
||||||
|
zero-span steps and coincident-X knots are handled; deserialize repairs the invariant
|
||||||
|
defensively. `eval` once per note-on keeps it off the per-frame path.
|
||||||
|
- **`sampler_core` voice/steal/mono machinery — clean** (beyond the findings above): the steal
|
||||||
|
policy is deterministic and as documented; the mono held-stack has correct range guards against
|
||||||
|
uint8 aliasing, order-preserving removal, per-note velocity for retrigger fallback, and CC 123
|
||||||
|
as its only reset path; `soundingNote()` correctly excludes ring-out tails from the Preserve cap
|
||||||
|
and legato predicates; the two-tier panic semantics are right; both render overloads share one
|
||||||
|
summation discipline with no allocation; envelope/keymap resolution honors the rate-free-seconds
|
||||||
|
invariant (frames resolved at keymap build against the live rate — the prior frame-domain
|
||||||
|
incident is not repeated here). The per-frame `std::pow` when the pitch envelope is active is
|
||||||
|
bounded and acceptable.
|
||||||
|
- **`pitch_shift` core machinery — clean** (beyond the findings above): the safe-band geometry,
|
||||||
|
filled-span clamps, normalized correlation, parabolic sub-sample refinement, complementary
|
||||||
|
raised-cosine fade (correct for phase-aligned content; the −6 dB midpoint on uncorrelated
|
||||||
|
content is a documented, benign trade), `freezeTail`'s fade re-anchor continuity, and the
|
||||||
|
down-shift ring-lap margin all audit sound. Down-shift writer-lap is unreachable for any ratio
|
||||||
|
above ≈ −109 st; up-shift fade drain is covered to +24 st and beyond by the ratio-scaled cap
|
||||||
|
(T1-02 is the slew case only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary table
|
||||||
|
|
||||||
|
| ID | Surface | Finding | Severity | Disposition proposal |
|
||||||
|
|-------|----------------------------------|------------------------------------------------------------|----------|---------------------------------------------------|
|
||||||
|
| T1-01 | pitch_shift + sampler_core | Stereo Preserve: independent L/R splice alignment | High | Bounded SOLA fix (linked lag) — recommend fix-now in Q-W0; Daniel's call |
|
||||||
|
| T1-02 | pitch_shift | Ratio slew mid-fade can lap the outgoing tap | Med | Document-and-defer; bounded re-cap noted |
|
||||||
|
| T1-03 | sampler_core (Preserve prime) | Prime ignores Trigger playEnd / pads short samples | Med | Bounded fix candidate in Q-W0; else defer w/ note |
|
||||||
|
| T1-04 | sampler_core (loop) | No sustain-loop crossfade (hard seam) | Med | Document-and-defer (feature, not reorg scope) |
|
||||||
|
| T1-05 | sampler_core + pitch_shift | Linear interp, no band-limiting on repitch | Low | Document-and-defer (recorded trade-off) |
|
||||||
|
| T1-06 | pitch_shift | Coarse-search HF mis-lock; ≥ ~80 Hz alignment bound | Low | Document-and-defer (stated operating range) |
|
||||||
|
| T1-07 | pitch_shift | maxJump clamp comment contradicts code | Low | Fix-now (comment-only), in Q-W0 |
|
||||||
|
| T1-08 | sampler_core (ADSR) | Linear-amplitude decay/release segments | Low | Document-and-defer (product decision) |
|
||||||
|
| T1-09 | sampler_core (declick) | `declickR_` dead state | Low | Fix-now-trivial, fold into any Q-W0 edit |
|
||||||
|
| T1-10 | wav_trim | Truncate drops post-`data` chunks (metadata) | Low | Document-and-defer (header note) |
|
||||||
|
| T1-11 | capture.cpp + capture_realtime | 1 s-resolution unique tag → batch collision window | Low-Med | Fix-now candidate (monotonic counter); route at triage |
|
||||||
|
|
||||||
|
Clean surfaces: `peaks`, `master_gain`, `velocity_curve` (fully); `wav_trim`, offline + realtime
|
||||||
|
capture paths, and the non-flagged machinery of `sampler_core` / `pitch_shift` (clean with the
|
||||||
|
noted exceptions above).
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# Q-W0 Track 2 — architecture-smell audit (functional lens)
|
||||||
|
|
||||||
|
Static analysis of the whole `src/` tree (extension + `src/vst/`), 2026-07-28, branch
|
||||||
|
`pq-w0-audit`. Complement to the grep-verified SOLID audit (§2) and naming audit (§2b) in
|
||||||
|
`docs/product/code-organization.md` — this track reports the **functional** smells those did not
|
||||||
|
target: duplicated *algorithms* (not merely duplicated responsibilities), reinvented wheels,
|
||||||
|
poor abstractions, and leaky pure/shell boundaries. Findings already catalogued there (the four
|
||||||
|
god-modules, the 4× JSON `Parser`, fat headers, `promptText`/`mintBankId` duplication, namespace
|
||||||
|
flatness, naming families) are **not restated**; where a finding below touches the same file it
|
||||||
|
is because the functional mechanism is new.
|
||||||
|
|
||||||
|
Every claim below was verified by grep/read of the actual tree. Line numbers are as of this
|
||||||
|
audit's snapshot. Wave assignments reference the Q-W1..Q-W6 waves (landed history in
|
||||||
|
`docs/ARCHIVE.md`; sequencing in `docs/product/code-organization.md` §5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### T2-01 — 3× copy-pasted length-prefixed wire `Cursor`, with security-hardening drift
|
||||||
|
**Location:** `src/provenance.cpp:56–137`, `src/assignment_request.cpp:25–121`,
|
||||||
|
`src/sample_usage.cpp:25–89` (plus a fourth sibling: `src/vst/bank_sync.cpp:11–28`
|
||||||
|
`parseBankGeneration` re-rolls the same guarded decimal accumulate).
|
||||||
|
|
||||||
|
**Mechanism.** The `<len>':'<bytes>` ext-state wire idiom ("one grammar across every ext-state
|
||||||
|
seam", per sample_usage's own comment) is implemented as three near-identical `putField` +
|
||||||
|
`Cursor` copies — and they have **drifted on the hardening**. The two newer copies
|
||||||
|
(`assignment_request`, `sample_usage`) carry a 20-digit length cap and an overflow guard
|
||||||
|
(`len > (SIZE_MAX - digit) / 10 → fail`) plus the subtraction-first bounds check
|
||||||
|
(`len > s_.size() - start`). The oldest copy (`provenance.cpp:65–82`) has **neither**: a crafted
|
||||||
|
long digit run wraps `len` silently, and the additive bounds check `start + len > s_.size()`
|
||||||
|
can itself wrap, letting a wrapped length pass. Downstream, `parseFingerprint`
|
||||||
|
(`provenance.cpp:197–199`) calls `r.trackGuids.reserve(guidCount)` on an **unbounded** count
|
||||||
|
parsed by the equally unguarded `fieldSizeT` — a corrupt/crafted `Sample.provenance` string in
|
||||||
|
the bank JSON can drive `reserve(huge)` into `std::length_error`/`bad_alloc` thrown through the
|
||||||
|
shell. (`sample_usage` fixed exactly this with its `count > wire.size()/4 + 1` sanity bound,
|
||||||
|
`sample_usage.cpp:121`; the fix was never backported.) `std::string::assign` clamping keeps the
|
||||||
|
wrap short of UB, but the parse-integrity promise ("never UB, never a partial value") is upheld
|
||||||
|
in two copies and eroded in the third — the textbook cost of a duplicated algorithm.
|
||||||
|
|
||||||
|
**Severity:** High (the drift already produced a concrete robustness gap on a persisted,
|
||||||
|
user-editable input; the class of bug will recur with every new wire seam).
|
||||||
|
**Disposition:** **fix-now, split:** (a) backport the hardened `field()` + a count sanity bound
|
||||||
|
to `provenance.cpp` **in Q-W0** — small, pure, existing `provenance_tests` covers round-trip and
|
||||||
|
malformed-input paths; (b) the structural collapse (one shared `wire` codec module beside
|
||||||
|
`core/json`, all three seams + `parseBankGeneration` consuming it) belongs to **Q-W1**, which is
|
||||||
|
already the serialization-extraction wave. Rationale: the hazard is cheap to close now; the
|
||||||
|
dedup is a relocation-adjacent move that should ride the wave already creating `core/`.
|
||||||
|
|
||||||
|
### T2-02 — a FIFTH hand-rolled JSON decoder the §2 audit did not count
|
||||||
|
**Location:** `src/tail_control.cpp:88–130` (`valueAfterKey` + `deserializeTailSetting`).
|
||||||
|
|
||||||
|
**Mechanism.** The catalogued DRY violation is "JSON `Parser` duplicated 4×" (`bank_model`,
|
||||||
|
`bank_book`, `view_mode_model`, `owned_manifest`). `tail_control` carries a fifth, structurally
|
||||||
|
different JSON decode: a substring-scan reader (`json.find("\"key\"")` → skip ws → parse token).
|
||||||
|
It is correct for the flat single-object payload it reads (the file argues this honestly), but
|
||||||
|
it is a fifth place JSON-reading behavior is defined, with different tolerance semantics (a key
|
||||||
|
found inside a *string value* would match — impossible today only because the writer is its own
|
||||||
|
sole producer). If Q-W1 extracts `core/json` from the four `Parser`s and misses this site, the
|
||||||
|
"one JSON path" goal is silently not achieved.
|
||||||
|
|
||||||
|
**Severity:** Med (no live bug; a completeness gap in the already-planned fix).
|
||||||
|
**Disposition:** **fix-now, folded into Q-W1** — add `tail_control` to the Q-W1 consumer list
|
||||||
|
explicitly. Rationale: zero extra cost when `core/json` lands; a stray fifth decoder afterward
|
||||||
|
would be a defect of the wave.
|
||||||
|
|
||||||
|
### T2-03 — `readFileBytes` hand-rolled five times, both sides of the artifact split
|
||||||
|
**Location:** `src/capture.cpp:232`, `src/capture_realtime.cpp:329` (as `readAllBytes`),
|
||||||
|
`src/ingest.cpp:86` (comment admits: "of capture.cpp's readFileBytes"),
|
||||||
|
`src/vst/reasampler_processor.cpp:72`, and inline in `src/vst/reasampler_editor.cpp:749–756`.
|
||||||
|
|
||||||
|
**Mechanism.** The identical ifstream-binary-ate/tellg/read whole-file loader exists five times
|
||||||
|
(two spellings, one anonymous inline). Well past extract-on-third-occurrence, and the copies
|
||||||
|
already disagree cosmetically (name, empty-on-failure comment placement) — the next divergence
|
||||||
|
will be behavioral (e.g. one copy gaining a size ceiling the others lack).
|
||||||
|
|
||||||
|
**Severity:** Med.
|
||||||
|
**Disposition:** **fix-now, folded into Q-W1** — a trivial pure `readFileBytes` helper in the
|
||||||
|
`core/` utility home Q-W1 creates; both CMake targets link it. Rationale: five occurrences of a
|
||||||
|
ten-line function is pure debt with a zero-risk fix, but creating its home is exactly Q-W1's
|
||||||
|
job — doing it days earlier in the flat tree would just move the file twice.
|
||||||
|
|
||||||
|
### T2-04 — the growing `GetProjExtState` read loop, three copies, pure half only half-used
|
||||||
|
**Location:** `src/persist.cpp:140–157` (`getProjExtStateString`),
|
||||||
|
`src/vst/reaper_bridge.cpp:93–107` (self-described "mirrors persist.cpp's growing strategy"),
|
||||||
|
`src/usage_scan.cpp:168–181` (self-described "the persist.cpp idiom").
|
||||||
|
|
||||||
|
**Mechanism.** The grow-buffer-until-it-fits retry loop over `GetProjExtState` is implemented
|
||||||
|
three times, in three TUs, on both sides of the split. The fiddly part — interpreting the int
|
||||||
|
return against the filled buffer — is *already extracted pure* as
|
||||||
|
`bridge_marshal::decodeGetProjExtState`, but only `reaper_bridge` consumes it; `persist` and
|
||||||
|
`usage_scan` interpret `rv` inline with their own conventions (persist: `rv <= 0` → absent;
|
||||||
|
bridge: return AND non-empty buffer). The absent-vs-truncated-vs-empty semantics are precisely
|
||||||
|
the kind of edge that drifts when defined thrice. `usage_scan`'s copy is prune-safety-adjacent
|
||||||
|
(an unreadable usage record must abort the prune) — its read loop deserves the tested pure
|
||||||
|
decode, not an inline reimplementation.
|
||||||
|
|
||||||
|
**Severity:** Med.
|
||||||
|
**Disposition:** **fix-now, assigned to the downstream wave that opens `persist`**
|
||||||
|
(Q-W4 per the current wave map; whichever wave splits `persist.cpp` is the moment). Generalize
|
||||||
|
the retry policy (next-capacity/done decision) into `bridge_marshal` (or its `core/` successor)
|
||||||
|
and make all three loops consume it. Rationale: touching persist's session machinery outside
|
||||||
|
its own wave risks the highest-traffic shell for a dedup that has no live bug today.
|
||||||
|
|
||||||
|
### T2-05 — 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family
|
||||||
|
**Location (structs):** `action_bar.h:61`, `bank_grid.h:23`, `card_drag.h:109`,
|
||||||
|
`component_geometry.h:28,53,106`, `drag_out.h:40`, `footer_bar.h:41`, `mode_switch.h:21,35`,
|
||||||
|
`overflow_menu.h:23,39`, `prune_button.h:32,46`, `tab_strip.h:24,51`, `tooltip.h:20`,
|
||||||
|
`vst/editor_geometry.h:19`, `vst/velocity_curve.h:124`.
|
||||||
|
**Location (predicates):** inline half-open `px >= r.x && px < r.x + r.width && …` re-typed in
|
||||||
|
`action_bar.cpp`, `bank_grid.cpp`, `card_drag.cpp` (×2), `component_geometry.cpp` (×2),
|
||||||
|
`drag_out.cpp`, `footer_bar.cpp`, `mode_switch.cpp`, `overflow_menu.cpp`, `prune_button.cpp`,
|
||||||
|
`tab_strip.cpp` (×2), `bank_panel.cpp:1049`, plus `vst/editor_geometry.cpp:20`.
|
||||||
|
|
||||||
|
**Mechanism.** §2b.2 catalogued the *naming/collision* half of this (the `footer_bar.h` "NAME
|
||||||
|
NOTE" hand-checking smell). The functional half is uncatalogued: nineteen structurally identical
|
||||||
|
axis-aligned `{x, y, w, h}` record types, each with its own hand-typed containment predicate.
|
||||||
|
Every new pure-UI module re-mints both. This is Daniel's heuristic (b) verbatim: N near-identical
|
||||||
|
concrete implementations that one shared type collapses at compile time — one `ui::Rect` + one
|
||||||
|
`contains(Rect, x, y)` free function (both already exist in embryo as `vst/editor_geometry`'s
|
||||||
|
`Rect`/`contains`), with per-module aliases or thin wrappers only where a struct carries extra
|
||||||
|
fields (e.g. `TabRect::index`). Zero runtime cost; deletes ~15 chances for the next half-open/
|
||||||
|
closed-interval inconsistency to slip in.
|
||||||
|
|
||||||
|
**Severity:** Med.
|
||||||
|
**Disposition:** **fix-now, folded into Q-W2** (the ui/ relocation wave) — collapsing the type
|
||||||
|
zoo is nearly free precisely when every one of these files is being moved and re-namespaced;
|
||||||
|
doing it pre-reorg would churn 19 headers twice. Rationale: same-moment-as-relocation is the
|
||||||
|
stated principle for renames (§2b intro); it holds identically for type unification.
|
||||||
|
|
||||||
|
### T2-06 — pure-computable layout math stranded in the VST editor shell (the §2 scope gap)
|
||||||
|
**Location:** `src/vst/reasampler_editor.cpp` — `SampleFaceLayout` (~line 922) and its builder,
|
||||||
|
`ClusterLayout` (~line 964), `zonesStripArea`/`noteEntryFieldsArea`/`noteEntryFieldRect`/
|
||||||
|
`zonesControlPanel`/`zonesDeckArea`/`zonesCurveButton` (lines 992–1042), the channel-toggle
|
||||||
|
segment rects (~line 1065), banner rect math (~line 1195), among ~49 inline geometry
|
||||||
|
computations across the 3,065-LOC TU.
|
||||||
|
|
||||||
|
**Mechanism.** The codebase's own grammar homes exactly this class of math in pure modules
|
||||||
|
(`editor_geometry`, `knob_deck`, `curve_popup`, `capture_browser`, …), yet the editor shell has
|
||||||
|
accreted a second, untested layout layer: whole named layout structs and pure `Rect → Rect`
|
||||||
|
functions that take only ints and rects, compiled into the one TU that cannot be unit-tested
|
||||||
|
without a host window. This is the mirrored form of the pure/shell leak (algorithm math living
|
||||||
|
untestable in a shell). Note also the audit-scope gap this exposes: §2's god-module catalogue
|
||||||
|
covered the extension tree only — `reasampler_editor.cpp` (3,065 LOC) and
|
||||||
|
`reasampler_processor.cpp` (1,164 LOC) repeat the bank_panel pattern on the VST side and appear
|
||||||
|
in no existing finding.
|
||||||
|
|
||||||
|
**Severity:** Med (no correctness bug found in the stranded math; the cost is untestability and
|
||||||
|
the growth trajectory — the editor gained ~500 LOC/phase through r11).
|
||||||
|
**Disposition:** **document-and-defer, with a named reshape:** Q-W0 should surface a downstream
|
||||||
|
point (the wave that opens `src/vst/`, or a new one) hoisting the Sample-face/Zone-panel layout
|
||||||
|
into the existing pure homes (`editor_geometry` is the natural owner). Rationale: a hoist is a
|
||||||
|
behavior-preserving mechanical move best done under the reorg's test discipline, not pre-reorg;
|
||||||
|
but it must be a recorded point or the layer keeps growing.
|
||||||
|
|
||||||
|
### T2-07 — the extension links the entire voice engine to serialize one preset blob
|
||||||
|
**Location:** `CMakeLists.txt:383–385` (`instrument_drop` → PUBLIC `sample_map`);
|
||||||
|
`src/instrument_drop.cpp` includes `vst/sample_map.h`, which pulls `sampler_core.h` →
|
||||||
|
`pitch_shift.h` + `velocity_curve.h`.
|
||||||
|
|
||||||
|
**Mechanism.** `instrument_drop` (extension side) deliberately reuses
|
||||||
|
`sample_map::serializeComponentState` so the `.vstpreset` payload and the instrument's own
|
||||||
|
reader cannot drift — the right DRY call, explicitly documented in CMake. But the shared writer
|
||||||
|
lives *inside* the module that also owns zone resolution, WAV decode plumbing, and (via header
|
||||||
|
fan-in) the whole voice engine — so `reaper_reasampler` compiles and links `sampler_core`,
|
||||||
|
`pitch_shift`, and `velocity_curve` object code it never executes. The abstraction is right; its
|
||||||
|
*granularity* is wrong: the ComponentState codec is not separable from the engine stack today.
|
||||||
|
|
||||||
|
**Severity:** Low (dead weight in the binary and a misleading dependency edge; no runtime cost —
|
||||||
|
heuristic (c) is about call chains, which this does not add).
|
||||||
|
**Disposition:** **document-and-defer to Q-W1/Q-W2 module-homing:** when serialization gets its
|
||||||
|
`core/` home, split a `component_state` codec module (types + serialize/deserialize only) out of
|
||||||
|
`sample_map`; both artifacts link the codec, only the VST links the engine. Rationale: purely
|
||||||
|
structural, zero behavior change, and exactly the kind of module-boundary decision the reorg
|
||||||
|
waves exist to make once, deliberately.
|
||||||
|
|
||||||
|
### T2-08 — WAV/RIFF byte-format knowledge spread across four modules, two chunk walkers
|
||||||
|
**Location:** `src/wav_trim.cpp` (canonical parse: `parseWavLayout`/`extractFloatFrames`),
|
||||||
|
`src/capture_paths.cpp:31–115` (a second, independent RIFF chunk walker for content hashing),
|
||||||
|
`src/ingest.cpp:108–160` (hand-built 32f WAV writer), `src/capture_realtime.cpp:423` (in-place
|
||||||
|
RIFF/data size patch).
|
||||||
|
|
||||||
|
**Mechanism.** The tree is disciplined about *decoding* ("no third WAV reader" — sample_map,
|
||||||
|
editor, processor all route through `wav_trim`), but RIFF *container* knowledge is still minted
|
||||||
|
per site: `capture_paths` walks chunks with its own tag/size/pad-byte logic to hash `fmt `+`data`
|
||||||
|
while skipping metadata; `wav_trim` walks the same container shape for layout; `ingest` writes
|
||||||
|
headers by hand; `capture_realtime` patches sizes by offset. Four places know the RIFF framing
|
||||||
|
rules (even-byte padding, chunk-header arithmetic); a drift in any one (e.g. pad-byte handling)
|
||||||
|
would desynchronize hashing from decoding — the dedup-by-hash and null-test invariants both sit
|
||||||
|
on this.
|
||||||
|
|
||||||
|
**Severity:** Low (all four are currently correct against each other by inspection; the smell is
|
||||||
|
the maintenance surface, not a live divergence).
|
||||||
|
**Disposition:** **document-and-defer** — consolidate into a `core/wav` home (walker + layout +
|
||||||
|
writer + patch) when the reorg assigns module homes. Rationale: pre-reorg consolidation churns
|
||||||
|
the capture hot path (§3 guardrail) for no functional gain; the reorg wave that relocates
|
||||||
|
`wav_trim` is the natural moment.
|
||||||
|
|
||||||
|
### T2-09 — the two capture backends' Sample-stamping epilogue is copy-paste with silent divergences
|
||||||
|
**Location:** `src/capture.cpp:490–537` vs `src/capture_realtime.cpp:505–540`.
|
||||||
|
|
||||||
|
**Mechanism.** The finished-capture metadata stamp — `trackGuids`, `channelCount`, `sampleRate`,
|
||||||
|
`Master_GetTempo`, the `TimeMap_GetTimeSigAtTime` block, the WAV-aware `hashWavContent` content
|
||||||
|
hash (comment block duplicated verbatim, ~10 lines), `createdTimestamp` — is written twice, once
|
||||||
|
per backend. The copies have already diverged in quiet ways: offline passes `proj = nullptr`
|
||||||
|
(active project) to `TimeMap_GetTimeSigAtTime` while realtime pins `st.proj_`; the sampleRate
|
||||||
|
fallback logic differs in shape; realtime overrides `lengthSeconds` post-hoc. Some divergence is
|
||||||
|
semantic (realtime's tail-trim length), but the shared stamp is one concept — a future field
|
||||||
|
(e.g. a new provenance stamp) must currently be added in two places, and the time-sig
|
||||||
|
active-project vs pinned-project asymmetry is exactly the kind of drift that produces a
|
||||||
|
wrong-project stamp during a background-project capture.
|
||||||
|
|
||||||
|
**Severity:** Med.
|
||||||
|
**Disposition:** **fix-now, folded into Q-W3** (the wave already hoisting capture orchestration
|
||||||
|
out of `main.cpp` / right-sizing `capture.h`). Extract a `stampCaptureSample(Sample&, const
|
||||||
|
CaptureRequest&, ReaProject*)` shared helper; the divergent bits (length override) stay in the
|
||||||
|
realtime caller. Rationale: the fix touches both backend TUs, which Q-W3 opens anyway; doing it
|
||||||
|
there keeps one review of the precision-invariant-adjacent code.
|
||||||
|
|
||||||
|
### T2-10 — the two thumbnail pipelines' cache-invalidation strategies have drifted
|
||||||
|
**Location:** `src/bank_panel.cpp:422–483` (extension: `computeThumbnail` + pure
|
||||||
|
`ThumbnailKey{id, width, generation}` via `bank_grid::thumbnailKeyString`) vs
|
||||||
|
`src/vst/reasampler_editor.cpp:715–789` (VST: `monoPcmFor` keyed by bare `sampleId`,
|
||||||
|
`thumbnailFor` keyed by ad-hoc `sampleId + "|" + binCount`, invalidated by wholesale
|
||||||
|
`clear()` at lines 159–160/894).
|
||||||
|
|
||||||
|
**Mechanism.** The dock panel and the VST browser render the same thumbnails through the shared
|
||||||
|
`peaks::computeEnvelope`, but the caching layer around it was re-designed independently on each
|
||||||
|
side: the extension bakes the bank generation into a *pure, tested* key type; the editor
|
||||||
|
hand-concats a string key with no generation and relies on call-site `clear()`s (bank-refresh,
|
||||||
|
resize). Both are correct **today** — but correctness on the editor side is distributed across
|
||||||
|
remembering every clear site, and the bin-clamp guard comment ("computeEnvelope pads binCount >
|
||||||
|
frameCount…") is duplicated verbatim in both TUs (`bank_panel.cpp:462`,
|
||||||
|
`reasampler_editor.cpp:780`), marking the copied design. A future refresh path that forgets the
|
||||||
|
clear shows stale waveforms with no test to catch it.
|
||||||
|
|
||||||
|
**Severity:** Low.
|
||||||
|
**Disposition:** **document-and-defer** — when T2-06's layout hoist opens the editor, adopt the
|
||||||
|
pure `ThumbnailKey` (or a shared `thumb_cache` helper) on the VST side. Rationale: no live bug;
|
||||||
|
unifying cache policy is a natural rider on the editor wave, pointless as standalone churn.
|
||||||
|
|
||||||
|
### T2-11 — ComponentState v1→v11 deserialize chain: sound, but the legacy branches triplicate the shared read
|
||||||
|
**Location:** `src/vst/sample_map.cpp:775–945` (`deserializeComponentState`).
|
||||||
|
|
||||||
|
**Mechanism.** Audited the full lift chain for functional soundness: the bounded `ByteReader`
|
||||||
|
latches on truncation, every version's tail fields carry per-field corrupt fallbacks
|
||||||
|
(previewVelocity → mid default, voiceCount → default-not-clamp, gain → unity, refs → keep-parsed
|
||||||
|
prefix), and the strict-prefix envelope discipline is honest. **No correctness finding.** The
|
||||||
|
smell is shape: the v3, v4, and v5 branches each re-implement the mode-byte → marker → idLen/id
|
||||||
|
→ zones read sequence that the v6+ shared path also implements (three near-copies of the same
|
||||||
|
cursor walk, lines 806–841 vs 851+), and each new envelope version adds another
|
||||||
|
`version >= kꞏꞏꞏV*Version` stanza to a function already ~170 lines long.
|
||||||
|
|
||||||
|
**Severity:** Low.
|
||||||
|
**Disposition:** **document-and-defer, explicitly.** The legacy branches are frozen back-compat
|
||||||
|
contract code with saved-project blobs as their only callers; rewriting them into a table-driven
|
||||||
|
lift risks the one thing they must never break, for zero user-visible gain. Record the pattern
|
||||||
|
so the *next* envelope bump (v12) prefers extending the shared path over minting another branch.
|
||||||
|
(The unbounded-suffix version-constant naming is §2b territory; not restated.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surfaces checked and found clean
|
||||||
|
|
||||||
|
Recorded per the wave's no-silent-omission rule; each was read/grepped this audit.
|
||||||
|
|
||||||
|
- **Pure-module include hygiene, both trees.** Every module CLAUDE.md claims pure was scanned
|
||||||
|
for REAPER/SWELL/WDL/LICE/VST3-SDK includes: all clean, `src/` and `src/vst/` both. The one
|
||||||
|
grep hit in `pitch_shift.h` is a comment (the S16 WDL-exclusion note), not an include. The one
|
||||||
|
cross-tree include (`instrument_drop` → `vst/sample_map.h`) is pure-to-pure — see T2-07 for
|
||||||
|
the granularity concern; it is not a boundary violation.
|
||||||
|
- **`bank_sync`** — the generation/consume decision rules are pure, explicit, and exhaustively
|
||||||
|
commented (rules 1–4); `parseBankGeneration` is overflow-guarded (its duplication is rolled
|
||||||
|
into T2-01's family, not a separate defect).
|
||||||
|
- **`bridge_marshal`** — one honest job, done pure, with the S1 string-scan JSON reader
|
||||||
|
documented as retired (verified: no second JSON parser on the VST side; `sample_map` routes
|
||||||
|
through `BankBook::deserialize`).
|
||||||
|
- **The realtime record lifecycle** — *not* an implicit state machine: `RecordPhase` is an
|
||||||
|
explicit enum with pure per-tick transitions in `realtime_record.h`; `main.cpp` holds only the
|
||||||
|
handle + project pointer. (Its *residence* in main.cpp is catalogued §2.1; nothing functional
|
||||||
|
to add.)
|
||||||
|
- **Project-identity transitions** — `classifyProjectTransition` is pure (capture_paths), the
|
||||||
|
shell passes `sameProjectObject` as a bool to keep it so; the GUID-primary layering is
|
||||||
|
decision-tabled in one place.
|
||||||
|
- **`usage_scan`** — every decision delegated to pure `sample_usage`; container recursion is
|
||||||
|
depth-bounded with a protect-on-truncation fail-safe; the `std::function` parm-getter
|
||||||
|
indirection is prune-scan-cold (heuristic (c) satisfied — no hot-path chain).
|
||||||
|
- **Exception boundaries** — the three `catch (...)` sites (`bank_book.cpp:794` stol guard,
|
||||||
|
`instrument_drop_win.cpp:74` REAPER-callback boundary, `render_settings.cpp:180` stod guard)
|
||||||
|
are all documented, narrow, and non-swallowing in intent (each converts to an explicit
|
||||||
|
failure value). No silent error swallowing found.
|
||||||
|
- **`FxBypassGuard` (main.cpp) vs `view.cpp` park/restore** — both are snapshot-mutate-restore
|
||||||
|
over track flags and *look* like a dedup candidate; they are deliberately not one. Different
|
||||||
|
flag sets, different invariants (precision-neutralization vs Design-View parking), different
|
||||||
|
failure postures. Duplication of shape, not of concept — correctly left separate.
|
||||||
|
- **Path resolution** — `capture_paths::resolveBankFile` is the single resolver on both sides
|
||||||
|
of the split (panel, insert, drag_out, editor, processor). No parallel path logic.
|
||||||
|
- **WAV decode on the play path** — `wav_trim` is genuinely the only decoder (T2-08 concerns
|
||||||
|
the *container* knowledge spread, not a second decoder).
|
||||||
|
- **Draw layer** — the VST editor/embed compile the same `draw_kit`/`theme`/`component_geometry`
|
||||||
|
the extension uses (verified in CMake + includes); no parallel draw vocabulary grew on the
|
||||||
|
VST side.
|
||||||
|
- **Interface cost audit (heuristic (c))** — `ICaptureBackend` is the tree's only virtual
|
||||||
|
interface; two real implementations, dispatched once per capture (cold). No hot-path virtual
|
||||||
|
or std::function chain found in `sampler_core`/`pitch_shift`/`sample_map` (all static calls).
|
||||||
|
No interface-with-one-implementation found anywhere.
|
||||||
|
- **Boolean-parameter proliferation** — swept `src/` headers for multi-bool signatures; the only
|
||||||
|
hit is `bank_grid::applyClick(…, bool ctrl, bool shift, …)`, which mirrors physical modifier
|
||||||
|
keys and reads fine at call sites. Not a finding.
|
||||||
|
|
||||||
|
## Cross-checks against the §2/§2b audits (gaps noted, not restated)
|
||||||
|
|
||||||
|
- §2's evidence base is scoped to the extension tree ("45 files / ~19,800 LOC"); the full tree
|
||||||
|
is now ~39,000 LOC. The VST shells repeat the god-module pattern uncatalogued
|
||||||
|
(`reasampler_editor.cpp` 3,065 LOC, `reasampler_processor.cpp` 1,164 LOC) — carried here as
|
||||||
|
T2-06's scope note so the reorg waves size the `src/vst/` work realistically.
|
||||||
|
- §2.1's "4× JSON Parser" undercounts by one — T2-02 (`tail_control`).
|
||||||
|
- §2b.2's shared-rect naming hazard has an uncatalogued functional twin — T2-05.
|
||||||
|
|
||||||
|
## Summary table
|
||||||
|
|
||||||
|
| ID | Finding | Severity | Disposition | Where |
|
||||||
|
|-------|---------------------------------------------------------------|----------|-------------------|--------------|
|
||||||
|
| T2-01 | Wire `Cursor` ×3 with hardening drift; provenance unguarded | High | fix-now (split) | Q-W0 backport + Q-W1 dedup |
|
||||||
|
| T2-02 | Fifth JSON decoder in `tail_control` | Med | fix-now | Q-W1 |
|
||||||
|
| T2-03 | `readFileBytes` ×5 across both artifacts | Med | fix-now | Q-W1 |
|
||||||
|
| T2-04 | Growing ext-state read loop ×3; pure decode half-adopted | Med | fix-now | persist's wave (Q-W4) |
|
||||||
|
| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates | Med | fix-now | Q-W2 |
|
||||||
|
| T2-06 | Layout math stranded in VST editor shell (+§2 scope gap) | Med | document-and-defer (named reshape) | src/vst wave |
|
||||||
|
| T2-07 | Extension links voice engine to share the preset serializer | Low | document-and-defer | Q-W1/Q-W2 homing |
|
||||||
|
| T2-08 | RIFF container knowledge in 4 modules / 2 chunk walkers | Low | document-and-defer | core/wav homing |
|
||||||
|
| T2-09 | Capture backends' Sample-stamp epilogue copy-paste w/ drift | Med | fix-now | Q-W3 |
|
||||||
|
| T2-10 | Thumbnail cache-invalidation strategies drifted across split | Low | document-and-defer | editor wave |
|
||||||
|
| T2-11 | ComponentState legacy lift branches triplicate the shared read | Low | document-and-defer | (pattern note for v12) |
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# Q-W0 Track 3 — env-coupled-constant domain-modeling audit
|
||||||
|
|
||||||
|
Static analysis, 2026-07-28, branch `pq-w0-audit`. Scope: any value stored in an
|
||||||
|
environment-coupled domain — frames, sample rate, DPI, pixels, tick cadence — that should be
|
||||||
|
stored **rate-free / device-free and resolved at the point of use** (`docs/product/code-organization.md`
|
||||||
|
§2c.3 env-coupled bullet; the load-bearing `sample_map` seconds invariant, documented in
|
||||||
|
`src/core/instrument/CLAUDE.md`). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar
|
||||||
|
applied: a finding requires (a) an env-coupled *stored* domain AND (b) an environment that can
|
||||||
|
actually change under it. Frame counts computed transiently from seconds at the use site are
|
||||||
|
correct and are not reported.
|
||||||
|
|
||||||
|
Waves referenced for disposition: Q-W1..Q-W6 open `bank_panel.cpp`, `main.cpp`, `actions.cpp`,
|
||||||
|
`persist.cpp`, and relocate the clean pure libs — **no downstream wave opens
|
||||||
|
`reasampler_processor.cpp` / `sampler_core.h` / `reasampler_editor.cpp` for logic change**, so
|
||||||
|
fix-now findings in those files must be remediated in Q-W0 itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### T3-01 — master-gain ramp step is a frame-domain constant anchored to 48 kHz
|
||||||
|
|
||||||
|
- **Location:** `src/vst/reasampler_processor.cpp:46-51` (`kGainRampRate = 1.0f / 960.0f`,
|
||||||
|
`kGainRampSnap`), applied per-sample at `:1069-1085` (stereo) and `:1114-1126` (mono).
|
||||||
|
- **Stored vs. correct domain:** stored as a **per-sample linear step** — `960` is literally
|
||||||
|
20 ms × 48 000 Hz, and the comment says so ("960 samples @ 48 kHz ≈ 20 ms"). The intended
|
||||||
|
quantity is a **wall-clock ramp time** (~20 ms); the correct model is a seconds/ms constant
|
||||||
|
with the per-sample step derived from `sampleRate_` at `setupProcessing` — exactly the
|
||||||
|
pattern the same file already uses two paragraphs away for `kPreserveWindowMs`
|
||||||
|
(`:618-623`, `:733-735`).
|
||||||
|
- **What breaks when the environment shifts:** the ramp's wall-clock length halves at 96 kHz
|
||||||
|
(~10 ms) and quarters at 192 kHz (~5 ms); at 44.1 kHz it stretches to ~21.8 ms. The FB1
|
||||||
|
"no zipper" contract degrades silently as the host rate rises. Not persisted, so no on-disk
|
||||||
|
breakage — but it is a live hardcoded-rate assumption in `src/`, which Daniel's standing
|
||||||
|
ruling forbids.
|
||||||
|
- **Severity:** Med.
|
||||||
|
- **Disposition:** **fix-now, remediated in Q-W0.** Rationale: trivial, isolated,
|
||||||
|
behavior-identical at 48 kHz; no downstream wave opens this file, so deferring means keeping
|
||||||
|
a named violation of the no-hardcoded-rate ruling through the whole reorg. Store
|
||||||
|
`kGainRampSeconds = 0.020`, derive the step from the live rate where `sampleRate_` is set.
|
||||||
|
|
||||||
|
### T3-02 — takeover-declick decay is a per-frame coefficient (documented deliberate)
|
||||||
|
|
||||||
|
- **Location:** `src/vst/sampler_core.h:409-410` (`kDeclickDecay = 0.95`,
|
||||||
|
`kDeclickFloor = 1e-4`), applied per output frame in `sampler_core.cpp:514-519`, `:643-645`.
|
||||||
|
- **Stored vs. correct domain:** a per-output-frame exponential coefficient; the implied
|
||||||
|
wall-clock decay-to-floor is ~4.2 ms at 44.1 kHz and ~1.9 ms at 96 kHz. The strict
|
||||||
|
domain-model form would be a time constant in seconds resolved to a coefficient at engine
|
||||||
|
build.
|
||||||
|
- **What breaks when the environment shifts:** the takeover-blend residue fades ~2× faster at
|
||||||
|
96 kHz. Audibly negligible for a declick micro-ramp — and the in-code comment
|
||||||
|
(`sampler_core.h:397-401`) **already documents this as a deliberate per-frame DSP micro-ramp,
|
||||||
|
"not a stored wall-clock quantity"**, with the 44.1–96 kHz variance stated and accepted.
|
||||||
|
- **Severity:** Low.
|
||||||
|
- **Disposition:** **document-and-defer.** Rationale: the coupling is already an explicit,
|
||||||
|
written, bounded design decision in the code; converting it buys no audible improvement.
|
||||||
|
Triage should ratify the in-code note as the record.
|
||||||
|
|
||||||
|
### T3-03 — Trigger-fade UI throw ceiling hardcodes 2 s × 44 100 as `88200.0` frames
|
||||||
|
|
||||||
|
- **Location:** `src/vst/reasampler_editor.cpp:395`
|
||||||
|
(`constexpr double kFadeMaxFrames = 88200.0`), used by `controlValue`/the commit path to
|
||||||
|
normalize the Trigger fade-in/out knobs.
|
||||||
|
- **Stored vs. correct domain:** the fade **storage** domain (int64 SOURCE frames, persisted in
|
||||||
|
the zones payload) is settled and correct — a source-timeline fact, invariant under project-
|
||||||
|
rate change (`docs/ARCHIVE.md` §S15). The *UI ceiling*, however, encodes a wall-clock intent ("2-second
|
||||||
|
max fade throw") as a frame count at an assumed 44.1 kHz source. `88200` is a rate-derived
|
||||||
|
literal in `src/`, brushing the no-hardcoded-rate ruling even though it never touches disk.
|
||||||
|
- **What breaks when the environment shifts:** the environment here is the **source file's
|
||||||
|
rate**: a 96 kHz capture's maximum fade throw is ~0.92 s; a 22.05 kHz file gets 4 s. The knob's
|
||||||
|
full-scale meaning silently varies per loaded sample.
|
||||||
|
- **Severity:** Low (UI-only, not persisted, comment flags it as a "build-time residual — one
|
||||||
|
place to retune").
|
||||||
|
- **Disposition:** **fix-now, remediated in Q-W0.** Rationale: small and contained — replace
|
||||||
|
with `kFadeMaxSeconds = 2.0` resolved against the loaded source's rate at the two normalize
|
||||||
|
sites (the editor already threads `frameCount + rate` through the pack/unpack path,
|
||||||
|
`envelope_edit.cpp:148`); storage domain unchanged. If triage prefers zero UI-feel change,
|
||||||
|
the fallback is document-and-defer with the comment amended to name the 44.1 k assumption.
|
||||||
|
|
||||||
|
### T3-04 — drop-hint banner duration stored in sync-timer ticks
|
||||||
|
|
||||||
|
- **Location:** `src/vst/reasampler_editor.cpp:2920-2923` (`dropHintTicks_ = 6`), decayed in
|
||||||
|
`onSyncTimer` (`:244-245`); field at `reasampler_editor.h:457`.
|
||||||
|
- **Stored vs. correct domain:** a wall-clock intent ("a few seconds of banner") stored as a
|
||||||
|
**count of `kSyncTimerIntervalMs` ticks** (6 × 500 ms). Correct model: a duration in ms,
|
||||||
|
ticks derived — or a `GetTickCount`-style deadline like the bank panel's tooltip already
|
||||||
|
uses.
|
||||||
|
- **What breaks when the environment shifts:** retuning the sync cadence (a plausible perf
|
||||||
|
tweak — the 500 ms value is itself a tuning constant) silently changes the banner duration.
|
||||||
|
The comment does state the coupling.
|
||||||
|
- **Severity:** Low.
|
||||||
|
- **Disposition:** **document-and-defer.** Rationale: cosmetic, self-documenting at the single
|
||||||
|
site, and the cadence and hint decay live three lines apart; a fix is fine to fold in
|
||||||
|
opportunistically if the file is ever opened, but does not justify a Q-W0 edit on its own.
|
||||||
|
|
||||||
|
### T3-05 — systemic: no DPI/content-scale support in either UI surface
|
||||||
|
|
||||||
|
- **Location:** systemic. VST3 editor: `reasampler_editor.cpp:150-153` (`ViewRect(0,0,840,620)`
|
||||||
|
default and the size floor at `:816`), all `editor_geometry` / `knob_deck` / `curve_popup` /
|
||||||
|
`envelope_overlay` px constants (e.g. the 8 px node min-separation, the 28×28 curve button),
|
||||||
|
cached font sizes in `draw_kit`. Extension side: the LICE-drawn `bank_panel` dock and its
|
||||||
|
geometry modules. No implementation of VST3's `IPlugViewContentScaleSupport` anywhere in
|
||||||
|
`src/vst/` (grep: zero hits for content-scale/DPI), no scale factor threaded through the
|
||||||
|
pure geometry modules.
|
||||||
|
- **Stored vs. correct domain:** every layout constant is a **physical device pixel** that
|
||||||
|
silently assumes ~96 DPI. Correct model: logical units × one scale factor resolved at draw
|
||||||
|
time (the pure geometry modules take widths/heights as parameters already, so a scale factor
|
||||||
|
threads through cleanly — the constants are centralized, which is the good news).
|
||||||
|
- **What breaks when the environment shifts:** on a 150–200 % Windows display the editor and
|
||||||
|
dock render physically small (or get bitmap-stretched by the host, blurring text); hit
|
||||||
|
targets like the 8 px min node separation shrink below comfortable pointer accuracy.
|
||||||
|
Usability, not correctness — nothing mis-plays and nothing persisted is wrong.
|
||||||
|
- **Severity:** Med (usability on modern displays; Windows-only product makes high-DPI common).
|
||||||
|
- **Disposition:** **document-and-defer.** Rationale: a proper UI-scaling pass is a feature
|
||||||
|
wave of its own (scale plumbing through ~15 geometry modules + font cache + both shells),
|
||||||
|
far outside Q-W0's remediation budget; deferral should be recorded as a named future phase,
|
||||||
|
and Q-W1's relocation of the geometry modules should keep the constants centralized so the
|
||||||
|
eventual scale factor lands in one place.
|
||||||
|
|
||||||
|
### T3-06 — legacy v3 zone-payload lift divides by the *current* project rate
|
||||||
|
|
||||||
|
- **Location:** `src/vst/sample_map.cpp:601-605` (v3 lift inside `readZonesPayload`), format
|
||||||
|
note at `sample_map.h:439-456`, `:510-513`.
|
||||||
|
- **Stored vs. correct domain:** the v3 blobs (Daniel's beta projects) stored wall-clock times
|
||||||
|
as frames — **the prior incident itself**. The lift converts frames → seconds by dividing by
|
||||||
|
the live `projectRate` threaded in at read time. That is exact only if the project rate today
|
||||||
|
equals the rate in effect when the S15/S16 editor wrote the frames; the write-era rate was
|
||||||
|
never recorded, so a project whose rate changed since lifts skewed times (old/new ratio,
|
||||||
|
e.g. ~8.8 % for 44.1→48 k).
|
||||||
|
- **What breaks when the environment shifts:** already broken by construction for
|
||||||
|
rate-changed-since-write projects; a one-time lift residue, after which v5+ re-saves in
|
||||||
|
seconds and the skew is frozen in, silently.
|
||||||
|
- **Severity:** Low (legacy-only, beta-project blobs, envelope-time magnitudes; unrecoverable
|
||||||
|
in principle — the missing datum was never written).
|
||||||
|
- **Disposition:** **document-and-defer.** Rationale: no better conversion exists; this is the
|
||||||
|
documented residue of the incident that motivated the seconds invariant. Worth one sentence
|
||||||
|
in the code-quality-audit report so the skew is a recorded known, not a mystery bug later.
|
||||||
|
|
||||||
|
### T3-07 — SOLA correlation-segment cap of 512 frames (deliberate CPU bound; cross-ref T1)
|
||||||
|
|
||||||
|
- **Location:** `src/vst/pitch_shift.cpp:72`
|
||||||
|
(`corrFrames_ = max(1, min(dLow_ - 1, 512))`; rationale comment at `:64-67`).
|
||||||
|
- **Stored vs. correct domain:** borderline by design. The quantity being bounded is **work per
|
||||||
|
splice** (multiply-accumulates), which is genuinely frame-domain — a CPU bound *should* be in
|
||||||
|
frames. The side effect is that the correlation segment's wall-clock span halves at 96 kHz
|
||||||
|
(512 frames ≈ 11.6 ms at 44.1 k, ≈ 5.3 ms at 96 k), raising the lowest frequency the
|
||||||
|
alignment search can lock onto at high rates. All other shifter geometry correctly derives
|
||||||
|
from `kPreserveWindowMs` resolved at the live rate.
|
||||||
|
- **What breaks when the environment shifts:** alignment quality for low-frequency content
|
||||||
|
degrades somewhat at high host rates; no correctness or persistence impact.
|
||||||
|
- **Severity:** Low.
|
||||||
|
- **Disposition:** **document-and-defer**, and hand to the T1 DSP audit for the quality call.
|
||||||
|
Rationale: the frame domain is arguably correct for a compute bound; whether 512 is the right
|
||||||
|
*number* is an algorithm-quality question (T1's territory), not a domain-modeling one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surfaces checked clean
|
||||||
|
|
||||||
|
- **`sample_map` v5+ persistence (the reference implementation):** AHDSR + pitch-env times as
|
||||||
|
SECONDS doubles; no rate constant anywhere in the read/write paths (`kLegacyV3NominalRate`
|
||||||
|
deliberately does not exist); legacy v3 lift takes the rate as a parameter. Clean.
|
||||||
|
- **ComponentState envelope v6–v11 fields:** channel mode, assign generation, preview velocity,
|
||||||
|
voice count/mode/trigger, `masterGainLinear` (dimensionless linear), explicit flag,
|
||||||
|
`SampleRefs` (paths + root/loop/channels intrinsics), `instanceGuid` — all rate-free or
|
||||||
|
file-fact domains. Clean.
|
||||||
|
- **Trigger `fadeInFrames`/`fadeOutFrames`/`startPoint`/`SampleLoop.start/end` persisted as
|
||||||
|
int64 SOURCE frames:** deliberate, settled source-timeline facts (`docs/ARCHIVE.md` §S15;
|
||||||
|
`bank_model.h:66-72` documents the loop rationale) — frames *of the file* are invariant under
|
||||||
|
project-rate change; the file's own rate is stored alongside and resolved at decode. Correct
|
||||||
|
domain, not a finding.
|
||||||
|
- **`trigger_seam`:** frames↔fraction with `startFrame` threaded both directions; the overlay's
|
||||||
|
fraction domain is expressly rate-invariant. Clean.
|
||||||
|
- **`kPreserveWindowMs` (50 ms):** resolved to frames against the live host rate at both call
|
||||||
|
sites (`reasampler_processor.cpp:618-623`, `:733-735`) — the correct pattern, cited here as
|
||||||
|
the model T3-01 should copy.
|
||||||
|
- **`pitch_shift` internal geometry:** ring length, fade, lag band, delay band all derived from
|
||||||
|
the rate-resolved `window_`; ratio-scaled live fade length. Clean (T3-07 cap noted above).
|
||||||
|
- **Tail system:** `TailSetting.manualMs` persisted in **ms**; `kMaxTailSeconds`/`kMaxTailMs`
|
||||||
|
wall-clock; trim threshold in **dB** with the linear ratio derived
|
||||||
|
(`render_settings.h:59-86`); the realtime decay scan resolves frames against the **file's own
|
||||||
|
authoritative rate** (`capture_realtime.cpp:384-408`). Clean.
|
||||||
|
- **`wav_trim`:** frame counts are parsed file facts and transient truncate plans. Clean.
|
||||||
|
- **`bank_model` persisted metadata:** source bounds in seconds + PPQ (both stored, each for
|
||||||
|
its consumer); loudness in dB; `sampleRate`/`channelCount` are *recorded facts about the
|
||||||
|
file*, not assumptions; capture tempo + meter stamped at capture time deliberately so the
|
||||||
|
bars.beats read-out is stable under later project meter changes (`card_meta`). Clean.
|
||||||
|
- **Ext-state wires** (`banks` JSON, view-mode model, owned manifest, `assignment_request`,
|
||||||
|
`rsusage_*`): no frame-domain values; the rate field in the assign wire is a recorded fact.
|
||||||
|
Clean.
|
||||||
|
- **Envelope schematic (`envelope_overlay`/`envelope_edit`):** param-domain px↔seconds scale
|
||||||
|
derived from the live rect (`gatePxPerSecond`), sample-length-free; editor time-slider
|
||||||
|
ceiling is `kEnvTimeMaxSeconds = 2.0` (seconds). Clean (pixel constants themselves fall under
|
||||||
|
the systemic T3-05).
|
||||||
|
- **Timers:** bank_panel tooltip delay uses `GetTickCount()` ms against `kTooltipDelayMs = 500`
|
||||||
|
(wall-clock — the pattern T3-04 should copy); editor sync timer is a 500 ms `SetTimer`
|
||||||
|
interval (ms, not ticks); the new-content detector is an event diff per tick with no
|
||||||
|
wall-clock meaning encoded in tick counts; `retireIdleDrain` is idleness-driven, not
|
||||||
|
time-driven. Clean.
|
||||||
|
- **`peaks` / `waveform_view` / `master_gain` / `velocity_curve` / `keyboard_strip`:** bins and
|
||||||
|
columns derived from rects at use; dB↔linear taper and curve math dimensionless; key rects
|
||||||
|
from the passed strip rect. Clean.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| ID | Location | Stored domain | Severity | Disposition |
|
||||||
|
|----|----------|---------------|----------|-------------|
|
||||||
|
| T3-01 | `reasampler_processor.cpp:46-51` gain-ramp step | per-sample step (20 ms @ 48 k baked in) | Med | **Fix-now (Q-W0)** — store seconds, derive step from `sampleRate_` |
|
||||||
|
| T3-02 | `sampler_core.h:409-410` declick decay | per-frame coefficient | Low | Document-and-defer — deliberate, already documented in-code |
|
||||||
|
| T3-03 | `reasampler_editor.cpp:395` fade throw ceiling | 88200 source frames (2 s @ 44.1 k) | Low | **Fix-now (Q-W0)** — seconds ceiling resolved vs. source rate at use |
|
||||||
|
| T3-04 | `reasampler_editor.cpp:2923` drop-hint duration | sync-timer ticks | Low | Document-and-defer — cosmetic, coupling stated in-code |
|
||||||
|
| T3-05 | systemic (both UI surfaces) | physical px, ~96 DPI assumed; no content-scale | Med | Document-and-defer — a UI-scaling phase of its own; keep geometry constants centralized through Q-W1 |
|
||||||
|
| T3-06 | `sample_map.cpp:601-605` v3 legacy lift | frames ÷ *current* project rate | Low | Document-and-defer — unrecoverable legacy residue; record as known skew |
|
||||||
|
| T3-07 | `pitch_shift.cpp:72` correlation cap | 512 frames (CPU bound) | Low | Document-and-defer — frame domain arguably correct for a compute bound; hand to T1 for the quality call |
|
||||||
|
|
||||||
|
Two fix-now findings (T3-01, T3-03), both assigned to **Q-W0 itself** — no downstream wave
|
||||||
|
opens those files for logic change. Five deferrals, each with a recorded rationale. The
|
||||||
|
persistence surfaces — the highest-stakes case — are clean: every wall-clock quantity written
|
||||||
|
to disk since the S12 remediation is in seconds or ms, and every frame-domain persisted value
|
||||||
|
is a source-file fact whose rate travels with it.
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
# Q-W0 Track 4 — structural sizing + placement audit
|
||||||
|
|
||||||
|
Date: 2026-07-28 · Branch: `pq-w0-audit` · READ-ONLY static analysis (no build, no code edits)
|
||||||
|
|
||||||
|
**Method.** Line counts measured with `wc -l` on the worktree; seams derived from function-definition
|
||||||
|
skeletons (`grep` for top-level definitions + section markers) plus targeted reads. Acceptance bar =
|
||||||
|
Daniel's three heuristics: (a) more directories a must, files ≤ ~600 lines, SRP applies to files and
|
||||||
|
namespaces; (b) templates are good where they dedup at compile time; (c) saved CPU beats abstraction —
|
||||||
|
no dispatch-stack blowouts, prefer static polymorphism where types are compile-time-known.
|
||||||
|
|
||||||
|
**Measured sizes differ from the wave brief in several places** (the tree moved after the brief was
|
||||||
|
drafted — GA/pS/pS-usage landed): `reasampler_editor.cpp` 3065 (brief said 3035),
|
||||||
|
`reasampler_processor.cpp` 1164 (1004), `sampler_core.cpp` 968 (1049), `sample_map.cpp` 970 (807),
|
||||||
|
`sample_map.h` 708 (600), `sampler_core.h` 762 (820), `actions.cpp` 1016 (996), `persist.cpp` 852
|
||||||
|
(812). All numbers below are the measured ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Oversize census + seams
|
||||||
|
|
||||||
|
Every `.cpp`/`.h` in `src/` (both sides) over ~600 lines, with the *real* responsibility clusters.
|
||||||
|
Where a file is genuinely one responsibility, I say so and recommend leaving it.
|
||||||
|
|
||||||
|
### 1.1 The four planned splits — do the plan's seams still land sub-600?
|
||||||
|
|
||||||
|
**T4-01 — `src/bank_panel.cpp` (3459; plan assumed 2424).**
|
||||||
|
The plan's six seams (`panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` /
|
||||||
|
`panel_bank_ops` / `panel_window`) no longer all land sub-600 at current size. Tally against the
|
||||||
|
skeleton:
|
||||||
|
|
||||||
|
| Planned TU | Functions (line spans) | Est. LOC | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `panel_thumbnails` | `computeThumbnail`/`thumbnailFor` (420–487) | ~130 | fine |
|
||||||
|
| `panel_render` | `drawCardMeta`/`drawThumbnail` (487–547), kit adapters (547–570), `drawFooter` (685–780), `drawToolbar`/`drawMoreButton`/`drawTooltip` (992–1155), `drawRegionGrid`/`drawCardDropTarget`/`drawRegionHeader`/`drawTabStrip`/`paintPanel` (1334–1646) | ~700 | **over — needs the layout cut below** |
|
||||||
|
| *(unplanned)* **`panel_layout`** | toolbar/footer/menu rect + row/cluster builders (571–684, 785–991), split geometry + region rects + L7 slot-order display bridge (1156–1333) | ~500 | **new TU required** — this is pure-ish geometry glue, distinct from LICE drawing; extracting it puts `panel_render` at ~550 |
|
||||||
|
| `panel_audition` | `initPreview`/`startAudition`/`stopAudition`/`deinitPreview` (1874–1965) | ~90 | fine (keep the direct call-through guardrail) |
|
||||||
|
| `panel_input` | input helpers + `regionAt` (1965–2010), click routing `handleBanksChromeClick`…`handleKey`/accelerator (2384–2710), plus new-content detection (1647–1874, ~230) | ~800 | **over — needs the drag cut below** |
|
||||||
|
| *(unplanned)* **`panel_drag`** | `updateDropTarget`/`dropTargetBankId`/`classifyCardDrag`/`applyDragCursor`/`resolveHover`/`updateHover`/`maybeShowTooltip`/`onMouseMove`/`doReorderDrop`/`doReplaceDrop`/`resetDragState`/`onLBtnUp`/`handleRightClick` (2711–3185) | ~475 | **new TU required** — the card-drag/hover state machine is a cohesive cluster of its own (it already has a pure mirror, `card_drag`); extracting it puts `panel_input` at ~550 |
|
||||||
|
| `panel_bank_ops` | `promptText`/`mintBankId`/`doCreateBank`…`removeSamples`/`focusedSelectionIds`/`resolveDragPathsForOs` (2006–2231) + popup menus (2231–2384) | ~375 | fine (menus ride with bank_ops or input — either works; they invoke the ops) |
|
||||||
|
| `panel_window` | `handleDropFiles`/`dlgProc`/`openPanel`/`closePanel` (3185–3336) + public API (3336–3459) | ~275 | fine |
|
||||||
|
|
||||||
|
**Proposal:** eight TUs, not six — add `panel_layout` and `panel_drag`.
|
||||||
|
**Severity:** high (it is the biggest file in the repo). **Disposition: reshapes wave Q-W2** —
|
||||||
|
the wave brief must name eight seams, or two of its six TUs ship >600 on day one.
|
||||||
|
|
||||||
|
**T4-02 — `src/main.cpp` (1897; plan assumed 1762).**
|
||||||
|
The plan's three hoists are the right seams, but `capture_orchestrator` as specced lands **~885
|
||||||
|
lines** — over by half again. Tally: `FxBypassGuard` (627–731, ~105), `renderOffline` +
|
||||||
|
`captureAndIndexOne` + `RunCapture` + `RunCaptureItemAssign` (731–909, ~180), batch family
|
||||||
|
(`ItemSelectionGuard`/`selectOnlyItem`/`RunBatchCaptureItems`/`collectRazorAreas`/
|
||||||
|
`TrackSelectionGuard`/`RunBatchCaptureRazor`, 909–1181, ~270), `RunRecaptureFromSource` (1200–1398,
|
||||||
|
~200), realtime + insert actions (1398–1512, ~115). `scope_resolve` (361–590) ≈ 230 ✓;
|
||||||
|
`realtime_lifecycle` (186–360) ≈ 175 ✓; registration/entry residue (1512–1897) ≈ 385 ✓ (shrinks
|
||||||
|
further under Q-W6's table).
|
||||||
|
**Proposal:** split the orchestrator seam once more: `capture_orchestrator` (FxBypassGuard +
|
||||||
|
single-capture path + realtime/insert action bodies, ~450) and **`capture_batch`** (batch family +
|
||||||
|
`RunRecaptureFromSource` + the two selection guards, ~470). Recapture is planner-driven like batch
|
||||||
|
and shares the selection-guard machinery — it belongs with batch, not the single-shot path.
|
||||||
|
**Severity:** high. **Disposition: reshapes wave Q-W3** — add the fourth TU to the brief.
|
||||||
|
|
||||||
|
**T4-03 — `src/actions.cpp` (1016; plan assumed 981).**
|
||||||
|
Plan's seams still land: `design_view_actions` (59–421, ~360), `bank_actions` (422–1016 minus prune,
|
||||||
|
~490), `prune_action` (`doBankPruneFolder` 821–916 + registration share, ~130). All sub-600.
|
||||||
|
**Disposition: no change to Q-W4.**
|
||||||
|
|
||||||
|
**T4-04 — `src/persist.cpp` (852; plan assumed 766).**
|
||||||
|
Plan's seams still land: `ext_state_io` (helpers + `saveToActiveProject` + `writeAssignmentRequest`,
|
||||||
|
112–288, ~180), `prune_fs` (`scanPruneOrphans`/`pruneDryRun`/`pruneOrphanSet`/`deleteOrphanFile`/
|
||||||
|
`pruneReclaim`, 288–539, ~250 — pS-usage growth landed here, exactly where the plan isolates it),
|
||||||
|
`session` (load/guid/poll/reload, 539–852, ~315). All sub-600.
|
||||||
|
**Disposition: no change to Q-W5.**
|
||||||
|
|
||||||
|
### 1.2 Known offenders beyond the planned four — extension side
|
||||||
|
|
||||||
|
**T4-05 — `src/bank_book.cpp` (1109) + `bank_book.h` (457).**
|
||||||
|
Three genuine seams: **`SlotMap`** (27–143, ~115 — a self-contained ordered-slot container with its
|
||||||
|
own serialize at 614), **`BankBook`** registry/CRUD/transfer/slot-reconcile (145–545, ~400), and
|
||||||
|
**JSON serialize + `Parser`** (547–1109, ~560). Q-W1 deletes the Parser + `ObjWriter`/`writeEscaped`
|
||||||
|
copies; what remains of serialization rewired onto `core/json` is ~150.
|
||||||
|
**Proposal:** after Q-W1, split `slot_map` into its own TU/header pair (it is a distinct type with
|
||||||
|
its own tests-worthy invariants); `bank_book.cpp` lands ~550. **Severity:** medium.
|
||||||
|
**Disposition:** fold into Q-W1 (the JSON rewire already opens this file; the `slot_map` file split
|
||||||
|
is one `git mv`-shaped extraction on top).
|
||||||
|
|
||||||
|
**T4-06 — `src/view_mode_model.cpp` (1049) + `view_mode_model.h` (748).**
|
||||||
|
Four seams: **indexes** (`ModeRegistry`/`MembershipIndex`/`LaneOwnershipIndex`, 29–140), **pure
|
||||||
|
planners** (`autoTagNewContent`/`planItemRetag`/`planLaneMinting`/`makeParkPlan`/`makeRestorePlan`/
|
||||||
|
`nextModeId`, 143–326, ~185), **`ViewModeModel`** state + visibility/toggle planning (332–490), and
|
||||||
|
**JSON serialize + `Parser`** (494–1049, ~555). Q-W1 deletes the Parser (~390); remainder ~660.
|
||||||
|
**Proposal:** split planners (`view_plan.cpp`) from model+indexes (`view_mode_model.cpp`, ~450 after
|
||||||
|
JSON extraction). The header's 26 structs split the same way: mode/membership/lane types + model
|
||||||
|
class vs. the plan-record structs (`TrackPlan`/`TogglePlan`/`AutoTag`/`ItemRetagOp`/`LaneMint*`).
|
||||||
|
**Severity:** medium. **Disposition:** fold into Q-W1 (JSON rewire opens the file; planner split
|
||||||
|
rides it). If the wave wants to stay minimal, the planner split can defer — post-extraction ~660 is
|
||||||
|
marginal, not pathological.
|
||||||
|
|
||||||
|
**T4-07 — `src/bank_model.cpp` (767).**
|
||||||
|
Two seams only: the model (`Sample` equality + `BankIndex` verbs, 25–145, ~120) and JSON
|
||||||
|
(`ObjWriter`/`writeSample`/`Parser::parseSample`/`parseIndex`, 148–767, ~620). This file is the
|
||||||
|
poster child for Q-W1: after the extraction it is ~250 total (model + thin serialize using
|
||||||
|
`core/json`). **Disposition: already owned by Q-W1; no new seam needed.**
|
||||||
|
|
||||||
|
**T4-08 — `src/capture_realtime.cpp` (867).**
|
||||||
|
Two seams: the **async record lifecycle** (`RealtimeCaptureState` snapshot/restore, `begin`/`tick`/
|
||||||
|
`abort`, 177–324 + 569–867, ~450) and the **file-side finalize** (`readAllBytes`/`writeU32LE`/
|
||||||
|
`trimAutoTailInPlace`/`finalizeRecording`, 324–566, ~240). The lifecycle is genuinely one
|
||||||
|
responsibility (the header itself documents why the restore lives on the state object). The finalize
|
||||||
|
half — WAV byte-patching, decay-scan trim, move-into-bank — is a distinct concern that talks to
|
||||||
|
`wav_trim`, not to the transport.
|
||||||
|
**Proposal:** split `capture_realtime_finalize.cpp` (~240); lifecycle TU lands ~600 with the file
|
||||||
|
banner. Both stay in `shell/capture/`. **Severity:** low-medium. **Disposition:** ride Q-W3 (the
|
||||||
|
wave already renames this family per the Q-9 naming rider — same-wave file surgery is free).
|
||||||
|
|
||||||
|
**T4-09 — `src/view.cpp` (677).**
|
||||||
|
Two halves: **park/restore + flag application** (`snapshotTrack`/`applyFlags`/`parkFxOffline`/
|
||||||
|
`restoreFxOffline`/`applyMode`, ~350) and **lane management** (`laneName`/`managedLaneOrdinals`/
|
||||||
|
`applyLanePlays`/`applyLaneOps`/`readLaneTracks`/`assignItemToLane`/`applyMintPlan`/
|
||||||
|
`mintManagedLanes`/`reconcileManagedLanes`, ~330). The D2 lane machinery arrived after the file's
|
||||||
|
original charter and is a separable concern.
|
||||||
|
**Proposal:** split `view_lanes.cpp`. **Severity:** low (677 is barely over). **Disposition:**
|
||||||
|
document-and-defer unless Q-W1's relocation is already touching it — then take the free split.
|
||||||
|
|
||||||
|
**T4-10 — `src/ingest.cpp` (636).**
|
||||||
|
Seams: **pure-ish WAV/PCM helpers** (`buildFloat32Wav`/`decodePcmSource` + byte I/O, 86–217, ~130 —
|
||||||
|
see T4-22: `buildFloat32Wav` is a pure function trapped in a shell TU), **`importFileIntoActiveBank`**
|
||||||
|
(249–420, ~170), and the three ingest surfaces + registration (420–636). Extracting the pure WAV
|
||||||
|
build into a testable core module (`wav_write` beside `wav_trim`, or one `wav_codec`) drops the shell
|
||||||
|
to ~500 and gains a test target.
|
||||||
|
**Severity:** low-medium. **Disposition:** fix in whichever wave lands the WAV consolidation
|
||||||
|
(T4-22); the file split itself is a rider.
|
||||||
|
|
||||||
|
### 1.3 Known offenders — VST side (entirely absent from the current plan)
|
||||||
|
|
||||||
|
**T4-11 — `src/vst/reasampler_editor.cpp` (3065) + `reasampler_editor.h` (539).**
|
||||||
|
The single biggest unplanned file — it grew past `main.cpp` in the r11 recomposition, *after* the
|
||||||
|
plan was written. It is now the `bank_panel.cpp` of the VST artifact, with the same god-module
|
||||||
|
profile. Real seams, from the skeleton:
|
||||||
|
|
||||||
|
| Proposed TU | Functions (line spans) | Est. LOC |
|
||||||
|
|---|---|---|
|
||||||
|
| `editor_session` | ctor/dtor, `refreshFromBank`/`rebuildVisible`/`onSyncTimer`/`commitAndReload`/`loadSelection`/`upsertPickedOverride`/effective-zone helpers (146–400), PCM + thumbnail caches `monoPcmFor`/`thumbnailFor` (719–800) | ~420 |
|
||||||
|
| `editor_controls` | control-value map `controlValue`/`applyControl` (399–475), knob-deck plumbing `zoneDeckGroupDescs`/`deckGroupDescs`/`deckControlNorm`/`applyDeckKnob`/`deckValueLabel` (475–649), envelope pack/unpack + `commitPickedMarkers` (649–719), `applyZoneControl` (2576–2587) | ~470 |
|
||||||
|
| `editor_layout` (pure candidate — see T4-23) | anon-ns geometry: `computeSampleBands`/`clusterRects`/zone-panel areas/`channelToggleRects` (904–1076), `computeBrowseModal` (1756–1785), `zoneContentArea` (1917–1924) | ~250 |
|
||||||
|
| `editor_paint_sample` | `paint` dispatch (1169), `drawTitleBand`, `paintSample`/`paintEnvelopeOverlay`/`paintVelocityCurve`/`paintKnobDeck`/`paintCurveButton`/`paintCurvePopup`/`paintEmptyState` + `drawKnobFace`/`drawSpectralStrip`/`drawRootMarker` (1076–1756) | ~590 |
|
||||||
|
| `editor_paint_browse_zone` | `paintBrowse` (1785–1917), `paintZone` (1924–2041) | ~260 |
|
||||||
|
| `editor_input` | `resolveHover` (2041–2145), `onMouseDown` (2147–2576 — a 430-line per-face dispatch), popup/curve mouse (1637–1735), `onMouseMove`/`onMouseUp`/`onMouseRDown`/`onMouseWheel`/`onSearchChar`/`onFilesDropped` (2587–2929) | ~880 → **split by face**: `editor_input_sample` (~500: sample-face hit branches + drag state + curve popup) and `editor_input_browse_zone` (~380: browser cards/scroll/search + zone strip/note entry) |
|
||||||
|
| `editor_platform` | IPlugView overrides `isPlatformTypeSupported`/`canResize`/`checkSizeConstraint`/`attachedToParent`/`removedFromParent`/`onSize`/`invalidate` (800–904), `wndProc` + non-Windows stubs (2929–3065) | ~280 |
|
||||||
|
|
||||||
|
Seven-to-eight TUs, all sub-600, each a real cohesive cluster (session/bridge state · param
|
||||||
|
plumbing · layout · paint × 2 · input × 2 · platform). The face structure (Sample / Browse / Zone)
|
||||||
|
is the natural input/paint split axis — it mirrors how the code already dispatches.
|
||||||
|
**Severity: highest of the audit** — this is the largest unowned file in the repo.
|
||||||
|
**Disposition: reshapes the wave plan — needs a new owning wave** (see §1.5 / summary table).
|
||||||
|
|
||||||
|
**T4-12 — `src/vst/reasampler_processor.cpp` (1164) + `reasampler_processor.h` (502).**
|
||||||
|
Four seams: **VST3 lifecycle + bus boilerplate** (`queryInterface`…`setupProcessing`,
|
||||||
|
`setBusArrangements`, 118–209 + 492–506, ~120), **component-state I/O** (`setState`/`getState` +
|
||||||
|
`legacyLiftShouldRun`, 209–349 + 774–904, ~270), **accessors/param setters** (349–492, ~140), and
|
||||||
|
**reload + drain + usage-publish + render** (`reloadInstrument`/`publishUsage`/
|
||||||
|
`publishBuiltLocked`/`rebuildVoiceEngine`/`retireIdleDrain` 506–774 ~270, `process` 904–1164 ~260).
|
||||||
|
**Proposal:** three TUs — `processor_state` (state I/O + accessors, ~410), `processor_reload`
|
||||||
|
(reload/drain/usage, ~290), `reasampler_processor.cpp` (lifecycle + `process()`, ~400). Keep
|
||||||
|
`process()` and its block-render helpers in one TU (heuristic c — see T4-27). All are member
|
||||||
|
functions of one class; partial-class-across-TUs is the same pattern the Q-W2 panel split uses.
|
||||||
|
**Severity:** medium-high. **Disposition:** new VST wave (see §1.5).
|
||||||
|
|
||||||
|
**T4-13 — `src/vst/sample_map.cpp` (970) + `sample_map.h` (708).**
|
||||||
|
Two clean halves plus a small third: **resolution core** (bank-JSON distill/select/list, SampleRefs
|
||||||
|
management, PCM downmix/extract/decode, `resolvePlay`, keymap builders, performance-map resolution +
|
||||||
|
`reconcileSingleCaptureZones`, 14–406, ~390) and **binary component-state codec** (`putU32le`/
|
||||||
|
`putU64le`/`ByteReader`, zones payload put/read, `serializePerformance`/`deserializePerformance`,
|
||||||
|
`serializeComponentState`/`deserializeComponentState` v3→v11 lift ladder, selection codec,
|
||||||
|
406–970, ~565).
|
||||||
|
**Proposal:** split `component_state_io.cpp` (the codec, ~565) from `sample_map.cpp` (resolution,
|
||||||
|
~400). The header splits the same way: wire/state structs (`ComponentState`/`SampleRefEntry`/codec
|
||||||
|
decls) vs. resolution API (`SelectedSample`/`PerformanceMap`/`ZonePlaySeconds`/resolvers). This is
|
||||||
|
the same shape as the extension's model-vs-JSON split and directly reduces rebuild fan-out — the
|
||||||
|
editor and processor both include `sample_map.h` today and recompile on every codec tweak.
|
||||||
|
**Severity:** medium-high (the codec grows every ComponentState version bump — v6→v11 in one
|
||||||
|
quarter; it will cross 600 on its own soon). **Disposition:** new VST wave.
|
||||||
|
|
||||||
|
**T4-14 — `src/vst/sampler_core.cpp` (968) + `sampler_core.h` (762).**
|
||||||
|
Contents: pitch math (15–30), `Keymap` (34–58), `AdsrEnvelope`/`TriggerEnvelope`/`PitchEnvelope`
|
||||||
|
(62–251, ~190), `Voice` (255–680 — `advanceFrame` alone is ~200), `VoiceEngine` (680–968, ~290).
|
||||||
|
**This TU is a genuine single responsibility — the realtime voice engine — and it is the hottest
|
||||||
|
code in the repo.** Every function in it sits on the per-sample render path; the envelope `tick()`s
|
||||||
|
and `Voice::advanceFrame` benefit from same-TU inlining (no LTO assumption in the build). Splitting
|
||||||
|
the .cpp along class lines would put per-sample calls across TU boundaries — precisely the
|
||||||
|
heuristic-(c) violation the phase forbids.
|
||||||
|
**Proposal:** **leave the TU whole at 968** (documented exception to the 600 bar, justified by the
|
||||||
|
hot path), but **split the header**, which is where the pain actually is: `zone_params.h` (the enums
|
||||||
|
+ `AdsrParams`/`TriggerParams`/`PitchEnvParams`/`ZonePlayParams`/`SampleLoop`/`SampleData` — what
|
||||||
|
`sample_map`, the editor, and the codec actually need, ~250) vs. `sampler_core.h` (Keymap + the
|
||||||
|
engine classes, ~500). Today every UI TU that reads a param struct recompiles when a `Voice` member
|
||||||
|
changes. **Severity:** medium. **Disposition:** header split in the new VST wave; TU stays —
|
||||||
|
recommend recording the exception in the wave brief so nobody "fixes" it later.
|
||||||
|
|
||||||
|
**T4-15 — `src/view_mode_model.h` (748)** — covered under T4-06 (splits with its TU).
|
||||||
|
**T4-16 — `src/vst/sample_map.h` (708)** — covered under T4-13.
|
||||||
|
**T4-17 — `src/vst/sampler_core.h` (762)** — covered under T4-14.
|
||||||
|
|
||||||
|
### 1.4 Borderline (no action, for the record)
|
||||||
|
|
||||||
|
`capture.cpp` (549), `reasampler_editor.h` (539 — shrinks when the editor splits move private
|
||||||
|
helpers into their TUs), `reasampler_processor.h` (502 — same), `bank_book.h` (457), `pitch_shift.cpp`
|
||||||
|
(371 — single responsibility, hot, leave), `persist.h`/`capture.h` (341/234 — Q-W6 fat-header pass
|
||||||
|
already owns them). None need action beyond what their TU splits imply.
|
||||||
|
|
||||||
|
### 1.5 The structural conclusion for the wave plan
|
||||||
|
|
||||||
|
The existing plan splits 4 files, all extension-side. The census says **9 files need splitting and 2
|
||||||
|
need header-only splits — 5 of them VST-side, which currently have no owning wave.** The VST work is
|
||||||
|
the same kind and size as Q-W2 (the editor alone ≈ the old bank_panel). Recommendation: add one VST
|
||||||
|
god-module wave (call it **Q-W2v**, runnable in parallel with Q-W2 — different artifact, zero file
|
||||||
|
overlap; or sequence after W5 as Q-W7 if Daniel wants serial waves). Q-W1's relocation scope also
|
||||||
|
grows: the ~20 clean VST pure libs relocate + namespace in W1 alongside the extension's 30.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `src/vst/` placement in the Q-3 directory map
|
||||||
|
|
||||||
|
The settled Q-3 map (`core/{model,view,capture,audio,ui,reclaim,version,json}`,
|
||||||
|
`shell/{capture,panel,view,persist,actions}`, `app/`) covers only the extension. Two viable shapes
|
||||||
|
for the VST artifact; **this is Daniel's fork to call at triage.**
|
||||||
|
|
||||||
|
**T4-18 — Leading recommendation: integrate into the same `core/`/`shell/` top split, with
|
||||||
|
`instrument/` subsystem dirs beneath.**
|
||||||
|
|
||||||
|
```
|
||||||
|
core/instrument/engine/ sampler_core, pitch_shift, velocity_curve, master_gain
|
||||||
|
core/instrument/map/ sample_map (+component_state_io), bank_sync, bridge_marshal, note_entry, trigger_seam
|
||||||
|
core/instrument/ui/ editor_geometry, keyboard_strip, waveform_view, capture_browser,
|
||||||
|
browser_scroll, param_slider, knob_deck, curve_popup,
|
||||||
|
envelope_overlay, envelope_edit, embed_strip
|
||||||
|
shell/instrument/ reaper_bridge, processor TUs, editor TUs, reasampler_embed, vst_entry,
|
||||||
|
reasampler_vst.h / reasampler_uid.h
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
1. **One rule, no special case.** Q-3's settled reasoning is "top-level by the load-bearing
|
||||||
|
discipline, because the pure/shell split is the invariant worth making structural." That reasoning
|
||||||
|
is artifact-agnostic — a file's directory should tell you whether it may touch a *host* type
|
||||||
|
(REAPER or VST3 SDK), and `shell/instrument/` says exactly that.
|
||||||
|
2. **The artifact boundary is a link-graph fact, not a source-layout fact — and the sources already
|
||||||
|
straddle it.** Verified cross-artifact consumers: `sample_map` links `bank_book` + `wav_trim` +
|
||||||
|
`sampler_core`; the editor includes `draw_kit`/`theme`/`component_geometry`/`capture_paths`/
|
||||||
|
`peaks`/`wav_trim`/`app_version`/`ext_keys` (extension-side modules); the extension's pure
|
||||||
|
`instrument_drop` includes `vst/reasampler_uid.h`. An artifact-first subtree would either
|
||||||
|
duplicate these or still reach across — the boundary it draws is already false.
|
||||||
|
3. **Namespace map falls out:** `reasampler::instrument::{engine,map,ui}` beside
|
||||||
|
`reasampler::model` etc. — the Q-4 rule applied uniformly.
|
||||||
|
4. **CMake impact: path edits only.** Targets, links, and test executables are unchanged; the
|
||||||
|
VST3-gate (`EXISTS pluginfactory.cpp`) already guards targets, not directories.
|
||||||
|
|
||||||
|
Cost to name: the VST3-gated targets stay interleaved through the top-level `CMakeLists.txt` rather
|
||||||
|
than being isolatable behind one `add_subdirectory`. Mitigable by grouping the instrument targets
|
||||||
|
into one guarded block (or one `include()`d .cmake file) without moving sources.
|
||||||
|
|
||||||
|
**T4-19 — Alternative: parallel artifact-first subtree** — `src/vst/core/{engine,map,ui}` +
|
||||||
|
`src/vst/shell/`, extension keeps `src/core|shell|app`. Pros: the artifact boundary is visible at
|
||||||
|
top level; the whole VST tree (sources *and* a dedicated `src/vst/CMakeLists.txt`) can sit behind
|
||||||
|
one SDK-gated `add_subdirectory`, which is the cleanest possible expression of "this half only
|
||||||
|
exists on Windows with the submodule slice". Cons: two parallel `core/` trees dilute the "directory
|
||||||
|
= may it touch a host type" invariant into "check which subtree first"; the shared-module reality
|
||||||
|
(point 2 above) means the subtree is not actually self-contained — its purity is cosmetic; and the
|
||||||
|
gated-`add_subdirectory` win is achievable under T4-18 with an `include()` anyway. **Recommend
|
||||||
|
T4-18; T4-19 is defensible if Daniel weighs artifact legibility above discipline uniformity.**
|
||||||
|
**Disposition: reshapes Q-W1** (the relocation wave executes whichever shape is chosen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Template-collapse opportunities
|
||||||
|
|
||||||
|
Judged per heuristic (b) — proposed only where duplication is real and the template earns it; two
|
||||||
|
anti-recommendations included, because a forced template is the worse smell.
|
||||||
|
|
||||||
|
**T4-20 — Little-endian byte codec: real template win.**
|
||||||
|
Five hand-rolled copies, verified: `putU32le`/`putU64le` + `ByteReader` (`vst/sample_map.cpp`
|
||||||
|
406–500), `writeU32LE` (`capture_realtime.cpp` 342), `readU32LE` lambda (`capture_paths.cpp` 49),
|
||||||
|
`putU32` lambda (`ingest.cpp` 134), `appendU32LE` (`instrument_drop.cpp` 18). One header —
|
||||||
|
`core/wire/bytes.h` (or beside `core/json`): `template <class T> void putLE(std::vector<uint8_t>&,
|
||||||
|
T)` / `template <class T> bool readLE(ByteReader&, T&)` with the double↔bits helpers — replaces all
|
||||||
|
five, compile-time dispatched, zero runtime cost, and gives the ComponentState codec (T4-13) a
|
||||||
|
tested primitive. Entirely off hot paths (serialization/file I/O only).
|
||||||
|
**Severity:** medium (each new ComponentState version re-duplicates today). **Disposition:** fix in
|
||||||
|
the wave that lands `component_state_io` (the biggest consumer); consumers rewire opportunistically.
|
||||||
|
|
||||||
|
**T4-21 — Rect family: unify, but with a concrete type, NOT a template.**
|
||||||
|
Verified 12+ byte-identical `{int x,y,width,height}` structs (`ActionBarRect`/`CellRect`/
|
||||||
|
`PanelClientRect`/`FooterBarRect`/`HeaderRect`/`SegmentRect`/`MenuBarRect`/`MenuButtonRect`/
|
||||||
|
`FooterRect`/`ButtonRect`/`TabStripRect`/`KitBox`…) plus a *second grammar* on the VST side
|
||||||
|
(`editor_geometry`'s `Rect` is LTRB with `left/top/right/bottom` + `height()`). The right tool is
|
||||||
|
one concrete `ui::Rect` + `contains()` with per-role type aliases (`using ButtonRect = ui::Rect;`)
|
||||||
|
so call sites keep their semantic names — Q-W1's "one `ui::` owner" note already points here; this
|
||||||
|
finding extends it: (a) retire the XYWH-vs-LTRB fork by picking one grammar (LTRB has the live
|
||||||
|
`contains`/`height` users; either works — pick once), and (b) the per-type `contains`/`hitTest*`
|
||||||
|
one-liners collapse for free. A template rect would model nothing — the types differ in name only.
|
||||||
|
**Watch:** cross-lib name collisions (extension `Rect` vs vst `Rect`) surface only when both headers
|
||||||
|
meet in one TU — `sample_map` and the editor are exactly such TUs; the Q-4 sub-namespaces are the fix.
|
||||||
|
**Severity:** medium. **Disposition:** ride Q-W1 (it is the settled `ui::` unification, widened to
|
||||||
|
include `editor_geometry::Rect`).
|
||||||
|
|
||||||
|
**T4-22 — Linear rect-scan hit-tests: small template, real but modest.**
|
||||||
|
`hitTestCell` (`bank_grid`), `hitTestSlot` (`card_drag`), and the tab/segment scans are the same
|
||||||
|
first-rect-containing-point loop over records that carry a rect plus extra fields (`SlotCellRect`
|
||||||
|
adds `slot`). After T4-21, a single `template <class R> int hitIndex(int px, int py,
|
||||||
|
span<const R>)` (requiring `r.rect.contains(px,py)` or a rect accessor) collapses them. Earns its
|
||||||
|
keep only if T4-21 lands first; alone it would be a forced template.
|
||||||
|
**Severity:** low. **Disposition:** document-and-defer; opportunistic rider on Q-W1.
|
||||||
|
|
||||||
|
**T4-23 — WAV build/parse consolidation (dedup, not template).**
|
||||||
|
`buildFloat32Wav` (ingest, 116–179) hand-writes the float32 header that `wav_trim` hand-parses and
|
||||||
|
`capture_paths`/`capture_realtime` chunk-scan/byte-patch. One pure `wav_codec` (or fold build into
|
||||||
|
`wav_trim`, renamed) gives one tested owner of the RIFF layout. Concrete functions; nothing to
|
||||||
|
template. **Severity:** low-medium. **Disposition:** fix-now-sized, but assign to the wave that
|
||||||
|
opens `ingest.cpp` (T4-10) to avoid a standalone churn commit.
|
||||||
|
|
||||||
|
**T4-24 — `clamp01`: dedup with one inline, anti-template.**
|
||||||
|
Six verified copies (`envelope_overlay`, `master_gain`, `param_slider`, `reasampler_editor`,
|
||||||
|
`velocity_curve` + `envelope_edit`'s `clamp`). One `constexpr inline double clamp01(double)` in a
|
||||||
|
shared core header (or just `std::clamp` at call sites). Not a template candidate — `std::clamp`
|
||||||
|
already is one. **Severity:** trivial. **Disposition:** rider on Q-W1 relocation.
|
||||||
|
|
||||||
|
**T4-25 — JSON `Parser`/`ObjWriter`/`writeEscaped`/`intToStr` ×4 — already owned by Q-W1;
|
||||||
|
confirmed still accurate** (verified in `bank_model`/`bank_book`/`view_mode_model`/
|
||||||
|
`owned_manifest`; `sample_usage` uses its own `rsusage` k/v wire, *not* a fifth JSON parser — no
|
||||||
|
scope growth). Concrete class, not a template. **Disposition:** no change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Indirection audit (heuristic c)
|
||||||
|
|
||||||
|
**T4-26 — `ICaptureBackend` is now a dead abstraction: one implementation, zero polymorphic call
|
||||||
|
sites. The brief's "two implementations — earning its keep" assumption is FALSE at current state.**
|
||||||
|
Verified: `capture.h` itself documents (lines 139–148, the "SEAM CHOICE" comment) that
|
||||||
|
`RealtimeRecordBackend` **deliberately does not implement** `ICaptureBackend` — it has a bespoke
|
||||||
|
async `begin/tick/abort` seam. `OfflineRenderBackend` is the sole deriver, and the only
|
||||||
|
construction site (`main.cpp:738`) instantiates the concrete type; nobody anywhere holds an
|
||||||
|
`ICaptureBackend*`/`&`. The interface costs a vtable and models nothing.
|
||||||
|
**Proposal:** delete `ICaptureBackend`; `OfflineRenderBackend` becomes a plain concrete class. Note
|
||||||
|
CLAUDE.md/CONTEXT still describe the module as "`ICaptureBackend` interface; two backends" — the doc
|
||||||
|
should be corrected in the same commit. **Severity:** low runtime, medium hygiene (it misleads —
|
||||||
|
this audit's own brief was misled). **Disposition:** fix in Q-W3 (the wave that rehomes the capture
|
||||||
|
orchestration and touches every call site).
|
||||||
|
|
||||||
|
**T4-27 — Warning to the Q-W2v brief (T4-14): do not split `sampler_core.cpp` along class lines.**
|
||||||
|
`AdsrEnvelope::tick`/`TriggerEnvelope::amplitudeAt`/`PitchEnvelope::tick` are called per-voice
|
||||||
|
per-sample from `Voice::advanceFrame`, which is called per-sample from `VoiceEngine::render`.
|
||||||
|
Same-TU definition is what lets the compiler inline this stack today (no LTO configured). A
|
||||||
|
by-class TU split converts the hottest inner loop into cross-TU calls — the exact dispatch-stack
|
||||||
|
blowout heuristic (c) forbids. If a split is ever wanted, the envelopes must move as
|
||||||
|
header-defined (inline) classes, not to a TU. The engine TU staying whole at 968 is the correct
|
||||||
|
trade.
|
||||||
|
|
||||||
|
**T4-28 — Warning to the Q-W2 brief (reaffirming the plan's own guardrail).** `panel_audition` and
|
||||||
|
the preview idle path must stay direct call-throughs after the 8-TU split — the plan already says
|
||||||
|
this; the two *added* TUs (T4-01: `panel_layout`, `panel_drag`) introduce no new risk (layout is
|
||||||
|
paint-time, drag is input-time), but the split of `onMouseMove` (which calls hover + drag + tooltip)
|
||||||
|
should keep per-mouse-move work as plain free-function calls, no interface.
|
||||||
|
|
||||||
|
**T4-29 — Warning to the processor split (T4-12).** Keep `process()` and any per-block helpers it
|
||||||
|
calls in one TU. The state/reload/accessor TUs are UI-thread or setup-time — safe to move freely.
|
||||||
|
The atomic-pointer-swap pattern (`publishBuiltLocked`) must not gain a virtual seam.
|
||||||
|
|
||||||
|
**T4-30 — No other gratuitous indirection found (verified, not assumed).** The only extension-side
|
||||||
|
`virtual` is T4-26. VST-side virtuals are all VST3-SDK-mandated overrides (`SingleComponentEffect`,
|
||||||
|
`CPluginView`, `IReaperUIEmbedInterface`) — not ours to remove. The layered pure→shell pairs
|
||||||
|
(`card_drag`→`bank_panel`, `realtime_record`→`capture_realtime`, `drag_out`→`drag_out_win`,
|
||||||
|
`prune_reconcile`→`persist`) are the load-bearing discipline, not forwarding waste — each layer
|
||||||
|
adds the decision/side-effect split, and all are direct calls. `bank_panel`'s ~20-function free-API
|
||||||
|
is a module boundary, not a dispatch chain; Q-W2's header segmentation thins it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary table — every oversize file → proposed seams → owning wave
|
||||||
|
|
||||||
|
| File (LOC) | Proposed TUs/headers | Owning wave |
|
||||||
|
|---|---|---|
|
||||||
|
| `bank_panel.cpp` (3459) | 8 TUs: render / **layout (new)** / thumbnails / audition / input / **drag (new)** / bank_ops / window | **Q-W2 (reshaped: 6→8 seams)** |
|
||||||
|
| `vst/reasampler_editor.cpp` (3065) | 8 TUs: session / controls / layout (pure candidate) / paint_sample / paint_browse_zone / input_sample / input_browse_zone / platform | **NEW wave Q-W2v** |
|
||||||
|
| `main.cpp` (1897) | orchestrator / **batch+recapture (new)** / scope_resolve / realtime_lifecycle / app-entry residue | **Q-W3 (reshaped: 3→4 hoists)** + Q-W6 |
|
||||||
|
| `vst/reasampler_processor.cpp` (1164) | processor_state / processor_reload / lifecycle+process (whole) | **NEW wave Q-W2v** |
|
||||||
|
| `bank_book.cpp` (1109) | slot_map / bank_book / JSON→`core/json` | Q-W1 (+slot_map rider) |
|
||||||
|
| `view_mode_model.cpp` (1049) + `.h` (748) | indexes+model / planners / JSON→`core/json`; header splits likewise | Q-W1 (+planner rider) |
|
||||||
|
| `actions.cpp` (1016) | design_view_actions / bank_actions / prune_action (unchanged) | Q-W4 (no change) |
|
||||||
|
| `vst/sample_map.cpp` (970) + `.h` (708) | sample_map (resolution) / component_state_io (codec); header splits likewise | **NEW wave Q-W2v** |
|
||||||
|
| `vst/sampler_core.cpp` (968) + `.h` (762) | **TU stays whole (hot-path exception, T4-27)**; header → zone_params.h + sampler_core.h | **NEW wave Q-W2v** (header only) |
|
||||||
|
| `capture_realtime.cpp` (867) | lifecycle / finalize | Q-W3 (rides the Q-9 rename) |
|
||||||
|
| `persist.cpp` (852) | session / ext_state_io / prune_fs (unchanged) | Q-W5 (no change) |
|
||||||
|
| `bank_model.cpp` (767) | model / JSON→`core/json` (unchanged) | Q-W1 (no change) |
|
||||||
|
| `view.cpp` (677) | apply / lanes | defer, or Q-W1 rider |
|
||||||
|
| `ingest.cpp` (636) | wav helpers→core / import / surfaces | wave owning T4-23 |
|
||||||
|
|
||||||
|
**Wave-plan deltas requested of triage:** (1) Q-W2 grows to 8 seams; (2) Q-W3 grows to 4 hoists +
|
||||||
|
the `ICaptureBackend` deletion; (3) a **new VST god-module wave (Q-W2v)** owns the editor /
|
||||||
|
processor / sample_map splits + the sampler_core header split — parallel-safe with Q-W2 (zero file
|
||||||
|
overlap); (4) Q-W1's relocation scope includes the VST pure libs under the placement shape chosen
|
||||||
|
at the T4-18/T4-19 fork; (5) the `core/wire/bytes.h` LE-codec template (T4-20) lands with
|
||||||
|
`component_state_io`.
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
# Capture tail — spec
|
# Capture tail — spec
|
||||||
|
|
||||||
Authoritative spec for the **capture-tail** feature: preserving reverb/release
|
Authoritative spec for the **capture-tail** feature: preserving reverb/release
|
||||||
tails that decay past the end of a capture range. The tickable milestone lives in
|
tails that decay past the end of a capture range. The tickable milestone's landed
|
||||||
`PLAN.md` (Milestone T); this doc holds the full technical detail **and** the
|
history is in `docs/ARCHIVE.md` (Milestone T); this doc holds the full technical
|
||||||
product framing.
|
detail **and** the product framing.
|
||||||
|
|
||||||
> **Why this doc carries the technical spec (not `CONTEXT.md`).** Every other
|
> **Why this doc carries the technical spec (not the architecture docs).** Every
|
||||||
> pillar (capture M0–M11, Design View, Multi-bank) keeps its authoritative
|
> other pillar (capture M0–M11, Design View, Multi-bank) keeps its authoritative
|
||||||
> technical spec as a `CONTEXT.md §` section and its *why* in a `docs/product/`
|
> technical spec as a per-directory `src/**/CLAUDE.md` section and its *why* in a
|
||||||
> note. Capture-tail is a rider on the already-shipped offline-render path
|
> `docs/product/` note. Capture-tail is a rider on the already-shipped
|
||||||
> (M3/M7), not a standalone pillar, and it is being specced without reopening
|
> offline-render path (M3/M7), not a standalone pillar, and it was specced without
|
||||||
> `CONTEXT.md`. So the authoritative detail lands **here**, house-styled to match
|
> reopening the architecture spec. So the authoritative detail lands **here**,
|
||||||
> the CONTEXT specs; when the tail work lands, doc-keeper may fold the invariant
|
> house-styled to match those specs; the landed invariant deltas are folded into
|
||||||
> deltas into `CONTEXT.md §Precision invariants` as landed history. Same standing
|
> root `CLAUDE.md` §Precision invariants and `src/core/capture/CLAUDE.md` /
|
||||||
> discipline applies: **verify every REAPER API name/flag against
|
> `src/shell/capture/CLAUDE.md` as landed history. Same standing discipline
|
||||||
|
> applies: **verify every REAPER API name/flag against
|
||||||
> `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use** — the flag values
|
> `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use** — the flag values
|
||||||
> below are transcribed from that header (line numbers cited) and are not guesses.
|
> below are transcribed from that header (line numbers cited) and are not guesses.
|
||||||
|
|
||||||
@@ -252,7 +253,7 @@ held to bit-identical repeats.
|
|||||||
- **Opt-in beyond the region.** The tail only ever adds audio past the range end
|
- **Opt-in beyond the region.** The tail only ever adds audio past the range end
|
||||||
when a tail is explicitly requested (Auto or Manual). This is exactly the existing
|
when a tail is explicitly requested (Auto or Manual). This is exactly the existing
|
||||||
invariant: *"no added silence unless a tail is explicitly requested"*
|
invariant: *"no added silence unless a tail is explicitly requested"*
|
||||||
(`CONTEXT.md §Precision invariants`, `CLAUDE.md §Exact bounds`). Auto-trim
|
(`CLAUDE.md §Precision invariants §Exact bounds`). Auto-trim
|
||||||
strengthens it — the tail added is decay, not silence, and the silence past the
|
strengthens it — the tail added is decay, not silence, and the silence past the
|
||||||
decay is trimmed off.
|
decay is trimmed off.
|
||||||
- **The null test uses NO tail.** The null-test / verify capture (M10) and any
|
- **The null test uses NO tail.** The null-test / verify capture (M10) and any
|
||||||
@@ -274,8 +275,8 @@ held to bit-identical repeats.
|
|||||||
the tail is **the track's own reverb/delay decay**, not the parent bus's. A track
|
the tail is **the track's own reverb/delay decay**, not the parent bus's. A track
|
||||||
with a reverb plugin captures that reverb's tail; a track feeding a folder reverb
|
with a reverb plugin captures that reverb's tail; a track feeding a folder reverb
|
||||||
does **not** capture the folder reverb's tail (that send is out of scope — and
|
does **not** capture the folder reverb's tail (that send is out of scope — and
|
||||||
note the pre-existing send-isolation caveat in `PLAN.md §Open questions`, which
|
note the pre-existing send-isolation caveat, which the tail inherits
|
||||||
the tail inherits unchanged, does not worsen). Correct and consistent.
|
unchanged, does not worsen). Correct and consistent.
|
||||||
- **Item scope:** item/take FX only, self-track + ancestors + master bypassed →
|
- **Item scope:** item/take FX only, self-track + ancestors + master bypassed →
|
||||||
the tail is the **item/take FX decay only**. An item with a take reverb captures
|
the tail is the **item/take FX decay only**. An item with a take reverb captures
|
||||||
its tail; the track's reverb does not ring into it. Correct.
|
its tail; the track's reverb does not ring into it. Correct.
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ subdirectories) **without sacrificing runtime performance**, against a stated qu
|
|||||||
>
|
>
|
||||||
> Bring the codebase "**into the realm of something I can stand to look at.**"
|
> Bring the codebase "**into the realm of something I can stand to look at.**"
|
||||||
|
|
||||||
Its build roadmap lives in **PLAN.md §Phase Q** and its authoritative spec in
|
Its build roadmap's landed history lives in **`docs/ARCHIVE.md`** ("Phase Q — Quality:
|
||||||
**CONTEXT.md §Phase Q**. This doc holds the *why* — the quality bar, the evidence base
|
the decision record") and its authoritative spec now lives in root **`CLAUDE.md`**
|
||||||
|
(§Directory and namespace layout, §Performance guardrails, §Structural heuristics)
|
||||||
|
plus the per-directory `src/**/CLAUDE.md` files. This doc holds the *why* — the quality bar, the evidence base
|
||||||
(a grep-verified SOLID audit), the target directory/namespace shape grounded in the Vital
|
(a grep-verified SOLID audit), the target directory/namespace shape grounded in the Vital
|
||||||
reference, and the numbered fork decisions.
|
reference, and the numbered fork decisions.
|
||||||
|
|
||||||
@@ -321,10 +323,10 @@ re-namespaced, and split, not carried forward untouched into a tidier tree. "Som
|
|||||||
look at" is partly shape (§2/§2b) and partly *functional soundness* (§2c).
|
look at" is partly shape (§2/§2b) and partly *functional soundness* (§2c).
|
||||||
|
|
||||||
Q-W0 is **not executed by product-designer** — this doc *defines the wave and its scope*; the audit
|
Q-W0 is **not executed by product-designer** — this doc *defines the wave and its scope*; the audit
|
||||||
itself is staff-engineer/DSP work. The authoritative wave definition lives in **PLAN.md §Q-W0** and
|
itself is staff-engineer/DSP work. The wave's landed history lives in **`docs/ARCHIVE.md`**
|
||||||
its spec framing in **CONTEXT.md §"The pre-restructure audit wave (Q-W0)"**; this section is the
|
("Q-W0 fix-now remediations" and "Phase Q — Quality: the decision record"), and its findings live in
|
||||||
evidence-doc framing (the *why* and the smell taxonomy), matching how §2/§2b frame the structural
|
**`docs/product/code-quality-audit.md`**; this section is the evidence-doc framing (the *why* and
|
||||||
audits.
|
the smell taxonomy), matching how §2/§2b frame the structural audits.
|
||||||
|
|
||||||
### 2c.1 Audit surfaces (the named targets)
|
### 2c.1 Audit surfaces (the named targets)
|
||||||
|
|
||||||
@@ -451,7 +453,7 @@ namespace of every header, splitting the four largest TUs). Meanwhile:
|
|||||||
- **Phase L** has **L3** (VST editor + embed-strip restyle, gated on Phase S landing on dev)
|
- **Phase L** has **L3** (VST editor + embed-strip restyle, gated on Phase S landing on dev)
|
||||||
still to land — it touches the Phase S draw shells (`reasampler_editor` / `reasampler_embed`),
|
still to land — it touches the Phase S draw shells (`reasampler_editor` / `reasampler_embed`),
|
||||||
which arrive on dev with Phase S. (L1/L2/L4/L5/L6/L7 have **already landed** — see
|
which arrive on dev with Phase S. (L1/L2/L4/L5/L6/L7 have **already landed** — see
|
||||||
`COMPLETED.md`; the once-listed "L2 pending" is stale and has been corrected here.)
|
`docs/ARCHIVE.md`; the once-listed "L2 pending" is stale and has been corrected here.)
|
||||||
- **D2** is **functionally complete** (D2-W1..W3-B landed; the only open item — a per-track
|
- **D2** is **functionally complete** (D2-W1..W3-B landed; the only open item — a per-track
|
||||||
lane-split panel indicator — is *explicitly deferred*, not a blocking residual). **M9** (slots)
|
lane-split panel indicator — is *explicitly deferred*, not a blocking residual). **M9** (slots)
|
||||||
is **abandoned** (Daniel, 2026-07-27) — will not be built. D2 is named in the gate only so a
|
is **abandoned** (Daniel, 2026-07-27) — will not be built. D2 is named in the gate only so a
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
# ReaSampler code-quality audit — Q-W0 findings report and triage
|
||||||
|
|
||||||
|
Date: 2026-07-28 · Branch: `pq-w0-audit` · Static analysis only; no code changed by the audit.
|
||||||
|
|
||||||
|
This is the committed Q-W0 findings report (Q-10 SETTLED: a committed doc beside the SOLID/naming
|
||||||
|
audit — `docs/product/code-organization.md` §2c.3; deliverable contract landed per `docs/ARCHIVE.md`
|
||||||
|
§Phase Q). It
|
||||||
|
synthesizes four parallel audit tracks; the full track notes remain in the tree as appendices and
|
||||||
|
are the evidence base for every claim here — this report cites finding IDs and does not restate
|
||||||
|
mechanisms in full:
|
||||||
|
|
||||||
|
- **Track 1 — DSP / audio algorithm quality:** [`audit-notes/q-w0-t1-dsp.md`](audit-notes/q-w0-t1-dsp.md) (T1-01…T1-11)
|
||||||
|
- **Track 2 — architecture smells (functional lens):** [`audit-notes/q-w0-t2-architecture.md`](audit-notes/q-w0-t2-architecture.md) (T2-01…T2-11)
|
||||||
|
- **Track 3 — env-coupled-constant domain modeling:** [`audit-notes/q-w0-t3-env-constants.md`](audit-notes/q-w0-t3-env-constants.md) (T3-01…T3-07)
|
||||||
|
- **Track 4 — structural sizing + placement:** [`audit-notes/q-w0-t4-sizing.md`](audit-notes/q-w0-t4-sizing.md) (T4-01…T4-30)
|
||||||
|
|
||||||
|
Dispositions below are **proposals**. Per the Q-W0 sign-off gate, Q-W1 does not begin until Daniel
|
||||||
|
has signed off on every disposition; the open calls are collected in §4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Verdicts up front
|
||||||
|
|
||||||
|
**DSP / pitch engine (the Q-11 question).** The correlation-aligned SOLA in `pitch_shift` is
|
||||||
|
**sound — no technique replacement (phase-vocoder / WSOLA) is warranted on this evidence** (T1
|
||||||
|
overall verdict). Track 1 finds the implementation "unusually well-defended" (normalized
|
||||||
|
correlation, ratio-scaled fades with drain-headroom derivation, prime-with-real-content onset,
|
||||||
|
frozen-writer tail, filled-span clamping) with RT discipline intact throughout. Every T1 finding
|
||||||
|
sits on the Q-11 escalation ladder's first rungs — bounded fixes within the existing technique, or
|
||||||
|
documented operating limits — exactly the settled default. The one High finding (T1-01, stereo
|
||||||
|
splice decorrelation) is a bounded fix inside the current design (link the per-channel lag
|
||||||
|
search), not a technique change. Track 1 states plainly that it cannot listen: every artifact is
|
||||||
|
mechanism + predicted audible consequence, and perceptual materiality is Daniel's call.
|
||||||
|
|
||||||
|
**Architecture (functional smells).** The load-bearing boundaries hold: no pure module includes a
|
||||||
|
host type, the VST bridge is genuinely read-only, WAV *decoding* has exactly one owner, the prune
|
||||||
|
and exception boundaries audit clean (T2 clean list). What Track 2 found instead is the classic
|
||||||
|
cost of duplicated *algorithms*: the length-prefixed wire `Cursor` copy-pasted 3× **with
|
||||||
|
security-hardening drift** — the oldest copy (`provenance`) missing the overflow guards its
|
||||||
|
siblings gained (T2-01, High, with a cheap Q-W0 backport); a fifth hand-rolled JSON decoder the
|
||||||
|
§2 audit did not count (T2-02); `readFileBytes` ×5 (T2-03); the ext-state grow-loop ×3 with its
|
||||||
|
pure decode half-adopted (T2-04); a 19-struct rect zoo (T2-05); and drifting copy-paste in the
|
||||||
|
capture-stamp epilogue (T2-09). All are dedup/relocation-shaped and route onto the reorg waves;
|
||||||
|
none is a live user-facing bug except the T2-01 robustness gap.
|
||||||
|
|
||||||
|
**Env-coupled constants (the prior-incident category).** The persistence surfaces — the
|
||||||
|
highest-stakes case — are **clean**: every wall-clock quantity written to disk since the S12
|
||||||
|
remediation is in seconds or ms, and every persisted frame-domain value is a source-file fact
|
||||||
|
whose rate travels with it (T3 clean list). Seven findings, only two fix-now: the master-gain
|
||||||
|
ramp step hard-codes 20 ms × 48 kHz (T3-01) and the Trigger-fade UI ceiling hard-codes
|
||||||
|
2 s × 44.1 kHz (T3-03) — both live hardcoded-rate residues in `src/`, both trivial, both in files
|
||||||
|
no then-planned downstream wave opens. The rest are deliberate-and-documented couplings or
|
||||||
|
recorded legacy residue, plus one systemic usability note (no DPI/content-scale support, T3-05)
|
||||||
|
that is a future phase of its own.
|
||||||
|
|
||||||
|
**Sizing + placement.** The wave plan's four planned splits are necessary but no longer
|
||||||
|
sufficient: the census measures **9 files needing TU splits and 2 needing header-only splits — 5
|
||||||
|
of them VST-side, which currently have no owning wave** (T4 §1.5). `reasampler_editor.cpp`
|
||||||
|
(3,065 LOC) is now the largest unowned file in the repo. Track 4's structural answer is a new
|
||||||
|
VST god-module wave (**Q-W2v**), reshaped seam lists for Q-W2 (6→8) and Q-W3 (3→4 hoists), one
|
||||||
|
documented exception (`sampler_core.cpp` stays whole — hot path, T4-14/T4-27), and one dead
|
||||||
|
abstraction to delete (`ICaptureBackend`, T4-26 — the brief's "two implementations" assumption is
|
||||||
|
false at current state). The `src/vst/` placement question is a genuine fork for Daniel
|
||||||
|
(T4-18/T4-19, §4a).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Unified findings register
|
||||||
|
|
||||||
|
Every finding from all four tracks, exactly once, with proposed disposition. Severities are the
|
||||||
|
tracks' own. "Q-W0" as a destination means remediated in this wave before it closes (post
|
||||||
|
sign-off). Cross-track overlaps are reconciled in §2.5.
|
||||||
|
|
||||||
|
### 2.1 Track 1 — DSP (appendix: `q-w0-t1-dsp.md`)
|
||||||
|
|
||||||
|
| ID | Finding (one line) | Sev | Proposed disposition | Rationale |
|
||||||
|
|----|--------------------|-----|----------------------|-----------|
|
||||||
|
| T1-01 | Stereo Preserve: per-channel independent splice alignment decorrelates L/R (image wander + mono-sum combing on stereo captures) | High | **Fix-now in Q-W0** (bounded SOLA fix: linked lag/schedule across channels) — **Daniel's call, §4b** | Hits the flagship path (Preserve default + permanently stereo bus); standard stereo-SOLA practice; no technique change |
|
||||||
|
| T1-02 | Ratio slew mid-fade can drain the outgoing tap past the writer (pitch-env attack case) | Med | Document-and-defer; bounded re-cap noted for when the file is next opened | Needs pitch-env + Preserve + steep attack to trigger; constant-ratio case already covered |
|
||||||
|
| T1-03 | Preserve prime ignores Trigger `playEnd_` bound; zero-pads sub-window samples as declared-valid ring content | Med | **Fix-now in Q-W0** (prime to feedBound + immediate `freezeTail()`) **if Daniel agrees short one-shots matter, else defer with note — §4b** | Small, contained in `Voice::start`; re-uses designed GA3 machinery |
|
||||||
|
| T1-04 | No sustain-loop crossfade — hard loop seam clicks unless loop points amplitude-matched | Med | Document-and-defer (record beside the zone-loop spec) | A crossfade is a feature (parameter + UI), wrong scope for a reorg phase |
|
||||||
|
| T1-05 | Linear interpolation + no band-limiting on repitch (both engines) | Low | Document-and-defer (recorded trade-off; cubic Hermite noted as drop-in if ever wanted) | Classic sampler behavior, deterministic and consistent across engines |
|
||||||
|
| T1-06 | Correlation search: coarse step-4 can mis-lock above ~5 kHz; maxLag bounds alignment to ≥ ~80 Hz | Low | Document-and-defer (record as the engine's stated operating range) | Inherent SOLA range/cost trades; widening costs splice-burst CPU linearly |
|
||||||
|
| T1-07 | `splice()` up-jump clamp comment contradicts the code (code is exactly tight; comment's margin direction is backwards) | Low | **Fix-now in Q-W0** (comment rewrite, zero behavior change) | Misleads the next maintainer of a safety-critical clamp; one line |
|
||||||
|
| T1-08 | Linear-in-amplitude ADSR decay/release (constant-dB nowhere; abrupt-late releases) | Low | Document-and-defer | Character-vs-correctness product decision; changing it alters every existing instrument's feel |
|
||||||
|
| T1-09 | `declickR_` is dead state (blend correctly shares one weight; R is seeded/decayed, never read) | Low | Fix-now-trivial **as a rider on any Q-W0 `sampler_core` edit** (T1-01/T1-03); else defer | Hygiene only, no audio effect; not worth a standalone change |
|
||||||
|
| T1-10 | `planWavTruncate` silently drops RIFF chunks after `data` (metadata loss on trim) | Low | Document-and-defer (note in the header's FORMAT ASSUMPTION block when next touched) | Metadata-only; preserving trailing chunks complicates the single-truncating-write design for no audio benefit |
|
||||||
|
| T1-11 | `makeUniqueTag` 1 s resolution → same-second batch captures collide (silent overwrite) | Low-Med | **Fix-now, assigned to Q-W3** (per-session monotonic counter, both call sites); Q-W0 fallback if triage prefers | T1 explicitly routes at triage; Q-W3 is the nearest wave opening the extension capture flow |
|
||||||
|
|
||||||
|
### 2.2 Track 2 — architecture (appendix: `q-w0-t2-architecture.md`)
|
||||||
|
|
||||||
|
| ID | Finding (one line) | Sev | Proposed disposition | Rationale |
|
||||||
|
|----|--------------------|-----|----------------------|-----------|
|
||||||
|
| T2-01 | Wire `Cursor` ×3 with hardening drift — `provenance` lacks the overflow/length guards its siblings have; unbounded `reserve` reachable from persisted input | High | **Fix-now, split:** (a) backport hardened `field()` + count sanity bound to `provenance.cpp` **in Q-W0** (§4c); (b) structural collapse to one shared wire codec **in Q-W1** | Hazard is cheap to close now with existing `provenance_tests`; the dedup should ride the wave already creating `core/` |
|
||||||
|
| T2-02 | Fifth hand-rolled JSON decoder in `tail_control` (the §2 "4× Parser" undercount) | Med | **Fix-now, folded into Q-W1** — add `tail_control` to the `core/json` consumer list explicitly | Zero extra cost when `core/json` lands; a stray fifth decoder afterward would be a defect of the wave |
|
||||||
|
| T2-03 | `readFileBytes` hand-rolled ×5 across both artifacts | Med | **Fix-now, folded into Q-W1** — one pure helper in the `core/` utility home; both targets link it | Five copies of a ten-line function; creating its home is exactly Q-W1's job |
|
||||||
|
| T2-04 | `GetProjExtState` grow-loop ×3; pure `bridge_marshal` decode only half-adopted (`usage_scan`'s copy is prune-safety-adjacent) | Med | **Fix-now, assigned to Q-W5** (the wave that splits `persist.cpp` — T2's own rule; its "Q-W4" label predates the plan's persist=W5 numbering) | Touching persist's session machinery outside its own wave risks the highest-traffic shell for a dedup with no live bug |
|
||||||
|
| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family | Med | **Fix-now, folded into Q-W1** — reconciled with T4-21 into one disposition, see §2.5(1) | Same-moment-as-relocation principle; PLAN Q-W1 already owns the `ui::` rect unification |
|
||||||
|
| T2-06 | Pure-computable layout math stranded in the VST editor shell (~49 inline geometry computations; §2's VST scope gap) | Med | Document-and-defer **with named reshape — satisfied by Q-W2v's `editor_layout` pure-candidate TU** (see §2.5(4), §3) | Behavior-preserving hoist best done under the reorg's test discipline; must be a recorded point or the layer keeps growing |
|
||||||
|
| T2-07 | Extension links the entire voice engine to serialize one preset blob (codec not separable from engine) | Low | Document-and-defer → **executed by Q-W2v's `component_state_io` split** (same split as T4-13, see §2.5(3)) | Right abstraction, wrong granularity; a module-homing decision the reorg waves exist to make once |
|
||||||
|
| T2-08 | WAV/RIFF container knowledge in 4 modules / 2 chunk walkers (dedup-by-hash + null-test invariants sit on their agreement) | Low | Document-and-defer → **consolidation moment is a triage question, §4e** (T2 prefers the `core/wav` homing moment; T4-23 prefers the wave that opens `ingest.cpp`) | All four currently correct against each other; pre-reorg consolidation churns the capture hot path for no functional gain |
|
||||||
|
| T2-09 | Capture backends' Sample-stamping epilogue copy-paste with silent divergences (active-project vs pinned-project time-sig read) | Med | **Fix-now, folded into Q-W3** — extract shared `stampCaptureSample` helper; divergent bits stay in the realtime caller | Q-W3 opens both backend TUs anyway; keeps one review of precision-invariant-adjacent code |
|
||||||
|
| T2-10 | Thumbnail cache-invalidation drifted across the split (extension: pure generation-baked key; editor: ad-hoc string key + call-site `clear()`s) | Low | Document-and-defer — adopt the pure `ThumbnailKey` on the VST side **as a rider on Q-W2v's editor work** | No live bug; pointless as standalone churn, natural rider on the editor wave |
|
||||||
|
| T2-11 | ComponentState v1→v11 deserialize chain sound, but v3/v4/v5 legacy branches triplicate the shared read | Low | Document-and-defer, explicitly — record that the next envelope bump (v12) extends the shared path rather than minting another branch | Legacy branches are frozen back-compat contract; rewriting them risks the one thing they must never break |
|
||||||
|
|
||||||
|
### 2.3 Track 3 — env-coupled constants (appendix: `q-w0-t3-env-constants.md`)
|
||||||
|
|
||||||
|
| ID | Finding (one line) | Sev | Proposed disposition | Rationale |
|
||||||
|
|----|--------------------|-----|----------------------|-----------|
|
||||||
|
| T3-01 | Master-gain ramp step is a per-sample constant baking in 20 ms × 48 kHz (`kGainRampRate = 1/960`); FB1 no-zipper contract degrades silently at higher rates | Med | **Fix-now in Q-W0** — store `kGainRampSeconds`, derive step from `sampleRate_` (the file's own `kPreserveWindowMs` pattern) — **§4d** | Trivial, isolated, behavior-identical at 48 kHz; a live violation of the no-hardcoded-rate ruling in a file no then-planned wave opens |
|
||||||
|
| T3-02 | Takeover-declick decay is a per-frame coefficient (~2× faster at 96 kHz) — documented deliberate in-code | Low | Document-and-defer — triage ratifies the in-code note as the record | Already an explicit, written, bounded design decision; converting buys no audible improvement |
|
||||||
|
| T3-03 | Trigger-fade UI throw ceiling hardcodes 2 s × 44 100 as `88200.0` frames (knob full-scale varies per source rate) | Low | **Fix-now in Q-W0** — `kFadeMaxSeconds = 2.0` resolved against the loaded source's rate; T3's stated fallback (defer, amend comment) if zero UI-feel change is preferred — **§4d** | Small and contained; storage domain unchanged; the editor already threads `frameCount + rate` through pack/unpack |
|
||||||
|
| T3-04 | Drop-hint banner duration stored in sync-timer ticks (6 × 500 ms) | Low | Document-and-defer; fold in opportunistically if the file is opened | Cosmetic, self-documenting, cadence and decay live three lines apart |
|
||||||
|
| T3-05 | Systemic: no DPI/content-scale support in either UI surface (all layout constants are physical px at ~96 DPI) | Med | Document-and-defer — record as a named future phase; Q-W1's geometry relocation keeps constants centralized so the eventual scale factor lands in one place | A proper UI-scaling pass is a feature wave of its own, far outside Q-W0's remediation budget |
|
||||||
|
| T3-06 | Legacy v3 zone-payload lift divides by the *current* project rate (skewed times if the rate changed since write; write-era rate never recorded) | Low | Document-and-defer — this report is the record; the skew is a known, not a future mystery bug | Unrecoverable in principle; the documented residue of the incident that motivated the seconds invariant |
|
||||||
|
| T3-07 | SOLA correlation-segment cap of 512 frames — frame-domain by design (CPU bound); wall-clock span halves at 96 kHz | Low | Document-and-defer — **resolved against Track 1's verdict, see §2.5(2)** | T3 judged the frame domain arguably correct for a compute bound and handed the quality call to T1 |
|
||||||
|
|
||||||
|
### 2.4 Track 4 — sizing + placement (appendix: `q-w0-t4-sizing.md`)
|
||||||
|
|
||||||
|
| ID | Finding (one line) | Sev | Proposed disposition | Rationale |
|
||||||
|
|----|--------------------|-----|----------------------|-----------|
|
||||||
|
| T4-01 | `bank_panel.cpp` (3459): the plan's six seams no longer land sub-600 — `panel_render` ~700, `panel_input` ~800 | High | **Fix-now → reshapes Q-W2:** eight TUs, adding `panel_layout` and `panel_drag` | Without the two new seams, two of six TUs ship >600 on day one |
|
||||||
|
| T4-02 | `main.cpp` (1897): `capture_orchestrator` as specced lands ~885 | High | **Fix-now → reshapes Q-W3:** fourth hoist `capture_batch` (batch family + recapture + selection guards) | Recapture is planner-driven like batch and shares the guard machinery — it belongs with batch |
|
||||||
|
| T4-03 | `actions.cpp` (1016): plan's seams still land sub-600 | — | No change to Q-W4 (confirmation) | Measured against the current tree |
|
||||||
|
| T4-04 | `persist.cpp` (852): plan's seams still land sub-600; pS-usage growth landed exactly where the plan isolates it | — | No change to Q-W5 (confirmation) | Measured against the current tree |
|
||||||
|
| T4-05 | `bank_book.cpp` (1109): `SlotMap` is a self-contained type; post-JSON-extraction remainder splits cleanly | Med | Fix-now, fold into Q-W1 (`slot_map` extraction rides the JSON rewire already opening this file) | One `git mv`-shaped extraction on top of owned work |
|
||||||
|
| T4-06 | `view_mode_model.cpp` (1049) + `.h` (748): planners separable from model+indexes after JSON extraction | Med | Fix-now, fold into Q-W1 (planner split rides the JSON rewire); T4 allows deferring the planner split if the wave wants to stay minimal (~660 post-extraction is marginal) | JSON rewire opens the file; header splits the same way |
|
||||||
|
| T4-07 | `bank_model.cpp` (767): model vs JSON — the Q-W1 poster child, ~250 after extraction | — | Already owned by Q-W1; no new seam (confirmation) | — |
|
||||||
|
| T4-08 | `capture_realtime.cpp` (867): async lifecycle vs file-side finalize are distinct concerns | Low-Med | Fix-now, ride Q-W3 (`capture_realtime_finalize.cpp` split rides the Q-9 naming rider) | Same-wave file surgery is free |
|
||||||
|
| T4-09 | `view.cpp` (677): park/restore vs D2 lane machinery are separable halves | Low | Document-and-defer unless Q-W1's relocation touches it — then take the free `view_lanes` split | 677 is barely over the bar |
|
||||||
|
| T4-10 | `ingest.cpp` (636): pure WAV/PCM build helpers trapped in a shell TU | Low-Med | Fix in whichever wave lands the WAV consolidation — **moment is the §4e triage question** (circular with T4-23; no current wave opens `ingest.cpp`) | Extracting the pure WAV build gains a test target and drops the shell to ~500 |
|
||||||
|
| T4-11 | `vst/reasampler_editor.cpp` (3065): the largest unowned file — the `bank_panel` of the VST artifact | Highest | **Fix-now → new wave Q-W2v:** eight TUs (session / controls / layout / paint ×2 / input ×2 / platform), split axis = the face structure | Grew past `main.cpp` after the plan was written; same god-module profile |
|
||||||
|
| T4-12 | `vst/reasampler_processor.cpp` (1164): four seams | Med-High | **Fix-now → Q-W2v:** three TUs (`processor_state` / `processor_reload` / lifecycle+`process()` whole) | Partial-class-across-TUs is the same pattern as the Q-W2 panel split |
|
||||||
|
| T4-13 | `vst/sample_map.cpp` (970) + `.h` (708): resolution core vs binary ComponentState codec | Med-High | **Fix-now → Q-W2v:** split `component_state_io.cpp` + matching header split — one disposition with T2-07, see §2.5(3) | The codec grows every envelope bump (v6→v11 in one quarter); reduces editor/processor rebuild fan-out |
|
||||||
|
| T4-14 | `vst/sampler_core.cpp` (968) + `.h` (762): TU is a genuine single responsibility on the hottest path | Med | **Fix-now → Q-W2v, header only:** split `zone_params.h` from `sampler_core.h`; **TU stays whole — documented exception to the 600 bar** (record in the wave brief so nobody "fixes" it later) | Same-TU inlining on the per-sample path; no LTO in the build (see T4-27) |
|
||||||
|
| T4-15 | `view_mode_model.h` (748) | — | Covered under T4-06 (splits with its TU) | — |
|
||||||
|
| T4-16 | `vst/sample_map.h` (708) | — | Covered under T4-13 | — |
|
||||||
|
| T4-17 | `vst/sampler_core.h` (762) | — | Covered under T4-14 | — |
|
||||||
|
| T4-18 | VST placement, leading recommendation: integrate into the one `core/`/`shell/` split with `instrument/` subsystem dirs | — | **Daniel's fork — §4a** (T4 recommends T4-18) | One rule, no special case; the artifact boundary is a link-graph fact the sources already straddle |
|
||||||
|
| T4-19 | VST placement, alternative: parallel artifact-first subtree (`src/vst/core|shell` behind one SDK-gated `add_subdirectory`) | — | **Daniel's fork — §4a** | Defensible if artifact legibility outweighs discipline uniformity; T4 notes its self-containment is cosmetic |
|
||||||
|
| T4-20 | Little-endian byte codec hand-rolled ×5 — real template win (`putLE`/`readLE`) | Med | Fix-now, lands with `component_state_io` in Q-W2v (its biggest consumer); other consumers rewire opportunistically — relationship to T2-03/T2-04 noted in §2.5(5) | Compile-time dispatch, zero runtime cost, entirely off hot paths; gives the codec a tested primitive |
|
||||||
|
| T4-21 | Rect family: unify with one **concrete** `ui::Rect` + `contains()` + per-role aliases — NOT a template; retire the XYWH-vs-LTRB fork | Med | **Fix-now, ride Q-W1** — reconciled with T2-05 into one disposition, see §2.5(1) | The types differ in name only; a template would model nothing; cross-lib `Rect` collision is fixed by the Q-4 sub-namespaces |
|
||||||
|
| T4-22 | Linear rect-scan hit-tests: one small `hitIndex` template collapses them — only after T4-21 | Low | Document-and-defer; opportunistic rider on Q-W1 once the rect unification lands | Alone it would be a forced template |
|
||||||
|
| T4-23 | WAV build/parse consolidation into one pure `wav_codec` owner (dedup, not template) | Low-Med | Fix-now-sized, but the owning moment is the **§4e triage question** (T4 assigns it to the wave opening `ingest.cpp`; T2-08 prefers the `core/wav` homing moment; no current wave opens ingest) | Avoid a standalone churn commit; one tested owner of the RIFF layout |
|
||||||
|
| T4-24 | `clamp01` ×6 — one `constexpr inline` (or `std::clamp` at sites); anti-template | Trivial | Fix-now, rider on Q-W1 relocation | — |
|
||||||
|
| T4-25 | JSON `Parser` ×4 confirmed still accurate; `sample_usage` is **not** a fifth JSON parser (no scope growth from this track) | — | No change to Q-W1 (confirmation; T2-02's fifth decoder is a different file and does grow the consumer list) | — |
|
||||||
|
| T4-26 | `ICaptureBackend` is a dead abstraction: one deriver, zero polymorphic call sites; the brief's "two implementations" assumption is FALSE (realtime deliberately has a bespoke seam) | Low (runtime) / Med (hygiene) | **Fix-now, in Q-W3:** delete the interface, `OfflineRenderBackend` becomes concrete; **correct CLAUDE.md/CONTEXT ("ICaptureBackend interface; two backends") in the same commit** | It misleads — this audit's own brief was misled; Q-W3 touches every call site |
|
||||||
|
| T4-27 | Warning: do **not** split `sampler_core.cpp` along class lines — envelope `tick()`s are per-voice-per-sample; a by-class split is the exact heuristic-(c) dispatch blowout | — | Record as a guardrail in the Q-W2v brief (pairs with T4-14's whole-TU exception) | Same-TU definition is what lets the compiler inline the stack today |
|
||||||
|
| T4-28 | Warning to Q-W2: audition/preview stays a direct call-through across the 8-TU split; per-mouse-move work stays plain free-function calls | — | Record as a guardrail in the (reshaped) Q-W2 brief — reaffirms the plan's own guardrail; the two added TUs introduce no new risk | — |
|
||||||
|
| T4-29 | Warning to the processor split: `process()` + per-block helpers stay one TU; the atomic-pointer-swap pattern must not gain a virtual seam | — | Record as a guardrail in the Q-W2v brief | — |
|
||||||
|
| T4-30 | No other gratuitous indirection found (verified: only extension-side `virtual` is T4-26; VST virtuals are SDK-mandated; layered pure→shell pairs are the discipline, all direct calls) | — | Clean verification — see §5 | — |
|
||||||
|
|
||||||
|
### 2.5 Cross-track dedupes and reconciliations
|
||||||
|
|
||||||
|
1. **T2-05 ≡ T4-21 (+ T4-22 rider) — the rect zoo. Reconciled into ONE disposition: fix-now,
|
||||||
|
ride Q-W1.** Both tracks found the same duplication (19 XYWH structs + inline predicates;
|
||||||
|
T4-21 adds the VST side's second LTRB grammar) and both prescribe the same mechanism — one
|
||||||
|
concrete `ui::Rect` + `contains()` with per-role aliases, explicitly **not** a template
|
||||||
|
(T4-21's ruling). The only divergence was the wave label: T2-05 said "Q-W2 (the ui/
|
||||||
|
relocation wave)", but in the plan the relocation wave — and the settled `ui::` rect
|
||||||
|
unification (PLAN §Q-W1, "shared pure-UI rect types … get one `ui::` owner") — is **Q-W1**.
|
||||||
|
T2-05's own rationale ("same-moment-as-relocation") therefore points at Q-W1; reconciled
|
||||||
|
there. T4-22's hit-test template stays a deferred opportunistic rider behind it.
|
||||||
|
2. **T3-07 → T1 — the 512-frame correlation cap. Resolved: document-and-defer.** T3 handed the
|
||||||
|
"is 512 the right number" quality call to Track 1. Track 1's verdict on the correlation
|
||||||
|
search's bounds (T1-06) is document-and-defer — record the alignment limits as the engine's
|
||||||
|
stated operating range; "widening either costs splice-burst CPU linearly." Note for accuracy:
|
||||||
|
T1-06 adjudicates the coarse-step and `maxLag` bounds specifically and does not name the
|
||||||
|
512-frame `corrFrames_` cap; the resolution here rests on T1's general verdict (bounded
|
||||||
|
limits recorded, no change recommended) applied to the same search-cost family. If triage
|
||||||
|
wants the 512 value separately adjudicated, that is a residual question — flagged rather than
|
||||||
|
silently absorbed.
|
||||||
|
3. **T2-07 ≡ T4-13 — the ComponentState codec split. One disposition: Q-W2v's
|
||||||
|
`component_state_io`.** T2-07's complaint (extension links the whole voice engine to share
|
||||||
|
the preset serializer) is *solved by* T4-13's proposed split; T2-07's link-weight rationale
|
||||||
|
rides the T4-13 seam. T4-14's `zone_params.h` header split completes the extension-side
|
||||||
|
decoupling. T2 had provisionally pointed at "Q-W1/Q-W2 module-homing"; with Q-W2v now
|
||||||
|
proposed as the wave that opens `sample_map`, that is the owning wave.
|
||||||
|
4. **T2-06 ≡ T4-11's `editor_layout` — the stranded editor layout math.** T2-06's
|
||||||
|
document-and-defer explicitly demanded a *named* downstream reshape; Q-W2v's `editor_layout`
|
||||||
|
TU (T4-11, marked "pure candidate") is that reshape. One disposition: assigned to Q-W2v, with
|
||||||
|
the hoist targeting the existing pure homes (`editor_geometry` is T2's named natural owner).
|
||||||
|
T2-06's scope note (the §2 god-module catalogue missed the VST side) is also the evidence
|
||||||
|
base for Q-W2v existing at all.
|
||||||
|
5. **T4-20 / T2-03 / T2-04 — related but distinct dedup family, three separate dispositions.**
|
||||||
|
All three converge on shared `core/` utility homes but touch disjoint code: T4-20 (LE byte
|
||||||
|
codec ×5) lands with `component_state_io` in Q-W2v; T2-03 (`readFileBytes` ×5) lands in
|
||||||
|
Q-W1's utility home; T2-04 (ext-state grow-loop ×3, generalizing `bridge_marshal`) lands in
|
||||||
|
Q-W5 with the persist split. Marked here so triage sees them as one family and no wave
|
||||||
|
assumes another already covered its slice.
|
||||||
|
6. **T2-08 / T4-23 / T4-10 — WAV/RIFF consolidation. Genuine track disagreement on the moment;
|
||||||
|
surfaced as §4e** rather than silently picked. T2-08 defers to "the reorg wave that
|
||||||
|
relocates `wav_trim`" (Q-W1's relocation); T4-23 assigns to "the wave that opens
|
||||||
|
`ingest.cpp`" — and T4-10 assigns the ingest split to "whichever wave lands the WAV
|
||||||
|
consolidation," which is circular: **no current wave opens `ingest.cpp`.**
|
||||||
|
7. **T1-11 — capture-tag collision.** T1 offered it to Track 2 ("Track 2 may claim it");
|
||||||
|
Track 2 did not. It remains a single T1 finding, routed per T1's own suggestion to Q-W3.
|
||||||
|
8. **Cross-track interaction on the DSP/VST fix-nows.** T1 and T3 both justified
|
||||||
|
fix-now-in-Q-W0 partly by "no downstream wave opens these files" — written before T4
|
||||||
|
proposed Q-W2v, which *does* open `reasampler_processor.cpp` / `reasampler_editor.cpp` /
|
||||||
|
`sampler_core.h`. The recommendation stands unchanged: Q-W2v is a behavior-preserving
|
||||||
|
mechanical-split wave, and folding behavior-changing DSP/domain fixes into it would break the
|
||||||
|
wave discipline (CTest-green mechanical moves, no logic change). Fix-now items stay in Q-W0;
|
||||||
|
noted so the rationale reads correctly against the reshaped plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Plan reshape — what Q-W0 proposes for Q-W1..Q-W6
|
||||||
|
|
||||||
|
The concrete deltas to the PLAN §Phase Q wave graph. Every wave is named; "no change"
|
||||||
|
confirmations included deliberately.
|
||||||
|
|
||||||
|
**Q-W0 (this wave, post sign-off) — remediations before close:** T2-01(a) provenance cursor
|
||||||
|
hardening backport; T3-01 gain-ramp seconds; T3-03 fade-ceiling seconds (or its stated fallback);
|
||||||
|
T1-07 comment fix; pending §4b — T1-01 linked-lag stereo fix and T1-03 prime bound, with T1-09
|
||||||
|
riding any `sampler_core` edit. Each behavior-touching remediation lands with its module's CTest
|
||||||
|
target green per the Q-W0 verify contract.
|
||||||
|
|
||||||
|
**Q-W1 (safe opener — scope grows):**
|
||||||
|
- Add `tail_control` to the `core/json` consumer list (T2-02) — the wave's "one JSON path" goal
|
||||||
|
is not met without it. (T4-25 confirms the original 4× scope is otherwise accurate.)
|
||||||
|
- Add the shared `readFileBytes` pure helper to the `core/` utility home (T2-03).
|
||||||
|
- Rect unification: one concrete `ui::Rect` + `contains()` + per-role aliases, including
|
||||||
|
retiring the XYWH-vs-LTRB fork and folding in `editor_geometry`'s `Rect` (T2-05 ≡ T4-21);
|
||||||
|
`clamp01` dedup rider (T4-24); `hitIndex` template only as an opportunistic follow-on (T4-22).
|
||||||
|
- Structural collapse of the wire `Cursor` family into one shared `wire` codec module beside
|
||||||
|
`core/json`, consumed by `provenance` / `assignment_request` / `sample_usage` /
|
||||||
|
`parseBankGeneration` (T2-01(b)).
|
||||||
|
- `slot_map` extraction rider on the `bank_book` JSON rewire (T4-05); `view_mode_model` planner
|
||||||
|
split rider (T4-06, optional per T4); `bank_model` unchanged-as-planned (T4-07); `view_lanes`
|
||||||
|
split only if relocation touches `view.cpp` anyway (T4-09).
|
||||||
|
- Relocation scope grows to include the ~20 clean VST pure libs, under whichever placement shape
|
||||||
|
§4a settles (T4 §1.5, T4-18/T4-19).
|
||||||
|
|
||||||
|
**Q-W2 (bank_panel split — 6→8 seams):** the wave brief must name **eight** TUs — the planned
|
||||||
|
six plus `panel_layout` and `panel_drag` (T4-01) — or two of its TUs ship >600 on day one.
|
||||||
|
Guardrails reaffirmed: audition direct call-through; per-mouse-move work stays plain free-function
|
||||||
|
calls (T4-28).
|
||||||
|
|
||||||
|
**Q-W2v (NEW — VST god-module wave; the T2-06/T4 §1.5 scope gap made structural):**
|
||||||
|
- `reasampler_editor.cpp` → eight TUs: session / controls / **layout (pure-candidate hoist into
|
||||||
|
the existing pure homes — this discharges T2-06)** / paint_sample / paint_browse_zone /
|
||||||
|
input_sample / input_browse_zone / platform (T4-11).
|
||||||
|
- `reasampler_processor.cpp` → three TUs: `processor_state` / `processor_reload` /
|
||||||
|
lifecycle+`process()` kept whole (T4-12), with the T4-29 guardrail (no virtual seam on the
|
||||||
|
atomic-swap pattern).
|
||||||
|
- `sample_map` → `component_state_io` codec split + matching header split (T4-13 ≡ T2-07).
|
||||||
|
- `sampler_core`: **TU stays whole at 968 — documented hot-path exception** recorded in the wave
|
||||||
|
brief (T4-14, T4-27); header splits into `zone_params.h` + `sampler_core.h`.
|
||||||
|
- The `core/wire/bytes.h` LE-codec template lands here with its biggest consumer (T4-20).
|
||||||
|
- Rider: adopt the pure `ThumbnailKey` on the VST side while the editor is open (T2-10).
|
||||||
|
- **Scheduling:** parallel-safe with Q-W2 (different artifact, zero file overlap) — T4 also
|
||||||
|
offers serial-after-W5 as "Q-W7" if Daniel prefers serial waves (§4f).
|
||||||
|
|
||||||
|
**Q-W3 (main.cpp split — 3→4 hoists):** add **`capture_batch`** (batch family +
|
||||||
|
`RunRecaptureFromSource` + the two selection guards) as a fourth TU so `capture_orchestrator`
|
||||||
|
lands ~450 (T4-02). Also owned here: **delete `ICaptureBackend`** and correct the
|
||||||
|
CLAUDE.md/CONTEXT description in the same commit (T4-26); the shared `stampCaptureSample`
|
||||||
|
epilogue dedup (T2-09); the `capture_realtime_finalize` split riding the Q-9 naming rider
|
||||||
|
(T4-08); the `makeUniqueTag` monotonic-counter fix (T1-11).
|
||||||
|
|
||||||
|
**Q-W4 (actions split): no change** — the planned seams still land sub-600 (T4-03).
|
||||||
|
|
||||||
|
**Q-W5 (persist split): seams unchanged** (T4-04); **add** the ext-state grow-loop dedup —
|
||||||
|
generalize the retry policy into `bridge_marshal` (or its `core/` successor) and rewire all three
|
||||||
|
loops, `usage_scan`'s prune-safety-adjacent copy included (T2-04).
|
||||||
|
|
||||||
|
**Q-W6 (registration table): no change**; T4-02 notes the `main.cpp` registration residue (~385)
|
||||||
|
shrinks further under its table.
|
||||||
|
|
||||||
|
**Unassigned pending §4e:** the WAV/RIFF consolidation family (T2-08 / T4-23 / T4-10) — no
|
||||||
|
current wave opens `ingest.cpp`; the owning moment is Daniel's call.
|
||||||
|
|
||||||
|
Updated dependency sketch:
|
||||||
|
|
||||||
|
```
|
||||||
|
Q-W0 (this report + remediations) ── SUB-GATE: Daniel signs off every disposition (§4) ──
|
||||||
|
▼
|
||||||
|
Q-W1 (core/json + wire codec + relocation incl. VST pure libs + rect unification + riders)
|
||||||
|
├─► Q-W2 (bank_panel split, 8 seams) ──► Q-W4 (actions; unchanged)
|
||||||
|
├─► Q-W2v (NEW: VST god-modules — editor/processor/sample_map splits, sampler_core header,
|
||||||
|
│ LE codec) [parallel-safe with Q-W2; serial "Q-W7" alternative — §4f]
|
||||||
|
├─► Q-W3 (main split, 4 hoists; ICaptureBackend deletion + doc fix; stamp dedup; T1-11)
|
||||||
|
│ └──► Q-W6 (registration table; unchanged)
|
||||||
|
└─► Q-W5 (persist; seams unchanged; + ext-state-loop dedup) [best after Q-W4]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Daniel's decision list (sign-off gate)
|
||||||
|
|
||||||
|
Each item: leading recommendation first, alternative second. Settled matters (Q-10, Q-11 framing,
|
||||||
|
the clean bills) are deliberately absent.
|
||||||
|
|
||||||
|
- **(a) VST placement fork — T4-18 vs T4-19.** *Recommend T4-18:* integrate `src/vst/` into the
|
||||||
|
one `core/`/`shell/` top split with `instrument/{engine,map,ui}` subsystem dirs — one rule
|
||||||
|
("directory = may it touch a host type"), the artifact boundary is a link-graph fact the
|
||||||
|
sources already straddle, CMake impact is path-edits only. *Alternative T4-19:* artifact-first
|
||||||
|
subtree (`src/vst/core|shell` behind one SDK-gated `add_subdirectory`) — cleanest expression
|
||||||
|
of the Windows-only gate, at the cost of two parallel `core/` trees and a subtree whose
|
||||||
|
self-containment is cosmetic. Q-W1 executes whichever shape is chosen.
|
||||||
|
- **(b) DSP bounded fixes in Q-W0 — T1-01 and T1-03.** *Recommend fix-now for T1-01* (linked
|
||||||
|
L/R lag + splice schedule): High severity on the flagship stereo-Preserve path, standard
|
||||||
|
stereo-SOLA practice, no technique change. *Alternative:* defer and record as the known
|
||||||
|
stereo-Preserve limitation. *For T1-03* (prime bound + immediate `freezeTail()` on
|
||||||
|
sub-window spans): T1's own framing — fix-now **if the short-one-shot case matters** to you
|
||||||
|
(short drum one-shots are realistic content); else document-and-defer with the T1 note as the
|
||||||
|
record. T1-09 (`declickR_` dead state) rides whichever `sampler_core` edit happens.
|
||||||
|
- **(c) T2-01(a) provenance wire-cursor hardening backport in Q-W0.** *Recommend yes:* small,
|
||||||
|
pure, closes a concrete robustness gap on persisted user-editable input, covered by existing
|
||||||
|
`provenance_tests`. *Alternative:* wait for the Q-W1 wire-codec collapse to fix it
|
||||||
|
structurally — leaves the gap open through the gate for no saving.
|
||||||
|
- **(d) Env-constant fix-nows in Q-W0 — T3-01 and T3-03.** *Recommend fix-now for both:* each
|
||||||
|
is trivial, isolated, and a live hardcoded-rate residue Daniel's standing ruling forbids;
|
||||||
|
behavior-identical at the baked-in rates. *Alternative for T3-03 only* (T3's stated
|
||||||
|
fallback): document-and-defer with the comment amended to name the 44.1 k assumption, if zero
|
||||||
|
UI-feel change is preferred. (Folding either into Q-W2v instead is *not* recommended — it
|
||||||
|
would put behavior changes inside a mechanical-split wave; §2.5(8).)
|
||||||
|
|
||||||
|
**Recorded deviation (Q-W0 remediation, code review):** T3-03 as implemented resolves
|
||||||
|
`fadeMaxFrames()` against `liveSampleRate()` (the host/project rate), not the per-file rate
|
||||||
|
this section's text literally suggests ("the loaded source's rate"). Reviewer verified this
|
||||||
|
is the more correct choice: no resample path exists anywhere in `src/`, the engine advances
|
||||||
|
one source frame per host frame, and this matches the time base `paintEnvelopeOverlay`
|
||||||
|
already uses for the same fades (`totalSeconds = frames / liveSampleRate()`). No further
|
||||||
|
action — recorded here so the audit text and the shipped behavior don't read as diverged.
|
||||||
|
- **(e) WAV/RIFF consolidation moment — the one true track disagreement (T2-08 vs T4-23/T4-10).**
|
||||||
|
T2 prefers the `core/wav` homing moment (the relocation wave); T4 prefers "the wave that opens
|
||||||
|
`ingest.cpp`" — which does not exist, and T4-10 points back circularly. *Recommend:* record
|
||||||
|
the consolidation (one pure `wav_codec` owner: walker + layout + build + patch, absorbing
|
||||||
|
T4-10's ingest extraction) as a named rider on **Q-W3** — the wave already opening the capture
|
||||||
|
family (`capture_realtime` finalize, T4-08) — with Q-W1-relocation as the alternative moment
|
||||||
|
if Daniel prefers T2's framing. Either way it must land somewhere named, or the
|
||||||
|
dedup-by-hash/null-test maintenance surface stays quadruplicated.
|
||||||
|
- **(f) Q-W2v scheduling.** *Recommend parallel with Q-W2* (different artifact, zero file
|
||||||
|
overlap — T4 §1.5). *Alternative:* sequence it serially after W5 as "Q-W7" if you want serial
|
||||||
|
waves throughout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Clean bills — surfaces audited and found clean
|
||||||
|
|
||||||
|
Consolidated coverage evidence; details in the appendices' clean-surface sections.
|
||||||
|
|
||||||
|
**DSP (T1):** `peaks` (bin partition exact, overflow-guarded, per-channel no-fold), `master_gain`
|
||||||
|
(taper math correct end to end), `velocity_curve` (Fritsch–Carlson monotonicity/no-overshoot
|
||||||
|
claims hold) — fully clean. Clean with only the noted findings: `wav_trim` (T1-10 metadata note),
|
||||||
|
offline capture path (T1-11; precision-invariant plumbing "disciplined"), realtime capture path
|
||||||
|
(T1-11 + already-in-code DAW-verify flags), `sampler_core`'s voice/steal/mono/panic machinery
|
||||||
|
(rate-free-seconds invariant honored; takeover blend mathematically sound), `pitch_shift`'s core
|
||||||
|
machinery (safe-band geometry, clamps, correlation, fades, `freezeTail` continuity all audit
|
||||||
|
sound; down-shift writer-lap unreachable above ≈ −109 st).
|
||||||
|
|
||||||
|
**Architecture (T2):** pure-module include hygiene across both trees (zero host-type includes in
|
||||||
|
any claimed-pure module); `bank_sync`; `bridge_marshal`; the realtime record lifecycle (explicit
|
||||||
|
enum state machine, not implicit); project-identity transitions; `usage_scan` (decisions
|
||||||
|
delegated pure, depth-bounded recursion, protect-on-truncation); the three `catch (...)` sites
|
||||||
|
(documented, narrow, non-swallowing); `FxBypassGuard` vs `view.cpp` park/restore (duplication of
|
||||||
|
shape, not concept — correctly separate); path resolution (`resolveBankFile` is the single
|
||||||
|
resolver both sides); WAV decode (one decoder); the draw layer (no parallel vocabulary on the VST
|
||||||
|
side); interface cost (no hot-path virtual/`std::function` chains); boolean parameters (no
|
||||||
|
smell). Also T2-11's headline: the ComponentState v1→v11 lift chain is **functionally sound** —
|
||||||
|
the finding is shape, not correctness.
|
||||||
|
|
||||||
|
**Env-coupled constants (T3):** `sample_map` v5+ persistence (the reference implementation);
|
||||||
|
ComponentState v6–v11 fields; Trigger fade/loop frames as source-file facts with the rate stored
|
||||||
|
alongside; `trigger_seam`; `kPreserveWindowMs` (the model pattern); `pitch_shift` internal
|
||||||
|
geometry; the tail system; `wav_trim`; `bank_model` persisted metadata; all ext-state wires; the
|
||||||
|
envelope schematic's param-domain scaling; every timer surveyed; `peaks` / `waveform_view` /
|
||||||
|
`master_gain` / `velocity_curve` / `keyboard_strip`. The persistence layer — the category's
|
||||||
|
highest-stakes surface — is clean end to end.
|
||||||
|
|
||||||
|
**Sizing/indirection (T4):** borderline files needing no action: `capture.cpp` (549),
|
||||||
|
`reasampler_editor.h`/`reasampler_processor.h` (shrink with their TU splits), `bank_book.h`,
|
||||||
|
`pitch_shift.cpp` (371 — single responsibility, hot, leave), `persist.h`/`capture.h` (owned by
|
||||||
|
Q-W6). T4-30: no gratuitous indirection anywhere beyond the dead `ICaptureBackend` — VST-side
|
||||||
|
virtuals are SDK-mandated, and the layered pure→shell pairs are the load-bearing discipline, all
|
||||||
|
direct calls. Plan-confirmations: Q-W4 and Q-W5 seams land as planned (T4-03/T4-04); Q-W1's JSON
|
||||||
|
scope confirmed accurate modulo T2-02 (T4-25).
|
||||||
+27
-17
@@ -1,8 +1,9 @@
|
|||||||
# Design View — product notes
|
# Design View — product notes
|
||||||
|
|
||||||
Framing, rationale, and design-direction calls behind the **Design View** phase.
|
Framing, rationale, and design-direction calls behind the **Design View** phase.
|
||||||
The tickable spec lives in `PLAN.md` (Phase D) and the authoritative technical
|
The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase D) and the
|
||||||
detail in `CONTEXT.md` (§Design View). This doc holds the *why* — the workflow
|
architecture detail in `src/core/view/CLAUDE.md` + `src/shell/view/CLAUDE.md`
|
||||||
|
(§Design View). This doc holds the *why* — the workflow
|
||||||
narrative, the N-mode reasoning, the screenset differentiation, and the
|
narrative, the N-mode reasoning, the screenset differentiation, and the
|
||||||
design-direction recommendations — so those don't clutter the build docs.
|
design-direction recommendations — so those don't clutter the build docs.
|
||||||
|
|
||||||
@@ -156,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
|
of the CPU reclaim, not a bug. It must be documented at the toggle affordance so
|
||||||
the user isn't surprised.
|
the user isn't surprised.
|
||||||
|
|
||||||
**Never touched:** `B_MUTE` and `I_SOLO`. The tool owns visibility, `B_MAINSEND`,
|
**Never touched:** `B_MUTE`. The tool owns visibility, `B_MAINSEND`, `I_FXEN`,
|
||||||
`I_FXEN`, and FX-offline — nothing else — across every managed leaf, tagged or
|
FX-offline, and `I_SOLO` — nothing else — across every managed leaf, tagged or
|
||||||
untagged. The user's mute/solo survives every toggle, untouched. This is the exact
|
untagged.
|
||||||
analog of the
|
|
||||||
capture pillar's non-destructive invariant: **the tool never destroys the user's
|
**Solo is owned but never lost.** Solo is a per-mode surface: switching modes banks
|
||||||
real state to do its job.**
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -315,8 +323,10 @@ Mirrors the capture pillar's split exactly.
|
|||||||
- Snapshots prior flag values before parking (reads the same flags it will drive).
|
- Snapshots prior flag values before parking (reads the same flags it will drive).
|
||||||
- Resolves track GUIDs via `GetTrackGUID` / `guidToString` / `stringToGuid` for the
|
- Resolves track GUIDs via `GetTrackGUID` / `guidToString` / `stringToGuid` for the
|
||||||
index; never uses track index (unstable across reorders).
|
index; never uses track index (unstable across reorders).
|
||||||
- Never touches the master track's visibility flags; never touches `B_MUTE` /
|
- On a real switch only, banks/clears/replays `I_SOLO` per the per-mode solo surface
|
||||||
`I_SOLO` on anything.
|
above.
|
||||||
|
- Never touches the master track's visibility flags; never touches `B_MUTE` on
|
||||||
|
anything.
|
||||||
|
|
||||||
**`persist` slice:**
|
**`persist` slice:**
|
||||||
- Serialize/deserialize the view section (modes + membership + show-both + snapshots
|
- Serialize/deserialize the view section (modes + membership + show-both + snapshots
|
||||||
@@ -339,8 +349,7 @@ tooltip on the switch.
|
|||||||
`docs/design/`), say so and it moves. Flagging because establishing the
|
`docs/design/`), say so and it moves. Flagging because establishing the
|
||||||
directory is a project convention, not mine to assume silently.
|
directory is a project convention, not mine to assume silently.
|
||||||
2. **Phase namespace.** Proposed **Phase D** (a lettered namespace) rather than
|
2. **Phase namespace.** Proposed **Phase D** (a lettered namespace) rather than
|
||||||
M12 — see PLAN.md rationale. Confirm you're happy with letters for parallel,
|
M12. Confirm you're happy with letters for parallel, non-capture phases.
|
||||||
non-capture phases.
|
|
||||||
3. **SDK name correction.** The brief cited `B_SHOWINMCP`; the verified SDK flag
|
3. **SDK name correction.** The brief cited `B_SHOWINMCP`; the verified SDK flag
|
||||||
for mixer-panel visibility is **`B_SHOWINMIXER`** (SDK header line 2235). The
|
for mixer-panel visibility is **`B_SHOWINMIXER`** (SDK header line 2235). The
|
||||||
spec uses the verified name. Just flagging the discrepancy so it isn't a
|
spec uses the verified name. Just flagging the discrepancy so it isn't a
|
||||||
@@ -585,11 +594,12 @@ The settled distinction:
|
|||||||
touch them**: a mode toggle never shows, hides, silences, re-lanes, or re-plays a
|
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.
|
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 —
|
This is the fixed-lane analog of the invariants already load-bearing in D1 —
|
||||||
*never touch `B_MUTE`/`I_SOLO`* and *never touch the master* — extended to a third
|
*never touch `B_MUTE`*, *never touch the master*, and *never lose the user's solo*
|
||||||
surface: **never drive a lane the tool did not mint.** It is the same non-destructive
|
(see "Never touched" above) — extended to a further surface: **never drive a lane
|
||||||
promise (the tool owns only what it created) reaching one level deeper, into the lane
|
the tool did not mint.** It is the same non-destructive promise (the tool owns only
|
||||||
dimension.
|
what it created, and restores what it parks) reaching one level deeper, into the
|
||||||
|
lane dimension.
|
||||||
|
|
||||||
### Lane-ownership index (the new data)
|
### Lane-ownership index (the new data)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
Framing for a **MIDI-triggered audio sampler** that plays back ReaSampler's captured
|
Framing for a **MIDI-triggered audio sampler** that plays back ReaSampler's captured
|
||||||
banks. This began as a discussion-shaping doc; with all forks now settled it has become
|
banks. This began as a discussion-shaping doc; with all forks now settled it has become
|
||||||
the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN.md
|
the **product framing behind a scoped phase**. Its build roadmap lives in
|
||||||
§Phase S** and its authoritative spec in **CONTEXT.md §Phase S** — this doc holds the
|
**`docs/ARCHIVE.md` §Phase S** and its authoritative spec in
|
||||||
*why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled
|
**`src/core/instrument/CLAUDE.md`** and **`src/shell/instrument/CLAUDE.md`** — this
|
||||||
decision record).
|
doc holds the *why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the
|
||||||
|
settled decision record).
|
||||||
|
|
||||||
Status: framed by product-designer (2026-07-26), **revised 2026-07-27 (r11)**. r11 records the
|
Status: framed by product-designer (2026-07-26), **revised 2026-07-27 (r11)**. r11 records the
|
||||||
**Sample-face recomposition** (Daniel's post-landing DAW pass, 2026-07-27): all linear sliders →
|
**Sample-face recomposition** (Daniel's post-landing DAW pass, 2026-07-27): all linear sliders →
|
||||||
@@ -42,10 +43,11 @@ demoted to an opt-in Zones panel — see the r6 Addendum in §4. r6 also settles
|
|||||||
S1–S6 instrument: the product name **ReaSampler 9000** and the **"better than RS5K" UX
|
S1–S6 instrument: the product name **ReaSampler 9000** and the **"better than RS5K" UX
|
||||||
overhaul** (Phase S points S10–S13) — see the r5 Addendum in §4. r4 (below) settled the four
|
overhaul** (Phase S points S10–S13) — see the r5 Addendum in §4. r4 (below) settled the four
|
||||||
residual forks D-A..D-D. The
|
residual forks D-A..D-D. The
|
||||||
"no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the
|
"no landed-roadmap footprint" era is **over** — with D-A through D-D settled (below), the
|
||||||
instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D"
|
instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D"
|
||||||
collides with the existing Design View phase). **PLAN.md §Phase S is now the
|
collides with the existing Design View phase). **`docs/ARCHIVE.md` §Phase S now records
|
||||||
authoritative roadmap; CONTEXT.md §Phase S is the authoritative spec.** This doc is the
|
the landed roadmap; `src/core/instrument/CLAUDE.md` and `src/shell/instrument/CLAUDE.md`
|
||||||
|
are the authoritative spec.** This doc is the
|
||||||
framing/decision record they point back to. Prior revisions (a) established that a REAPER
|
framing/decision record they point back to. Prior revisions (a) established that a REAPER
|
||||||
*extension* cannot be a MIDI instrument, (b) corrected a material omission — REAPER's
|
*extension* cannot be a MIDI instrument, (b) corrected a material omission — REAPER's
|
||||||
**VST-host bridge**, which lets a VST3 plugin *hosted inside REAPER* call back into
|
**VST-host bridge**, which lets a VST3 plugin *hosted inside REAPER* call back into
|
||||||
@@ -410,8 +412,8 @@ it doesn't carry" — is unchanged. What the bridge settles is *where that mappi
|
|||||||
between extension and instrument as **live shared `"reasampler"` state**, not a file one
|
between extension and instrument as **live shared `"reasampler"` state**, not a file one
|
||||||
writes and the other re-parses.
|
writes and the other re-parses.
|
||||||
|
|
||||||
**What the current index carries** (from `bank_model`'s `Sample`, per CONTEXT.md §Data
|
**What the current index carries** (from `bank_model`'s `Sample`): id, display name,
|
||||||
model): id, display name, relative path, source range, channel count, sample rate,
|
relative path, source range, channel count, sample rate,
|
||||||
length, capture tempo, an **optional key**, peak/RMS/LUFS, content hash, tier,
|
length, capture tempo, an **optional key**, peak/RMS/LUFS, content hash, tier,
|
||||||
provenance, timestamp. Notably it *already* has an optional key field and capture
|
provenance, timestamp. Notably it *already* has an optional key field and capture
|
||||||
tempo — the seeds of pitch-mapping are there.
|
tempo — the seeds of pitch-mapping are there.
|
||||||
@@ -426,8 +428,8 @@ tempo — the seeds of pitch-mapping are there.
|
|||||||
to velocity zones).
|
to velocity zones).
|
||||||
- **Round-robin groups** (cycle through N samples on repeated same-note hits).
|
- **Round-robin groups** (cycle through N samples on repeated same-note hits).
|
||||||
- **Loop points** (sustain loop start/end for held notes; sample-accurate,
|
- **Loop points** (sustain loop start/end for held notes; sample-accurate,
|
||||||
zero-crossing-aware — CONTEXT already flags loop/zero-crossing handling as
|
zero-crossing-aware — loop/zero-crossing handling is day-one-relevant for
|
||||||
day-one-relevant for wavetable material).
|
wavetable material).
|
||||||
- **Amplitude envelope** (ADSR) and optionally filter/pitch envelopes.
|
- **Amplitude envelope** (ADSR) and optionally filter/pitch envelopes.
|
||||||
- **Tuning/gain trim** per sample.
|
- **Tuning/gain trim** per sample.
|
||||||
|
|
||||||
@@ -559,7 +561,7 @@ ReaSampler-native way to build it and it's assumed, not debated, going forward.
|
|||||||
|
|
||||||
All four residual decisions are now called. Each is marked **SETTLED** with Daniel's
|
All four residual decisions are now called. Each is marked **SETTLED** with Daniel's
|
||||||
choice and the reasoning kept as the record of *why* — do not re-litigate. They are
|
choice and the reasoning kept as the record of *why* — do not re-litigate. They are
|
||||||
scoped into **PLAN.md §Phase S** / **CONTEXT.md §Phase S**.
|
scoped into **`docs/ARCHIVE.md` §Phase S** / **`src/core/instrument/CLAUDE.md`**.
|
||||||
|
|
||||||
**D-A — SETTLED: bare Steinberg VST3 SDK + LICE editor (no JUCE).** *(The central fork.
|
**D-A — SETTLED: bare Steinberg VST3 SDK + LICE editor (no JUCE).** *(The central fork.
|
||||||
§1a is the assessment that fed it. The sub-question — who draws the editor? — was the
|
§1a is the assessment that fed it. The sub-question — who draws the editor? — was the
|
||||||
@@ -649,7 +651,7 @@ After Phase S was scoped (D-A..D-D), Daniel set two further directions. These ar
|
|||||||
**settled directions**, not open forks — specced as new Phase S points (S7–S9), not
|
**settled directions**, not open forks — specced as new Phase S points (S7–S9), not
|
||||||
re-litigated. Recorded here per the doc's settled-decisions convention.
|
re-litigated. Recorded here per the doc's settled-decisions convention.
|
||||||
|
|
||||||
**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ PLAN.md S7).**
|
**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ `docs/ARCHIVE.md` §S7).**
|
||||||
Captures are often stereo; the current mono downmix is a Tier-0 simplification. The
|
Captures are often stereo; the current mono downmix is a Tier-0 simplification. The
|
||||||
engine gets a **per-instance channel-mode toggle (1 mono / 2 stereo)** that "works with
|
engine gets a **per-instance channel-mode toggle (1 mono / 2 stereo)** that "works with
|
||||||
the REAPER audio bus automatically" — the VST3 declares/negotiates its output bus
|
the REAPER audio bus automatically" — the VST3 declares/negotiates its output bus
|
||||||
@@ -662,8 +664,8 @@ Cross-mode policy: mono-source-in-stereo → dual-mono; stereo-source-in-mono
|
|||||||
never a bank fact). Sequenced **first after the editor/embed work** because it touches the
|
never a bank fact). Sequenced **first after the editor/embed work** because it touches the
|
||||||
engine Daniel smoke-tests.
|
engine Daniel smoke-tests.
|
||||||
|
|
||||||
**Ingest routes through the bank — "option 1"; the extension owns ingest (→ PLAN.md
|
**Ingest routes through the bank — "option 1"; the extension owns ingest (→
|
||||||
S8 + S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
|
`docs/ARCHIVE.md` §S8 + §S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
|
||||||
+ auto-assign to the active instance. The **extension owns ingest** (it has arrange
|
+ auto-assign to the active instance. The **extension owns ingest** (it has arrange
|
||||||
access, Media-Explorer access, and the drop-target surface on its own panels); the
|
access, Media-Explorer access, and the drop-target surface on its own panels); the
|
||||||
**instrument stays a read-only bank consumer** — it never captures or imports. Sub-parts,
|
**instrument stays a read-only bank consumer** — it never captures or imports. Sub-parts,
|
||||||
@@ -690,7 +692,7 @@ with the honest SDK reality verified against the vendored headers:
|
|||||||
|
|
||||||
*The genuine spikes flagged (not decisions Daniel owes, just build-time unknowns):* the
|
*The genuine spikes flagged (not decisions Daniel owes, just build-time unknowns):* the
|
||||||
ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both
|
ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both
|
||||||
are honestly-flagged as spikes in PLAN.md S8, not promised.
|
are honestly-flagged as spikes in `docs/ARCHIVE.md` §S8, not promised.
|
||||||
|
|
||||||
### Addendum — product name + UX overhaul (Daniel, 2026-07-26, post-S1–S6 DAW test)
|
### Addendum — product name + UX overhaul (Daniel, 2026-07-26, post-S1–S6 DAW test)
|
||||||
|
|
||||||
@@ -700,7 +702,7 @@ open forks (the two flagged forks below are the only calls left to Daniel).
|
|||||||
|
|
||||||
**The instrument's product name is `ReaSampler 9000`.** The extension stays **ReaSampler**
|
**The instrument's product name is `ReaSampler 9000`.** The extension stays **ReaSampler**
|
||||||
(capture + organization); the instrument is **ReaSampler 9000** (playback). Propagation is
|
(capture + organization); the instrument is **ReaSampler 9000** (playback). Propagation is
|
||||||
a checklist item (PLAN.md §Phase S — product name; CONTEXT.md §Product name): the VST3
|
recorded in `docs/ARCHIVE.md` §Phase S — product name: the VST3
|
||||||
class **display name** string, the `IPlugView` editor title band (today "ReaSampler
|
class **display name** string, the `IPlugView` editor title band (today "ReaSampler
|
||||||
Instrument"), the S6 embed-strip label, and the docs. **Compat guard (load-bearing):** the
|
Instrument"), the S6 embed-strip label, and the docs. **Compat guard (load-bearing):** the
|
||||||
**VST3 class UID must NOT change** — instances in saved projects key off it; a UID change
|
**VST3 class UID must NOT change** — instances in saved projects key off it; a UID change
|
||||||
@@ -747,8 +749,9 @@ names the editor as the wound.
|
|||||||
|
|
||||||
After the r5 UX-overhaul directive was specced (keymap-first S10), Daniel reframed the
|
After the r5 UX-overhaul directive was specced (keymap-first S10), Daniel reframed the
|
||||||
workflow before S10 was implemented. This **revises S10** and settles S-NAME-1. Settled
|
workflow before S10 was implemented. This **revises S10** and settles S-NAME-1. Settled
|
||||||
directions, not open forks — recorded here per the doc's settled-decisions convention; PLAN.md
|
directions, not open forks — recorded here per the doc's settled-decisions convention;
|
||||||
§S10 and CONTEXT.md §Phase S (workflow hierarchy) carry the spec.
|
`docs/ARCHIVE.md` §S10 records what landed and `src/core/instrument/CLAUDE.md` (the
|
||||||
|
editor `ui/` modules) documents the current architecture.
|
||||||
|
|
||||||
**The reframe, verbatim (Daniel, 2026-07-26):** *"We need to think hard about the workflow
|
**The reframe, verbatim (Daniel, 2026-07-26):** *"We need to think hard about the workflow
|
||||||
with this plugin. Have a giant list of 'item' blocks is visually useless. When the plugin is
|
with this plugin. Have a giant list of 'item' blocks is visually useless. When the plugin is
|
||||||
@@ -806,8 +809,8 @@ partly on filename, fall back to keeping the filename and record that as shipped
|
|||||||
|
|
||||||
Daniel directed a set of engine features for the sampler, specced as **new Phase S points
|
Daniel directed a set of engine features for the sampler, specced as **new Phase S points
|
||||||
S15 (Trigger vs Gate) and S16 (pitch envelope)**. **The feature set is settled** — recorded
|
S15 (Trigger vs Gate) and S16 (pitch envelope)**. **The feature set is settled** — recorded
|
||||||
here per the doc's settled-decisions convention; PLAN.md §S15/S16 and CONTEXT.md §Sampling
|
here per the doc's settled-decisions convention; `docs/ARCHIVE.md` §S15 / §S16 records what
|
||||||
modes carry the spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param
|
landed and `src/core/instrument/CLAUDE.md` §Sampling modes documents the current spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param
|
||||||
granularity); the WDL question was resolved by inspection.
|
granularity); the WDL question was resolved by inspection.
|
||||||
|
|
||||||
**Directive, verbatim (Daniel, 2026-07-26):** *"let's have product spec out some features
|
**Directive, verbatim (Daniel, 2026-07-26):** *"let's have product spec out some features
|
||||||
@@ -927,9 +930,10 @@ This reshapes S16 and **flips the r7 WDL verdict** on `WDL_SimplePitchShifter`.
|
|||||||
`process` allocation; measure per-voice CPU + onset latency against the polyphony cap. Treat
|
`process` allocation; measure per-voice CPU + onset latency against the polyphony cap. Treat
|
||||||
S16's Preserve-engine point as the phase's next real DSP spike, not a thin envelope add-on.
|
S16's Preserve-engine point as the phase's next real DSP spike, not a thin envelope add-on.
|
||||||
|
|
||||||
**Where the spec lives:** PLAN.md §S16 (reshaped to "pitch engine modes + pitch envelope",
|
**Where the spec lives:** `docs/ARCHIVE.md` §S16 (reshaped to "pitch engine modes + pitch
|
||||||
with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16 interaction note;
|
envelope", with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16
|
||||||
CONTEXT.md §Pitch engine modes — Varispeed vs Preserve + the corrected WDL surface finding.
|
interaction note; `src/core/instrument/CLAUDE.md` §Sampling modes — Varispeed vs Preserve
|
||||||
|
+ the WDL surface finding.
|
||||||
|
|
||||||
### Addendum — VST channel isolation (Daniel, 2026-07-26)
|
### Addendum — VST channel isolation (Daniel, 2026-07-26)
|
||||||
|
|
||||||
@@ -972,9 +976,9 @@ with or right after the in-flight waves (S9 ext_keys, S15/S16 processor/editor)
|
|||||||
channel's banks; stable-project + beta-VST = clean empty (not error); the S-NAME-1
|
channel's banks; stable-project + beta-VST = clean empty (not error); the S-NAME-1
|
||||||
rename/rebind test extends to the beta UID.
|
rename/rebind test extends to the beta UID.
|
||||||
|
|
||||||
**Where the spec lives:** PLAN.md §S18; CONTEXT.md §VST3 channel identity — the UID pair + the
|
**Where the spec lives:** `docs/ARCHIVE.md` §S18; `src/shell/instrument/CLAUDE.md` §VST3
|
||||||
pairing surface. The pairing surface's data half is already load-bearing V4 machinery; S18
|
channel identity — the UID pair + the pairing surface. The pairing surface's data half is
|
||||||
adds only the identity fork on top.
|
already load-bearing V4 machinery; S18 adds only the identity fork on top.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -986,8 +990,9 @@ good — but the two-view editor (today's "Browser" + "Zones" toggle) misallocat
|
|||||||
the default window is undersized for a 1080p world, and the drop-a-capture-onto-FX gesture is
|
the default window is undersized for a 1080p world, and the drop-a-capture-onto-FX gesture is
|
||||||
broken in practice. The directive: **make the one job — pick a capture, tune it, play it —
|
broken in practice. The directive: **make the one job — pick a capture, tune it, play it —
|
||||||
fast, easy, and fun. Style is a critical ingredient. No spreadsheet aesthetics.** These are the
|
fast, easy, and fun. Style is a critical ingredient. No spreadsheet aesthetics.** These are the
|
||||||
`r9` calls. Authoritative spec: **CONTEXT.md §Phase S — editor view-model redesign (S-VIEW)**;
|
`r9` calls. Current architecture: **`src/core/instrument/CLAUDE.md`** and
|
||||||
build roadmap: **PLAN.md §Phase S — editor view-model redesign**.
|
**`src/shell/instrument/CLAUDE.md`**; landed record: **`docs/ARCHIVE.md` §Phase S — editor
|
||||||
|
view-model redesign**.
|
||||||
|
|
||||||
**The reference devices (the north star for control density).** Daniel named Ableton **Simpler**
|
**The reference devices (the north star for control density).** Daniel named Ableton **Simpler**
|
||||||
and a Kilohearts/Phase-Plant **sampler group** as the composition targets. Both share one
|
and a Kilohearts/Phase-Plant **sampler group** as the composition targets. Both share one
|
||||||
@@ -1021,7 +1026,7 @@ grammar, and it is the grammar the redesign adopts:
|
|||||||
loading a new one is a distinct act), not a three-way radio. *Why the reframe matters:* it
|
loading a new one is a distinct act), not a three-way radio. *Why the reframe matters:* it
|
||||||
makes "I just want to play this capture" the zero-click default, and "I want a different one"
|
makes "I just want to play this capture" the zero-click default, and "I want a different one"
|
||||||
a single deliberate gesture, instead of making the user re-choose their whole stance every
|
a single deliberate gesture, instead of making the user re-choose their whole stance every
|
||||||
time. See CONTEXT.md §S-VIEW for the precise navigation model.
|
time. See `docs/ARCHIVE.md` §S-VIEW-1 for the precise navigation model as landed.
|
||||||
|
|
||||||
2. **The Sample view earns the hero treatment; Browse gets ruthlessly cut.** Browse today
|
2. **The Sample view earns the hero treatment; Browse gets ruthlessly cut.** Browse today
|
||||||
carries a waveform preview, root-note piano-roll, loop-point labels, a track-root message, and
|
carries a waveform preview, root-note piano-roll, loop-point labels, a track-root message, and
|
||||||
@@ -1037,10 +1042,10 @@ grammar, and it is the grammar the redesign adopts:
|
|||||||
3. **Two engineering prerequisites, framed but routed to implementation.** The **drop-to-FX bug**
|
3. **Two engineering prerequisites, framed but routed to implementation.** The **drop-to-FX bug**
|
||||||
(dropping a capture onto a track's FX chain does not instantiate + init ReaSampler 9000) and
|
(dropping a capture onto a track's FX chain does not instantiate + init ReaSampler 9000) and
|
||||||
the **undersized default window** are not design decisions — they are a bug and a one-line
|
the **undersized default window** are not design decisions — they are a bug and a one-line
|
||||||
default. Both are framed in CONTEXT.md §S-VIEW with the SDK reality swept (drop-to-FX: the S17
|
default. Both are recorded landed in `docs/ARCHIVE.md` §S-VIEW-BUG-1 (drop-to-FX: the S17
|
||||||
machinery is SDK-correct, so this is a *diagnosis* task, not a redesign; window size: the
|
machinery is SDK-correct, so this is a *diagnosis* task, not a redesign) and §S-VIEW-SIZE-1
|
||||||
`getSize`/`checkSizeConstraint` mechanism is verified), and both are flagged for
|
(window size: the `getSize`/`checkSizeConstraint` mechanism is verified), and both were
|
||||||
staff-engineer, not for a product fork.
|
flagged for staff-engineer, not for a product fork.
|
||||||
|
|
||||||
**New parameters this introduces (both instrument performance state, D-B — never bank facts):**
|
**New parameters this introduces (both instrument performance state, D-B — never bank facts):**
|
||||||
|
|
||||||
@@ -1064,7 +1069,7 @@ grammar, and it is the grammar the redesign adopts:
|
|||||||
new top-level `previewVelocity` field), **not** the extension's `persist` project ext-state —
|
new top-level `previewVelocity` field), **not** the extension's `persist` project ext-state —
|
||||||
that module is REAPER-project-scoped and extension-owned, so it would make the level
|
that module is REAPER-project-scoped and extension-owned, so it would make the level
|
||||||
project-global instead of per-instance and route an instrument concern through a bank-read-only
|
project-global instead of per-instance and route an instrument concern through a bank-read-only
|
||||||
seam. See CONTEXT.md §S-VIEW for the round-trip and back-compat lift. This is what makes the
|
seam. See `docs/ARCHIVE.md` §S-VIEW-4 for the round-trip and back-compat lift as landed. This is what makes the
|
||||||
preview button *fun*: tap it hard or soft without reaching for a controller — and it remembers.
|
preview button *fun*: tap it hard or soft without reaching for a controller — and it remembers.
|
||||||
|
|
||||||
**Two visual components the redesign commits to:**
|
**Two visual components the redesign commits to:**
|
||||||
@@ -1101,8 +1106,10 @@ persisted fields is not a compat event; saved instances rebind and restore. And
|
|||||||
(extended additively — `keyTrack` per-zone, `previewVelocity` per-instance via an envelope bump to
|
(extended additively — `keyTrack` per-zone, `previewVelocity` per-instance via an envelope bump to
|
||||||
v6, both with back-compat defaults on read) are the same load-bearing core.
|
v6, both with back-compat defaults on read) are the same load-bearing core.
|
||||||
|
|
||||||
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW); PLAN.md
|
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (envelope overlay, key-tracking,
|
||||||
§Phase S — editor view-model redesign. This Addendum is the *why*; those are the *what/how*.
|
preview-velocity ownership) documents the current architecture; `docs/ARCHIVE.md` §Phase S
|
||||||
|
— editor view-model redesign records what landed (S-VIEW-1 through S-VIEW-10). This
|
||||||
|
Addendum is the *why*; those are the *what/how*.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1217,8 +1224,9 @@ for preview velocity — a different struct on a different version axis). Concre
|
|||||||
the L1 kit, routing mouse through `velocity_curve`), gated on the foundation track and composing
|
the L1 kit, routing mouse through `velocity_curve`), gated on the foundation track and composing
|
||||||
with the S-VIEW-2 Sample face + S-VIEW-3 envelope-overlay work.
|
with the S-VIEW-2 Sample face + S-VIEW-3 envelope-overlay work.
|
||||||
|
|
||||||
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW), velocity-curve
|
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `velocity_curve` module, its
|
||||||
sub-section; PLAN.md §Phase S — editor view-model redesign (S-VIEW-9/S-VIEW-10 + fork R10-F1). This
|
engine application point, and its ownership rules) documents the current architecture;
|
||||||
|
`docs/ARCHIVE.md` §S-VIEW-9 / §S-VIEW-10 records what landed (fork R10-F1 resolved). This
|
||||||
Addendum is the *why*; those are the *what/how*.
|
Addendum is the *why*; those are the *what/how*.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1273,16 +1281,20 @@ small labelled knobs in functional clusters** — and drives the **Wave B editor
|
|||||||
same knob deck + button/popup — Sample and Zone already edit one storage site (S15-F2); the lean
|
same knob deck + button/popup — Sample and Zone already edit one storage site (S15-F2); the lean
|
||||||
is that they should speak one control grammar too. Daniel's call (it grows Wave B scope).
|
is that they should speak one control grammar too. Daniel's call (it grows Wave B scope).
|
||||||
|
|
||||||
**What does NOT change (guardrails).** Zero engine/state/persistence change — this is a view
|
**What does NOT change (guardrails).** Zero engine-behavior change — this is a view
|
||||||
recomposition of *existing* controls; no new params, no component-state bump, VST3 class UID
|
recomposition of *existing* controls, with one additive exception: the new post-mixer master
|
||||||
unchanged. All drawing through the L1 kit by palette role; all layout/hit-test in new pure modules
|
gain persists as `masterGainLinear`, bumping `ComponentState` v7→v8 (back-compat: pre-v8 blobs
|
||||||
|
lift to unity gain); VST3 class UID unchanged. All drawing through the L1 kit by palette role;
|
||||||
|
all layout/hit-test in new pure modules
|
||||||
(`knob_deck`, `curve_popup` — mirrors of `action_bar`/`overflow_menu`); the knobs and the hero's
|
(`knob_deck`, `curve_popup` — mirrors of `action_bar`/`overflow_menu`); the knobs and the hero's
|
||||||
envelope nodes remain two surfaces on one param model (S-VIEW-F2's structural sync, untouched).
|
envelope nodes remain two surfaces on one param model (S-VIEW-F2's structural sync, untouched).
|
||||||
The full inventory contract (every landed element → its r11 home) is in the CONTEXT.md spec.
|
The full inventory of what landed (every element → its r11 home) is recorded in
|
||||||
|
`docs/ARCHIVE.md` §FB1 and §FB2.
|
||||||
|
|
||||||
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW) → "The
|
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `knob_deck`/`curve_popup`/
|
||||||
Sample-face recomposition (r11)"; PLAN.md §Phase S — editor Wave B (S-VIEW-11/12/13 + forks
|
`master_gain` modules) documents the current architecture; `docs/ARCHIVE.md` §FB1 and §FB2
|
||||||
R11-F1/R11-F2). This Addendum is the *why*; those are the *what/how*.
|
record what landed (S-VIEW-11/12/13 + forks R11-F1/R11-F2 resolved). This Addendum is the
|
||||||
|
*why*; those are the *what/how*.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1329,7 +1341,8 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden
|
|||||||
frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), and
|
frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), and
|
||||||
DS-3 (thorough panel layout) are all **SETTLED (2026-07-26)**. Framing + palette + the three
|
DS-3 (thorough panel layout) are all **SETTLED (2026-07-26)**. Framing + palette + the three
|
||||||
visual directions + forks: `docs/product/visual-design-language.md` (on `dev`); roadmap +
|
visual directions + forks: `docs/product/visual-design-language.md` (on `dev`); roadmap +
|
||||||
spec: **PLAN.md §Phase L + CONTEXT.md §Phase L** (on `dev`). **S10–S13 build with the
|
spec: **`docs/ARCHIVE.md` §Phase L** (landed record) and **`src/core/ui/CLAUDE.md`**
|
||||||
|
(current architecture) (on `dev`). **S10–S13 build with the
|
||||||
current drawing and adopt the L1 kit when it lands — not gated on Phase L.** Answers
|
current drawing and adopt the L1 kit when it lands — not gated on Phase L.** Answers
|
||||||
Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26,
|
Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26,
|
||||||
post-S1–S6 DAW test).
|
post-S1–S6 DAW test).
|
||||||
@@ -1350,15 +1363,16 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden
|
|||||||
S15-F1 (choke, held) / S15-F2 (param granularity, lean per-zone). Feature set settled;
|
S15-F1 (choke, held) / S15-F2 (param granularity, lean per-zone). Feature set settled;
|
||||||
the engine default is Daniel's fork.
|
the engine default is Daniel's fork.
|
||||||
|
|
||||||
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original
|
**Authoritative from here:** **`docs/ARCHIVE.md` §Phase S** is the landed roadmap (S1–S6
|
||||||
dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
|
the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier
|
||||||
UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the ReaSampler
|
1 → embedded UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the
|
||||||
9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine features);
|
ReaSampler 9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine
|
||||||
**CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the channel-mode
|
features); **`src/core/instrument/CLAUDE.md`**, **`src/shell/instrument/CLAUDE.md`**, and
|
||||||
/ ingest / bank-generation / sampling-mode / pitch-engine contracts, the UX-overhaul spec,
|
**`src/core/wire/CLAUDE.md`** are the current spec (seam-field semantics, scope contracts,
|
||||||
the product-name convention, the pure/shell split, the WDL finding, the must-verify
|
the channel-mode / ingest / bank-generation / sampling-mode / pitch-engine contracts, the
|
||||||
SDK/bridge surfaces). This doc is the framing/decision record they point back to. The "no
|
UX-overhaul spec, the product-name convention, the pure/shell split, the WDL finding, the
|
||||||
PLAN.md footprint" era is over.
|
must-verify SDK/bridge surfaces). This doc is the framing/decision record they point back
|
||||||
|
to. The "no landed-roadmap footprint" era is over.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Multi-bank — product notes
|
# Multi-bank — product notes
|
||||||
|
|
||||||
Framing, rationale, and design-direction calls behind the **Multi-bank** phase.
|
Framing, rationale, and design-direction calls behind the **Multi-bank** phase.
|
||||||
The tickable spec lives in `PLAN.md` (Phase B) and the authoritative technical
|
The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase B) and the
|
||||||
detail in `CONTEXT.md` (§Multi-bank). This doc holds the *why* — the workflow
|
architecture detail in `src/core/model/CLAUDE.md` + `src/shell/bank_ops/CLAUDE.md`
|
||||||
|
(§Multi-bank). This doc holds the *why* — the workflow
|
||||||
narrative, the pool-privilege reasoning, the movement semantics, and the
|
narrative, the pool-privilege reasoning, the movement semantics, and the
|
||||||
design-direction recommendations — so those don't clutter the build docs.
|
design-direction recommendations — so those don't clutter the build docs.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
|||||||
# Provenance — product notes
|
# Provenance — product notes
|
||||||
|
|
||||||
Framing, rationale, and the dual-canvas reconciliation behind the reshaped
|
Framing, rationale, and the dual-canvas reconciliation behind the reshaped
|
||||||
**Milestone 10 (provenance)**. The tickable spec lives in `PLAN.md` (M10); the
|
**Milestone 10 (provenance)**. The tickable spec's landed history is in
|
||||||
authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus this
|
`docs/ARCHIVE.md` (M10); the architecture detail is in `src/core/model/CLAUDE.md`
|
||||||
note for the reconciliation calls. This doc holds the *why* and the open forks so
|
and `src/shell/capture/CLAUDE.md` plus this note for the reconciliation calls.
|
||||||
they don't clutter the build docs.
|
This doc holds the *why* and the open forks so they don't clutter the build docs.
|
||||||
|
|
||||||
Status: **IMPLEMENTED (2026-07-26).** Settled 2026-07-23; landed 2026-07-26.
|
Status: **IMPLEMENTED (2026-07-26).** Settled 2026-07-23; landed 2026-07-26.
|
||||||
Reshaped from the old "provenance + null-test verify" M10. Two decisions were fixed
|
Reshaped from the old "provenance + null-test verify" M10. Two decisions were fixed
|
||||||
|
|||||||
@@ -2,24 +2,26 @@
|
|||||||
|
|
||||||
Framing, rationale, and open forks behind the two missing removal capabilities:
|
Framing, rationale, and open forks behind the two missing removal capabilities:
|
||||||
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
|
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
|
||||||
path CONTEXT.md keeps forward-referencing but never scoped). The tickable spec
|
path the spec kept forward-referencing but never scoped). The tickable spec's
|
||||||
lives in `PLAN.md` (Phase B point B5 for remove; **Phase R** for prune) and the
|
landed history is in `docs/ARCHIVE.md` (Phase B point B5 for remove; **Phase R**
|
||||||
authoritative technical detail in `CONTEXT.md` (§Sample removal, §Prune — file
|
for prune) and the architecture detail lives in `src/core/model/CLAUDE.md` +
|
||||||
lifecycle). This doc holds the *why* — the workflow, the guardrails, the
|
`src/shell/bank_ops/CLAUDE.md` (§Sample removal) and `src/core/reclaim/CLAUDE.md`
|
||||||
index-vs-file boundary, and the forks that need a Daniel decision.
|
+ `src/shell/persist/CLAUDE.md` (§Prune — file lifecycle). This doc holds the
|
||||||
|
*why* — the workflow, the guardrails, the index-vs-file boundary, and the forks
|
||||||
|
that need a Daniel decision.
|
||||||
|
|
||||||
Status: framed by product-designer (2026-07-23); **all five forks settled by Daniel
|
Status: framed by product-designer (2026-07-23); **all five forks settled by Daniel
|
||||||
(2026-07-24)** — R-A this-bank-primary, R-B batched REAPER undo points
|
(2026-07-24)** — R-A this-bank-primary, R-B batched REAPER undo points
|
||||||
(Phase-B-wide), R-C trash-preferred-with-unlink-fallback, R-D owned-file manifest
|
(Phase-B-wide), R-C trash-preferred-with-unlink-fallback, R-D owned-file manifest
|
||||||
(seam lands early in Phase B / capture), R-E manual action + panel button. The
|
(seam lands early in Phase B / capture), R-E manual action + panel button. The
|
||||||
decisions are folded into the fork sections below and into the B5 / Phase R spec
|
decisions are folded into the fork sections below and into the B5 / Phase R
|
||||||
prose in CONTEXT.md and the tickable points in PLAN.md.
|
history in `docs/ARCHIVE.md` and the architecture docs above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The one boundary that governs everything: index vs. file
|
## The one boundary that governs everything: index vs. file
|
||||||
|
|
||||||
ReaSampler already draws a hard line, stated repeatedly in CONTEXT.md: **a bank
|
ReaSampler already draws a hard line: **a bank
|
||||||
operation touches the *index*, never the *file*.** Move, copy, evacuate, and
|
operation touches the *index*, never the *file*.** Move, copy, evacuate, and
|
||||||
delete-bank are all index-only; files persist on disk "until prune." Every
|
delete-bank are all index-only; files persist on disk "until prune." Every
|
||||||
removal capability below sits on exactly one side of that line, and keeping the
|
removal capability below sits on exactly one side of that line, and keeping the
|
||||||
@@ -120,7 +122,7 @@ line. It is the verb Phase B forgot, not a new pillar.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prune — the file-lifecycle path CONTEXT.md kept promising
|
## Prune — the file-lifecycle path the spec kept promising
|
||||||
|
|
||||||
### What the user is doing
|
### What the user is doing
|
||||||
|
|
||||||
@@ -131,7 +133,7 @@ purpose. Over a long project the bank folder accumulates dead `.wav` files that
|
|||||||
cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder,
|
cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder,
|
||||||
delete the files nothing references, tell me what you reclaimed."
|
delete the files nothing references, tell me what you reclaimed."
|
||||||
|
|
||||||
This is the path CONTEXT.md forward-references in at least four places ("files
|
This is the path the spec forward-references in at least four places ("files
|
||||||
persist on disk until prune," "the capture/prune path reclaims it") but never
|
persist on disk until prune," "the capture/prune path reclaims it") but never
|
||||||
scopes. It is a real, promised capability with **no phase, no module, no point**
|
scopes. It is a real, promised capability with **no phase, no module, no point**
|
||||||
— a dangling reference the plan has to make good on.
|
— a dangling reference the plan has to make good on.
|
||||||
@@ -141,8 +143,8 @@ scopes. It is a real, promised capability with **no phase, no module, no point**
|
|||||||
ReaSampler already shipped this exact shape once. Design View's `view_mode_model`
|
ReaSampler already shipped this exact shape once. Design View's `view_mode_model`
|
||||||
has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set
|
has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set
|
||||||
(the tracks that still exist), returning the residual membership entries to drop
|
(the tracks that still exist), returning the residual membership entries to drop
|
||||||
(CONTEXT.md §Design View: "prunes orphaned snapshots on every toggle/load;
|
(`src/core/view/CLAUDE.md`: "tolerates unknown/stale GUIDs (pruned on reconcile
|
||||||
tolerates unknown/stale GUIDs (prune on reconcile)"). Prune is the **file-pool
|
via `ViewModeModel::reconcile(liveGuids)`)"). Prune is the **file-pool
|
||||||
mirror of that pure pattern**:
|
mirror of that pure pattern**:
|
||||||
|
|
||||||
> `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*.
|
> `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*.
|
||||||
@@ -224,7 +226,7 @@ Prune is **not** a Phase B point. Three reasons it earns its own lettered phase
|
|||||||
|
|
||||||
1. **It is a different pillar.** Phase B is the *bank container* pillar
|
1. **It is a different pillar.** Phase B is the *bank container* pillar
|
||||||
(index-only, non-destructive, above the file). Prune is the *file lifecycle*
|
(index-only, non-destructive, above the file). Prune is the *file lifecycle*
|
||||||
pillar (the one path that deletes files). CONTEXT.md already names it as a
|
pillar (the one path that deletes files). The spec already named it as a
|
||||||
separate concern every time it says "the capture/**prune** path" — file
|
separate concern every time it says "the capture/**prune** path" — file
|
||||||
lifecycle is spoken of as its own thing, owned by neither the capture nor the
|
lifecycle is spoken of as its own thing, owned by neither the capture nor the
|
||||||
bank layer. Giving it its own phase matches how the spec already talks about it.
|
bank layer. Giving it its own phase matches how the spec already talks about it.
|
||||||
@@ -337,7 +339,8 @@ capture).** The book tracks the set of files it has created; prune reclaims
|
|||||||
ext-state**, and Phase R's R1/R2 *consume* that manifest. The exact persistence
|
ext-state**, and Phase R's R1/R2 *consume* that manifest. The exact persistence
|
||||||
shape — a sibling ext-state key vs. folded into the `banks` blob — is a small
|
shape — a sibling ext-state key vs. folded into the `banks` blob — is a small
|
||||||
residual to settle at build; the **manifest-now decision is firm**. (Settled
|
residual to settle at build; the **manifest-now decision is firm**. (Settled
|
||||||
2026-07-24; the up-front point is added to Phase B / the capture path in PLAN.md.)
|
2026-07-24; the up-front point is recorded in `docs/ARCHIVE.md` under Phase B /
|
||||||
|
the capture path.)
|
||||||
|
|
||||||
**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.
|
**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.
|
||||||
SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action
|
SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ concrete and decidable:
|
|||||||
2. **A beta side-channel** — so development can continue and a beta build run
|
2. **A beta side-channel** — so development can continue and a beta build run
|
||||||
*alongside* the stable one without the beta clobbering the release.
|
*alongside* the stable one without the beta clobbering the release.
|
||||||
|
|
||||||
This doc holds the *why*, the forks, and a recommendation. When Daniel picks, the
|
This doc holds the *why*, the forks, and a recommendation. The tickable points'
|
||||||
tickable points land in `PLAN.md` and the deploy/build wiring hands off to dev-ops.
|
landed history is in `docs/ARCHIVE.md`; the deploy/build wiring hands off to dev-ops.
|
||||||
This is a framing note; it changes no source or CMake.
|
This is a framing note; it changes no source or CMake.
|
||||||
|
|
||||||
Status: framed by product-designer (2026-07-26); **all four forks settled by Daniel
|
Status: framed by product-designer (2026-07-26); **all four forks settled by Daniel
|
||||||
@@ -54,7 +54,7 @@ Two sharp edges follow directly and recur throughout this note:
|
|||||||
Both binaries reading/writing the same namespace on the same open project means a
|
Both binaries reading/writing the same namespace on the same open project means a
|
||||||
**beta can read — and rewrite — a stable project's saved bank/view state.** Given
|
**beta can read — and rewrite — a stable project's saved bank/view state.** Given
|
||||||
the forward-only migrations already in the design (legacy `bank_index` retired
|
the forward-only migrations already in the design (legacy `bank_index` retired
|
||||||
after promotion; `banks` authoritative thereafter — CONTEXT.md §Multi-bank), a
|
after promotion; `banks` authoritative thereafter — `docs/ARCHIVE.md` §Phase B), a
|
||||||
beta that writes a newer schema into a project a user then reopens in stable is a
|
beta that writes a newer schema into a project a user then reopens in stable is a
|
||||||
real corruption path, not a theoretical one.
|
real corruption path, not a theoretical one.
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ written after Daniel DAW-tested the S1–S6 instrument:
|
|||||||
revised 2026-07-26 (Daniel)** from the single electric-cyan accent to a **three-accent
|
revised 2026-07-26 (Daniel)** from the single electric-cyan accent to a **three-accent
|
||||||
pastel system** (primary/secondary/tertiary = pastel lime green / pastel teal / pastel
|
pastel system** (primary/secondary/tertiary = pastel lime green / pastel teal / pastel
|
||||||
purple) — see §6. (A stylish-font upgrade was considered and **declined** by Daniel — no
|
purple) — see §6. (A stylish-font upgrade was considered and **declined** by Daniel — no
|
||||||
font bundling/redistribution; the kit keeps its current cached-font face.) The build points it feeds live in
|
font bundling/redistribution; the kit keeps its current cached-font face.) The build points it
|
||||||
**PLAN.md §Phase L** (**L1** the shared LICE drawing kit, **L2** the dock-panel layout
|
fed have landed; their history is in **`docs/ARCHIVE.md`** ("Phase L — Look-and-feel", **L1** the
|
||||||
redesign, **L3** the VST editor + embed-strip restyle) and **CONTEXT.md §Phase L** (the
|
shared LICE drawing kit, **L2** the dock-panel layout redesign, **L3** the VST editor +
|
||||||
design-system spec). Toolkit facts below are **verified against the vendored `vendor/WDL`
|
embed-strip restyle) and the architecture now lives in **`src/core/ui/CLAUDE.md`** /
|
||||||
tree**, not lore.
|
**`src/shell/panel/CLAUDE.md`** (the design-system implementation). Toolkit facts below are
|
||||||
|
**verified against the vendored `vendor/WDL` tree**, not lore.
|
||||||
|
|
||||||
> **Phase-boundary note (2026-07-26).** This look-and-feel effort was originally drafted
|
> **Phase-boundary note (2026-07-26).** This look-and-feel effort was originally drafted
|
||||||
> as Phase S points **S0-DS** (the shared kit) and **S14** (the panel refresh). It was
|
> as Phase S points **S0-DS** (the shared kit) and **S14** (the panel refresh). It was
|
||||||
@@ -415,14 +416,34 @@ against the WCAG tests — §2.1):
|
|||||||
| Role | Pastel | Starting RGB (hex) | Job |
|
| Role | Pastel | Starting RGB (hex) | Job |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `accent/primary` | pastel lime green | `~176,224,152` (`#B0E098`) | the live/active/selected signal — the eye-magnet |
|
| `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/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) |
|
| `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` ≈
|
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
|
`#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
|
margin is **much smaller than it was on near-black** (~7.6:1 primary / ~5.5:1 tertiary /
|
||||||
on `#12121x`). This is a real DS-2-revision tension: **the greyer background pulls the
|
~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
|
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
|
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
|
still clearing the floor on **grey, not near-black** — but **if any pastel used as a state
|
||||||
@@ -469,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
|
(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
|
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:
|
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
|
the same hues that mean "live / category A / category B" elsewhere anchor the spectrum here,
|
||||||
and midpoint of the spectrum here, so the strip reads as an extension of the accent system,
|
so the strip reads as an extension of the accent system, not a separate neon flourish.
|
||||||
not a separate neon flourish. The **active** zone still lifts to `accent/primary` +
|
**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.
|
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
|
- **Feel:** premium, almost visualizer-grade — but soft and cohesive, a pastel spectrum
|
||||||
@@ -497,7 +521,7 @@ within the pastel intent — is one file.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. The build shape (feeds PLAN.md / CONTEXT.md §Phase L)
|
## 5. The build shape (landed history in `docs/ARCHIVE.md` §Phase L)
|
||||||
|
|
||||||
Three points and their sequencing.
|
Three points and their sequencing.
|
||||||
|
|
||||||
@@ -547,7 +571,7 @@ draw it.
|
|||||||
affordances, per-selection **move / copy / remove** sample menu.
|
affordances, per-selection **move / copy / remove** sample menu.
|
||||||
- **Prune** button (R-E) — the byte-deleting action, `warn`-colored.
|
- **Prune** button (R-E) — the byte-deleting action, `warn`-colored.
|
||||||
|
|
||||||
*M11 adds (dev PLAN.md M11 — merging now):*
|
*M11 adds (landed — `docs/ARCHIVE.md` Milestone 11):*
|
||||||
- **Action trigger buttons** — clickable buttons that fire the capture + provenance
|
- **Action trigger buttons** — clickable buttons that fire the capture + provenance
|
||||||
action family directly (capture item / capture track scopes, re-capture from source,
|
action family directly (capture item / capture track scopes, re-capture from source,
|
||||||
resample-and-mute-source, batch capture, conform-on-insert, insert-at-cursor, drag-out,
|
resample-and-mute-source, batch capture, conform-on-insert, insert-at-cursor, drag-out,
|
||||||
@@ -737,3 +761,100 @@ re-skin).**
|
|||||||
font obligation.
|
font obligation.
|
||||||
- **Phase S is not gated on Phase L** — S7–S13 proceeded in parallel; they adopted the
|
- **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).
|
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).
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's
|
|
||||||
// TASK-GROUPED toolbars (Phase L, L2 + L4 + L6). L2's dock-panel layout redesign (DS-3: a
|
|
||||||
// thorough layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact
|
|
||||||
// bar of clusters, each button carrying a label sub-rect spanning
|
|
||||||
// its full height — a single-row short label (L6: the keybinding sub-row was on the button face
|
|
||||||
// through L5; L6 moves it to the hover tooltip instead). The bar degrades gracefully on a narrow
|
|
||||||
// panel by dropping WHOLE trailing buttons (never clipping) so the frequent leading cluster
|
|
||||||
// survives.
|
|
||||||
//
|
|
||||||
// L4 re-homes the inventory across TWO toolbars, BOTH driven by this one module: a TOP toolbar
|
|
||||||
// (Capture + Placement — the two acts the tool exists for) and a BOTTOM toolbar (the Design-View
|
|
||||||
// verbs, Tagging then Switching). The tiling is cluster-agnostic — it walks the caller's
|
|
||||||
// ClusterSpec list in order — so the same computeBarSlots / hitTestActionBar serve both bars;
|
|
||||||
// only the cluster membership and the band rect differ per toolbar.
|
|
||||||
//
|
|
||||||
// Why pure (CLAUDE.md §load-bearing split, DS-1 caution): the panel shell owns the SWELL
|
|
||||||
// window, the L1-kit draws, and the NamedCommandLookup/Main_OnCommand dispatch — all
|
|
||||||
// DAW-verified. What is NOT DAW-bound — how the clusters tile the bar, where each button and
|
|
||||||
// its label sub-rect sit, and which button a click hits — lives HERE, unit-tested outside the
|
|
||||||
// DAW. Mirror of mode_switch / prune_button.
|
|
||||||
//
|
|
||||||
// NAME NOTE (brief §name-collision): ButtonRect / ButtonStripRect / ActionButtonRect /
|
|
||||||
// SegmentRect / CellRect / FooterRect / KitButtonBox are already owned in this namespace, so
|
|
||||||
// this module's types are ActionBarRect / ActionBarSlot / ActionCluster — grep-checked free
|
|
||||||
// before minting. They are a distinct concept (a task-grouped multi-cluster bar with text
|
|
||||||
// sub-rects), so the separate names are correct, not merely non-colliding.
|
|
||||||
//
|
|
||||||
// SCOPE: the destructive PRUNE button is NOT in this bar — it stays set-apart in the footer,
|
|
||||||
// warn-marked, owned by prune_button (L2 keeps prune deliberately away from the frequent
|
|
||||||
// action cluster). This module lays out only the non-destructive capture/placement/maintenance
|
|
||||||
// actions.
|
|
||||||
//
|
|
||||||
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
|
|
||||||
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// The task cluster a button belongs to (the L2 "group by task" mandate). The order here is
|
|
||||||
// NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum
|
|
||||||
// only names the groups so a slot can carry (and a test/shell can assert) its membership.
|
|
||||||
//
|
|
||||||
// L4 split the panel's buttons across TWO toolbars, each an action_bar instance:
|
|
||||||
// * the TOP toolbar draws Capture + Placement (the two acts the tool exists for);
|
|
||||||
// * the BOTTOM toolbar draws the Design-View verbs, grouped Tagging then Switching.
|
|
||||||
// Both toolbars share this ONE pure layout module (the tiling is cluster-agnostic — it walks
|
|
||||||
// the caller's ClusterSpec list in order), so a cluster value belongs to whichever toolbar
|
|
||||||
// the shell places it in; nothing here couples a cluster to a specific bar.
|
|
||||||
enum class ActionCluster {
|
|
||||||
Capture, // capture item / track / realtime / batch — top toolbar, primary gesture
|
|
||||||
Placement, // insert at cursor / insert-conform — top toolbar, placing a sample
|
|
||||||
Maintenance, // re-capture from source / cancel realtime — rarer upkeep actions
|
|
||||||
Tagging, // tag / untag selected tracks for the active mode — bottom toolbar (L4)
|
|
||||||
Switching, // activate Arrange / Design, toggle mode, show-both — bottom toolbar (L4)
|
|
||||||
};
|
|
||||||
|
|
||||||
// The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the
|
|
||||||
// top-left corner; width/height are the bar extents. The panel reserves this as a fixed-height
|
|
||||||
// band (its own judgment where — above the tail footer, below the split body).
|
|
||||||
struct ActionBarRect {
|
|
||||||
int x = 0;
|
|
||||||
int y = 0;
|
|
||||||
int width = 0;
|
|
||||||
int height = 0;
|
|
||||||
|
|
||||||
bool operator==(const ActionBarRect& o) const {
|
|
||||||
return x == o.x && y == o.y && width == o.width && height == o.height;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// One visible button's placement within the bar, top-left origin. `index` is the button's
|
|
||||||
// position in the caller's flat action list (the caller supplies actions in cluster order, so
|
|
||||||
// index also selects the action to fire on a hit). `cluster` is the task group it was laid out
|
|
||||||
// under (surfaced so a test can assert the grouping is structural, and the shell can tint a
|
|
||||||
// cluster). `box` is the whole button rect; `labelBox` is the text area inset horizontally so
|
|
||||||
// text clears the button edge. Only VISIBLE buttons get a slot — a button that does not fit is
|
|
||||||
// omitted, never returned clipped, so every slot is fully drawable.
|
|
||||||
struct ActionBarSlot {
|
|
||||||
int index = 0;
|
|
||||||
ActionCluster cluster = ActionCluster::Capture;
|
|
||||||
int x = 0;
|
|
||||||
int y = 0;
|
|
||||||
int width = 0;
|
|
||||||
int height = 0;
|
|
||||||
// Label rect (absolute, top-left origin), inside `box`. The label spans the full button
|
|
||||||
// height — a single-row short label only (L6: keybinding sub-row removed from the face;
|
|
||||||
// binding is surfaced in the hover tooltip instead).
|
|
||||||
int labelX = 0, labelY = 0, labelW = 0, labelH = 0;
|
|
||||||
|
|
||||||
bool operator==(const ActionBarSlot& o) const {
|
|
||||||
return index == o.index && cluster == o.cluster &&
|
|
||||||
x == o.x && y == o.y && width == o.width && height == o.height &&
|
|
||||||
labelX == o.labelX && labelY == o.labelY &&
|
|
||||||
labelW == o.labelW && labelH == o.labelH;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// One cluster's button count, in the caller's flat action-list order. The caller passes these
|
|
||||||
// in the left-to-right order it wants them drawn (top toolbar: Capture then Placement; bottom
|
|
||||||
// toolbar: Tagging then Switching); a cluster with count 0 is skipped (no gap emitted for it).
|
|
||||||
// The flat action index a slot carries is the running sum across clusters (cluster 0's buttons
|
|
||||||
// are indices [0, counts[0]), etc.), so the shell's flat action table lines up with the slots
|
|
||||||
// by index.
|
|
||||||
struct ClusterSpec {
|
|
||||||
ActionCluster cluster = ActionCluster::Capture;
|
|
||||||
int count = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Layout inputs for the bar, in pixels. Defaults are the bank_panel action-bar metrics; the
|
|
||||||
// shell passes its own so draw and hit-test share ONE source of truth.
|
|
||||||
// * buttonWidth — each button's fixed width (buttons never render narrower; overflow drops
|
|
||||||
// whole trailing buttons instead of shrinking below this).
|
|
||||||
// * buttonGap — horizontal gap between buttons WITHIN a cluster.
|
|
||||||
// * clusterGap — horizontal gap between adjacent clusters (wider than buttonGap so the
|
|
||||||
// task grouping reads visually; the 8px-grid density decision).
|
|
||||||
// * sidePad — left/right inset from the bar edges to the first/last button.
|
|
||||||
// * verticalInset — top/bottom gap inside the bar (buttons read as raised, not full-bleed).
|
|
||||||
struct ActionBarSpec {
|
|
||||||
int buttonWidth = 108;
|
|
||||||
int buttonGap = 4;
|
|
||||||
int clusterGap = 16;
|
|
||||||
int sidePad = 8;
|
|
||||||
int verticalInset = 3;
|
|
||||||
};
|
|
||||||
|
|
||||||
// How many buttons (from the front, cluster by cluster) fit the bar at `spec.buttonWidth`.
|
|
||||||
// Split from slot tiling so the shell can size an overflow affordance / count without
|
|
||||||
// re-deriving it. Trailing buttons that do not fit are the overflow (dropped whole). A
|
|
||||||
// non-positive bar width, or a bar too narrow for even one button, yields 0. Clamps to
|
|
||||||
// [0, total-button-count].
|
|
||||||
struct BarFit {
|
|
||||||
int visibleCount = 0; // buttons that fit (laid out), counted from the front
|
|
||||||
int hiddenCount = 0; // total - visibleCount (the overflow, dropped whole)
|
|
||||||
};
|
|
||||||
|
|
||||||
BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters,
|
|
||||||
const ActionBarSpec& spec);
|
|
||||||
|
|
||||||
// Lays out the VISIBLE buttons (per computeBarFit) left-to-right in cluster order: buttons
|
|
||||||
// pack at buttonWidth with buttonGap inside a cluster and clusterGap between clusters, starting
|
|
||||||
// at bar.x + sidePad. Each slot carries its flat action index, its cluster, its box, and the
|
|
||||||
// label sub-rect (full-height single row). Empty clusters emit no gap. Returns exactly
|
|
||||||
// visibleCount slots in ascending index order. A degenerate bar (width/height <= 0), an empty
|
|
||||||
// cluster list, or a non-positive buttonWidth yields empty.
|
|
||||||
std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar,
|
|
||||||
const std::vector<ClusterSpec>& clusters,
|
|
||||||
const ActionBarSpec& spec);
|
|
||||||
|
|
||||||
// The flat action index the point (px, py) (SWELL/LICE top-left client coords) lands on, or -1
|
|
||||||
// for a miss: outside the bar band, in an inter-button / inter-cluster gap, or past the last
|
|
||||||
// visible button (the narrow-panel overflow dead-zone — a harmless no-op the shell ignores).
|
|
||||||
// Half-open bounds [x, x+width) x [y, y+height) match computeBarSlots so no pixel is double-
|
|
||||||
// claimed and the hit maps to the button drawn there. Unlike an equal-tiled strip, the bar has
|
|
||||||
// real gaps, so a gap point is a clean miss (not the nearest button).
|
|
||||||
int hitTestActionBar(int px, int py, const ActionBarRect& bar,
|
|
||||||
const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec);
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
-1016
File diff suppressed because it is too large
Load Diff
@@ -1,92 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// actions — the Design View action family (Phase D4). Registers the bindable
|
|
||||||
// actions that drive the mode workflow and wires them end-to-end: toggle/activate
|
|
||||||
// a mode, tag/untag/show-both the current track selection. Each action mutates the
|
|
||||||
// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies
|
|
||||||
// the active mode through the view shell (D2) so the change takes effect immediately.
|
|
||||||
//
|
|
||||||
// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT
|
|
||||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This
|
|
||||||
// header is SDK-free; main.cpp calls register/handle/unregister and nothing else.
|
|
||||||
//
|
|
||||||
// Split out of main.cpp (rather than inlined there) to match CONTEXT.md's planned
|
|
||||||
// `actions` module and keep main.cpp's entrypoint focused on API-pointer ownership
|
|
||||||
// and lifecycle. The reapply-on-open glue stays in main.cpp (it owns the timer that
|
|
||||||
// drives persist.poll()); this module only registers and services the actions.
|
|
||||||
|
|
||||||
// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct
|
|
||||||
// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real
|
|
||||||
// definition. Declared before the namespace so it is the global type, not a
|
|
||||||
// namespace-local shadow.
|
|
||||||
struct reaper_plugin_info_t;
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
class ReaSamplerSession;
|
|
||||||
|
|
||||||
// Registers the Design View action family against `rec` (command_id + gaccel +
|
|
||||||
// hookcommand-routing is owned by the caller's single hookcommand). `session` is the
|
|
||||||
// live session the actions mutate; it must outlive the registration. Idempotent is
|
|
||||||
// NOT promised — call exactly once at load, mirror-unregister once at unload.
|
|
||||||
void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session);
|
|
||||||
|
|
||||||
// Services one fired command. Returns true iff `command` is one of this module's
|
|
||||||
// action ids (and it was handled); false otherwise so the caller's hookcommand keeps
|
|
||||||
// looking (per the contract: claim only our own ids). Safe to call for any command.
|
|
||||||
bool designViewHandleCommand(int command);
|
|
||||||
|
|
||||||
// Mirror-unregisters everything designViewRegisterActions registered, with the
|
|
||||||
// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr.
|
|
||||||
void designViewUnregisterActions(reaper_plugin_info_t* rec);
|
|
||||||
|
|
||||||
// --- Multi-bank action family (Phase B3) -----------------------------------
|
|
||||||
// The bindable action set that drives the multi-bank workflow: create / rename /
|
|
||||||
// delete / evacuate a bank, activate a bank (direct pool/design-free + cycle), move /
|
|
||||||
// copy the panel's selected samples into a bank, and the two vertical-split
|
|
||||||
// full-height toggles. Every mutating action drives the B1 model on
|
|
||||||
// g_session.book() and persists via g_session.saveToActiveProject() so the change
|
|
||||||
// travels with the .rpp; the toggles flip the B4-rendered layout bit on the panel.
|
|
||||||
//
|
|
||||||
// Same registration/routing/unload contract as the Design View family above and the
|
|
||||||
// same shared g_session. Kept a distinct trio (not folded into the Design View one)
|
|
||||||
// because the two families are orthogonal pillars — but they share the single
|
|
||||||
// hookcommand main.cpp owns; each family's Handle claims only its own ids.
|
|
||||||
|
|
||||||
// Registers the multi-bank family against `rec`. `session` is the live session (must
|
|
||||||
// outlive registration). Call exactly once at load. Shares g_session with the Design
|
|
||||||
// View family — pass the SAME session pointer.
|
|
||||||
void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session);
|
|
||||||
|
|
||||||
// Services one fired command for the multi-bank family. True iff it was one of this
|
|
||||||
// family's ids (and handled); false otherwise so the caller's hookcommand keeps
|
|
||||||
// looking. Safe for any command.
|
|
||||||
bool bankHandleCommand(int command);
|
|
||||||
|
|
||||||
// Mirror-unregisters the multi-bank family with '-'-prefixed strings. Call once on
|
|
||||||
// rec==nullptr (before g_session is torn down).
|
|
||||||
void bankUnregisterActions(reaper_plugin_info_t* rec);
|
|
||||||
|
|
||||||
// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 before
|
|
||||||
// registration. The bank_panel prune button fires the action THROUGH this id via
|
|
||||||
// Main_OnCommand (fork R-E: the button dispatches the command, it does not call the session
|
|
||||||
// directly) so the panel affordance and the bindable action share one guarded code path.
|
|
||||||
int bankPruneCommandId();
|
|
||||||
|
|
||||||
// Persists a completed bank-index verb as a single REAPER undo point (R-B).
|
|
||||||
// Wraps persistBook() (= SetProjExtState) in a Begin/End block with UNDO_STATE_MISCCFG
|
|
||||||
// so the bank op is one Ctrl-Z. On an unsaved / no-active project persistBook() no-ops
|
|
||||||
// and the block is closed with an empty label + zero flag (REAPER discards it). Callers
|
|
||||||
// must invoke this ONLY after a successful/effective mutation — rejected ops (duplicate
|
|
||||||
// name, un-deletable pool, etc.) must return before reaching here so no empty undo
|
|
||||||
// point is ever opened for a no-op. Defined in actions.cpp alongside persistBook().
|
|
||||||
//
|
|
||||||
// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a live
|
|
||||||
// instance would PLAY — move / copy / remove / evacuate / delete-with-members (a sample left,
|
|
||||||
// arrived, or dropped out of a bank an instance may reference). Leave it false (the default)
|
|
||||||
// for a PURELY ORGANIZATIONAL verb — create / rename / activate / reorder — which changes no
|
|
||||||
// existing (bankId, sampleId) -> content mapping, so no instance need refresh. The bump (when
|
|
||||||
// requested) happens INSIDE the block, BEFORE persistBook(), so the stamped counter rides the
|
|
||||||
// same ext-state write and undo captures the pre/post generation with the rest of the blob.
|
|
||||||
void persistBankOp(const char* label, bool bumpGeneration = false);
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# src/app — REAPER extension entry point
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Contains only `main.cpp`. Since the Phase Q hoists (Q-W3 onward), this TU is ONLY
|
||||||
|
pointers + entry + dispatch — the actual capture/panel/persist/action orchestration
|
||||||
|
lives in `shell/`. `main.cpp` owns: receiving REAPER's dispatch struct
|
||||||
|
(`ReaperPluginEntry`), resolving the REAPER API function pointers
|
||||||
|
(`REAPERAPI_LoadAPI`), the globals other files reference via `extern` (`g_hInst`,
|
||||||
|
`g_rec`), the `ReaSamplerSession` instance, its own bindable-action family via the
|
||||||
|
Q-W6 data-driven registration table (`shell/actions/action_registry`), and invoking
|
||||||
|
the other action families' (`design_view` / `bank` / `ingest`) own
|
||||||
|
register/handle/unregister triples at load and unload.
|
||||||
|
|
||||||
|
Exactly **one** translation unit defines `REAPERAPI_IMPLEMENT` — that is `main.cpp`.
|
||||||
|
Every other `.cpp` includes `reaper_plugin_functions.h` without the define and gets
|
||||||
|
`extern` declarations for the global API function pointers.
|
||||||
|
|
||||||
|
See root `CLAUDE.md`'s "REAPER extension contract" section for the full four-step
|
||||||
|
action-registration contract (`command_id` / `gaccel` / `hookcommand` / unload
|
||||||
|
mirror-unregister) that both this file's own action-table rows and the other
|
||||||
|
families' register/handle/unregister triples follow.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
- `main.cpp` — the REAPER extension's entry point and the sole `REAPERAPI_IMPLEMENT` TU; see root `CLAUDE.md`'s "REAPER extension contract" section for the registration contract this file implements.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- This is intentionally a thin TU post-Phase-Q. Adding a new bindable action to
|
||||||
|
`main.cpp`'s own family means adding one row to its `ActionTableRow` table and a
|
||||||
|
flat handler function — do not hand-roll a parallel register/hookcommand/unregister
|
||||||
|
mechanism alongside the table.
|
||||||
|
- Never let a second `.cpp` define `REAPERAPI_IMPLEMENT` — that would double-allocate
|
||||||
|
the global REAPER API function pointers.
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
|
||||||
|
//
|
||||||
|
// REAPER dlopen()s reaper_*.dll|dylib|so from UserPlugins/ and calls the exported
|
||||||
|
// ReaperPluginEntry, handing over `rec` (rec->GetFunc resolves API pointers,
|
||||||
|
// rec->Register plugs our callbacks in). Exactly ONE .cpp defines
|
||||||
|
// REAPERAPI_IMPLEMENT (this one) — that allocates storage for the global API
|
||||||
|
// pointers every other TU gets `extern`. Never let a second TU define it.
|
||||||
|
//
|
||||||
|
// This TU is ONLY pointers + entry + dispatch. Its own action family registers
|
||||||
|
// through the data-driven table below (buildMainActionTable + action_registry) —
|
||||||
|
// adding a bindable action means adding ONE row and its handler function (OCP). The
|
||||||
|
// design_view / bank / ingest families keep their own register/handle/unregister
|
||||||
|
// triples, called from entry.
|
||||||
|
|
||||||
|
#define REAPERAPI_IMPLEMENT
|
||||||
|
#include "reaper_plugin.h"
|
||||||
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/capture/render_settings.h" // captureActionTable
|
||||||
|
#include "core/version/app_version.h" // appVersion
|
||||||
|
#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
|
||||||
|
#include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded
|
||||||
|
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
|
||||||
|
#include "shell/persist/session.h" // ReaSamplerSession
|
||||||
|
#include "shell/view/view.h" // reconcileManagedLanes / applyMode
|
||||||
|
|
||||||
|
namespace capture = reasampler::capture;
|
||||||
|
|
||||||
|
// Globals other files reference via `extern`.
|
||||||
|
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr;
|
||||||
|
reaper_plugin_info_t* g_rec = nullptr;
|
||||||
|
|
||||||
|
// Retired command-id SUFFIXES: kept ONLY to mirror-unregister on unload so a user's
|
||||||
|
// stale keybindings are cleaned up. Never re-register these. The four-mode WET ids,
|
||||||
|
// the removed master scope/realtime actions, and the removed per-action tail variants
|
||||||
|
// (tail is now a panel toggle, not a paired action).
|
||||||
|
static const char* const kRetiredCaptureCmdSuffixes[] = {
|
||||||
|
"CAPTURE_TRACKS_WET",
|
||||||
|
"CAPTURE_ITEMS_WET",
|
||||||
|
"CAPTURE_RAZOR_WET",
|
||||||
|
"CAPTURE_MASTER",
|
||||||
|
"CAPTURE_MASTER_REALTIME",
|
||||||
|
"CAPTURE_ITEM_TAIL",
|
||||||
|
"CAPTURE_TRACK_TAIL",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Owns the in-memory BankModel and bridges it to project ext state. A timer tick
|
||||||
|
// drives g_session.poll() to detect project load / Save-As; capture adds Samples to
|
||||||
|
// g_session.bank() (resolves to the active bank's index), and we serialize the book
|
||||||
|
// back into the active project's ext state (the `banks` key) so it travels with the .rpp.
|
||||||
|
static reasampler::ReaSamplerSession g_session;
|
||||||
|
|
||||||
|
// Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so
|
||||||
|
// OnToggleAction's checked-state poll is a single int compare (no per-poll lookup).
|
||||||
|
static int g_cmdToggleBankPanel = 0;
|
||||||
|
|
||||||
|
// Each handler is a thin stateless routing shim: (session, per-row arg) -> the
|
||||||
|
// action body in shell/capture/ or shell/panel/, existing only so table rows can be
|
||||||
|
// plain data with flat function pointers.
|
||||||
|
|
||||||
|
// `arg` is the captureActionTable() row index — the table rows below are built by
|
||||||
|
// iterating that pure taxonomy, so the routing stays 1:1 by construction.
|
||||||
|
static void RunCaptureScopeRow(int arg) {
|
||||||
|
capture::RunCapture(g_session,
|
||||||
|
capture::captureActionTable()[static_cast<std::size_t>(arg)]);
|
||||||
|
}
|
||||||
|
static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); }
|
||||||
|
static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); }
|
||||||
|
// `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (never silent); 0
|
||||||
|
// inserts at native length.
|
||||||
|
static void RunInsertSelected(int arg) {
|
||||||
|
capture::RunInsertSelected(g_session, arg != 0);
|
||||||
|
}
|
||||||
|
static void RunBatchCaptureItems(int) { capture::RunBatchCaptureItems(g_session); }
|
||||||
|
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).
|
||||||
|
ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix, Actions-list
|
||||||
|
// phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload
|
||||||
|
// mirror-unregister all iterate this data. The capture scope rows come first,
|
||||||
|
// sourced from the pure captureActionTable() taxonomy; the rest are this TU's singles.
|
||||||
|
static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
||||||
|
using reasampler::ActionTableRow;
|
||||||
|
std::vector<ActionTableRow> rows;
|
||||||
|
|
||||||
|
const auto& cap = capture::captureActionTable();
|
||||||
|
for (std::size_t i = 0; i < cap.size(); ++i)
|
||||||
|
rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase,
|
||||||
|
&RunCaptureScopeRow, static_cast<int>(i)});
|
||||||
|
|
||||||
|
// Show/hide the docked bank panel (display-only; never captures/inserts).
|
||||||
|
rows.push_back({"TOGGLE_BANK_PANEL", "toggle bank panel", &RunToggleBankPanel});
|
||||||
|
rows.push_back({"CAPTURE_ITEM_ASSIGN",
|
||||||
|
"capture selected item into bank + assign to active instance",
|
||||||
|
&RunCaptureItemAssign});
|
||||||
|
// Two variants differing ONLY in InsertOptions — native length vs conform opt-in.
|
||||||
|
rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor",
|
||||||
|
&RunInsertSelected, 0});
|
||||||
|
rows.push_back({"INSERT_SELECTED_CONFORM",
|
||||||
|
"insert selected sample at edit cursor (conform to tempo)",
|
||||||
|
&RunInsertSelected, 1});
|
||||||
|
// One action fires N captures (per selected item / per razor area); the original
|
||||||
|
// selection is restored on every exit path. Bank-only, never places.
|
||||||
|
rows.push_back({"CAPTURE_BATCH_ITEMS",
|
||||||
|
"batch capture selected items (one per item)",
|
||||||
|
&RunBatchCaptureItems});
|
||||||
|
rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)",
|
||||||
|
&RunBatchCaptureRazor});
|
||||||
|
// Realtime sibling of the offline CAPTURE_TRACK scope, plus its cancel-in-flight
|
||||||
|
// companion (stop + restore, non-destructive).
|
||||||
|
rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)",
|
||||||
|
&RunCaptureRealtime});
|
||||||
|
rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture",
|
||||||
|
&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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The timer callback REAPER runs periodically (registered via "timer"). It only
|
||||||
|
// forwards to the session poll — cheap per tick (reads the active project id and
|
||||||
|
// its .rpp path, acts only on a change).
|
||||||
|
static void OnTimer()
|
||||||
|
{
|
||||||
|
// Advance any in-flight realtime capture FIRST, so a project switch is caught and
|
||||||
|
// torn down/restored before session.poll() reacts to that switch. LOAD-BEARING:
|
||||||
|
// the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
|
||||||
|
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
|
||||||
|
|
||||||
|
g_session.poll();
|
||||||
|
|
||||||
|
// persist stays MODEL-ONLY (loads the saved view model but does not apply
|
||||||
|
// visibility, to avoid coupling persist to the view shell); poll() raises a
|
||||||
|
// one-shot load signal that we drain here to reapply the SAVED active mode so a
|
||||||
|
// project saved in Design mode parks Arrange tracks automatically. The same
|
||||||
|
// signal re-arms the bank panel's new-content detector — notified BEFORE the
|
||||||
|
// reapply so re-arm and model restore ride the one load event (otherwise
|
||||||
|
// pre-existing tracks can be mis-detected as "new" and mass-tagged).
|
||||||
|
if (g_session.consumeLoadSignal()) {
|
||||||
|
reasampler::bankPanelNotifyProjectLoaded();
|
||||||
|
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
|
||||||
|
// the cross-session source of truth) BEFORE reapplying visibility. Never
|
||||||
|
// re-mints, never mass-tags.
|
||||||
|
reasampler::reconcileManagedLanes(g_session.view(), nullptr);
|
||||||
|
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Ctrl-Z/Ctrl-Shift-Z rolls back/forward the "reasampler" project ext state on disk
|
||||||
|
// but keeps the SAME project identity, so the timer's identity poll never re-reads
|
||||||
|
// ext state on undo/redo — the in-memory book/view would stay stale until
|
||||||
|
// close+reopen. REAPER's projectconfig fires BeginLoadProjectState on every
|
||||||
|
// project-state (re)load INCLUDING undo/redo (isUndo == true for both); we hook it.
|
||||||
|
//
|
||||||
|
// TIMING: BeginLoadProjectState fires BEFORE any state restore, so reading
|
||||||
|
// GetProjExtState here would return the PRE-undo value. Instead we raise a one-shot
|
||||||
|
// reload request that OnTimer's poll() drains on the NEXT tick, once REAPER has
|
||||||
|
// finished restoring the <EXTSTATE> block. A normal project open also fires this
|
||||||
|
// (isUndo=false); ignored here so a normal open flows solely through the timer's
|
||||||
|
// identity-transition Load path (no double load).
|
||||||
|
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
|
||||||
|
{
|
||||||
|
if (isUndo)
|
||||||
|
g_session.requestReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this
|
||||||
|
// extension's own project lines. The struct is registered ONLY for the
|
||||||
|
// BeginLoadProjectState undo/redo notification.
|
||||||
|
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
|
||||||
|
bool /*isUndo*/, project_config_extension_t* /*reg*/)
|
||||||
|
{
|
||||||
|
return false; // we own no project lines — ext state carries our data
|
||||||
|
}
|
||||||
|
|
||||||
|
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
|
||||||
|
project_config_extension_t* /*reg*/)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
|
||||||
|
static project_config_extension_t g_projectConfig{
|
||||||
|
&OnProcessExtensionLine,
|
||||||
|
&OnSaveExtensionConfig,
|
||||||
|
&OnBeginLoadProjectState,
|
||||||
|
nullptr, // userData
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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*/)
|
||||||
|
{
|
||||||
|
if (command == 0) return false;
|
||||||
|
if (reasampler::actionTableHandleCommand(command)) return true;
|
||||||
|
if (reasampler::designViewHandleCommand(command)) return true;
|
||||||
|
if (reasampler::bankHandleCommand(command)) return true;
|
||||||
|
if (reasampler::ingestHandleCommand(command)) return true;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (command != 0 && command == g_cmdToggleBankPanel)
|
||||||
|
return reasampler::bankPanelIsOpen() ? 1 : 0;
|
||||||
|
return -1; // not ours / non-toggling
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||||
|
REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec)
|
||||||
|
{
|
||||||
|
if (!rec)
|
||||||
|
{
|
||||||
|
// rec == nullptr => REAPER is UNLOADING us.
|
||||||
|
if (g_rec)
|
||||||
|
{
|
||||||
|
// Abort any in-flight realtime capture FIRST, while the API pointers are
|
||||||
|
// still live, so we never leave a temp track, an armed track, or an
|
||||||
|
// altered transport/cursor in the user's project on unload.
|
||||||
|
capture::AbortRealtimeCaptureForUnload(g_session);
|
||||||
|
|
||||||
|
g_rec->Register("-timer", (void*)&OnTimer);
|
||||||
|
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);
|
||||||
|
// This TU's own family, reverse table order; each '-command_id'
|
||||||
|
// re-presents the SAME interned pointer used at register.
|
||||||
|
reasampler::unregisterActionTable(g_rec);
|
||||||
|
// Retire the REMOVED command ids (command_id only — we never held a gaccel
|
||||||
|
// for them this session).
|
||||||
|
for (const char* suffix : kRetiredCaptureCmdSuffixes)
|
||||||
|
g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix));
|
||||||
|
}
|
||||||
|
// Before dropping the API pointers: DockWindowRemove/DestroyWindow need them live.
|
||||||
|
reasampler::bankPanelShutdown();
|
||||||
|
g_rec = nullptr;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ABI guard: the struct layout we compiled against must match this REAPER.
|
||||||
|
if (rec->caller_version != REAPER_PLUGIN_VERSION)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Resolve every REAPER API function pointer. Returns the number that FAILED
|
||||||
|
// to load; 0 == success. Non-zero usually means REAPER is older than our SDK.
|
||||||
|
if (REAPERAPI_LoadAPI(rec->GetFunc) != 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
g_hInst = hInstance;
|
||||||
|
g_rec = rec;
|
||||||
|
|
||||||
|
// Point the bank panel at the live session BEFORE registering its action, so a
|
||||||
|
// toggle firing immediately has a session to read. Does not open the window.
|
||||||
|
reasampler::bankPanelInit(&g_session);
|
||||||
|
|
||||||
|
{
|
||||||
|
const std::vector<reasampler::ActionTableRow> rows = buildMainActionTable();
|
||||||
|
reasampler::registerActionTable(rec, rows.data(), rows.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The panel toggle renders a checked state — resolve its minted id once and
|
||||||
|
// register the toggleaction hook that reports it.
|
||||||
|
g_cmdToggleBankPanel = reasampler::actionTableCommandId("TOGGLE_BANK_PANEL");
|
||||||
|
if (g_cmdToggleBankPanel)
|
||||||
|
rec->Register("toggleaction", (void*)&OnToggleAction);
|
||||||
|
|
||||||
|
// Each family mints its own command_id + gaccel, shares g_session, and is routed
|
||||||
|
// by the same hookcommand below. Registered before the hook so every id is
|
||||||
|
// minted first.
|
||||||
|
reasampler::designViewRegisterActions(rec, &g_session);
|
||||||
|
reasampler::bankRegisterActions(rec, &g_session);
|
||||||
|
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
|
||||||
|
// it relocates the bank folder under the new .rpp.
|
||||||
|
rec->Register("timer", (void*)&OnTimer);
|
||||||
|
|
||||||
|
// An UNDO/REDO state restore reloads the session's book + view from the restored
|
||||||
|
// ext state. The timer's identity poll cannot see an undo (same project
|
||||||
|
// identity), so this hook owns it (see OnBeginLoadProjectState).
|
||||||
|
rec->Register("projectconfig", (void*)&g_projectConfig);
|
||||||
|
|
||||||
|
return 1; // success — REAPER keeps us loaded
|
||||||
|
}
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// app_version — the REAPER-free version-identity core (Phase V, V1 + V4). The single
|
|
||||||
// source of truth for the version STRING lives in CMake (a `REASAMPLER_VERSION`
|
|
||||||
// variable threaded in via configure_file -> version_generated.h); this module
|
|
||||||
// re-exports it as the canonical constant and owns every pure operation on it: the
|
|
||||||
// exact-string render, the parse/compare arithmetic a within-channel forward
|
|
||||||
// migration will lean on, and the "which version wrote this project" result that
|
|
||||||
// persist reads back from ext state (absent stamp = pre-versioning, never an error).
|
|
||||||
//
|
|
||||||
// V4 (beta-in-isolation) extends this module into the SINGLE SOURCE OF TRUTH FOR
|
|
||||||
// CHANNEL IDENTITY too. A compile-time flag (`-DREASAMPLER_CHANNEL=beta`, threaded
|
|
||||||
// through the same configure_file'd version_generated.h as REASAMPLER_CHANNEL_IS_BETA)
|
|
||||||
// selects stable (the default, absent-flag build — byte-for-byte today's identity) or
|
|
||||||
// a fully isolated beta build. Every channel-qualified identity string the shells
|
|
||||||
// register with REAPER — the display suffix, the ext-state namespace, the command-id
|
|
||||||
// prefix, the Actions-list name prefix, the binary/dock idents — is DERIVED HERE from
|
|
||||||
// the one channel bit, so no scattered #ifdef forks live across the translation units;
|
|
||||||
// the shells just consume these accessors. This keeps "what makes a beta a beta" one
|
|
||||||
// auditable definition and makes the channel-derived rendering unit-testable.
|
|
||||||
//
|
|
||||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
|
||||||
// only. Builds and unit-tests without REAPER (mirror of bank_model / tail_control).
|
|
||||||
//
|
|
||||||
// Leading-zero fidelity (V1, Daniel-fixed): the displayed/stamped string preserves the
|
|
||||||
// configured version EXACTLY as written — padded and unpadded versions are both
|
|
||||||
// legitimate (e.g. "0.9.8" and "0.9.80" are different versions; a "0.9.01" renders its
|
|
||||||
// zero-padded patch verbatim). That exactness is why the version STRING is the
|
|
||||||
// authoritative artifact (sourced verbatim from the one CMake variable), not a
|
|
||||||
// reconstruction from numeric components — CMake's `project(VERSION)` may normalize a
|
|
||||||
// numeric patch field, so we never round-trip the string through integers to render it.
|
|
||||||
// Guarded against reconstruct-from-components regressions in app_version.cpp by
|
|
||||||
// app_version_padding_tests (the padded canary build). The canary does NOT guard
|
|
||||||
// against the CMakeLists.txt source-of-truth line being changed to a CMake variable
|
|
||||||
// derivation — that case is guarded by the comment block at the top of CMakeLists.txt.
|
|
||||||
|
|
||||||
#include <optional>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// --- Channel identity (V4, beta-in-isolation) ---------------------------------------
|
|
||||||
//
|
|
||||||
// The build channel, fixed at compile time by REASAMPLER_CHANNEL_IS_BETA (0 = stable,
|
|
||||||
// the default absent-flag build; 1 = beta, from -DREASAMPLER_CHANNEL=beta). Stable is
|
|
||||||
// today's build with byte-identical identity in EVERY string below — any divergence on
|
|
||||||
// the stable channel is a defect. Beta forks every identity so a beta binary coexists
|
|
||||||
// with stable in one REAPER (both are dlopen'd at startup) without colliding on project
|
|
||||||
// ext-state, keybindings, or any REAPER-global registration.
|
|
||||||
enum class Channel { Stable, Beta };
|
|
||||||
|
|
||||||
// The channel this build was compiled for. Constant per binary.
|
|
||||||
Channel channel();
|
|
||||||
|
|
||||||
// True on the beta build only. Convenience over channel() == Channel::Beta.
|
|
||||||
bool isBeta();
|
|
||||||
|
|
||||||
// The user-visible version render. Stable: EXACTLY the configured CMake string. Beta:
|
|
||||||
// that string plus a plain "-beta" suffix — a plain suffix, NOT a git-describe
|
|
||||||
// decoration (V2, Daniel-fixed). This is what the show-version action and the
|
|
||||||
// bank-panel readout display. It is NOT the ext-state stamp value (see stampVersion).
|
|
||||||
const std::string& appVersion();
|
|
||||||
|
|
||||||
// The ext-state STAMP value — the writing-version recorded into a saved project. This is
|
|
||||||
// the NUMERIC TRIPLE ONLY (the configured string, no channel suffix) on BOTH channels:
|
|
||||||
// it deliberately carries NO channel suffix, so (a) parseVersion classifies it as
|
|
||||||
// Stamped when its own channel reads it back (a "-beta"-suffixed stamp would classify
|
|
||||||
// as Unknown — the V4 stamp-classifiability requirement), and (b) stable's stamp value
|
|
||||||
// is byte-identical regardless of the channel build. The channel is carried by the
|
|
||||||
// ISOLATED namespace (see extStateNamespace), never baked into the stamp. Distinct from
|
|
||||||
// appVersion() precisely so the display can say "-beta" while the stamp stays
|
|
||||||
// classifiable and stable-identical.
|
|
||||||
const std::string& stampVersion();
|
|
||||||
|
|
||||||
// The project ext-state namespace this channel reads and writes. Stable: "reasampler"
|
|
||||||
// (byte-identical to the pre-V4 build). Beta: "reasampler_beta". FOREVER-STABLE per
|
|
||||||
// channel once shipped — changing either orphans every already-saved project's state.
|
|
||||||
//
|
|
||||||
// ISOLATION SEMANTICS (V4, accepted — not a bug): a channel reads/writes ONLY its own
|
|
||||||
// namespace. A project saved by stable shows empty/default ReaSampler state when opened
|
|
||||||
// in beta, and vice versa. There is NO cross-namespace read, migration, or fallback in
|
|
||||||
// this wave — that isolation is the safety property (a beta can never read or rewrite a
|
|
||||||
// stable project's bank/view/tail state).
|
|
||||||
const std::string& extStateNamespace();
|
|
||||||
|
|
||||||
// The FOREVER-STABLE command-id prefix every bindable action mints its id from. Stable:
|
|
||||||
// "CEREBELLUM_REASAMPLER_" (byte-identical to the shipped ids). Beta:
|
|
||||||
// "CEREBELLUM_REASAMPLER_BETA_", a DISTINCT forever-family so beta and stable actions
|
|
||||||
// never collide in REAPER's one Actions list and their keybindings stay independent.
|
|
||||||
// Callers concatenate their per-action suffix onto this (e.g. prefix + "CAPTURE_TRACK").
|
|
||||||
// PERMANENT once a beta ships — mark any minted id FOREVER-STABLE like stable's.
|
|
||||||
const std::string& commandIdPrefix();
|
|
||||||
|
|
||||||
// The Actions-list DISPLAY-NAME prefix, so two coexisting channels are distinguishable in
|
|
||||||
// REAPER's Actions list. Stable: "ReaSampler: " (unchanged). Beta: "ReaSampler beta: ".
|
|
||||||
// Callers build a gaccel desc as actionDisplayPrefix() + "capture selected track", etc.
|
|
||||||
const std::string& actionDisplayPrefix();
|
|
||||||
|
|
||||||
// The binary/module OUTPUT NAME base. Stable: "reaper_reasampler". Beta:
|
|
||||||
// "reaper_reasampler_beta". Mirrors the CMake OUTPUT_NAME (which is the authoritative
|
|
||||||
// artifact name); exposed here for any in-binary self-identification. REAPER dlopen's
|
|
||||||
// any reaper_* module, so both channels load side-by-side.
|
|
||||||
const std::string& binaryName();
|
|
||||||
|
|
||||||
// The docked bank-panel identity strings, channel-qualified so the two panels are
|
|
||||||
// distinguishable and do not fight over one persisted dock slot (a REAPER-global
|
|
||||||
// collision surface — DockWindowAddEx's identstr keys the saved dock position).
|
|
||||||
// dockTitle() — the visible dock tab title. Stable: "ReaSampler Bank".
|
|
||||||
// Beta: "ReaSampler Bank beta".
|
|
||||||
// dockIdent() — the persisted dock-position ident. Stable: "reasampler_bank_panel".
|
|
||||||
// Beta: "reasampler_bank_panel_beta". FOREVER-STABLE per channel.
|
|
||||||
const std::string& dockTitle();
|
|
||||||
const std::string& dockIdent();
|
|
||||||
|
|
||||||
// --- VST3 instrument identity (S18, beta-in-isolation) ------------------------------
|
|
||||||
//
|
|
||||||
// The ReaSampler 9000 VST3 instrument forks its plugin identity per channel exactly as the
|
|
||||||
// extension forks its binary/dock idents above — one channel per binary, all derived from
|
|
||||||
// the ONE channel bit here, so the VST shell carries no #ifdef fork. These are the VST's
|
|
||||||
// analogues of binaryName()/dockTitle(): the on-disk module name and the human-facing name.
|
|
||||||
//
|
|
||||||
// vstOutputName() — the CMake OUTPUT_NAME base for the .vst3 module. Stable:
|
|
||||||
// "reasampler_9000" (byte-identical to pre-S18). Beta:
|
|
||||||
// "reasampler_9000_beta". Mirrors the CMake target's OUTPUT_NAME (the
|
|
||||||
// authoritative artifact name); exposed here so the one derivation lives
|
|
||||||
// in this module. FOREVER-STABLE per channel — the on-disk filename a
|
|
||||||
// REAPER project's saved instance path may reference.
|
|
||||||
// vstPluginName() — the factory display name (FX browser), editor title band, and S6
|
|
||||||
// embed-strip label. Stable: "ReaSampler 9000". Beta:
|
|
||||||
// "ReaSampler 9000 beta". Sourced from here, never a literal in
|
|
||||||
// reasampler_vst.h / vst_entry.cpp / the editor / the embed strip.
|
|
||||||
//
|
|
||||||
// NOTE: the VST3 CLASS UID is NOT here — a UID is not a string derivation but a compile-time
|
|
||||||
// FUID/INLINE_UID constant the factory needs in brace-init form; it lives in reasampler_vst.h,
|
|
||||||
// channel-selected by the same REASAMPLER_CHANNEL_IS_BETA bit. This module owns the string
|
|
||||||
// identity; reasampler_vst.h owns the binary UID identity. The version display the factory
|
|
||||||
// stamps into PClassInfo2 reuses appVersion() (it already renders "-beta" on beta) — no
|
|
||||||
// separate VST version accessor.
|
|
||||||
const std::string& vstOutputName();
|
|
||||||
const std::string& vstPluginName();
|
|
||||||
|
|
||||||
// --- Channel-qualified action id / name builders ------------------------------------
|
|
||||||
//
|
|
||||||
// The two composition helpers every action-registering shell (main.cpp, actions.cpp)
|
|
||||||
// funnels through, so command ids and Actions-list names are qualified IDENTICALLY on
|
|
||||||
// every channel from ONE definition — no shell re-implements the concatenation.
|
|
||||||
//
|
|
||||||
// channelCommandId(suffix): commandIdPrefix() + suffix. `suffix` is the per-action tail
|
|
||||||
// WITHOUT the family prefix (e.g. "CAPTURE_TRACK", "SHOW_VERSION"). Stable yields the
|
|
||||||
// exact shipped id ("CEREBELLUM_REASAMPLER_CAPTURE_TRACK"); beta yields the isolated
|
|
||||||
// forever-family id ("CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK"). FOREVER-STABLE per
|
|
||||||
// channel — a suffix, once shipped, is as permanent as the prefix.
|
|
||||||
// channelActionName(phrase): actionDisplayPrefix() + phrase. `phrase` is the action's
|
|
||||||
// human description WITHOUT the "ReaSampler: " lead (e.g. "capture selected track(s)").
|
|
||||||
// Stable yields "ReaSampler: capture selected track(s)"; beta prefixes "ReaSampler beta: "
|
|
||||||
// so the two channels' actions are distinguishable in one Actions list.
|
|
||||||
std::string channelCommandId(const std::string& suffix);
|
|
||||||
std::string channelActionName(const std::string& phrase);
|
|
||||||
|
|
||||||
// A parsed semver triple. Kept minimal — major.minor.patch as integers, for ORDERING
|
|
||||||
// only. It deliberately does NOT round-trip back to the display string (the leading
|
|
||||||
// zero is a rendering concern owned by the authoritative string, not reconstructable
|
|
||||||
// from the integer patch). parseVersion returns nullopt on malformed input.
|
|
||||||
struct Version {
|
|
||||||
int major = 0;
|
|
||||||
int minor = 0;
|
|
||||||
int patch = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse "x.y.z" (each component a non-negative integer, leading zeros allowed) into a
|
|
||||||
// Version. Returns nullopt on anything malformed: wrong component count, non-digits, a
|
|
||||||
// leading `-`, empty components, or trailing garbage. Used for ordering two stamps and
|
|
||||||
// for validating a stored stamp before comparing.
|
|
||||||
std::optional<Version> parseVersion(const std::string& s);
|
|
||||||
|
|
||||||
// Numeric ordering by (major, minor, patch). a < b iff a precedes b. So
|
|
||||||
// 0.9.01 < 0.9.02 < 0.10.01 (numeric compare, NOT lexicographic — 10 > 9).
|
|
||||||
bool versionLess(const Version& a, const Version& b);
|
|
||||||
|
|
||||||
// The writing-version a project was last saved with, recovered from its ext-state
|
|
||||||
// stamp. A project saved before this feature shipped has NO stamp — that is the
|
|
||||||
// explicit `preVersioning` case (kind), not an error and not a warning. A present-but-
|
|
||||||
// malformed value is `unknown` (also silent — a corrupt stamp is ignored, never
|
|
||||||
// throws). A well-formed value is `stamped` and carries the exact stored string plus
|
|
||||||
// its parsed triple for comparison.
|
|
||||||
struct WritingVersion {
|
|
||||||
enum class Kind {
|
|
||||||
PreVersioning, // no stamp stored — a project written before versioning shipped
|
|
||||||
Unknown, // a stamp was stored but is not parseable — ignored, not an error
|
|
||||||
Stamped, // a well-formed stamp
|
|
||||||
};
|
|
||||||
Kind kind = Kind::PreVersioning;
|
|
||||||
std::string raw; // the exact stored string (empty for PreVersioning)
|
|
||||||
Version parsed; // meaningful only when kind == Stamped
|
|
||||||
};
|
|
||||||
|
|
||||||
// Classify a raw stored stamp value (exactly what GetProjExtState returned for the
|
|
||||||
// version key). Empty -> PreVersioning; non-empty but unparseable -> Unknown; parseable
|
|
||||||
// -> Stamped. Pure so the three-way classification is test-pinned; persist calls this
|
|
||||||
// with the raw ext-state read and never has to reason about the cases itself.
|
|
||||||
WritingVersion classifyWritingVersion(const std::string& rawStamp);
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
// assignment_request.cpp — see assignment_request.h. Pure: standard library only.
|
|
||||||
|
|
||||||
#include "assignment_request.h"
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <limits>
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
constexpr const char* kMagic = "rsassign1";
|
|
||||||
|
|
||||||
// Append one length-prefixed field: <decimal-len> ':' <bytes>. Mirror of
|
|
||||||
// provenance's putField so the two seams share one wire idiom.
|
|
||||||
void putField(std::string& out, const std::string& field) {
|
|
||||||
out += std::to_string(field.size());
|
|
||||||
out += ':';
|
|
||||||
out += field;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor over the encoded string. All reads are bounds-checked; a short read fails
|
|
||||||
// the whole parse (ok_ latches false). Mirror of provenance's Cursor, trimmed to the
|
|
||||||
// three field kinds this record needs.
|
|
||||||
class Cursor {
|
|
||||||
public:
|
|
||||||
explicit Cursor(const std::string& s) : s_(s) {}
|
|
||||||
|
|
||||||
bool ok() const { return ok_; }
|
|
||||||
bool atEnd() const { return pos_ >= s_.size(); }
|
|
||||||
|
|
||||||
// Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or
|
|
||||||
// non-numeric length, a length that overflows SIZE_MAX, or a length that runs past
|
|
||||||
// the end. The digit count is capped at 20 (the decimal width of SIZE_MAX on a
|
|
||||||
// 64-bit host) so a crafted 200-digit length cannot accumulate past SIZE_MAX via
|
|
||||||
// repeated multiply. "never UB" promise from the header is upheld here.
|
|
||||||
bool field(std::string& out) {
|
|
||||||
if (!ok_) return false;
|
|
||||||
const std::size_t colon = s_.find(':', pos_);
|
|
||||||
if (colon == std::string::npos) return fail();
|
|
||||||
if (colon == pos_) return fail(); // empty length token
|
|
||||||
// Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus.
|
|
||||||
if (colon - pos_ > 20u) return fail();
|
|
||||||
std::size_t len = 0;
|
|
||||||
for (std::size_t i = pos_; i < colon; ++i) {
|
|
||||||
const char c = s_[i];
|
|
||||||
if (c < '0' || c > '9') return fail();
|
|
||||||
const std::size_t digit = static_cast<std::size_t>(c - '0');
|
|
||||||
// Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail.
|
|
||||||
if (len > (std::numeric_limits<std::size_t>::max() - digit) / 10u)
|
|
||||||
return fail();
|
|
||||||
len = len * 10u + digit;
|
|
||||||
}
|
|
||||||
const std::size_t start = colon + 1;
|
|
||||||
// Guard: start may equal s_.size() (empty remainder), in which case only len==0
|
|
||||||
// is valid; start > s_.size() cannot happen (colon < s_.size() by find()).
|
|
||||||
// Use subtraction-first form to avoid start+len wrapping on a huge len.
|
|
||||||
if (start > s_.size() || len > s_.size() - start) return fail();
|
|
||||||
out.assign(s_, start, len);
|
|
||||||
pos_ = start + len;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads a length-prefixed field and parses it as a signed 64-bit decimal (an
|
|
||||||
// optional leading '-'). Fails on empty, non-digit, trailing bytes, or a value
|
|
||||||
// that would overflow INT64_MAX / underflow INT64_MIN. The digit count is capped
|
|
||||||
// at 19 (the decimal width of INT64_MAX, plus 1 for the optional sign = 20
|
|
||||||
// characters maximum) so a crafted 21-digit field cannot accumulate UB. "never UB"
|
|
||||||
// promise from the header is upheld: all arithmetic is done on positive digits
|
|
||||||
// and capped before applying the sign.
|
|
||||||
bool fieldInt64(std::int64_t& out) {
|
|
||||||
std::string f;
|
|
||||||
if (!field(f)) return false;
|
|
||||||
if (f.empty()) return fail();
|
|
||||||
std::size_t i = 0;
|
|
||||||
bool neg = false;
|
|
||||||
if (f[0] == '-') {
|
|
||||||
neg = true;
|
|
||||||
i = 1;
|
|
||||||
if (f.size() == 1) return fail(); // bare "-"
|
|
||||||
}
|
|
||||||
// Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit
|
|
||||||
// positive value would overflow INT64_MAX; a 20-digit negative might be valid
|
|
||||||
// (INT64_MIN = -9223372036854775808) but we conservatively reject it too: the
|
|
||||||
// generation field is a unix timestamp, never near INT64 limits in practice.
|
|
||||||
if (f.size() - i > 19u) return fail();
|
|
||||||
std::int64_t v = 0;
|
|
||||||
for (; i < f.size(); ++i) {
|
|
||||||
const char c = f[i];
|
|
||||||
if (c < '0' || c > '9') return fail();
|
|
||||||
const std::int64_t digit = static_cast<std::int64_t>(c - '0');
|
|
||||||
// Overflow guard: v * 10 + digit must not exceed INT64_MAX.
|
|
||||||
if (v > (std::numeric_limits<std::int64_t>::max() - digit) / 10)
|
|
||||||
return fail();
|
|
||||||
v = v * 10 + digit;
|
|
||||||
}
|
|
||||||
out = neg ? -v : v;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consumes an exact literal at the cursor (the magic tag). Fails if absent.
|
|
||||||
bool literal(const char* lit) {
|
|
||||||
if (!ok_) return false;
|
|
||||||
std::size_t i = 0;
|
|
||||||
for (; lit[i] != '\0'; ++i) {
|
|
||||||
if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail();
|
|
||||||
}
|
|
||||||
pos_ += i;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
bool fail() {
|
|
||||||
ok_ = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const std::string& s_;
|
|
||||||
std::size_t pos_ = 0;
|
|
||||||
bool ok_ = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
std::string encodeAssignmentRequest(const AssignmentRequest& req) {
|
|
||||||
std::string out = kMagic;
|
|
||||||
putField(out, req.bankId);
|
|
||||||
putField(out, req.sampleId);
|
|
||||||
putField(out, std::to_string(req.generation));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::optional<AssignmentRequest> decodeAssignmentRequest(const std::string& wire) {
|
|
||||||
Cursor cur(wire);
|
|
||||||
if (!cur.literal(kMagic)) return std::nullopt;
|
|
||||||
|
|
||||||
AssignmentRequest req;
|
|
||||||
if (!cur.field(req.bankId)) return std::nullopt;
|
|
||||||
if (!cur.field(req.sampleId)) return std::nullopt;
|
|
||||||
if (!cur.fieldInt64(req.generation)) return std::nullopt;
|
|
||||||
|
|
||||||
// Reject trailing garbage: a well-formed value ends exactly at the last field.
|
|
||||||
if (!cur.ok() || !cur.atEnd()) return std::nullopt;
|
|
||||||
return req;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// assignment_request — the pure core of the S8 ingest assignment-request seam.
|
|
||||||
//
|
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3,
|
|
||||||
// NO vendor/ includes. Standard library only. Unit-tested outside the DAW — the same
|
|
||||||
// "small pure type + length-prefixed round-trip" pattern as provenance / owned_manifest.
|
|
||||||
//
|
|
||||||
// -- What it is --------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// When the EXTENSION ingests a sample (S8: arrange capture / Media-Explorer import /
|
|
||||||
// drop-onto-panel) it writes an ASSIGNMENT REQUEST to its own "reasampler" ext-state
|
|
||||||
// namespace: "the active sampler instance should now play THIS sample." The value
|
|
||||||
// names the ingested sample by (bankId, sampleId) plus a monotonic `generation` the
|
|
||||||
// reader compares to decide the request is NEW (a fresh ingest, even of the same id).
|
|
||||||
//
|
|
||||||
// This module owns ONLY the value's WIRE FORMAT — build/parse round-trip. Writing it
|
|
||||||
// to ext-state is the persist shell's job; READING it is the instrument's job in a
|
|
||||||
// LATER dispatch (S8 instrument-side follow-up, after S10 merges). This is why the
|
|
||||||
// format is documented here in the header, not just in code: the reader lands elsewhere
|
|
||||||
// and must decode exactly what this writer produced.
|
|
||||||
//
|
|
||||||
// -- The data-ownership boundary (load-bearing) ------------------------------
|
|
||||||
//
|
|
||||||
// The EXTENSION writes this; the instrument only READS it. That does not violate the
|
|
||||||
// instrument's read-only-over-the-bank rule: the assignment request is the extension
|
|
||||||
// writing its OWN namespace (a request FROM the extension TO the instrument), never the
|
|
||||||
// instrument writing back into the bank. The instrument, on reading a new generation,
|
|
||||||
// updates its OWN component-state selection (the same selection S4 persists) and reloads.
|
|
||||||
//
|
|
||||||
// -- Why `generation` -------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Instances reference sample IDs, so re-assigning the SAME id (e.g. a recapture, or a
|
|
||||||
// re-drop of the same file) would be indistinguishable from a stale value without a
|
|
||||||
// changing field. `generation` is a monotonic disambiguator (the ingest writer supplies
|
|
||||||
// a wall-clock unix-epoch stamp today — see the writer shell) so the reader can tell
|
|
||||||
// "assigned again just now" from "already saw this." It is DELIBERATELY the same shape
|
|
||||||
// the S9 bank-generation counter will use, but it is NOT that counter — S9 is a separate
|
|
||||||
// point; this field is self-contained to the request and does not depend on S9 landing.
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <optional>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// One assignment request: the ingested sample's identity + a monotonic disambiguator.
|
|
||||||
// bankId — the bank the sample was ingested into (the active/target bank).
|
|
||||||
// sampleId — the ingested Sample's stable id (BankIndex key).
|
|
||||||
// generation — a monotonic value the reader compares to detect a NEW request. The
|
|
||||||
// writer supplies a unix-epoch-seconds stamp; the reader treats it as an
|
|
||||||
// opaque "did this change?" token, not a wall-clock it interprets.
|
|
||||||
struct AssignmentRequest {
|
|
||||||
std::string bankId;
|
|
||||||
std::string sampleId;
|
|
||||||
std::int64_t generation = 0;
|
|
||||||
|
|
||||||
bool operator==(const AssignmentRequest& o) const {
|
|
||||||
return bankId == o.bankId && sampleId == o.sampleId &&
|
|
||||||
generation == o.generation;
|
|
||||||
}
|
|
||||||
bool operator!=(const AssignmentRequest& o) const { return !(*this == o); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Encode an assignment request to the wire string. Length-prefixed fields behind a
|
|
||||||
// magic+version tag ("rsassign1"), so arbitrary bytes in an id (a GUID, a display-
|
|
||||||
// derived id) round-trip whole with no escaping ambiguity — the same idiom provenance
|
|
||||||
// uses. Deterministic: the same request always yields the same string.
|
|
||||||
//
|
|
||||||
// FORMAT (documented for the LATER instrument-side reader):
|
|
||||||
// "rsassign1" <len>':'<bankId> <len>':'<sampleId> <len>':'<generation-decimal>
|
|
||||||
// where each <len> is the decimal byte length of the field that follows the ':'.
|
|
||||||
std::string encodeAssignmentRequest(const AssignmentRequest& req);
|
|
||||||
|
|
||||||
// Parse a wire string produced by encodeAssignmentRequest. std::nullopt on any
|
|
||||||
// malformed / truncated / trailing-garbage input (never UB, never a partial value) —
|
|
||||||
// the reader shell treats absence/malformed as "no pending request." Round-trips:
|
|
||||||
// decodeAssignmentRequest(encodeAssignmentRequest(x)) == x.
|
|
||||||
//
|
|
||||||
// READER REQUIREMENT (instrument-side, S8 follow-up dispatch): after successfully
|
|
||||||
// decoding a request, the reader MUST verify that (bankId, sampleId) resolves to an
|
|
||||||
// existing sample before acting on it. An undo on the extension side rolls back the
|
|
||||||
// `banks` ext-state key (removing the sample) but cannot atomically clear the
|
|
||||||
// `assign_request` key if the write happened outside the undo block. Even with the
|
|
||||||
// undo-grouping fix (Major 2), the reader must guard against this: treat an
|
|
||||||
// unresolvable (bankId, sampleId) pair as a stale/no-op request and discard it
|
|
||||||
// silently, never crashing or selecting a nonexistent entry.
|
|
||||||
std::optional<AssignmentRequest> decodeAssignmentRequest(const std::string& wire);
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
-1109
File diff suppressed because it is too large
Load Diff
-457
@@ -1,457 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free
|
|
||||||
// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the
|
|
||||||
// third instance of the same "pure registry + JSON round-trip, unit-tested outside
|
|
||||||
// the DAW" pattern as bank_model and view_mode_model.
|
|
||||||
//
|
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
|
||||||
// vendor/ includes. Standard library only.
|
|
||||||
//
|
|
||||||
// -- What it is --------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// An ordered registry of banks. Each bank = { stable id, display name, ordinal,
|
|
||||||
// BankIndex }. The book WRAPS N BankIndex instances — bank_model / BankIndex are
|
|
||||||
// UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is
|
|
||||||
// index-only (remove from source's BankIndex, add to destination's); files never
|
|
||||||
// relocate — banks are logical groupings over one shared file pool.
|
|
||||||
//
|
|
||||||
// -- The pool (privileged, not special-cased) --------------------------------
|
|
||||||
//
|
|
||||||
// Structurally the pool is bank-zero — one Bank among many, seeded on construction
|
|
||||||
// with a fixed id (kPoolBankId) and fixed display name (kPoolBankName), ordinal 0.
|
|
||||||
// Semantically it is privileged, and the privileges are enforced HERE in the pure
|
|
||||||
// rules layer (CONTEXT.md §Multi-bank guardrail — not deferred to a shell):
|
|
||||||
// * always exists (seeded on construction; the book never reaches zero banks)
|
|
||||||
// * un-deletable (deleteBank rejects the pool)
|
|
||||||
// * un-renamable (renameBank rejects the pool)
|
|
||||||
// * un-evacuable (evacuate rejects the pool — the pool is evacuation's
|
|
||||||
// destination, not a source)
|
|
||||||
//
|
|
||||||
// -- Id minting is the CALLER'S job (design decision) ------------------------
|
|
||||||
//
|
|
||||||
// createBank takes a caller-supplied stable id, mirroring bank_model's "id
|
|
||||||
// assigned by the caller" and view_mode_model's mode ids. The pure core has no
|
|
||||||
// REAPER genGuid / RNG and deliberately introduces none: a fake in-model id source
|
|
||||||
// would not be a real GUID anyway, and keeping ids caller-supplied lets the B2
|
|
||||||
// shell mint a genuine REAPER GUID while the model stays pure and deterministically
|
|
||||||
// testable. The model still enforces the invariants: non-empty, unique, not the
|
|
||||||
// reserved pool id.
|
|
||||||
|
|
||||||
#include <optional>
|
|
||||||
#include <string>
|
|
||||||
#include <utility>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "bank_model.h"
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// The pool's fixed identity. The id is reserved: createBank rejects it, and the
|
|
||||||
// pool is always bank-zero. The name is fixed: renameBank rejects the pool.
|
|
||||||
inline constexpr const char* kPoolBankId = "pool";
|
|
||||||
inline constexpr const char* kPoolBankName = "Pool";
|
|
||||||
|
|
||||||
// SlotMap — the L7 gap-preserving display-position carrier for ONE bank (F2 settled:
|
|
||||||
// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a
|
|
||||||
// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps
|
|
||||||
// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty
|
|
||||||
// first row above an occupied second row). At most one id per slot (a slot is never
|
|
||||||
// double-occupied) and at most one slot per id (an id sits in exactly one place).
|
|
||||||
//
|
|
||||||
// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one
|
|
||||||
// sample into two banks may sit at different slots, so position is a per-bank display
|
|
||||||
// concern owned by the bank's membership. bank_model / Sample stay untouched.
|
|
||||||
//
|
|
||||||
// PURE: standard library only. Hard-tested to the bar of BankIndex's round-trip.
|
|
||||||
class SlotMap {
|
|
||||||
public:
|
|
||||||
// The slot an id occupies, or -1 if the id is not mapped. O(N).
|
|
||||||
int slotOf(const std::string& id) const;
|
|
||||||
|
|
||||||
// The id occupying `slot`, or "" if the slot is empty. O(N).
|
|
||||||
std::string idAt(int slot) const;
|
|
||||||
|
|
||||||
// The highest occupied slot, or -1 when the map is empty. Defines the append
|
|
||||||
// frontier and (with trailing-empty trim) the content extent.
|
|
||||||
int maxSlot() const;
|
|
||||||
|
|
||||||
// Ids in ASCENDING slot order (the deterministic display order). Empty slots
|
|
||||||
// produce no entry — the caller iterates occupants; sparse layout is a draw
|
|
||||||
// concern that reads slotOf/idAt, not this list.
|
|
||||||
std::vector<std::string> orderedIds() const;
|
|
||||||
|
|
||||||
// Places `id` at the next free slot after the last occupied one (append). If the
|
|
||||||
// id is already mapped it is first removed (leaving its old slot empty), then
|
|
||||||
// appended — an append never fills an earlier gap. No-op guard: empty id ignored.
|
|
||||||
void append(const std::string& id);
|
|
||||||
|
|
||||||
// Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id
|
|
||||||
// keeps its position. Returns true if the id was mapped.
|
|
||||||
bool remove(const std::string& id);
|
|
||||||
|
|
||||||
// Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics):
|
|
||||||
// * target slot EMPTY -> `id` moves there; its old slot is left empty.
|
|
||||||
// * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and
|
|
||||||
// every occupant at slot >= targetSlot (except `id` itself) shifts up by one,
|
|
||||||
// preserving their relative order and never colliding. Matches file-manager
|
|
||||||
// reorder. Interior gaps between shifted occupants are preserved as-is
|
|
||||||
// (shift is +1 on each occupant, so the gap structure above the target is kept).
|
|
||||||
// * negative targetSlot is clamped to 0.
|
|
||||||
// Returns false (no mutation) if `id` is not mapped. Deterministic.
|
|
||||||
bool reorder(const std::string& id, int targetSlot);
|
|
||||||
|
|
||||||
// Rebuilds the map densely from `ids` in the given order (slot i = ids[i]),
|
|
||||||
// dropping any prior state. The migration path: a pre-L7 bank with no persisted
|
|
||||||
// slot data is seeded from its BankIndex insertion order, densely packed (no gaps),
|
|
||||||
// so it is visually identical on first post-L7 load. Empty/duplicate ids skipped.
|
|
||||||
void resetDense(const std::vector<std::string>& ids);
|
|
||||||
|
|
||||||
// Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left
|
|
||||||
// the index) and appends any live id that has NO mapping yet (a sample the index
|
|
||||||
// gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps
|
|
||||||
// the map consistent with the bank's membership without a re-pack. Deterministic:
|
|
||||||
// orphan appends follow `liveIds` order.
|
|
||||||
void reconcile(const std::vector<std::string>& liveIds);
|
|
||||||
|
|
||||||
bool empty() const { return entries_.empty(); }
|
|
||||||
std::size_t size() const { return entries_.size(); }
|
|
||||||
|
|
||||||
bool operator==(const SlotMap& o) const;
|
|
||||||
|
|
||||||
// JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the
|
|
||||||
// bank envelope's "slots" member by BankBook::serialize; parsed back by its parser.
|
|
||||||
// Round-trips losslessly with the rest of the bank.
|
|
||||||
std::string serialize() const;
|
|
||||||
|
|
||||||
// Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces
|
|
||||||
// the map invariants defensively against a hand-edited blob: a duplicate id keeps
|
|
||||||
// its FIRST occurrence; a slot already taken by a kept id drops the later pair
|
|
||||||
// (never double-occupies); an empty id or negative slot is dropped. The result is
|
|
||||||
// sorted ascending by slot. reconcile() against live membership runs afterward, so
|
|
||||||
// a lossy repair here degrades gracefully rather than corrupting lookup.
|
|
||||||
static SlotMap fromEntries(const std::vector<std::pair<std::string, int>>& pairs);
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct Entry {
|
|
||||||
std::string id;
|
|
||||||
int slot = 0;
|
|
||||||
bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; }
|
|
||||||
};
|
|
||||||
std::vector<Entry> entries_; // kept sorted ascending by slot (invariant)
|
|
||||||
|
|
||||||
void sortBySlot();
|
|
||||||
};
|
|
||||||
|
|
||||||
// One bank: a stable id, a display name, an ordinal (tab/display order), and its
|
|
||||||
// own BankIndex. The pool is the bank whose id == kPoolBankId.
|
|
||||||
struct Bank {
|
|
||||||
std::string id; // stable, persisted; the pool's is kPoolBankId
|
|
||||||
std::string displayName; // mutable for named banks; fixed "Pool" for the pool
|
|
||||||
int ordinal = 0; // display order; pool is 0, named banks 1..N
|
|
||||||
BankIndex index; // this bank's samples
|
|
||||||
SlotMap slots; // L7 display positions of this bank's samples (gap-preserving)
|
|
||||||
|
|
||||||
bool isPool() const { return id == kPoolBankId; }
|
|
||||||
|
|
||||||
bool operator==(const Bank& o) const {
|
|
||||||
return id == o.id && displayName == o.displayName &&
|
|
||||||
ordinal == o.ordinal && index == o.index && slots == o.slots;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Outcome of a cross-bank sample move/copy. Mirrors AddResult's honesty: the op
|
|
||||||
// reports what happened rather than silently mutating on a bad request.
|
|
||||||
// - Moved / Copied: the sample was transferred to the destination as a new entry.
|
|
||||||
// - Collapsed: the destination already held the hash; it collapsed onto the
|
|
||||||
// existing entry (a no-op add on the destination side). For a
|
|
||||||
// MOVE the source entry is STILL removed; for a COPY the source
|
|
||||||
// entry is (as always) retained.
|
|
||||||
// - RejectedUnknownBank: a source or destination id named no bank.
|
|
||||||
// - RejectedSampleAbsent: the sample id was not in the source bank.
|
|
||||||
// - RejectedSameBank: source and destination were the same bank (no-op).
|
|
||||||
enum class TransferResult {
|
|
||||||
Moved,
|
|
||||||
Copied,
|
|
||||||
Collapsed,
|
|
||||||
RejectedUnknownBank,
|
|
||||||
RejectedSampleAbsent,
|
|
||||||
RejectedSameBank,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default
|
|
||||||
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam —
|
|
||||||
// live and tested at the model level, promotable later behind this parameter without
|
|
||||||
// a rewrite, but never wired to an affordance in B5.
|
|
||||||
// - ThisBank: drop the entry from the one named source bank only. A same-hash entry
|
|
||||||
// in another bank survives (no cross-bank cascade — dedup is per-bank).
|
|
||||||
// - AllBanks: drop the sample's entry from EVERY bank that holds the source id
|
|
||||||
// ("purge from the library"). Latent; unsurfaced.
|
|
||||||
enum class RemoveScope {
|
|
||||||
ThisBank,
|
|
||||||
AllBanks,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports
|
|
||||||
// what happened rather than silently mutating on a bad request.
|
|
||||||
// - Removed: at least one index entry was dropped.
|
|
||||||
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
|
|
||||||
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed).
|
|
||||||
enum class RemoveResult {
|
|
||||||
Removed,
|
|
||||||
RejectedUnknownBank,
|
|
||||||
RejectedSampleAbsent,
|
|
||||||
};
|
|
||||||
|
|
||||||
// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample
|
|
||||||
// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the
|
|
||||||
// multi-bank phase — mirror of bank_model / view_mode_model.
|
|
||||||
class BankBook {
|
|
||||||
public:
|
|
||||||
BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
|
|
||||||
// active bank = pool; zero named banks.
|
|
||||||
|
|
||||||
// -- Bank lifecycle ------------------------------------------------------
|
|
||||||
|
|
||||||
// Creates a named bank with the caller-supplied stable id and display name,
|
|
||||||
// assigning the next ordinal. Rejects (returns false, no mutation) an empty id,
|
|
||||||
// a duplicate id, the reserved pool id, or a display name that duplicates an
|
|
||||||
// existing bank's name (including the pool's "Pool"). Display-name uniqueness is
|
|
||||||
// trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide.
|
|
||||||
bool createBank(const std::string& id, const std::string& displayName);
|
|
||||||
|
|
||||||
// Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or a
|
|
||||||
// target name already used by a DIFFERENT bank (trimmed + case-insensitive, as
|
|
||||||
// createBank). Renaming a bank to its own current name is a no-op success.
|
|
||||||
bool renameBank(const std::string& id, const std::string& displayName);
|
|
||||||
|
|
||||||
// Deletes a NAMED bank, removing it (and its member index entries) from the
|
|
||||||
// registry. Files are a shell/prune concern and are NOT touched here. Rejects
|
|
||||||
// (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are
|
|
||||||
// compacted so the pool stays 0 and named banks stay contiguous 1..N. If the
|
|
||||||
// deleted bank was active, the active bank falls back to the pool.
|
|
||||||
bool deleteBank(const std::string& id);
|
|
||||||
|
|
||||||
// Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range),
|
|
||||||
// shifting the others to keep ordinals contiguous. The pool is pinned at 0 and
|
|
||||||
// cannot be reordered. Rejects (false, no mutation) an unknown id or the pool.
|
|
||||||
bool reorderBank(const std::string& id, int newOrdinal);
|
|
||||||
|
|
||||||
// Moves EVERY member of a named bank into the pool (index-only, observing the
|
|
||||||
// same destination-collapse-by-hash as a move), leaving the bank empty. Rejects
|
|
||||||
// (false, no mutation) an unknown id or the pool (the pool is the destination,
|
|
||||||
// never a source). Returns true on success even if the bank was already empty.
|
|
||||||
bool evacuate(const std::string& id);
|
|
||||||
|
|
||||||
// -- Active bank ---------------------------------------------------------
|
|
||||||
|
|
||||||
// The active bank's id (the capture target). Defaults to the pool.
|
|
||||||
const std::string& activeBankId() const { return activeBankId_; }
|
|
||||||
|
|
||||||
// Sets the active bank. Rejects (returns false, no change) an id that names no
|
|
||||||
// bank — an invalid set never corrupts state.
|
|
||||||
bool setActiveBank(const std::string& id);
|
|
||||||
|
|
||||||
// The active bank's BankIndex — the index the capture layer adds to. Always
|
|
||||||
// valid (the active id always names a live bank; it falls back to the pool).
|
|
||||||
BankIndex& activeIndex();
|
|
||||||
const BankIndex& activeIndex() const;
|
|
||||||
|
|
||||||
// -- Sample movement (index-only; files never relocate) ------------------
|
|
||||||
|
|
||||||
// Moves a sample by id from `fromBankId` to `toBankId`: removes it from the
|
|
||||||
// source index and adds it to the destination (observing destination
|
|
||||||
// collapse-by-hash). See TransferResult for the full outcome set.
|
|
||||||
TransferResult moveSample(const std::string& sampleId,
|
|
||||||
const std::string& fromBankId,
|
|
||||||
const std::string& toBankId);
|
|
||||||
|
|
||||||
// Copies a sample by id from `fromBankId` to `toBankId`: the source entry is
|
|
||||||
// retained, the destination gains it (observing destination collapse-by-hash).
|
|
||||||
// Same hash may then live in both banks — cross-bank dedup is NOT enforced.
|
|
||||||
TransferResult copySample(const std::string& sampleId,
|
|
||||||
const std::string& fromBankId,
|
|
||||||
const std::string& toBankId);
|
|
||||||
|
|
||||||
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) --
|
|
||||||
|
|
||||||
// Drops a sample's index entry (the sample-level sibling of move/copy/evacuate).
|
|
||||||
// Index-only and non-destructive to the file: a last-reference remove leaves the
|
|
||||||
// file on disk, orphaned until Phase R prune — remove NEVER deletes bytes.
|
|
||||||
//
|
|
||||||
// Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from
|
|
||||||
// `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank
|
|
||||||
// that holds it. See RemoveResult for the outcome set.
|
|
||||||
// * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent
|
|
||||||
// if that bank does not hold the id; Removed on a drop.
|
|
||||||
// * AllBanks: `fromBankId` is ignored (the id is purged book-wide);
|
|
||||||
// RejectedSampleAbsent if NO bank held the id; Removed otherwise.
|
|
||||||
// No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer).
|
|
||||||
RemoveResult removeSample(const std::string& sampleId,
|
|
||||||
const std::string& fromBankId,
|
|
||||||
RemoveScope scope = RemoveScope::ThisBank);
|
|
||||||
|
|
||||||
// -- Sample display order (L7; index membership untouched) ---------------
|
|
||||||
|
|
||||||
// The bank's sample ids in DISPLAY (slot) order — the deterministic order the grid
|
|
||||||
// iterates, sourced from the bank's SlotMap. Reconciles the map against live index
|
|
||||||
// membership first (drops stale markers, appends unmapped samples densely), so a
|
|
||||||
// freshly-migrated or out-of-band-mutated bank always yields a complete order. An
|
|
||||||
// unknown bank id yields an empty vector. Const-logical but reconciles lazily, so
|
|
||||||
// it is a non-const member.
|
|
||||||
std::vector<std::string> orderedSampleIds(const std::string& bankId);
|
|
||||||
|
|
||||||
// Ensures every bank's SlotMap is consistent with its index membership: seeds a
|
|
||||||
// map that has NO overlap with its index from insertion order (the pre-L7 migration
|
|
||||||
// default — dense, no gaps), and reconciles a partially-populated map (drop stale,
|
|
||||||
// append unmapped). Idempotent. Called after deserialize and after any capture/
|
|
||||||
// transfer that added samples out-of-band of the L7 reorder path.
|
|
||||||
void reconcileSlots();
|
|
||||||
|
|
||||||
// Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see
|
|
||||||
// SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and
|
|
||||||
// metadata are untouched (capture != placement holds). Reconciles the bank's slots
|
|
||||||
// first so the target space is complete. Returns false (no mutation) on an unknown
|
|
||||||
// bank or an id the bank does not hold.
|
|
||||||
bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot);
|
|
||||||
|
|
||||||
// Alt-replace (L7 F3): the dragged sample `newId` (already a member of `bankId`)
|
|
||||||
// takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s
|
|
||||||
// index (index-only, same semantics as removeSample ThisBank — the file stays on
|
|
||||||
// disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the
|
|
||||||
// last-reference case). Position of the slot is preserved; only its occupant changes.
|
|
||||||
//
|
|
||||||
// POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the
|
|
||||||
// remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed.
|
|
||||||
// For the pool this is permitted whenever the occupant exists (per-sample removal
|
|
||||||
// is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate,
|
|
||||||
// never per-sample remove). If the removal would be rejected (occupant absent), the
|
|
||||||
// whole replace is rejected: false, NO mutation (neither the index nor the slots
|
|
||||||
// change), so the shell can fall back to the default insert-shift or a no-op.
|
|
||||||
// Rejects (false, no mutation) an unknown bank, a `newId`/`oldId` the bank does not
|
|
||||||
// hold, or `newId == oldId`. NEVER touches disk; introduces no new deletion authority.
|
|
||||||
bool replaceSample(const std::string& newId, const std::string& oldId,
|
|
||||||
const std::string& bankId);
|
|
||||||
|
|
||||||
// Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture):
|
|
||||||
// finds the bank holding `sampleId` and replaces its entry with `updated`
|
|
||||||
// (order-preserving, no dedup — see BankIndex::updateInPlace). Scans banks in
|
|
||||||
// ordinal order and updates the FIRST holder (a sample id is unique within a
|
|
||||||
// bank; the same id living in two banks via copy would update the earliest, which
|
|
||||||
// is acceptable — re-capture operates on the panel's focused single selection).
|
|
||||||
// Returns false (no mutation) if no bank holds the id or the replacement's path
|
|
||||||
// is absolute. Index-only and non-destructive to the timeline.
|
|
||||||
bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);
|
|
||||||
|
|
||||||
// Reference-count query backing the confirm-on-last-reference guardrail: does any
|
|
||||||
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
|
|
||||||
//
|
|
||||||
// Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key
|
|
||||||
// the whole model already reasons in (findByHash / collapse-by-hash), and two
|
|
||||||
// entries that share content share one file — so "some other bank still references
|
|
||||||
// this hash" is exactly "removing here does not orphan the file." An EMPTY hash is
|
|
||||||
// never matched (it does not participate in dedup, mirroring findByHash), so an
|
|
||||||
// empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting
|
|
||||||
// direction (we cannot prove another bank shares an unhashed file).
|
|
||||||
bool hashReferencedElsewhere(const std::string& hash,
|
|
||||||
const std::string& exceptBankId) const;
|
|
||||||
|
|
||||||
// Every project-relative file path referenced by ANY bank in the book, pool
|
|
||||||
// included — the union across the whole book (Phase R, prune). This is the
|
|
||||||
// safety-critical referenced-set the prune core subtracts: a file referenced by
|
|
||||||
// any bank (INCLUDING via a copy into a second bank) appears here, so prune never
|
|
||||||
// reclaims it. Paths are returned VERBATIM (Sample.relativePath, exact strings —
|
|
||||||
// no normalization), first-seen order across banks in ordinal order then sample
|
|
||||||
// insertion order, and DE-DUPLICATED (one file referenced by N banks appears
|
|
||||||
// once). An empty relativePath is skipped (it references no file). Additive
|
|
||||||
// read-only query; adds no mutation and no coupling to Phase R.
|
|
||||||
std::vector<std::string> referencedPaths() const;
|
|
||||||
|
|
||||||
// -- Query ---------------------------------------------------------------
|
|
||||||
|
|
||||||
// The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
|
|
||||||
Bank* bank(const std::string& id);
|
|
||||||
const Bank* bank(const std::string& id) const;
|
|
||||||
|
|
||||||
// The bank's BankIndex by id, or nullptr. Convenience over bank()->index.
|
|
||||||
BankIndex* index(const std::string& id);
|
|
||||||
const BankIndex* index(const std::string& id) const;
|
|
||||||
|
|
||||||
// The pool (always present). Never null.
|
|
||||||
Bank& pool();
|
|
||||||
const Bank& pool() const;
|
|
||||||
|
|
||||||
// All banks in ordinal order (pool first). The pool is always banks()[0].
|
|
||||||
const std::vector<Bank>& banks() const { return banks_; }
|
|
||||||
|
|
||||||
std::size_t size() const { return banks_.size(); } // >= 1 (the pool)
|
|
||||||
|
|
||||||
bool operator==(const BankBook& o) const {
|
|
||||||
return banks_ == o.banks_ && activeBankId_ == o.activeBankId_;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Persistence ---------------------------------------------------------
|
|
||||||
|
|
||||||
// Serializes the whole book to a JSON string (lossless round-trip): the pool
|
|
||||||
// folded in as bank-zero + named banks + per-bank indices + ordinals + active
|
|
||||||
// id. deserialize(serialize(x)) == x.
|
|
||||||
std::string serialize() const;
|
|
||||||
|
|
||||||
// Parses a book JSON produced by serialize(). std::nullopt on malformed input.
|
|
||||||
//
|
|
||||||
// LEGACY MIGRATION: a bare legacy bank_index JSON (the pre-multi-bank shape, an
|
|
||||||
// object with a "samples" array and no "banks" key) is promoted into the pool's
|
|
||||||
// index, yielding a book of { pool } with zero named banks — one-way, lossless.
|
|
||||||
// After migration the book blob is authoritative (the caller persists the book
|
|
||||||
// shape going forward; the legacy key is retired by the B2 shell).
|
|
||||||
static std::optional<BankBook> deserialize(const std::string& json);
|
|
||||||
|
|
||||||
// Resolve a BankBook from the two persisted ext-state values a project may carry:
|
|
||||||
// the authoritative `banks` blob and the retired-but-possibly-present legacy
|
|
||||||
// `bank_index` blob. The persist shell (B2) hands both raw strings straight here so
|
|
||||||
// the load-source decision stays REAPER-free and unit-tested. Precedence:
|
|
||||||
// 1. non-empty `banksJson` present -> deserialize it (authoritative). If it is
|
|
||||||
// MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks`
|
|
||||||
// blob is an error, not an absence; return an empty book so a stale legacy key
|
|
||||||
// can never resurrect a superseded single-bank state over a broken book.
|
|
||||||
// 2. else non-empty `legacyJson` -> deserialize it (one-way pool migration).
|
|
||||||
// 3. else (both absent/empty) -> a fresh empty book (pool only).
|
|
||||||
// Never returns nullopt: an unloadable input degrades to the empty book (matching
|
|
||||||
// the shell's existing "malformed -> ignore, start empty" behaviour), so the caller
|
|
||||||
// has one branchless install path.
|
|
||||||
static BankBook loadFromPersisted(const std::string& banksJson,
|
|
||||||
const std::string& legacyJson);
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::vector<Bank> banks_; // ordinal order; banks_[0] is always the pool
|
|
||||||
std::string activeBankId_; // always names a live bank; defaults to pool
|
|
||||||
|
|
||||||
// True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key
|
|
||||||
// (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check;
|
|
||||||
// pass exceptId=id to let a bank keep (or re-case/-space) its own name.
|
|
||||||
bool displayNameTaken(const std::string& name, const std::string& exceptId) const;
|
|
||||||
|
|
||||||
// Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a
|
|
||||||
// contiguous 0..N-1 so the pool is 0 and named banks are 1..N. Called after any
|
|
||||||
// structural change (create / delete / reorder).
|
|
||||||
void normalizeOrdinals();
|
|
||||||
|
|
||||||
// Replaces the book's banks with a parsed set, normalizes ordinals, and resolves
|
|
||||||
// the active bank (falling back to the pool if the id names no bank). Used only
|
|
||||||
// by deserialize; kept private so the public surface stays create/rename/etc.
|
|
||||||
void adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The next bank id to activate when cycling the active bank forward, in ordinal
|
|
||||||
// order (the ids arrive pool-first, named 1..N, matching banks()). Wraps: the id
|
|
||||||
// after the last returns the first (pool → named → … → pool). This is the pure
|
|
||||||
// decision behind the "cycle active bank" action — the shell reads the book's
|
|
||||||
// ordered bank ids + current active id, asks for the next, and activates it.
|
|
||||||
// * empty list -> "" (nothing to cycle to)
|
|
||||||
// * single id (pool-only) -> that id (a one-bank book stays put)
|
|
||||||
// * currentBankId not present -> the first id (a sane home to jump to)
|
|
||||||
// Exposed as a free function (not a BankBook member) so it is unit-testable against
|
|
||||||
// a bare id vector without a full book. Mirror of view_mode_model's nextModeId.
|
|
||||||
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
|
|
||||||
const std::string& currentBankId);
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
-186
@@ -1,186 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// bank_grid — the REAPER-free layout math and cache-key logic behind the docked
|
|
||||||
// bank_panel (M5, Wave A). The panel shell (bank_panel.cpp) owns the SWELL window,
|
|
||||||
// LICE drawing, and PCM reads; ALL of that is REAPER-bound and DAW-verified. What
|
|
||||||
// is NOT DAW-bound — how N sample cells tile a panel of a given pixel size, and
|
|
||||||
// the key that identifies a cached thumbnail — lives here so it is unit-tested
|
|
||||||
// outside the DAW (CLAUDE.md §load-bearing split).
|
|
||||||
//
|
|
||||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
|
||||||
// only. Builds and unit-tests without REAPER.
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE
|
|
||||||
// convention). (x, y) is the top-left corner; width/height are the cell extents.
|
|
||||||
// These are the draw bounds for one sample's thumbnail; the panel draws its
|
|
||||||
// waveform envelope inside this rect (minus any internal padding it applies).
|
|
||||||
struct CellRect {
|
|
||||||
int x = 0;
|
|
||||||
int y = 0;
|
|
||||||
int width = 0;
|
|
||||||
int height = 0;
|
|
||||||
|
|
||||||
bool operator==(const CellRect& o) const {
|
|
||||||
return x == o.x && y == o.y && width == o.width && height == o.height;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fixed inputs that shape the grid. All in pixels. cellWidth/cellHeight are the
|
|
||||||
// TARGET cell size; the layout fits as many whole columns as the panel width
|
|
||||||
// allows (>= 1) and wraps to as many rows as N requires. gap is the pixel spacing
|
|
||||||
// between adjacent cells (and the outer margin), so cells never touch.
|
|
||||||
struct GridSpec {
|
|
||||||
int cellWidth = 120;
|
|
||||||
int cellHeight = 72;
|
|
||||||
int gap = 8;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Computes the number of columns that fit in a panel of the given pixel width for
|
|
||||||
// the spec. Always >= 1 (a panel narrower than one cell still shows one column,
|
|
||||||
// clipped by the window). Pure arithmetic — the panel passes its live client
|
|
||||||
// width here and to computeCellRects.
|
|
||||||
int columnsForWidth(int panelWidth, const GridSpec& spec);
|
|
||||||
|
|
||||||
// Tiles `itemCount` cells left-to-right, top-to-bottom into a panel of the given
|
|
||||||
// pixel width, honoring the spec's cell size and gap. Returns exactly itemCount
|
|
||||||
// rects in item order (rect i is sample i). A partial last row is left-aligned
|
|
||||||
// and simply shorter — no centering, no stretching. itemCount == 0 -> empty.
|
|
||||||
// panelWidth is used only to derive the column count; the returned rects may
|
|
||||||
// extend below any fixed viewport height (the panel scrolls/clips in Wave B).
|
|
||||||
std::vector<CellRect> computeCellRects(int itemCount,
|
|
||||||
int panelWidth,
|
|
||||||
const GridSpec& spec);
|
|
||||||
|
|
||||||
// The total pixel height the grid occupies for itemCount cells at the given panel
|
|
||||||
// width and spec (top margin + rows*cellHeight + inter-row gaps + bottom margin).
|
|
||||||
// 0 when itemCount == 0. The panel uses this to know its full content height
|
|
||||||
// (scroll extent in Wave B; for Wave A it sizes the empty-vs-populated decision).
|
|
||||||
int contentHeight(int itemCount, int panelWidth, const GridSpec& spec);
|
|
||||||
|
|
||||||
// Identifies one cached thumbnail. A cached envelope is valid only while the
|
|
||||||
// sample's identity, the draw width it was computed at, and the bank generation
|
|
||||||
// it was computed under all match. Width is part of the key because the envelope
|
|
||||||
// has exactly `width` bins per channel (peaks::computeEnvelope is width-driven);
|
|
||||||
// a resized panel needs a fresh envelope. Generation lets the panel invalidate
|
|
||||||
// every entry when the bank changes (capture / project load) without diffing.
|
|
||||||
struct ThumbnailKey {
|
|
||||||
std::string sampleId;
|
|
||||||
int width = 0;
|
|
||||||
std::uint64_t generation = 0;
|
|
||||||
|
|
||||||
bool operator==(const ThumbnailKey& o) const {
|
|
||||||
return sampleId == o.sampleId && width == o.width &&
|
|
||||||
generation == o.generation;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// A stable string form of the key, suitable as a map key. Deterministic: the same
|
|
||||||
// key always yields the same string, distinct keys always differ (the sampleId is
|
|
||||||
// length-prefixed so an id containing the delimiter cannot collide with another).
|
|
||||||
std::string thumbnailKeyString(const ThumbnailKey& key);
|
|
||||||
|
|
||||||
// --- Interaction (M5 Wave B): hit-test, selection, keyboard nav --------------
|
|
||||||
//
|
|
||||||
// All REAPER-free so the panel's interaction LOGIC is unit-tested outside the DAW,
|
|
||||||
// exactly as the layout math is. The panel shell (bank_panel.cpp) reads live mouse
|
|
||||||
// coordinates / key codes / modifier state via SWELL and calls into these; it owns
|
|
||||||
// no selection arithmetic of its own.
|
|
||||||
|
|
||||||
// Hit-tests a point (SWELL/LICE top-left client coords) against a cell-rect list.
|
|
||||||
// Returns the index of the FIRST rect that contains the point, or -1 for a miss
|
|
||||||
// (a click in the inter-cell gap, the margin, or below the last row). Half-open
|
|
||||||
// bounds [x, x+width) x [y, y+height) so adjacent rects never both claim a pixel.
|
|
||||||
int hitTestCell(int px, int py, const std::vector<CellRect>& rects);
|
|
||||||
|
|
||||||
// The panel's selection state. `indices` is the selected set as a SORTED, unique
|
|
||||||
// ascending vector (deterministic for tests and for highlight iteration). `focus`
|
|
||||||
// is the cell the caret sits on — the audition/extend target — or -1 when nothing
|
|
||||||
// is focused. `anchor` is the fixed end of a shift-range (the cell a range extends
|
|
||||||
// FROM); -1 when there is no active range origin. An empty selection has focus and
|
|
||||||
// anchor both -1.
|
|
||||||
//
|
|
||||||
// Invariants (upheld by the pure mutators below, asserted in tests):
|
|
||||||
// * indices is sorted ascending with no duplicates;
|
|
||||||
// * every index (and focus/anchor when >= 0) is in [0, itemCount);
|
|
||||||
// * focus, when >= 0, is a member of indices.
|
|
||||||
struct Selection {
|
|
||||||
std::vector<int> indices;
|
|
||||||
int focus = -1;
|
|
||||||
int anchor = -1;
|
|
||||||
|
|
||||||
bool operator==(const Selection& o) const {
|
|
||||||
return indices == o.indices && focus == o.focus && anchor == o.anchor;
|
|
||||||
}
|
|
||||||
bool contains(int index) const;
|
|
||||||
bool empty() const { return indices.empty(); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Applies a mouse click on cell `index` to `current`, returning the new selection.
|
|
||||||
// Modifier semantics (standard multi-select, matching file-manager conventions):
|
|
||||||
// * plain (no modifier): select ONLY `index`; focus = anchor = index.
|
|
||||||
// * ctrl: TOGGLE `index` in/out of the set; focus = index. Anchor moves to
|
|
||||||
// index on add, and to index on remove too (a ctrl-click reseeds the
|
|
||||||
// range origin at the clicked cell). If the toggle empties the set,
|
|
||||||
// focus stays at index (the caret) but the set is empty.
|
|
||||||
// * shift: select the inclusive RANGE from `anchor` to `index` (replacing the
|
|
||||||
// set); focus = index, anchor unchanged. With no prior anchor (anchor
|
|
||||||
// == -1) shift behaves like a plain click (anchor seeds at index).
|
|
||||||
// `index` out of [0, itemCount) or itemCount <= 0 returns `current` unchanged.
|
|
||||||
// ctrl and shift together: shift takes precedence (range select), matching common
|
|
||||||
// UI; documented so the panel need not special-case it.
|
|
||||||
Selection applyClick(const Selection& current, int index, bool ctrl, bool shift,
|
|
||||||
int itemCount);
|
|
||||||
|
|
||||||
// A directional key for keyboard navigation. REAPER-free (the shell maps VK_* to
|
|
||||||
// these) so nav math is testable without SWELL. Enter/Space/Esc are NOT here: they
|
|
||||||
// drive audition, which is a shell concern (no selection math), so the shell reads
|
|
||||||
// those key codes directly.
|
|
||||||
enum class NavKey { Left, Right, Up, Down, Home, End };
|
|
||||||
|
|
||||||
// Moves the focus by one step for `key` in a grid of `cols` columns holding
|
|
||||||
// `itemCount` cells, returning the new selection. `cols` >= 1.
|
|
||||||
// * Left/Right move by one cell in linear (row-major) order; Up/Down move by
|
|
||||||
// `cols`. Movement CLAMPS at the grid ends (no wrap): Right on the last cell,
|
|
||||||
// Left on the first, Up on the top row, Down past the last cell all stay put.
|
|
||||||
// (Clamp, not wrap: wrap on a partial last row is surprising and error-prone;
|
|
||||||
// clamp is the predictable choice — flagged as the deliberate decision.)
|
|
||||||
// * Down from the second-to-last row into a column with no cell in the last row
|
|
||||||
// clamps to the last cell rather than overshooting past itemCount.
|
|
||||||
// * Without shift: the moved-to cell becomes the sole selection; focus = anchor
|
|
||||||
// = newIndex (a plain arrow reseeds the range origin).
|
|
||||||
// * With shift: focus moves to newIndex and the selection becomes the inclusive
|
|
||||||
// range from anchor to newIndex (anchor unchanged); a first shift-arrow with no
|
|
||||||
// anchor seeds the anchor at the ORIGIN cell before moving.
|
|
||||||
// * Empty selection (focus == -1): the first arrow focuses cell 0 (Home-like),
|
|
||||||
// so an arrow press on a fresh panel starts navigation predictably.
|
|
||||||
// itemCount <= 0 returns `current` unchanged.
|
|
||||||
Selection navigate(const Selection& current, NavKey key, int cols, int itemCount,
|
|
||||||
bool shift);
|
|
||||||
|
|
||||||
// --- Waveform display compression --------------------------------------------
|
|
||||||
//
|
|
||||||
// Maps a raw linear amplitude magnitude to a perceptual display fraction so
|
|
||||||
// quiet and medium content remains visible in the thumbnail.
|
|
||||||
//
|
|
||||||
// The floor below which amplitude is treated as silence (display fraction 0).
|
|
||||||
// At -60 dB, 0.001 linear magnitude maps to ~0. Tune this constant in-DAW to
|
|
||||||
// taste — it is the only knob for the compression curve.
|
|
||||||
constexpr float kDisplayFloorDb = -60.0f;
|
|
||||||
|
|
||||||
// Maps a signed linear amplitude value in [-1, 1] (a raw envelope extreme such
|
|
||||||
// as PeakBin::max or PeakBin::min) to a signed display fraction in [-1, 1].
|
|
||||||
//
|
|
||||||
// The magnitude |linear| is converted to dB, clamped to [kDisplayFloorDb, 0],
|
|
||||||
// then normalized so kDisplayFloorDb -> 0 and 0 dB -> 1. The original sign is
|
|
||||||
// re-applied so positive max values still map positive (draw up) and negative
|
|
||||||
// min values still map negative (draw down). Exact-zero input returns 0.0f
|
|
||||||
// (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f.
|
|
||||||
float compressAmplitudeForDisplay(float linear);
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -1,767 +0,0 @@
|
|||||||
#include "bank_model.h"
|
|
||||||
|
|
||||||
#include <cctype>
|
|
||||||
#include <cerrno>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
// bank_model implementation.
|
|
||||||
//
|
|
||||||
// JSON is hand-rolled and self-contained (brief: keep the pure core
|
|
||||||
// dependency-free — no third-party JSON lib, no WDL coupling). The field set is
|
|
||||||
// a flat struct of primitives, strings, one enum, a small string array, and a
|
|
||||||
// few optionals, so a compact writer + recursive-descent parser is the simplest
|
|
||||||
// thing that works. Doubles are emitted with 17 significant digits (%.17g), the
|
|
||||||
// shortest form that round-trips every IEEE-754 double exactly, so the
|
|
||||||
// deserialize(serialize(x)) == x invariant holds bit-for-bit.
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// equality
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
bool SourceRange::operator==(const SourceRange& o) const {
|
|
||||||
return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
|
|
||||||
startPpq == o.startPpq && endPpq == o.endPpq;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Provenance::operator==(const Provenance& o) const {
|
|
||||||
return parentSampleId == o.parentSampleId && fxChainSnapshot == o.fxChainSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Levels::operator==(const Levels& o) const {
|
|
||||||
return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool LoopPoints::operator==(const LoopPoints& o) const {
|
|
||||||
return start == o.start && end == o.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Sample::operator==(const Sample& o) const {
|
|
||||||
return id == o.id && displayName == o.displayName && relativePath == o.relativePath &&
|
|
||||||
sourceMode == o.sourceMode && sourceRange == o.sourceRange &&
|
|
||||||
trackGuids == o.trackGuids && wetDry == o.wetDry &&
|
|
||||||
channelCount == o.channelCount && sampleRate == o.sampleRate &&
|
|
||||||
lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats &&
|
|
||||||
captureTempo == o.captureTempo &&
|
|
||||||
captureTimeSigNum == o.captureTimeSigNum &&
|
|
||||||
captureTimeSigDenom == o.captureTimeSigDenom && key == o.key &&
|
|
||||||
rootNote == o.rootNote && loop == o.loop && levels == o.levels &&
|
|
||||||
clipped == o.clipped && tier == o.tier && contentHash == o.contentHash &&
|
|
||||||
provenance == o.provenance && createdTimestamp == o.createdTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// path invariant
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// DECISION: reject absolute paths rather than normalize them. The pure model has
|
|
||||||
// no knowledge of the project root, so it cannot correctly relativize an absolute
|
|
||||||
// path — any "normalization" would be a guess that could point at the wrong file.
|
|
||||||
// Rejecting at the boundary is honest and deterministic; the capture backend (M3)
|
|
||||||
// is responsible for handing us an already-relative path. Covers POSIX ("/x"),
|
|
||||||
// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share")
|
|
||||||
// forms. Any leading <alpha>: is rejected regardless of the character that follows —
|
|
||||||
// drive-relative paths ("C:foo.wav") resolve against the drive's current directory,
|
|
||||||
// not the project root, so they violate the relative-paths-only invariant just as
|
|
||||||
// much as "C:\foo.wav" does.
|
|
||||||
static bool isAbsolutePath(const std::string& p) {
|
|
||||||
if (p.empty()) return false;
|
|
||||||
if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC
|
|
||||||
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
|
|
||||||
return true; // Windows drive (C:\, C:/, C:foo, C:)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// BankIndex
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
AddResult BankIndex::add(const Sample& sample) {
|
|
||||||
if (sample.id.empty()) return AddResult::RejectedEmptyId;
|
|
||||||
if (isAbsolutePath(sample.relativePath)) return AddResult::RejectedAbsolutePath;
|
|
||||||
|
|
||||||
if (findByHash(sample.contentHash) != nullptr)
|
|
||||||
return AddResult::Collapsed;
|
|
||||||
|
|
||||||
samples_.push_back(sample);
|
|
||||||
return AddResult::Added;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool BankIndex::remove(const std::string& id) {
|
|
||||||
for (auto it = samples_.begin(); it != samples_.end(); ++it) {
|
|
||||||
if (it->id == id) {
|
|
||||||
samples_.erase(it);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool BankIndex::updateInPlace(const std::string& id, const Sample& updated) {
|
|
||||||
if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds
|
|
||||||
for (auto& s : samples_) {
|
|
||||||
if (s.id == id) {
|
|
||||||
s = updated; // replace in place — position (insertion order) preserved
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Sample* BankIndex::query(const std::string& id) const {
|
|
||||||
for (const auto& s : samples_)
|
|
||||||
if (s.id == id) return &s;
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Sample* BankIndex::findByHash(const std::string& contentHash) const {
|
|
||||||
if (contentHash.empty()) return nullptr; // empty hashes never dedup
|
|
||||||
for (const auto& s : samples_)
|
|
||||||
if (s.contentHash == contentHash) return &s;
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool BankIndex::moveTier(const std::string& id, Tier tier) {
|
|
||||||
for (auto& s : samples_) {
|
|
||||||
if (s.id == id) {
|
|
||||||
s.tier = tier;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<Sample> BankIndex::byTier(Tier tier) const {
|
|
||||||
std::vector<Sample> out;
|
|
||||||
for (const auto& s : samples_)
|
|
||||||
if (s.tier == tier) out.push_back(s);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// JSON writer
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
void writeEscaped(std::string& out, const std::string& s) {
|
|
||||||
out += '"';
|
|
||||||
for (char c : s) {
|
|
||||||
switch (c) {
|
|
||||||
case '"': out += "\\\""; break;
|
|
||||||
case '\\': out += "\\\\"; break;
|
|
||||||
case '\b': out += "\\b"; break;
|
|
||||||
case '\f': out += "\\f"; break;
|
|
||||||
case '\n': out += "\\n"; break;
|
|
||||||
case '\r': out += "\\r"; break;
|
|
||||||
case '\t': out += "\\t"; break;
|
|
||||||
default:
|
|
||||||
if (static_cast<unsigned char>(c) < 0x20) {
|
|
||||||
char buf[8];
|
|
||||||
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(c));
|
|
||||||
out += buf;
|
|
||||||
} else {
|
|
||||||
out += c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out += '"';
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string numToStr(double v) {
|
|
||||||
char buf[32];
|
|
||||||
std::snprintf(buf, sizeof(buf), "%.17g", v);
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string numToStr(std::int64_t v) {
|
|
||||||
char buf[32];
|
|
||||||
std::snprintf(buf, sizeof(buf), "%lld", static_cast<long long>(v));
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string numToStr(int v) { return numToStr(static_cast<std::int64_t>(v)); }
|
|
||||||
|
|
||||||
class ObjWriter {
|
|
||||||
public:
|
|
||||||
explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; }
|
|
||||||
~ObjWriter() { out_ += '}'; }
|
|
||||||
|
|
||||||
void keyRaw(const char* key, const std::string& rawValue) {
|
|
||||||
sep();
|
|
||||||
writeEscaped(out_, key);
|
|
||||||
out_ += ':';
|
|
||||||
out_ += rawValue;
|
|
||||||
}
|
|
||||||
void keyStr(const char* key, const std::string& value) {
|
|
||||||
sep();
|
|
||||||
writeEscaped(out_, key);
|
|
||||||
out_ += ':';
|
|
||||||
writeEscaped(out_, value);
|
|
||||||
}
|
|
||||||
// Begin a nested value; caller writes the value immediately after.
|
|
||||||
void keyBegin(const char* key) {
|
|
||||||
sep();
|
|
||||||
writeEscaped(out_, key);
|
|
||||||
out_ += ':';
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
void sep() {
|
|
||||||
if (first_) first_ = false; else out_ += ',';
|
|
||||||
}
|
|
||||||
std::string& out_;
|
|
||||||
bool first_ = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
void writeStringArray(std::string& out, const std::vector<std::string>& v) {
|
|
||||||
out += '[';
|
|
||||||
for (std::size_t i = 0; i < v.size(); ++i) {
|
|
||||||
if (i) out += ',';
|
|
||||||
writeEscaped(out, v[i]);
|
|
||||||
}
|
|
||||||
out += ']';
|
|
||||||
}
|
|
||||||
|
|
||||||
void writeSample(std::string& out, const Sample& s) {
|
|
||||||
ObjWriter w(out);
|
|
||||||
w.keyStr("id", s.id);
|
|
||||||
w.keyStr("displayName", s.displayName);
|
|
||||||
w.keyStr("relativePath", s.relativePath);
|
|
||||||
w.keyRaw("sourceMode", numToStr(static_cast<int>(s.sourceMode)));
|
|
||||||
|
|
||||||
w.keyBegin("sourceRange");
|
|
||||||
{
|
|
||||||
ObjWriter r(out);
|
|
||||||
r.keyRaw("startSeconds", numToStr(s.sourceRange.startSeconds));
|
|
||||||
r.keyRaw("endSeconds", numToStr(s.sourceRange.endSeconds));
|
|
||||||
r.keyRaw("startPpq", numToStr(s.sourceRange.startPpq));
|
|
||||||
r.keyRaw("endPpq", numToStr(s.sourceRange.endPpq));
|
|
||||||
}
|
|
||||||
|
|
||||||
w.keyBegin("trackGuids");
|
|
||||||
writeStringArray(out, s.trackGuids);
|
|
||||||
|
|
||||||
w.keyRaw("wetDry", numToStr(s.wetDry));
|
|
||||||
w.keyRaw("channelCount", numToStr(s.channelCount));
|
|
||||||
w.keyRaw("sampleRate", numToStr(s.sampleRate));
|
|
||||||
w.keyRaw("lengthSeconds", numToStr(s.lengthSeconds));
|
|
||||||
w.keyRaw("lengthBeats", numToStr(s.lengthBeats));
|
|
||||||
w.keyRaw("captureTempo", numToStr(s.captureTempo));
|
|
||||||
w.keyRaw("captureTimeSigNum", numToStr(s.captureTimeSigNum));
|
|
||||||
w.keyRaw("captureTimeSigDenom", numToStr(s.captureTimeSigDenom));
|
|
||||||
|
|
||||||
// Optionals are emitted as null when absent so present/absent round-trips.
|
|
||||||
w.keyBegin("key");
|
|
||||||
if (s.key) writeEscaped(out, *s.key); else out += "null";
|
|
||||||
|
|
||||||
// Phase S seam fields (D-B). Emitted as null when absent (same shape as `key`
|
|
||||||
// and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely —
|
|
||||||
// parses to empty optionals and re-serializes without invention.
|
|
||||||
w.keyBegin("rootNote");
|
|
||||||
if (s.rootNote) out += numToStr(*s.rootNote); else out += "null";
|
|
||||||
|
|
||||||
w.keyBegin("loop");
|
|
||||||
if (s.loop) {
|
|
||||||
ObjWriter lp(out);
|
|
||||||
lp.keyRaw("start", numToStr(s.loop->start));
|
|
||||||
lp.keyRaw("end", numToStr(s.loop->end));
|
|
||||||
} else {
|
|
||||||
out += "null";
|
|
||||||
}
|
|
||||||
|
|
||||||
w.keyBegin("levels");
|
|
||||||
{
|
|
||||||
ObjWriter l(out);
|
|
||||||
l.keyRaw("peakDb", numToStr(s.levels.peakDb));
|
|
||||||
l.keyRaw("rmsDb", numToStr(s.levels.rmsDb));
|
|
||||||
l.keyRaw("lufs", numToStr(s.levels.lufs));
|
|
||||||
}
|
|
||||||
|
|
||||||
w.keyRaw("clipped", s.clipped ? "true" : "false");
|
|
||||||
w.keyRaw("tier", numToStr(static_cast<int>(s.tier)));
|
|
||||||
w.keyStr("contentHash", s.contentHash);
|
|
||||||
|
|
||||||
w.keyBegin("provenance");
|
|
||||||
if (s.provenance) {
|
|
||||||
ObjWriter p(out);
|
|
||||||
p.keyStr("parentSampleId", s.provenance->parentSampleId);
|
|
||||||
p.keyStr("fxChainSnapshot", s.provenance->fxChainSnapshot);
|
|
||||||
} else {
|
|
||||||
out += "null";
|
|
||||||
}
|
|
||||||
|
|
||||||
w.keyRaw("createdTimestamp", numToStr(s.createdTimestamp));
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
std::string BankIndex::serialize() const {
|
|
||||||
std::string out;
|
|
||||||
{
|
|
||||||
ObjWriter root(out);
|
|
||||||
root.keyRaw("version", numToStr(1));
|
|
||||||
root.keyBegin("samples");
|
|
||||||
out += '[';
|
|
||||||
for (std::size_t i = 0; i < samples_.size(); ++i) {
|
|
||||||
if (i) out += ',';
|
|
||||||
writeSample(out, samples_[i]);
|
|
||||||
}
|
|
||||||
out += ']';
|
|
||||||
} // root closes the object here — not deferred to function return (NRVO would
|
|
||||||
// otherwise let the caller observe `out` before the closing brace is appended)
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// JSON parser (recursive descent). Returns false on any malformed input; never
|
|
||||||
// reads out of bounds. Only supports the subset our writer emits.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
class Parser {
|
|
||||||
public:
|
|
||||||
explicit Parser(const std::string& s) : s_(s) {}
|
|
||||||
|
|
||||||
bool parseIndex(BankIndex& out);
|
|
||||||
|
|
||||||
private:
|
|
||||||
const std::string& s_;
|
|
||||||
std::size_t pos_ = 0;
|
|
||||||
|
|
||||||
bool eof() const { return pos_ >= s_.size(); }
|
|
||||||
char peek() const { return s_[pos_]; }
|
|
||||||
|
|
||||||
void skipWs() {
|
|
||||||
while (!eof()) {
|
|
||||||
char c = s_[pos_];
|
|
||||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool consume(char c) {
|
|
||||||
skipWs();
|
|
||||||
if (eof() || s_[pos_] != c) return false;
|
|
||||||
++pos_;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool parseString(std::string& out);
|
|
||||||
bool parseRawScalar(std::string& out); // number / true / false / null token
|
|
||||||
bool parseDouble(double& out);
|
|
||||||
bool parseInt64(std::int64_t& out);
|
|
||||||
bool parseInt(int& out);
|
|
||||||
bool parseBool(bool& out);
|
|
||||||
bool expectNullOr(bool& wasNull); // peeks for `null`; consumes if present
|
|
||||||
|
|
||||||
bool parseSample(Sample& out);
|
|
||||||
bool parseKey(std::string& key); // an object member key + ':'
|
|
||||||
bool skipValue(); // for forward-compat unknown keys
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX
|
|
||||||
// for control chars). Positioned at the opening quote after whitespace.
|
|
||||||
bool Parser::parseString(std::string& out) {
|
|
||||||
skipWs();
|
|
||||||
if (eof() || s_[pos_] != '"') return false;
|
|
||||||
++pos_;
|
|
||||||
out.clear();
|
|
||||||
while (!eof()) {
|
|
||||||
char c = s_[pos_++];
|
|
||||||
if (c == '"') return true;
|
|
||||||
if (c == '\\') {
|
|
||||||
if (eof()) return false;
|
|
||||||
char e = s_[pos_++];
|
|
||||||
switch (e) {
|
|
||||||
case '"': out += '"'; break;
|
|
||||||
case '\\': out += '\\'; break;
|
|
||||||
case '/': out += '/'; break;
|
|
||||||
case 'b': out += '\b'; break;
|
|
||||||
case 'f': out += '\f'; break;
|
|
||||||
case 'n': out += '\n'; break;
|
|
||||||
case 'r': out += '\r'; break;
|
|
||||||
case 't': out += '\t'; break;
|
|
||||||
case 'u': {
|
|
||||||
// Decode a \uXXXX escape to its code point.
|
|
||||||
auto readHex4 = [&](unsigned int& cp) -> bool {
|
|
||||||
if (pos_ + 4 > s_.size()) return false;
|
|
||||||
cp = 0;
|
|
||||||
for (int i = 0; i < 4; ++i) {
|
|
||||||
char h = s_[pos_++];
|
|
||||||
cp <<= 4;
|
|
||||||
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
|
|
||||||
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
|
|
||||||
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
|
|
||||||
else return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
unsigned int hi = 0;
|
|
||||||
if (!readHex4(hi)) return false;
|
|
||||||
|
|
||||||
unsigned int codePoint = hi;
|
|
||||||
if (hi >= 0xD800 && hi <= 0xDBFF) {
|
|
||||||
// High surrogate — must be followed by \uDC00–\uDFFF.
|
|
||||||
if (pos_ + 6 > s_.size()) return false;
|
|
||||||
if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
|
|
||||||
pos_ += 2;
|
|
||||||
unsigned int lo = 0;
|
|
||||||
if (!readHex4(lo)) return false;
|
|
||||||
if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate
|
|
||||||
codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
|
|
||||||
} else if (hi >= 0xDC00 && hi <= 0xDFFF) {
|
|
||||||
return false; // unpaired low surrogate — malformed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode codePoint as UTF-8.
|
|
||||||
if (codePoint <= 0x7F) {
|
|
||||||
out += static_cast<char>(codePoint);
|
|
||||||
} else if (codePoint <= 0x7FF) {
|
|
||||||
out += static_cast<char>(0xC0 | (codePoint >> 6));
|
|
||||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
|
||||||
} else if (codePoint <= 0xFFFF) {
|
|
||||||
out += static_cast<char>(0xE0 | (codePoint >> 12));
|
|
||||||
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
|
||||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
|
||||||
} else {
|
|
||||||
out += static_cast<char>(0xF0 | (codePoint >> 18));
|
|
||||||
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
|
|
||||||
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
|
||||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: return false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out += c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false; // unterminated string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads a bare token (number, true, false, null) up to the next structural char.
|
|
||||||
bool Parser::parseRawScalar(std::string& out) {
|
|
||||||
skipWs();
|
|
||||||
std::size_t start = pos_;
|
|
||||||
while (!eof()) {
|
|
||||||
char c = s_[pos_];
|
|
||||||
if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' ||
|
|
||||||
c == '\n' || c == '\r')
|
|
||||||
break;
|
|
||||||
++pos_;
|
|
||||||
}
|
|
||||||
if (pos_ == start) return false;
|
|
||||||
out.assign(s_, start, pos_ - start);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Parser::parseDouble(double& out) {
|
|
||||||
std::string tok;
|
|
||||||
if (!parseRawScalar(tok)) return false;
|
|
||||||
const char* b = tok.c_str();
|
|
||||||
char* end = nullptr;
|
|
||||||
errno = 0;
|
|
||||||
double v = std::strtod(b, &end);
|
|
||||||
if (end != b + tok.size()) return false;
|
|
||||||
if (errno == ERANGE) return false; // overflow / underflow → malformed
|
|
||||||
out = v;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Parser::parseInt64(std::int64_t& out) {
|
|
||||||
std::string tok;
|
|
||||||
if (!parseRawScalar(tok)) return false;
|
|
||||||
const char* b = tok.c_str();
|
|
||||||
char* end = nullptr;
|
|
||||||
errno = 0;
|
|
||||||
long long v = std::strtoll(b, &end, 10);
|
|
||||||
if (end != b + tok.size()) return false;
|
|
||||||
if (errno == ERANGE) return false; // overflow → malformed
|
|
||||||
out = static_cast<std::int64_t>(v);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Parser::parseInt(int& out) {
|
|
||||||
std::int64_t v = 0;
|
|
||||||
if (!parseInt64(v)) return false;
|
|
||||||
out = static_cast<int>(v);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Parser::parseBool(bool& out) {
|
|
||||||
std::string tok;
|
|
||||||
if (!parseRawScalar(tok)) return false;
|
|
||||||
if (tok == "true") { out = true; return true; }
|
|
||||||
if (tok == "false") { out = false; return true; }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the next value is the `null` token, consumes it and sets wasNull=true.
|
|
||||||
// Otherwise leaves the position untouched and sets wasNull=false. Returns false
|
|
||||||
// only on eof.
|
|
||||||
bool Parser::expectNullOr(bool& wasNull) {
|
|
||||||
skipWs();
|
|
||||||
if (eof()) return false;
|
|
||||||
if (s_.compare(pos_, 4, "null") == 0) {
|
|
||||||
pos_ += 4;
|
|
||||||
wasNull = true;
|
|
||||||
} else {
|
|
||||||
wasNull = false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Parser::parseKey(std::string& key) {
|
|
||||||
if (!parseString(key)) return false;
|
|
||||||
if (!consume(':')) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skips one JSON value (object / array / string / scalar) for forward-compat
|
|
||||||
// with keys we don't recognize. Assumes position is at the start of the value.
|
|
||||||
bool Parser::skipValue() {
|
|
||||||
skipWs();
|
|
||||||
if (eof()) return false;
|
|
||||||
char c = s_[pos_];
|
|
||||||
if (c == '"') {
|
|
||||||
std::string tmp;
|
|
||||||
return parseString(tmp);
|
|
||||||
}
|
|
||||||
if (c == '{' || c == '[') {
|
|
||||||
char open = c, close = (c == '{') ? '}' : ']';
|
|
||||||
++pos_;
|
|
||||||
int depth = 1;
|
|
||||||
while (!eof() && depth > 0) {
|
|
||||||
char d = s_[pos_];
|
|
||||||
if (d == '"') {
|
|
||||||
std::string tmp;
|
|
||||||
if (!parseString(tmp)) return false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (d == open) ++depth;
|
|
||||||
else if (d == close) --depth;
|
|
||||||
++pos_;
|
|
||||||
}
|
|
||||||
return depth == 0;
|
|
||||||
}
|
|
||||||
std::string tmp;
|
|
||||||
return parseRawScalar(tmp);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Parser::parseSample(Sample& s) {
|
|
||||||
if (!consume('{')) return false;
|
|
||||||
skipWs();
|
|
||||||
if (consume('}')) return true; // empty object (shouldn't happen, but valid)
|
|
||||||
|
|
||||||
do {
|
|
||||||
std::string key;
|
|
||||||
if (!parseKey(key)) return false;
|
|
||||||
|
|
||||||
if (key == "id") {
|
|
||||||
if (!parseString(s.id)) return false;
|
|
||||||
} else if (key == "displayName") {
|
|
||||||
if (!parseString(s.displayName)) return false;
|
|
||||||
} else if (key == "relativePath") {
|
|
||||||
if (!parseString(s.relativePath)) return false;
|
|
||||||
} else if (key == "sourceMode") {
|
|
||||||
int v = 0;
|
|
||||||
if (!parseInt(v)) return false;
|
|
||||||
// Valid range: MasterMix(0) .. Realtime(5).
|
|
||||||
if (v < static_cast<int>(SourceMode::MasterMix) ||
|
|
||||||
v > static_cast<int>(SourceMode::Realtime))
|
|
||||||
return false;
|
|
||||||
s.sourceMode = static_cast<SourceMode>(v);
|
|
||||||
} else if (key == "sourceRange") {
|
|
||||||
if (!consume('{')) return false;
|
|
||||||
do {
|
|
||||||
std::string rk;
|
|
||||||
if (!parseKey(rk)) return false;
|
|
||||||
double dv = 0.0;
|
|
||||||
if (!parseDouble(dv)) return false;
|
|
||||||
if (rk == "startSeconds") s.sourceRange.startSeconds = dv;
|
|
||||||
else if (rk == "endSeconds") s.sourceRange.endSeconds = dv;
|
|
||||||
else if (rk == "startPpq") s.sourceRange.startPpq = dv;
|
|
||||||
else if (rk == "endPpq") s.sourceRange.endPpq = dv;
|
|
||||||
} while (consume(','));
|
|
||||||
if (!consume('}')) return false;
|
|
||||||
} else if (key == "trackGuids") {
|
|
||||||
if (!consume('[')) return false;
|
|
||||||
skipWs();
|
|
||||||
if (!consume(']')) {
|
|
||||||
do {
|
|
||||||
std::string g;
|
|
||||||
if (!parseString(g)) return false;
|
|
||||||
s.trackGuids.push_back(g);
|
|
||||||
} while (consume(','));
|
|
||||||
if (!consume(']')) return false;
|
|
||||||
}
|
|
||||||
} else if (key == "wetDry") {
|
|
||||||
if (!parseDouble(s.wetDry)) return false;
|
|
||||||
} else if (key == "channelCount") {
|
|
||||||
if (!parseInt(s.channelCount)) return false;
|
|
||||||
} else if (key == "sampleRate") {
|
|
||||||
if (!parseInt(s.sampleRate)) return false;
|
|
||||||
} else if (key == "lengthSeconds") {
|
|
||||||
if (!parseDouble(s.lengthSeconds)) return false;
|
|
||||||
} else if (key == "lengthBeats") {
|
|
||||||
if (!parseDouble(s.lengthBeats)) return false;
|
|
||||||
} else if (key == "captureTempo") {
|
|
||||||
if (!parseDouble(s.captureTempo)) return false;
|
|
||||||
} else if (key == "captureTimeSigNum") {
|
|
||||||
if (!parseInt(s.captureTimeSigNum)) return false;
|
|
||||||
} else if (key == "captureTimeSigDenom") {
|
|
||||||
if (!parseInt(s.captureTimeSigDenom)) return false;
|
|
||||||
} else if (key == "key") {
|
|
||||||
bool wasNull = false;
|
|
||||||
if (!expectNullOr(wasNull)) return false;
|
|
||||||
if (wasNull) {
|
|
||||||
s.key.reset();
|
|
||||||
} else {
|
|
||||||
std::string k;
|
|
||||||
if (!parseString(k)) return false;
|
|
||||||
s.key = k;
|
|
||||||
}
|
|
||||||
} else if (key == "rootNote") {
|
|
||||||
bool wasNull = false;
|
|
||||||
if (!expectNullOr(wasNull)) return false;
|
|
||||||
if (wasNull) {
|
|
||||||
s.rootNote.reset();
|
|
||||||
} else {
|
|
||||||
int v = 0;
|
|
||||||
if (!parseInt(v)) return false;
|
|
||||||
// Valid MIDI note range: 0..127 inclusive (boundaries valid).
|
|
||||||
if (v < 0 || v > 127) return false;
|
|
||||||
s.rootNote = v;
|
|
||||||
}
|
|
||||||
} else if (key == "loop") {
|
|
||||||
bool wasNull = false;
|
|
||||||
if (!expectNullOr(wasNull)) return false;
|
|
||||||
if (wasNull) {
|
|
||||||
s.loop.reset();
|
|
||||||
} else {
|
|
||||||
if (!consume('{')) return false;
|
|
||||||
LoopPoints lp;
|
|
||||||
do {
|
|
||||||
std::string lk;
|
|
||||||
if (!parseKey(lk)) return false;
|
|
||||||
std::int64_t lv = 0;
|
|
||||||
if (!parseInt64(lv)) return false;
|
|
||||||
if (lk == "start") lp.start = lv;
|
|
||||||
else if (lk == "end") lp.end = lv;
|
|
||||||
} while (consume(','));
|
|
||||||
if (!consume('}')) return false;
|
|
||||||
// Invariant: 0 <= start <= end. start == end is a valid zero-length
|
|
||||||
// marker; a negative index or start > end is malformed, not silently
|
|
||||||
// clamped (mirrors the enum-range rejection above).
|
|
||||||
if (lp.start < 0 || lp.end < lp.start) return false;
|
|
||||||
s.loop = lp;
|
|
||||||
}
|
|
||||||
} else if (key == "levels") {
|
|
||||||
if (!consume('{')) return false;
|
|
||||||
do {
|
|
||||||
std::string lk;
|
|
||||||
if (!parseKey(lk)) return false;
|
|
||||||
double dv = 0.0;
|
|
||||||
if (!parseDouble(dv)) return false;
|
|
||||||
if (lk == "peakDb") s.levels.peakDb = dv;
|
|
||||||
else if (lk == "rmsDb") s.levels.rmsDb = dv;
|
|
||||||
else if (lk == "lufs") s.levels.lufs = dv;
|
|
||||||
} while (consume(','));
|
|
||||||
if (!consume('}')) return false;
|
|
||||||
} else if (key == "clipped") {
|
|
||||||
if (!parseBool(s.clipped)) return false;
|
|
||||||
} else if (key == "tier") {
|
|
||||||
int v = 0;
|
|
||||||
if (!parseInt(v)) return false;
|
|
||||||
// Valid range: Scratch(0) .. Archive(1).
|
|
||||||
if (v < static_cast<int>(Tier::Scratch) ||
|
|
||||||
v > static_cast<int>(Tier::Archive))
|
|
||||||
return false;
|
|
||||||
s.tier = static_cast<Tier>(v);
|
|
||||||
} else if (key == "contentHash") {
|
|
||||||
if (!parseString(s.contentHash)) return false;
|
|
||||||
} else if (key == "provenance") {
|
|
||||||
bool wasNull = false;
|
|
||||||
if (!expectNullOr(wasNull)) return false;
|
|
||||||
if (wasNull) {
|
|
||||||
s.provenance.reset();
|
|
||||||
} else {
|
|
||||||
if (!consume('{')) return false;
|
|
||||||
Provenance p;
|
|
||||||
do {
|
|
||||||
std::string pk;
|
|
||||||
if (!parseKey(pk)) return false;
|
|
||||||
std::string pv;
|
|
||||||
if (!parseString(pv)) return false;
|
|
||||||
if (pk == "parentSampleId") p.parentSampleId = pv;
|
|
||||||
else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv;
|
|
||||||
} while (consume(','));
|
|
||||||
if (!consume('}')) return false;
|
|
||||||
s.provenance = p;
|
|
||||||
}
|
|
||||||
} else if (key == "createdTimestamp") {
|
|
||||||
if (!parseInt64(s.createdTimestamp)) return false;
|
|
||||||
} else {
|
|
||||||
if (!skipValue()) return false; // forward-compat: ignore unknown
|
|
||||||
}
|
|
||||||
} while (consume(','));
|
|
||||||
|
|
||||||
return consume('}');
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Parser::parseIndex(BankIndex& out) {
|
|
||||||
if (!consume('{')) return false;
|
|
||||||
skipWs();
|
|
||||||
if (consume('}')) return true; // empty object — vacuously an empty index
|
|
||||||
|
|
||||||
std::vector<Sample> parsed;
|
|
||||||
do {
|
|
||||||
std::string key;
|
|
||||||
if (!parseKey(key)) return false;
|
|
||||||
|
|
||||||
if (key == "samples") {
|
|
||||||
if (!consume('[')) return false;
|
|
||||||
skipWs();
|
|
||||||
if (!consume(']')) {
|
|
||||||
do {
|
|
||||||
Sample s;
|
|
||||||
if (!parseSample(s)) return false;
|
|
||||||
parsed.push_back(std::move(s));
|
|
||||||
} while (consume(','));
|
|
||||||
if (!consume(']')) return false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!skipValue()) return false; // version, or unknown keys
|
|
||||||
}
|
|
||||||
} while (consume(','));
|
|
||||||
|
|
||||||
if (!consume('}')) return false;
|
|
||||||
|
|
||||||
// Trailing garbage after the root object is malformed.
|
|
||||||
skipWs();
|
|
||||||
if (!eof()) return false;
|
|
||||||
|
|
||||||
// Rebuild via add() so the same invariants (relative-path, dedup) that guard
|
|
||||||
// live inserts also guard deserialized data. Rejected/collapsed entries are
|
|
||||||
// dropped silently — a well-formed serialized index never triggers them.
|
|
||||||
for (auto& s : parsed) out.add(s);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
std::optional<BankIndex> BankIndex::deserialize(const std::string& json) {
|
|
||||||
BankIndex idx;
|
|
||||||
Parser p(json);
|
|
||||||
if (!p.parseIndex(idx)) return std::nullopt;
|
|
||||||
return idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
-3459
File diff suppressed because it is too large
Load Diff
@@ -1,130 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// bank_panel — the docked grid window (M5, Wave A). REAPER-facing shell: it owns
|
|
||||||
// a SWELL dialog docked via DockWindowAddEx, and paints the current project's
|
|
||||||
// bank as a grid of LICE-drawn waveform thumbnails. The panel itself NEVER inserts
|
|
||||||
// into the arrange or mutates the project/bank (CONTEXT.md §load-bearing
|
|
||||||
// principle). Audition / multi-select / keyboard nav are Wave B.
|
|
||||||
//
|
|
||||||
// The header is REAPER-free as practical: main.cpp drives the panel through these
|
|
||||||
// free functions, passing the live session so the panel reads the current bank.
|
|
||||||
// All SWELL / LICE / PCM_source use is confined to bank_panel.cpp. The pure
|
|
||||||
// layout math and cache keys live in bank_grid (unit-tested outside the DAW).
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "tail_control.h" // TailSetting — the panel's tail-mode toggle state
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
class ReaSamplerSession;
|
|
||||||
|
|
||||||
// Wires the panel into main.cpp's lifecycle. Called once after the API pointers
|
|
||||||
// are loaded, BEFORE the toggle action is registered. `session` must outlive the
|
|
||||||
// panel (it is the extension-lifetime g_session). Stores the session pointer the
|
|
||||||
// panel reads on every repaint; does not create the window yet.
|
|
||||||
void bankPanelInit(ReaSamplerSession* session);
|
|
||||||
|
|
||||||
// Toggles the docked window: creates+docks it if hidden, hides+undocks it if
|
|
||||||
// shown. Bound to the "toggle bank panel" action. Safe to call before the first
|
|
||||||
// timer tick.
|
|
||||||
void bankPanelToggle();
|
|
||||||
|
|
||||||
// Whether the panel window is currently open/visible. Feeds the action's
|
|
||||||
// checked-state (toggleaction) so REAPER shows a tick next to the menu entry.
|
|
||||||
bool bankPanelIsOpen();
|
|
||||||
|
|
||||||
// The stable ids of the currently-selected samples, in bank (insertion) order.
|
|
||||||
// Empty when nothing is selected or the panel has never opened. This is the clean
|
|
||||||
// seam the `insert` action reads to know WHAT to place — it returns ids (not grid
|
|
||||||
// indices) so the caller resolves against the live bank and is unaffected by the
|
|
||||||
// panel's internal index bookkeeping. READ of panel state only; no mutation.
|
|
||||||
//
|
|
||||||
// Note: the panel's selection is cleared on a bank change (capture / project
|
|
||||||
// load), so a returned id always names a sample present in the current bank at
|
|
||||||
// the moment of the call; the caller still tolerates an absent id gracefully.
|
|
||||||
//
|
|
||||||
// Phase B4 (vertical split): the selection lives in whichever REGION the user last
|
|
||||||
// interacted with (the pool grid on top or a named-bank grid below), which is NOT
|
|
||||||
// necessarily the active/capture-target bank. The returned ids therefore name
|
|
||||||
// samples in the FOCUSED region's displayed bank — the bank the user visibly
|
|
||||||
// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those
|
|
||||||
// ids belong to (the move/copy source).
|
|
||||||
std::vector<std::string> bankPanelSelectedSampleIds();
|
|
||||||
|
|
||||||
// The bank id the current selection belongs to — the displayed bank of the region
|
|
||||||
// the user last interacted with (pool region -> the pool id; named-banks region ->
|
|
||||||
// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current
|
|
||||||
// selection, and it is distinct from the active/capture-target bank (active ≠ shown).
|
|
||||||
// Returns the pool id when nothing is selected or the panel has never opened (a safe
|
|
||||||
// default source). READ of panel state only; no mutation.
|
|
||||||
std::string bankPanelSelectedSourceBankId();
|
|
||||||
|
|
||||||
// Requests a repaint if the bank changed since the last paint (generation bump).
|
|
||||||
// Cheap when nothing changed. Driven by the timer so a capture / project load is
|
|
||||||
// reflected without the panel diffing the bank itself.
|
|
||||||
void bankPanelRefresh();
|
|
||||||
|
|
||||||
// Notifies the panel that persist just (re)loaded a project's view model (membership +
|
|
||||||
// active mode). main.cpp calls this on the exact tick it drains persist's load signal
|
|
||||||
// and reapplies the active mode. It re-arms the new-content detector so the just-loaded
|
|
||||||
// project's PRE-EXISTING content is taken as the baseline (reported as nothing new),
|
|
||||||
// never diffed against the previously-open project and mass-tagged into the active mode.
|
|
||||||
// This coordinates the detector's project-identity signal with persist's authoritative
|
|
||||||
// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address,
|
|
||||||
// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/
|
|
||||||
// arm of panel state only; no project or bank mutation.
|
|
||||||
void bankPanelNotifyProjectLoaded();
|
|
||||||
|
|
||||||
// The panel's current tail-mode setting (mode + Manual length), read by the plain
|
|
||||||
// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture
|
|
||||||
// applies whatever the panel toggle is set to. Default None (exact bounds) — a
|
|
||||||
// capture with no explicit choice stays byte-identical to today. Extension-session
|
|
||||||
// setting: persists across project loads and panel open/close within a REAPER session;
|
|
||||||
// resets to None only when the extension unloads (fresh REAPER session). Project
|
|
||||||
// persistence across REAPER restarts is a noted follow-on.
|
|
||||||
// Safe to call before the panel has ever opened (returns the default). READ of panel
|
|
||||||
// state only; the toggle is mutated by a click inside the panel, never here.
|
|
||||||
TailSetting bankPanelTailSetting();
|
|
||||||
|
|
||||||
// The vertical-split full-height layout state (Phase B). The bank window splits
|
|
||||||
// vertically — pool on top, named-banks region below — and two toggles collapse the
|
|
||||||
// split: pool full-height (hide the named-banks region) and banks full-height (hide
|
|
||||||
// the pool). The two are mutually exclusive with the default (both regions shown),
|
|
||||||
// so one enum captures the whole state.
|
|
||||||
//
|
|
||||||
// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives
|
|
||||||
// here beside the tail setting — the other session-level view-layout bit the panel
|
|
||||||
// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not
|
|
||||||
// project state, so it must not travel with the .rpp. In-memory for the extension's
|
|
||||||
// lifetime; resets to Split on unload.
|
|
||||||
enum class BankPanelFullHeight {
|
|
||||||
Split, // default: pool region on top, named-banks region below
|
|
||||||
PoolOnly, // pool full-height — named-banks region hidden
|
|
||||||
BanksOnly, // banks full-height — pool region hidden
|
|
||||||
};
|
|
||||||
|
|
||||||
// The current full-height layout state (default Split). READ by B4's panel to decide
|
|
||||||
// which region(s) to draw. Safe before the panel has ever opened.
|
|
||||||
BankPanelFullHeight bankPanelFullHeight();
|
|
||||||
|
|
||||||
// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from
|
|
||||||
// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool
|
|
||||||
// full-height" action. Requests a repaint so an open panel reflects the change.
|
|
||||||
void bankPanelToggledPoolFullHeight();
|
|
||||||
|
|
||||||
// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle.
|
|
||||||
// Bound to the "banks full-height" action. Requests a repaint.
|
|
||||||
void bankPanelToggledBanksFullHeight();
|
|
||||||
|
|
||||||
// Requests an immediate repaint of the panel if it is open. A no-op when the panel
|
|
||||||
// is closed (safe to call unconditionally). Called by the actions layer after a
|
|
||||||
// mode change so the footer [Arrange|Design] toggle reflects the new mode without
|
|
||||||
// requiring a hide/reshow.
|
|
||||||
void bankPanelInvalidate();
|
|
||||||
|
|
||||||
// Tears the panel down on extension unload: destroys the window and releases any
|
|
||||||
// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened.
|
|
||||||
void bankPanelShutdown();
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// batch_capture — the REAPER-free logic behind M11 batch capture (one action fires
|
|
||||||
// N captures: one bank sample per selected item / per razor area).
|
|
||||||
//
|
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
|
||||||
// vendor/ includes. Standard library only. The batch shell (main.cpp) reads the DAW
|
|
||||||
// state (selected items -> their exact bounds; every track's P_RAZOREDITS -> areas)
|
|
||||||
// and hands the raw ranges here so the genuinely-pure, easy-to-get-wrong pieces are
|
|
||||||
// unit-tested outside the DAW:
|
|
||||||
//
|
|
||||||
// 1. planCaptureUnits: an ordered list of (start,end) source ranges -> an ordered
|
|
||||||
// list of CaptureUnit, each carrying its 1-based ordinal and validated bounds.
|
|
||||||
// Empty/inverted ranges are DROPPED (mirrors the offline backend's own
|
|
||||||
// end>start guard) so a zero-length item/area never produces a stray render.
|
|
||||||
// Order is preserved: unit ordinals count only the KEPT units, so a batch of
|
|
||||||
// three valid items yields ordinals 1,2,3 regardless of dropped neighbors.
|
|
||||||
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a summary
|
|
||||||
// (succeeded / failed counts + the ordered list of failures) so the shell can
|
|
||||||
// report a mixed result with one console line and no partial-corruption
|
|
||||||
// ambiguity. The AGGREGATION is pure; the render loop that feeds it is shell.
|
|
||||||
//
|
|
||||||
// Range is the ONLY thing that varies per unit here. FX scope (item vs track) is a
|
|
||||||
// per-ACTION constant the shell already owns (fxBypassPlanFor); it is not a
|
|
||||||
// per-unit field. Item-batch uses item scope; razor-batch uses track scope — the
|
|
||||||
// shell passes the scope straight through to each render, unchanged from the
|
|
||||||
// single-capture path.
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// One capture in a batch: an exact source range plus its 1-based ordinal within the
|
|
||||||
// KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's
|
|
||||||
// unique tag is 1-second-granular, so a fast batch could otherwise collide N files
|
|
||||||
// onto one name) and labels a failure in the summary.
|
|
||||||
struct CaptureUnit {
|
|
||||||
int ordinal = 0; // 1-based, counts kept units only
|
|
||||||
double startSeconds = 0.0; // exact — no rounding
|
|
||||||
double endSeconds = 0.0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// A source range handed in by the shell (a selected item's [pos, pos+len] or one
|
|
||||||
// razor area's [start, end]). Kept as a distinct type from CaptureUnit so the input
|
|
||||||
// (raw, possibly-invalid) and the output (validated, ordinal-assigned) do not share
|
|
||||||
// a shape by accident. Named BatchRange (not SourceRange) to avoid collision with
|
|
||||||
// bank_model's SourceRange, which carries PPQ fields this planner does not need.
|
|
||||||
struct BatchRange {
|
|
||||||
double startSeconds = 0.0;
|
|
||||||
double endSeconds = 0.0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validates + orders a batch's source ranges into capture units. Preserves input
|
|
||||||
// order; DROPS every range with end <= start (empty/inverted) so no stray render is
|
|
||||||
// planned; assigns 1-based ordinals over the KEPT units. An empty input (no selected
|
|
||||||
// item / no razor area) yields an empty plan — the shell reports "nothing to batch"
|
|
||||||
// and writes nothing (the same no-op posture the single-capture path takes).
|
|
||||||
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges);
|
|
||||||
|
|
||||||
// The per-unit verdict the shell records after each render attempt, in unit order.
|
|
||||||
struct BatchUnitResult {
|
|
||||||
int ordinal = 0; // the CaptureUnit's ordinal this result is for
|
|
||||||
bool ok = false; // true iff the render + bank-add succeeded
|
|
||||||
std::string detail; // failure reason (empty on success) — for the summary
|
|
||||||
};
|
|
||||||
|
|
||||||
// Order-preserving aggregation of a batch's per-unit results. Built incrementally by
|
|
||||||
// the shell (record() after each unit) so a mid-batch failure is captured without
|
|
||||||
// aborting the remaining units (no partial corruption: each unit is independent, and
|
|
||||||
// the selection is restored on every exit path by the shell's RAII guard).
|
|
||||||
class BatchOutcome {
|
|
||||||
public:
|
|
||||||
// Records one unit's verdict. Order of calls IS the reported order.
|
|
||||||
void record(int ordinal, bool ok, std::string detail = {});
|
|
||||||
|
|
||||||
std::size_t total() const { return results_.size(); }
|
|
||||||
std::size_t succeeded() const;
|
|
||||||
std::size_t failed() const;
|
|
||||||
|
|
||||||
const std::vector<BatchUnitResult>& results() const { return results_; }
|
|
||||||
|
|
||||||
// The ordered subset of results that failed (ok == false). For the summary line.
|
|
||||||
std::vector<BatchUnitResult> failures() const;
|
|
||||||
|
|
||||||
// A single human summary line for the console (explicit-action response — allowed
|
|
||||||
// by the console policy; a batch-completion summary with failure counts qualifies,
|
|
||||||
// per-unit success spam does not). `noun` is the unit word ("item" / "razor area").
|
|
||||||
// Examples:
|
|
||||||
// all-success, 3 items : "ReaSampler batch capture: 3 items captured."
|
|
||||||
// partial, 3 of 5 : "ReaSampler batch capture: 3 of 5 items captured "
|
|
||||||
// "(2 failed: #2, #4)."
|
|
||||||
// empty plan : "ReaSampler batch capture: nothing to capture."
|
|
||||||
std::string summaryLine(const std::string& noun) const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::vector<BatchUnitResult> results_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
-549
@@ -1,549 +0,0 @@
|
|||||||
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend).
|
|
||||||
//
|
|
||||||
// Compiled into the reaper_reasampler MODULE. Includes
|
|
||||||
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU
|
|
||||||
// that defines the API pointers; here they are extern (CLAUDE.md §contract).
|
|
||||||
//
|
|
||||||
// Renders a CaptureRequest's source over its requested range. The full three-scope
|
|
||||||
// capture family (item / track / master, each over a razor-else-time range) is
|
|
||||||
// driven here — all wet-only with optional tail. FX scope is enforced by the
|
|
||||||
// caller (via FX-bypass-around-render / FxBypassGuard) before invoking capture;
|
|
||||||
// this backend is source-agnostic and does not itself read the DAW selection.
|
|
||||||
// Drives the RENDER_* project settings via GetSetProjectInfo / _String
|
|
||||||
// (the source-selection bits come from render_settings.cpp, the pure mapping),
|
|
||||||
// snapshots and restores every setting it changes (non-destructive), triggers a
|
|
||||||
// render, then populates a Sample. It NEVER inserts into the arrange
|
|
||||||
// (load-bearing principle) — RENDER_ADDTOPROJ&1 is cleared on every path.
|
|
||||||
//
|
|
||||||
// The backend is SOURCE-AGNOSTIC: it does NOT read the DAW selection. The action
|
|
||||||
// layer (main.cpp) resolves each source mode to a concrete time range (+ track
|
|
||||||
// GUIDs for track captures) and hands it in via the CaptureRequest. This keeps
|
|
||||||
// the render-driving here and the selection-reading testable/visible up in the
|
|
||||||
// actions layer.
|
|
||||||
//
|
|
||||||
// RENDER PROGRESS WINDOW (Item 2 finding — not suppressible via stock API):
|
|
||||||
// Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show
|
|
||||||
// its offline-render progress dialog (progress bar + waveform view) for the
|
|
||||||
// duration of the render. The RENDER_SETTINGS bits documented in
|
|
||||||
// reaper_plugin_functions.h (line ~3041) contain no "no-dialog", "headless", or
|
|
||||||
// "suppress-progress-window" flag. No GetSetProjectInfo desc documents such a
|
|
||||||
// flag either. There is no stock, header-verifiable mechanism to prevent REAPER
|
|
||||||
// from showing this UI for an offline file render triggered via Main_OnCommand.
|
|
||||||
// This is inherent to REAPER's offline render path. The dialog-free alternative
|
|
||||||
// is the realtime-record backend (M8), which captures the master bus output to a
|
|
||||||
// temp track during playback and never invokes the offline render pipeline.
|
|
||||||
|
|
||||||
#include "capture.h"
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <ctime>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <fstream>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "capture_paths.h"
|
|
||||||
#include "render_settings.h"
|
|
||||||
|
|
||||||
#define REAPERAPI_MINIMAL
|
|
||||||
#define REAPERAPI_WANT_EnumProjects
|
|
||||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
|
||||||
#define REAPERAPI_WANT_GetSetProjectInfo_String
|
|
||||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
|
||||||
#define REAPERAPI_WANT_Main_OnCommand
|
|
||||||
#define REAPERAPI_WANT_Main_SaveProject
|
|
||||||
#define REAPERAPI_WANT_Master_GetTempo
|
|
||||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
|
||||||
#include "reaper_plugin_functions.h"
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// --- Render command / setting constants -------------------------------------
|
|
||||||
//
|
|
||||||
// DAW-ONLY ASSUMPTION (open question, CONTEXT.md §Open questions): the no-dialog
|
|
||||||
// render is triggered by the built-in action "File: Render project, using the
|
|
||||||
// most recent render settings" — command id 42230. This is a stock REAPER main
|
|
||||||
// action id, NOT part of reaper_plugin_functions.h, so it CANNOT be verified
|
|
||||||
// against the SDK header; it must be confirmed in a running REAPER. It renders
|
|
||||||
// headlessly (no dialog) using whatever RENDER_* settings are currently on the
|
|
||||||
// project — which is exactly why we set them all explicitly first.
|
|
||||||
constexpr int kActionRenderUsingMostRecentSettings = 42230;
|
|
||||||
|
|
||||||
// RENDER_BOUNDSFLAG value 0 = custom time bounds (we set STARTPOS/ENDPOS
|
|
||||||
// ourselves for exact, unrounded bounds). Verified: SDK header line ~3042.
|
|
||||||
constexpr double kBoundsCustom = 0.0;
|
|
||||||
|
|
||||||
// RENDER_TAILFLAG / RENDER_TAILMS / RENDER_NORMALIZE / RENDER_TRIMEND for the tail
|
|
||||||
// are driven from the pure tailRenderSettingsFor mapping (render_settings.h),
|
|
||||||
// unit-tested outside the DAW. See the tail-driving block in capture() below.
|
|
||||||
|
|
||||||
// RENDER_DITHER disable-all: &16 = disable all dither/noise-shaping.
|
|
||||||
// Verified: SDK header line ~3050: "&16=disable all".
|
|
||||||
// Float-32 output does not need dither, but if the user's project has dither
|
|
||||||
// enabled the render would obey it, breaking bit-identical repeats. Force off.
|
|
||||||
constexpr double kDitherDisableAll = 16.0;
|
|
||||||
|
|
||||||
// --- WAV render sink configuration ------------------------------------------
|
|
||||||
//
|
|
||||||
// FORMAT CHOICE (CONTEXT.md open question — surfaced for Daniel to confirm):
|
|
||||||
// 32-bit IEEE float. Rationale: float is lossless and needs NO dither, so
|
|
||||||
// identical inputs render bit-identically (enables the M10 null test) and a dry
|
|
||||||
// capture nulls exactly against its source. 16/24-bit int paths require dither
|
|
||||||
// for correctness, which is nondeterministic — unacceptable for a precision tool.
|
|
||||||
//
|
|
||||||
// API FACT (SDK header line ~3114): GetSetProjectInfo_String("RENDER_FORMAT", ...)
|
|
||||||
// uses the BASE64-ENCODED string form of the sink config — NOT raw binary bytes.
|
|
||||||
// Writing raw bytes causes REAPER to silently reject the value and fall back to
|
|
||||||
// the project's default render format (typically 16-bit/44.1 kHz). This was the
|
|
||||||
// confirmed root cause of the M3 offline-capture regression.
|
|
||||||
//
|
|
||||||
// GROUND TRUTH: base64 string captured from a live REAPER configured to
|
|
||||||
// WAV / 32-bit float. Decodes to 7 bytes: 65 76 61 77 20 00 00
|
|
||||||
// = "evaw" (WAV fourcc, little-endian) + 0x20 (=32, the float bit-depth field)
|
|
||||||
// + 0x00 0x00 (flags: little-endian, no BWF/loop metadata).
|
|
||||||
constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA==";
|
|
||||||
|
|
||||||
// Int16 / Int24 blob strings are NOT implemented in M3 — their byte encoding
|
|
||||||
// was not captured from a live REAPER and must not be guessed. If M7+ adds
|
|
||||||
// them, capture the ground-truth base64 from a running REAPER first.
|
|
||||||
//
|
|
||||||
// Returns nullptr for unsupported depths.
|
|
||||||
const char* wavSinkConfigBase64(WavBitDepth depth) {
|
|
||||||
switch (depth) {
|
|
||||||
case WavBitDepth::Float32: return kRenderFormatWavFloat32;
|
|
||||||
case WavBitDepth::Int16: return nullptr; // M7+: capture ground-truth blob first
|
|
||||||
case WavBitDepth::Int24: return nullptr; // M7+: capture ground-truth blob first
|
|
||||||
}
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- RENDER_* snapshot / restore --------------------------------------------
|
|
||||||
//
|
|
||||||
// The RENDER_* settings are project-GLOBAL: clobbering them would destroy the
|
|
||||||
// user's render configuration. We snapshot every value we are about to change,
|
|
||||||
// then restore all of them in the reverse order on the way out (non-destructive
|
|
||||||
// invariant). Modeled as a small RAII guard so early returns cannot leak a
|
|
||||||
// half-restored state.
|
|
||||||
struct RenderSettingsSnapshot {
|
|
||||||
ReaProject* proj = nullptr;
|
|
||||||
|
|
||||||
// Numeric settings (GetSetProjectInfo).
|
|
||||||
double boundsFlag = 0.0;
|
|
||||||
double startPos = 0.0;
|
|
||||||
double endPos = 0.0;
|
|
||||||
double tailFlag = 0.0;
|
|
||||||
double tailMs = 0.0;
|
|
||||||
double srate = 0.0;
|
|
||||||
double channels = 0.0;
|
|
||||||
double renderSettings = 0.0;
|
|
||||||
double addToProj = 0.0;
|
|
||||||
double dither = 0.0; // RENDER_DITHER — snapshotted so user's setting is restored
|
|
||||||
double normalize = 0.0; // RENDER_NORMALIZE — snapshotted so user's setting is restored
|
|
||||||
double trimEnd = 0.0; // RENDER_TRIMEND — snapshotted so the Auto trim threshold is restored
|
|
||||||
|
|
||||||
// String settings (GetSetProjectInfo_String). Big buffers: REAPER writes the
|
|
||||||
// full value in, and RENDER_FORMAT is a base64 blob that can be long.
|
|
||||||
std::string renderFile;
|
|
||||||
std::string renderPattern;
|
|
||||||
std::string renderFormat;
|
|
||||||
|
|
||||||
bool captured = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
std::string getProjString(ReaProject* proj, const char* desc) {
|
|
||||||
std::vector<char> buf(4096, '\0');
|
|
||||||
GetSetProjectInfo_String(proj, desc, buf.data(), false);
|
|
||||||
return std::string(buf.data());
|
|
||||||
}
|
|
||||||
|
|
||||||
void setProjString(ReaProject* proj, const char* desc, const std::string& value) {
|
|
||||||
// GetSetProjectInfo_String takes a non-const char*; copy into a mutable buf.
|
|
||||||
std::vector<char> buf(value.begin(), value.end());
|
|
||||||
buf.push_back('\0');
|
|
||||||
GetSetProjectInfo_String(proj, desc, buf.data(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
void snapshotRenderSettings(RenderSettingsSnapshot& s, ReaProject* proj) {
|
|
||||||
s.proj = proj;
|
|
||||||
s.boundsFlag = GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", 0.0, false);
|
|
||||||
s.startPos = GetSetProjectInfo(proj, "RENDER_STARTPOS", 0.0, false);
|
|
||||||
s.endPos = GetSetProjectInfo(proj, "RENDER_ENDPOS", 0.0, false);
|
|
||||||
s.tailFlag = GetSetProjectInfo(proj, "RENDER_TAILFLAG", 0.0, false);
|
|
||||||
s.tailMs = GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, false);
|
|
||||||
s.srate = GetSetProjectInfo(proj, "RENDER_SRATE", 0.0, false);
|
|
||||||
s.channels = GetSetProjectInfo(proj, "RENDER_CHANNELS", 0.0, false);
|
|
||||||
s.renderSettings = GetSetProjectInfo(proj, "RENDER_SETTINGS", 0.0, false);
|
|
||||||
s.addToProj = GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, false);
|
|
||||||
s.dither = GetSetProjectInfo(proj, "RENDER_DITHER", 0.0, false);
|
|
||||||
s.normalize = GetSetProjectInfo(proj, "RENDER_NORMALIZE", 0.0, false);
|
|
||||||
s.trimEnd = GetSetProjectInfo(proj, "RENDER_TRIMEND", 0.0, false);
|
|
||||||
s.renderFile = getProjString(proj, "RENDER_FILE");
|
|
||||||
s.renderPattern = getProjString(proj, "RENDER_PATTERN");
|
|
||||||
s.renderFormat = getProjString(proj, "RENDER_FORMAT");
|
|
||||||
s.captured = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void restoreRenderSettings(const RenderSettingsSnapshot& s) {
|
|
||||||
if (!s.captured) return;
|
|
||||||
// Restore strings first, then numerics — order is not load-bearing since the
|
|
||||||
// fields are independent, but we mirror snapshot order for readability.
|
|
||||||
setProjString(s.proj, "RENDER_FILE", s.renderFile);
|
|
||||||
setProjString(s.proj, "RENDER_PATTERN", s.renderPattern);
|
|
||||||
setProjString(s.proj, "RENDER_FORMAT", s.renderFormat);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_BOUNDSFLAG", s.boundsFlag, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_STARTPOS", s.startPos, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_ENDPOS", s.endPos, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_TAILFLAG", s.tailFlag, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_TAILMS", s.tailMs, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_SRATE", s.srate, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_CHANNELS", s.channels, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_SETTINGS", s.renderSettings, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_ADDTOPROJ", s.addToProj, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_DITHER", s.dither, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_NORMALIZE", s.normalize, true);
|
|
||||||
GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// RAII wrapper: guarantees restore on every return path from capture().
|
|
||||||
struct ScopedRenderSettings {
|
|
||||||
RenderSettingsSnapshot snap;
|
|
||||||
explicit ScopedRenderSettings(ReaProject* proj) {
|
|
||||||
snapshotRenderSettings(snap, proj);
|
|
||||||
}
|
|
||||||
~ScopedRenderSettings() { restoreRenderSettings(snap); }
|
|
||||||
ScopedRenderSettings(const ScopedRenderSettings&) = delete;
|
|
||||||
ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete;
|
|
||||||
};
|
|
||||||
|
|
||||||
// A monotonic, filesystem-safe timestamp tag so repeated captures in one session
|
|
||||||
// do not collide on the file name. NOTE: the tag varies the file NAME, not the
|
|
||||||
// audio bytes — bit-identical-repeat is about identical *content* for identical
|
|
||||||
// requests; two deliberate captures naturally live in two files.
|
|
||||||
std::string makeUniqueTag() {
|
|
||||||
std::time_t now = std::time(nullptr);
|
|
||||||
return std::to_string(static_cast<long long>(now));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads the whole file into a byte buffer. Returns an empty vector on any I/O
|
|
||||||
// failure (the caller then leaves contentHash empty — the safe, confirm-eliciting
|
|
||||||
// direction for an unreadable file).
|
|
||||||
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
|
||||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
|
||||||
if (!f) return {};
|
|
||||||
const std::streamoff size = f.tellg();
|
|
||||||
if (size <= 0) return {};
|
|
||||||
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
|
|
||||||
f.seekg(0);
|
|
||||||
f.read(reinterpret_cast<char*>(bytes.data()), size);
|
|
||||||
if (!f) return {};
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|
||||||
CaptureResult result;
|
|
||||||
|
|
||||||
// Resolve the RENDER_SETTINGS source/processing bits for this mode + wet/dry
|
|
||||||
// (pure mapping, unit-tested in render_settings). An unsupported mode (only
|
|
||||||
// SourceMode::Realtime — that is the M8 realtime backend) is refused here so
|
|
||||||
// the offline path never silently renders the wrong thing.
|
|
||||||
const RenderSettingsChoice choice =
|
|
||||||
renderSettingsFor(request.sourceMode, request.wetDry);
|
|
||||||
if (!choice.supported) {
|
|
||||||
result.status = CaptureStatus::UnsupportedMode;
|
|
||||||
result.message = "OfflineRenderBackend does not render this source mode "
|
|
||||||
"(realtime capture is the M8 backend).";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact bounds: reject an empty/inverted range rather than render silence.
|
|
||||||
if (!(request.endSeconds > request.startSeconds)) {
|
|
||||||
result.status = CaptureStatus::EmptyRange;
|
|
||||||
result.message = "Capture range is empty (end <= start).";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current project (idx -1 == the active project tab). Verified: SDK header
|
|
||||||
// line ~1264, EnumProjects(int idx, char*, int).
|
|
||||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
|
||||||
if (!proj) {
|
|
||||||
result.status = CaptureStatus::NoProject;
|
|
||||||
result.message = "No active project.";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the project directory from the .rpp file path.
|
|
||||||
//
|
|
||||||
// Unsaved-project detection: we use EnumProjects(-1, buf, bufsz) to read
|
|
||||||
// the project's .rpp filename. Per SDK header line ~1262:
|
|
||||||
// EnumProjects(int idx, char* projfnOutOptional, int sz)
|
|
||||||
// "idx=-1 for current project, projfn can be NULL if not interested in filename."
|
|
||||||
// The out-parameter is the full path to the .rpp file, and is EMPTY for a
|
|
||||||
// project that has never been saved — making it a reliable unsaved sentinel.
|
|
||||||
//
|
|
||||||
// WHY NOT GetProjectPathEx: that function returns the project *recording path*
|
|
||||||
// (SDK header line ~2548: "Get the project recording path."), NOT the .rpp
|
|
||||||
// location. For an unsaved project it returns REAPER's default media/recording
|
|
||||||
// directory — never empty — so it cannot detect the unsaved state. Using it
|
|
||||||
// caused the original bug: the guard never fired, and captures landed in
|
|
||||||
// REAPER's default media location rather than alongside the .rpp.
|
|
||||||
//
|
|
||||||
// WHY NOT GetProjectPathEx for the saved-project dir: even for a saved project,
|
|
||||||
// GetProjectPathEx returns the recording path (which may be a media subfolder),
|
|
||||||
// not the .rpp parent directory. We need the .rpp parent so reasampler_bank/
|
|
||||||
// sits alongside the .rpp and travels with the project.
|
|
||||||
//
|
|
||||||
// FLOW:
|
|
||||||
// 1. Read .rpp path via EnumProjects(-1, buf, bufsz).
|
|
||||||
// 2. If non-empty (saved) -> derive project dir as parent of the .rpp.
|
|
||||||
// 3. If empty (unsaved) -> Main_SaveProject(proj, true) prompts Save-As.
|
|
||||||
// Re-read. If now non-empty -> proceed. If still empty (user cancelled) ->
|
|
||||||
// refuse CaptureStatus::NoProject, write nothing.
|
|
||||||
//
|
|
||||||
// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save/Save-As
|
|
||||||
// dialog and blocks until the user dismisses it. "true" = forceSaveAsIn.
|
|
||||||
// Verified SDK header line ~4599:
|
|
||||||
// void Main_SaveProject(ReaProject* proj, bool forceSaveAsInOptional)
|
|
||||||
// The blocking behaviour and dialog appearance can only be confirmed in a
|
|
||||||
// running REAPER.
|
|
||||||
auto readRppPath = [&]() -> std::string {
|
|
||||||
std::vector<char> buf(4096, '\0');
|
|
||||||
// EnumProjects(-1, ...) returns the active project and writes the .rpp
|
|
||||||
// path into buf. We already have the ReaProject* from the earlier call
|
|
||||||
// (nullptr-checked above), but calling EnumProjects again is the only
|
|
||||||
// stock, header-documented way to read the .rpp filename.
|
|
||||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
|
||||||
return std::string(buf.data());
|
|
||||||
};
|
|
||||||
|
|
||||||
std::string rppPath = readRppPath();
|
|
||||||
if (rppPath.empty()) {
|
|
||||||
// Project is unsaved. Prompt the user to choose a save location.
|
|
||||||
Main_SaveProject(proj, true);
|
|
||||||
// Re-read: non-empty if the user confirmed, still empty if cancelled.
|
|
||||||
rppPath = readRppPath();
|
|
||||||
}
|
|
||||||
if (rppPath.empty()) {
|
|
||||||
// User cancelled the save dialog — refuse, write nothing.
|
|
||||||
result.status = CaptureStatus::NoProject;
|
|
||||||
result.message = "Project must be saved before capture — nothing captured.";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the project directory as the parent folder of the .rpp file.
|
|
||||||
// std::filesystem::path handles both forward- and back-slash paths; .parent_path()
|
|
||||||
// gives the containing directory. Convert to forward-slash string so the rest
|
|
||||||
// of the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path.
|
|
||||||
const std::string projectDir = [&]() -> std::string {
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
std::string dir = fs::path(rppPath).parent_path().string();
|
|
||||||
// normalizeSlashes is in capture_paths (pure); replicate the transform
|
|
||||||
// inline here to avoid a cross-module dependency for a one-liner.
|
|
||||||
for (char& c : dir) { if (c == '\\') c = '/'; }
|
|
||||||
// Strip a single trailing slash (defensive; parent_path usually omits it).
|
|
||||||
if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
|
|
||||||
return dir;
|
|
||||||
}();
|
|
||||||
|
|
||||||
// Compute the unique tag ONCE so the file stem and Sample.id carry the same
|
|
||||||
// timestamp. Calling makeUniqueTag() twice could yield different values if a
|
|
||||||
// second boundary crosses between the two calls (bug: id and filename diverge).
|
|
||||||
const std::string uniqueTag = makeUniqueTag();
|
|
||||||
const BankPaths paths =
|
|
||||||
deriveBankPaths(projectDir, request.baseName, uniqueTag);
|
|
||||||
|
|
||||||
// Snapshot + auto-restore ALL render settings we are about to touch.
|
|
||||||
ScopedRenderSettings guard(proj);
|
|
||||||
|
|
||||||
// --- Drive the render settings (exact, deterministic) -------------------
|
|
||||||
// Custom time bounds so the rendered length equals the requested range with
|
|
||||||
// NO rounding and NO added silence (unless a tail was explicitly requested).
|
|
||||||
GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true);
|
|
||||||
GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
|
|
||||||
GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);
|
|
||||||
|
|
||||||
// Tail: TAILFLAG / TAILMS / NORMALIZE / TRIMEND all come from the pure mapping
|
|
||||||
// (render_settings.h, unit-tested). None -> exact bounds + disable-all normalize
|
|
||||||
// (byte-identical to the pre-tail path); Auto -> 8 s tail + surgical trim-end
|
|
||||||
// normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no trim.
|
|
||||||
// RENDER_NORMALIZE is driven HERE from the mapping (not the determinism block
|
|
||||||
// below) so the Auto surgical value is not clobbered — the snapshot guard restores
|
|
||||||
// the user's original RENDER_NORMALIZE / RENDER_TRIMEND on every exit path.
|
|
||||||
const TailRenderSettings tail =
|
|
||||||
tailRenderSettingsFor(request.tailMode, request.tailMs);
|
|
||||||
GetSetProjectInfo(proj, "RENDER_TAILFLAG",
|
|
||||||
static_cast<double>(tail.tailFlag), true);
|
|
||||||
GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true);
|
|
||||||
|
|
||||||
// Source-selection bits for this mode, from the pure render_settings mapping
|
|
||||||
// (verified against SDK header ~3041). All M7 actions are wet-only:
|
|
||||||
// master mix = 0; tracks = &128; items = &32|single-file; razor = &4096|single-file.
|
|
||||||
GetSetProjectInfo(proj, "RENDER_SETTINGS",
|
|
||||||
static_cast<double>(choice.settings), true);
|
|
||||||
|
|
||||||
// Resolve the effective sample rate. When the request carries 0 ("follow
|
|
||||||
// project"), read PROJECT_SRATE explicitly so RENDER_SRATE is set to the
|
|
||||||
// actual value — not left as 0 for REAPER to interpret. SDK header line ~3064:
|
|
||||||
// PROJECT_SRATE = sample rate (ignored unless PROJECT_SRATE_USE set); the
|
|
||||||
// value is still readable via GetSetProjectInfo even when _USE is clear.
|
|
||||||
const int effectiveSampleRate = (request.sampleRate > 0)
|
|
||||||
? request.sampleRate
|
|
||||||
: static_cast<int>(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false));
|
|
||||||
|
|
||||||
// Pin RENDER_SRATE only when the resolved rate is known (> 0). PROJECT_SRATE
|
|
||||||
// can read 0 on a project that has never explicitly pinned a sample rate (e.g.
|
|
||||||
// brand-new projects before the user has visited the project settings). Forcing
|
|
||||||
// RENDER_SRATE = 0 would re-introduce the "0 as literal" trap we fixed by
|
|
||||||
// moving away from blind passthrough. When the rate is unknown, leave
|
|
||||||
// RENDER_SRATE unset so REAPER follows its own project-rate default — which is
|
|
||||||
// correct behaviour for that project — rather than pinning a bogus 0.
|
|
||||||
if (effectiveSampleRate > 0) {
|
|
||||||
GetSetProjectInfo(proj, "RENDER_SRATE",
|
|
||||||
static_cast<double>(effectiveSampleRate), true);
|
|
||||||
}
|
|
||||||
GetSetProjectInfo(proj, "RENDER_CHANNELS",
|
|
||||||
static_cast<double>(request.channelCount), true);
|
|
||||||
|
|
||||||
// Load-bearing principle: do NOT add the rendered file to the project as an
|
|
||||||
// item. Clearing RENDER_ADDTOPROJ&1 keeps capture out of the arrange.
|
|
||||||
GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true);
|
|
||||||
|
|
||||||
// Determinism: disable dither so identical inputs produce bit-identical files
|
|
||||||
// and a dry capture nulls to silence. RENDER_DITHER &16 = disable all dither/
|
|
||||||
// noise-shaping (SDK header line ~3050). Snapshotted above; restored by the guard.
|
|
||||||
GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true);
|
|
||||||
|
|
||||||
// RENDER_NORMALIZE + RENDER_TRIMEND come from the tail mapping (above). None /
|
|
||||||
// Manual -> disable-all (byte-identical to the pre-tail path); Auto -> surgical
|
|
||||||
// trim-end (only &32768) + the -72 dB TRIMEND. A fixed-threshold trailing-silence
|
|
||||||
// trim scales/limits/fades nothing, so Auto stays deterministic and un-coloring
|
|
||||||
// (spec §surgical normalize). TRIMEND is only consulted when the trim bit is set,
|
|
||||||
// but we write it unconditionally (harmless when clear) so the value is explicit.
|
|
||||||
GetSetProjectInfo(proj, "RENDER_NORMALIZE",
|
|
||||||
static_cast<double>(tail.normalize), true);
|
|
||||||
GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true);
|
|
||||||
|
|
||||||
// Output location: directory (RENDER_FILE) + file stem (RENDER_PATTERN).
|
|
||||||
// RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the
|
|
||||||
// format extension. Use paths.fileStem — capture_paths owns the .wav suffix
|
|
||||||
// knowledge; re-stripping here would duplicate that coupling.
|
|
||||||
setProjString(proj, "RENDER_FILE", paths.absoluteDir);
|
|
||||||
setProjString(proj, "RENDER_PATTERN", paths.fileStem);
|
|
||||||
|
|
||||||
// Pin the WAV format using the ground-truth base64 blob for the chosen depth.
|
|
||||||
// Int16/Int24 are not implemented (no live-captured blob) — fail explicitly
|
|
||||||
// rather than silently mis-render at the wrong bit depth.
|
|
||||||
const char* fmtBase64 = wavSinkConfigBase64(request.bitDepth);
|
|
||||||
if (!fmtBase64) {
|
|
||||||
result.status = CaptureStatus::UnsupportedFormat;
|
|
||||||
result.message = "Requested bit depth has no verified RENDER_FORMAT blob "
|
|
||||||
"(M3 supports Float32 only; Int16/Int24 are M7+).";
|
|
||||||
return result;
|
|
||||||
// guard's dtor restores every RENDER_* setting here.
|
|
||||||
}
|
|
||||||
setProjString(proj, "RENDER_FORMAT", fmtBase64);
|
|
||||||
|
|
||||||
// --- Trigger the render -------------------------------------------------
|
|
||||||
// DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs
|
|
||||||
// the render synchronously on the current build. REAPER will show its
|
|
||||||
// offline-render progress window for the duration (see file-top comment —
|
|
||||||
// the progress UI is not suppressible via stock API).
|
|
||||||
Main_OnCommand(kActionRenderUsingMostRecentSettings, 0);
|
|
||||||
|
|
||||||
// --- Verify the output file exists ---------------------------------------
|
|
||||||
// Main_OnCommand returns void, so a failed render is silent. Stat the
|
|
||||||
// expected output path; if the file does not exist the render failed.
|
|
||||||
// Note: std::filesystem is used only in this REAPER-facing .cpp — the pure
|
|
||||||
// libs (capture_paths, bank_model) remain filesystem-free.
|
|
||||||
const std::string expectedPath = paths.absoluteDir + "/" + paths.fileName;
|
|
||||||
if (!std::filesystem::exists(expectedPath)) {
|
|
||||||
result.status = CaptureStatus::RenderFailed;
|
|
||||||
result.message = "Render produced no output file (expected: " +
|
|
||||||
expectedPath + "). Check the REAPER console for errors.";
|
|
||||||
return result;
|
|
||||||
// guard's dtor restores every RENDER_* setting here.
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Populate the Sample -------------------------------------------------
|
|
||||||
// We record the request's own bounds (exact) rather than re-measuring the
|
|
||||||
// file, so the Sample's range is precisely what was asked for.
|
|
||||||
Sample s;
|
|
||||||
// Use the same uniqueTag that named the file — calling makeUniqueTag() again
|
|
||||||
// here would risk a different timestamp if a second boundary crosses between
|
|
||||||
// the two calls, making Sample.id inconsistent with the file name.
|
|
||||||
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
|
|
||||||
s.displayName = request.baseName;
|
|
||||||
s.relativePath = paths.relativePath; // project-relative (invariant)
|
|
||||||
s.sourceMode = request.sourceMode;
|
|
||||||
s.sourceRange.startSeconds = request.startSeconds;
|
|
||||||
s.sourceRange.endSeconds = request.endSeconds;
|
|
||||||
// DEFERRED (M6/M7): startPpq, endPpq, and lengthBeats are left at 0.
|
|
||||||
// PPQ mapping via TimeMap2_timeToBeats is a musical-placement concern for the
|
|
||||||
// insert milestone; the model refuses to re-derive one bound from the other.
|
|
||||||
// Seconds are the authoritative source for the render. Do NOT add DAW-
|
|
||||||
// unverifiable PPQ resolution here — it requires a live REAPER to validate.
|
|
||||||
s.wetDry = request.wetDry;
|
|
||||||
// Track GUIDs for track-scoped captures (empty for master/items/razor). The
|
|
||||||
// caller resolved the selection to canonical GUID strings; we record them so a
|
|
||||||
// "re-capture from source" (M10) knows which tracks the sample came from.
|
|
||||||
s.trackGuids = request.trackGuids;
|
|
||||||
s.channelCount = request.channelCount;
|
|
||||||
// Store the resolved sample rate only when it is known (> 0). If the project
|
|
||||||
// never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE
|
|
||||||
// either, so the render ran at REAPER's project default — an unknown value from
|
|
||||||
// this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather
|
|
||||||
// than store a bogus literal; M6/M7 can fill it in by probing the rendered file.
|
|
||||||
s.sampleRate = effectiveSampleRate; // 0 when project rate was unknown
|
|
||||||
s.lengthSeconds = request.endSeconds - request.startSeconds;
|
|
||||||
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
|
|
||||||
// Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime
|
|
||||||
// (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time,
|
|
||||||
// int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at that
|
|
||||||
// project time, so a sample captured under 3/4 keeps a 3/4 read-out even if the
|
|
||||||
// project later switches to 4/4. proj=nullptr => the active project (matches the
|
|
||||||
// Master_GetTempo() call above, which is also active-project). The tempoOut is
|
|
||||||
// ignored — captureTempo already carries the master tempo. Leaves 0/0 (unstamped)
|
|
||||||
// if the API is somehow unavailable; the formatter renders a blank musical read-out.
|
|
||||||
{
|
|
||||||
int tsNum = 0, tsDenom = 0;
|
|
||||||
double tsTempo = 0.0;
|
|
||||||
TimeMap_GetTimeSigAtTime(nullptr, request.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
|
||||||
s.captureTimeSigNum = tsNum;
|
|
||||||
s.captureTimeSigDenom = tsDenom;
|
|
||||||
}
|
|
||||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
|
||||||
// Content hash: WAV-aware FNV-1a over the rendered file's fmt+data chunks so
|
|
||||||
// hashReferencedElsewhere can identify copies in other banks and suppress the
|
|
||||||
// last-reference confirm when another bank still holds the same file. Using
|
|
||||||
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
|
|
||||||
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders of identical
|
|
||||||
// audio collapse to the same hash. Best-effort: an unreadable file leaves
|
|
||||||
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats
|
|
||||||
// "" as non-participating in dedup, which is the existing fallback semantics).
|
|
||||||
{
|
|
||||||
const std::vector<std::uint8_t> fileBytes = readFileBytes(expectedPath);
|
|
||||||
if (!fileBytes.empty()) {
|
|
||||||
s.contentHash = hashWavContent(fileBytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
|
||||||
// Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a
|
|
||||||
// master mix / track / time-selection is not a single played note, so no root
|
|
||||||
// note is derivable here — we do NOT guess one. Loop points are set later by an
|
|
||||||
// explicit user action, not at capture. Leaving them empty is the honest default;
|
|
||||||
// the instrument (Phase S) treats an absent root note as "not a pitched sample".
|
|
||||||
|
|
||||||
result.status = CaptureStatus::Ok;
|
|
||||||
result.sample = s;
|
|
||||||
result.message = "Captured [" +
|
|
||||||
std::to_string(request.startSeconds) + "s, " +
|
|
||||||
std::to_string(request.endSeconds) + "s] -> " +
|
|
||||||
paths.relativePath;
|
|
||||||
return result;
|
|
||||||
// guard's dtor restores every RENDER_* setting here.
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
-234
@@ -1,234 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split).
|
|
||||||
//
|
|
||||||
// This header declares the capture *seam* the later milestones fill:
|
|
||||||
// * CaptureRequest — everything a capture needs, source-mode-agnostic.
|
|
||||||
// * ICaptureBackend — the SYNCHRONOUS interface OfflineRenderBackend implements
|
|
||||||
// (headless, immediate, returns a finished Sample).
|
|
||||||
// * OfflineRenderBackend — the deterministic default; drives the offline scopes.
|
|
||||||
// * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven
|
|
||||||
// across timer ticks; deliberately NOT an ICaptureBackend
|
|
||||||
// (see the SEAM CHOICE note at its declaration).
|
|
||||||
//
|
|
||||||
// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER
|
|
||||||
// headers — the .cpp is the REAPER-facing translation unit. Keeping this header
|
|
||||||
// REAPER-free lets callers (main.cpp, future actions.cpp) depend on the seam
|
|
||||||
// without dragging the SDK into every include site.
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "bank_model.h"
|
|
||||||
#include "render_settings.h" // TailMode (pure) — the three-state tail contract
|
|
||||||
|
|
||||||
// MediaTrack is forward-declared (like track_guid.h) so this header stays
|
|
||||||
// REAPER-free while RealtimeRecordBackend::begin can take the resolved source
|
|
||||||
// MediaTrack* to tap. The pointers are opaque here — never dereferenced in a
|
|
||||||
// pure/header context; only the REAPER-facing capture_realtime.cpp touches them.
|
|
||||||
class MediaTrack;
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// Audio bit-depth for the rendered wav. 32-bit float is the M3 default —
|
|
||||||
// rationale lives in capture.cpp next to the sink-config bytes.
|
|
||||||
enum class WavBitDepth {
|
|
||||||
Int16,
|
|
||||||
Int24,
|
|
||||||
Float32,
|
|
||||||
};
|
|
||||||
|
|
||||||
// One capture, independent of source mode. Populated by the caller (the action
|
|
||||||
// handler in M3; the action family in M7) and consumed by a backend.
|
|
||||||
//
|
|
||||||
// M3 fills only the fields the master-mix/time-selection path needs; the rest
|
|
||||||
// are declared now so M7/M8 do not reshape the struct (they are the seam).
|
|
||||||
struct CaptureRequest {
|
|
||||||
SourceMode sourceMode = SourceMode::MasterMix;
|
|
||||||
|
|
||||||
// Sample-accurate render bounds in project seconds. For the M3 spike these
|
|
||||||
// come straight from the time selection (GetSet_LoopTimeRange) — NO rounding.
|
|
||||||
double startSeconds = 0.0;
|
|
||||||
double endSeconds = 0.0;
|
|
||||||
|
|
||||||
// 1.0 = fully wet, 0.0 = fully dry. All three-scope capture actions set this to 1.0 (wet).
|
|
||||||
// The field is kept as the seam for future true-dry work (M10 null test):
|
|
||||||
// true pre-FX dry offline is NOT available via RENDER_SETTINGS — it requires
|
|
||||||
// FX-bypass-around-render or the M8 realtime pre-FX path, and will be
|
|
||||||
// designed alongside the M10 null test. Also recorded on the Sample.
|
|
||||||
double wetDry = 1.0;
|
|
||||||
|
|
||||||
// Track GUID(s) the capture came from, when the source mode is track-scoped
|
|
||||||
// (SelectedTracks). Empty for master/items/razor. The action layer (M7)
|
|
||||||
// resolves the selection to canonical GUID strings and passes them here; the
|
|
||||||
// backend copies them onto the Sample (it does NOT itself read the selection —
|
|
||||||
// it stays source-agnostic, driven entirely by the request).
|
|
||||||
std::vector<std::string> trackGuids;
|
|
||||||
|
|
||||||
// Render tail (docs/product/capture-tail.md §The three tail states). Default
|
|
||||||
// None: exact bounds, no added silence — the precision invariant, and the only
|
|
||||||
// mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for
|
|
||||||
// TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the
|
|
||||||
// 8 s cap + -72 dB trim internally, None ignores it.
|
|
||||||
TailMode tailMode = TailMode::None;
|
|
||||||
double tailMs = 0.0;
|
|
||||||
|
|
||||||
// Output format. 0 sampleRate => follow project rate (deterministic: the
|
|
||||||
// project rate is fixed for a given project).
|
|
||||||
int sampleRate = 0;
|
|
||||||
int channelCount = 2;
|
|
||||||
WavBitDepth bitDepth = WavBitDepth::Float32;
|
|
||||||
|
|
||||||
// Human base name for the file stem; sanitized by capture_paths. The unique
|
|
||||||
// tag (disambiguator) is supplied separately by the backend caller so the
|
|
||||||
// pure naming logic stays testable.
|
|
||||||
std::string baseName = "capture";
|
|
||||||
std::string uniqueTag; // e.g. a timestamp/counter; may be empty
|
|
||||||
};
|
|
||||||
|
|
||||||
// Outcome of a capture attempt. `Ok` carries the populated Sample; every failure
|
|
||||||
// is an explicit code (never a thrown exception across the REAPER boundary) so
|
|
||||||
// the action handler can log a precise reason.
|
|
||||||
enum class CaptureStatus {
|
|
||||||
Ok,
|
|
||||||
NoProject, // no active project to render / resolve a bank folder
|
|
||||||
EmptyRange, // start >= end: nothing to render
|
|
||||||
UnsupportedMode, // backend does not implement this source mode (M3 scope)
|
|
||||||
UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only)
|
|
||||||
RenderFailed, // the render action ran but produced no output file
|
|
||||||
TransportBusy, // realtime backend: transport already playing/recording — refused
|
|
||||||
};
|
|
||||||
|
|
||||||
struct CaptureResult {
|
|
||||||
CaptureStatus status = CaptureStatus::RenderFailed;
|
|
||||||
Sample sample; // valid only when status == Ok
|
|
||||||
std::string message; // human-readable detail for the console log
|
|
||||||
};
|
|
||||||
|
|
||||||
// The capture seam. One method: run a request, return a populated Sample (or a
|
|
||||||
// failure code). Backends are non-destructive — they must restore any global
|
|
||||||
// state they touch before returning (OfflineRenderBackend snapshots/restores the
|
|
||||||
// RENDER_* project settings).
|
|
||||||
class ICaptureBackend {
|
|
||||||
public:
|
|
||||||
virtual ~ICaptureBackend() = default;
|
|
||||||
virtual CaptureResult capture(const CaptureRequest& request) = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deterministic offline-render backend. Drives the full offline source family —
|
|
||||||
// master mix / time selection, selected tracks, selected items, razor area — all
|
|
||||||
// wet-only (render_settings.h) with optional tail. The source selection + range
|
|
||||||
// are resolved by the caller (the action layer) and handed in via the
|
|
||||||
// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection
|
|
||||||
// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend).
|
|
||||||
class OfflineRenderBackend : public ICaptureBackend {
|
|
||||||
public:
|
|
||||||
CaptureResult capture(const CaptureRequest& request) override;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Realtime-record backend: the ASYNC seam ---------------------------------
|
|
||||||
//
|
|
||||||
// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport
|
|
||||||
// on REAPER's audio thread and returns immediately — it does NOT block until the
|
|
||||||
// range completes, which takes (end - start) wall-clock seconds. Blocking the main
|
|
||||||
// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN
|
|
||||||
// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called
|
|
||||||
// from the same OnTimer that runs session.poll()) advances the in-flight record and
|
|
||||||
// reports when it is done.
|
|
||||||
//
|
|
||||||
// SEAM CHOICE (surfaced): RealtimeRecordBackend deliberately does NOT implement the
|
|
||||||
// synchronous ICaptureBackend — that interface returns a finished Sample from one
|
|
||||||
// call, which no longer fits a record that spans ticks. The two backends have
|
|
||||||
// genuinely different lifecycles (offline is headless + immediate; realtime is
|
|
||||||
// transport-driven + async), so forcing a shared async interface would make offline
|
|
||||||
// fake a lifecycle it does not have (its tick() would always be Done on the first
|
|
||||||
// call — dead code / an LSP smell). Offline stays synchronous and unchanged; the
|
|
||||||
// realtime backend owns this small bespoke async seam, driven by exactly one caller
|
|
||||||
// (main.cpp's OnTimer). This is the split-sync/async fork, chosen over a unified
|
|
||||||
// async interface for that reason.
|
|
||||||
|
|
||||||
// One tick's verdict from the in-flight record.
|
|
||||||
enum class RealtimeTickStatus {
|
|
||||||
InProgress, // still recording — call tick() again next timer tick
|
|
||||||
Done, // finished (range end reached, or the user stopped) — `result` is set
|
|
||||||
Failed, // an error tore the capture down — `result.message` explains
|
|
||||||
};
|
|
||||||
|
|
||||||
struct RealtimeTickResult {
|
|
||||||
RealtimeTickStatus status = RealtimeTickStatus::InProgress;
|
|
||||||
CaptureResult result; // meaningful only when status == Done or Failed
|
|
||||||
};
|
|
||||||
|
|
||||||
// The opaque in-flight capture state. Owns the snapshot of everything to restore
|
|
||||||
// (temp track + its receive sends from the source tracks, other tracks' I_RECARM,
|
|
||||||
// transport, edit cursor, time selection) and the record's own project handle.
|
|
||||||
// Defined in
|
|
||||||
// capture_realtime.cpp; the header stays REAPER-free (no MediaTrack*/ReaProject*
|
|
||||||
// leaks here) by holding it behind a forward-declared type + unique_ptr.
|
|
||||||
//
|
|
||||||
// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope
|
|
||||||
// RAII guard) because the record spans ticks — no single stack frame outlives it.
|
|
||||||
// Every terminal path (normal completion, user stop, error, project switch, unload)
|
|
||||||
// funnels through the same single restore, safe to call once from whichever fires.
|
|
||||||
class RealtimeCaptureState;
|
|
||||||
|
|
||||||
// Out-of-line deleter so callers (main.cpp) can own a unique_ptr to the opaque
|
|
||||||
// RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the delete is
|
|
||||||
// compiled in capture_realtime.cpp where the type is complete, keeping this header
|
|
||||||
// REAPER-free (load-bearing split).
|
|
||||||
struct RealtimeCaptureStateDeleter {
|
|
||||||
void operator()(RealtimeCaptureState* p) const noexcept;
|
|
||||||
};
|
|
||||||
using RealtimeCaptureHandle =
|
|
||||||
std::unique_ptr<RealtimeCaptureState, RealtimeCaptureStateDeleter>;
|
|
||||||
|
|
||||||
// Realtime-record backend — captures by RECORDING in realtime (transport-driven)
|
|
||||||
// into a hidden temp track, then moves the recorded file into the bank as a Sample.
|
|
||||||
// For sources offline render cannot do (hardware, performed FX) and as the true
|
|
||||||
// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline
|
|
||||||
// render has none). Dialog-free: never invokes the offline-render progress window.
|
|
||||||
//
|
|
||||||
// Non-bit-identical by nature (it is realtime); offline stays the deterministic
|
|
||||||
// default. Non-destructive across EVERY terminal path — the review gate — which is
|
|
||||||
// harder here than offline because the record spans ticks: the snapshot + restore
|
|
||||||
// live on RealtimeCaptureState, not a function-scope RAII destructor.
|
|
||||||
//
|
|
||||||
// SCOPE (this increment): TRACK scope only — records the selected track's OWN
|
|
||||||
// output (item + that track's own FX + its own fader/pan, PRE-parent), matching
|
|
||||||
// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's
|
|
||||||
// output is naturally PRE-parent (the parent has not summed it yet), so the tap is
|
|
||||||
// chain-independent by construction. Item realtime is deferred (UnsupportedMode).
|
|
||||||
class RealtimeRecordBackend {
|
|
||||||
public:
|
|
||||||
// Starts a realtime record: validates the request (track scope, non-empty range,
|
|
||||||
// at least one source track, active + saved project, transport idle), snapshots
|
|
||||||
// all state to restore, creates the hidden temp track, routes a send FROM each
|
|
||||||
// source track INTO the temp track, arms, and CSurf_OnRecord — then returns
|
|
||||||
// IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to
|
|
||||||
// tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free,
|
|
||||||
// carrying only the provenance GUIDs). On success the returned unique_ptr owns the
|
|
||||||
// in-flight state; drive it with tick(). On a validation/setup failure returns
|
|
||||||
// nullptr and fills `outFailure` with the CaptureStatus + message (nothing was
|
|
||||||
// left mutated — begin() restores on its own failure paths).
|
|
||||||
RealtimeCaptureHandle begin(const CaptureRequest& request,
|
|
||||||
const std::vector<MediaTrack*>& sourceTracks,
|
|
||||||
CaptureResult& outFailure);
|
|
||||||
|
|
||||||
// Advances the in-flight record one tick. Reads the transport (bound to the
|
|
||||||
// record's OWN project handle so a project switch cannot confuse it), and on a
|
|
||||||
// terminal verdict stops the transport, finalizes the recorded file into the
|
|
||||||
// bank Sample (Done) or reports the failure (Failed), then restores ALL
|
|
||||||
// snapshotted state. Returns InProgress while the record is still running.
|
|
||||||
// After Done/Failed the state is spent — the caller drops the unique_ptr.
|
|
||||||
RealtimeTickResult tick(RealtimeCaptureState& state);
|
|
||||||
|
|
||||||
// Force-terminate an in-flight record NOW without waiting for the range end:
|
|
||||||
// stops the transport, finalizes whatever was captured (best effort) or abandons
|
|
||||||
// it, and restores ALL snapshotted state. For the shutdown / project-switch
|
|
||||||
// paths (extension unload, a new project became active) where the record must
|
|
||||||
// not leak a temp track / armed track / altered transport into the user's
|
|
||||||
// project. Idempotent — safe even if a prior tick already tore the state down.
|
|
||||||
RealtimeTickResult abort(RealtimeCaptureState& state);
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
#include "capture_paths.h"
|
|
||||||
|
|
||||||
#include <cassert>
|
|
||||||
#include <cctype>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring> // std::memcmp
|
|
||||||
#include <filesystem>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
|
||||||
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
|
|
||||||
// Constants from the FNV spec (http://www.isthe.com/chongo/tech/comp/fnv/).
|
|
||||||
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
|
|
||||||
constexpr std::uint64_t kPrime = 1099511628211ULL;
|
|
||||||
std::uint64_t h = kOffsetBasis;
|
|
||||||
for (std::size_t i = 0; i < len; ++i) {
|
|
||||||
h ^= static_cast<std::uint64_t>(data[i]);
|
|
||||||
h *= kPrime;
|
|
||||||
}
|
|
||||||
// Format as 16-digit lowercase hex (zero-padded) for a fixed-length string.
|
|
||||||
char buf[17];
|
|
||||||
std::snprintf(buf, sizeof(buf), "%016llx",
|
|
||||||
static_cast<unsigned long long>(h));
|
|
||||||
return std::string(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
|
||||||
// Walk the RIFF/WAVE container and feed only the `fmt ` body and `data` body
|
|
||||||
// through FNV-1a, prefixed with the domain-separation tag byte 'W' (0x57).
|
|
||||||
// Any render-varying metadata chunks (bext, iXML, LIST, SMED, etc.) are skipped.
|
|
||||||
// If the file does not parse as RIFF/WAVE with both fmt and data chunks, fall back
|
|
||||||
// to whole-file hashBytes (no prefix) so an unrecognized file still gets a hash.
|
|
||||||
//
|
|
||||||
// The chunk-walk mirrors wav_trim::parseWavLayout's structure but accumulates
|
|
||||||
// FNV state instead of recording geometry — no second parser, same logic.
|
|
||||||
|
|
||||||
// FNV-1a 64-bit constants (same as hashBytes).
|
|
||||||
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
|
|
||||||
constexpr std::uint64_t kPrime = 1099511628211ULL;
|
|
||||||
|
|
||||||
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
|
|
||||||
auto tagEq = [&](std::size_t off, const char* tag) -> bool {
|
|
||||||
return off + 4 <= bytes.size() &&
|
|
||||||
std::memcmp(bytes.data() + off, tag, 4) == 0;
|
|
||||||
};
|
|
||||||
auto readU32LE = [&](std::size_t off) -> std::uint32_t {
|
|
||||||
return static_cast<std::uint32_t>(bytes[off]) |
|
|
||||||
(static_cast<std::uint32_t>(bytes[off + 1]) << 8) |
|
|
||||||
(static_cast<std::uint32_t>(bytes[off + 2]) << 16) |
|
|
||||||
(static_cast<std::uint32_t>(bytes[off + 3]) << 24);
|
|
||||||
};
|
|
||||||
|
|
||||||
bool isWav = bytes.size() >= 12 &&
|
|
||||||
tagEq(0, "RIFF") &&
|
|
||||||
tagEq(8, "WAVE");
|
|
||||||
|
|
||||||
if (isWav) {
|
|
||||||
// Accumulate FNV-1a starting with the domain-separation tag byte 'W'.
|
|
||||||
std::uint64_t h = kOffsetBasis;
|
|
||||||
auto feedByte = [&](std::uint8_t b) {
|
|
||||||
h ^= static_cast<std::uint64_t>(b);
|
|
||||||
h *= kPrime;
|
|
||||||
};
|
|
||||||
|
|
||||||
bool haveFmt = false;
|
|
||||||
bool haveData = false;
|
|
||||||
|
|
||||||
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
|
|
||||||
// whole-file hash of different bytes that happen to be the same length.
|
|
||||||
feedByte(static_cast<std::uint8_t>('W'));
|
|
||||||
|
|
||||||
std::size_t pos = 12;
|
|
||||||
while (pos + 8 <= bytes.size()) {
|
|
||||||
const std::size_t bodyOffset = pos + 8;
|
|
||||||
const std::uint32_t bodySize = readU32LE(pos + 4);
|
|
||||||
|
|
||||||
if (tagEq(pos, "fmt ")) {
|
|
||||||
// Feed the entire fmt body (all fields, including format tag, channels,
|
|
||||||
// sample rate, bits-per-sample — everything that defines the audio format).
|
|
||||||
if (bodyOffset + bodySize <= bytes.size()) {
|
|
||||||
for (std::uint32_t i = 0; i < bodySize; ++i)
|
|
||||||
feedByte(bytes[bodyOffset + i]);
|
|
||||||
haveFmt = true;
|
|
||||||
}
|
|
||||||
} else if (tagEq(pos, "data")) {
|
|
||||||
// Feed the entire PCM payload.
|
|
||||||
if (bodyOffset + bodySize <= bytes.size()) {
|
|
||||||
for (std::uint32_t i = 0; i < bodySize; ++i)
|
|
||||||
feedByte(bytes[bodyOffset + i]);
|
|
||||||
haveData = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.
|
|
||||||
|
|
||||||
// Advance past this chunk's body, honoring RIFF even-byte padding.
|
|
||||||
std::size_t advance = bodySize;
|
|
||||||
if (advance & 1u) ++advance; // RIFF pad byte
|
|
||||||
if (advance > bytes.size() - bodyOffset) break; // overrun guard
|
|
||||||
pos = bodyOffset + advance;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (haveFmt && haveData) {
|
|
||||||
char buf[17];
|
|
||||||
std::snprintf(buf, sizeof(buf), "%016llx",
|
|
||||||
static_cast<unsigned long long>(h));
|
|
||||||
return std::string(buf);
|
|
||||||
}
|
|
||||||
// Falls through to whole-file fallback if chunks were missing/malformed.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: not a parseable RIFF/WAVE — hash the whole file (same as the old
|
|
||||||
// per-call hashBytes). No prefix tag: identical to hashBytes(data, size).
|
|
||||||
return hashBytes(bytes.data(), bytes.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string normalizeSlashes(const std::string& path) {
|
|
||||||
std::string out = path;
|
|
||||||
for (char& c : out) {
|
|
||||||
if (c == '\\') c = '/';
|
|
||||||
}
|
|
||||||
// Strip a single trailing slash so joins do not double up. Preserve a lone
|
|
||||||
// "/" (root) — stripping it would turn root into empty.
|
|
||||||
if (out.size() > 1 && out.back() == '/') {
|
|
||||||
out.pop_back();
|
|
||||||
}
|
|
||||||
#ifdef _WIN32
|
|
||||||
// Windows paths are case-insensitive. Fold to lowercase so that two paths
|
|
||||||
// that differ only in drive-letter or component casing compare equal (e.g.
|
|
||||||
// "C:/Foo/BAR.wav" == "c:/foo/bar.wav"). On macOS/Linux, exact case is
|
|
||||||
// preserved (the filesystem is case-sensitive; folding would be wrong).
|
|
||||||
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
||||||
#endif
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string sanitizeStem(const std::string& baseName) {
|
|
||||||
std::string out;
|
|
||||||
out.reserve(baseName.size());
|
|
||||||
for (unsigned char c : baseName) {
|
|
||||||
const bool keep = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
|
||||||
(c >= '0' && c <= '9') || c == '.' || c == '_' ||
|
|
||||||
c == '-';
|
|
||||||
out.push_back(keep ? static_cast<char>(c) : '_');
|
|
||||||
}
|
|
||||||
// Collapse to a stable default if nothing usable survived (e.g. all spaces).
|
|
||||||
// A stem of only separators ('.', '_', '-') is also unhelpful as a name.
|
|
||||||
bool hasAlnum = false;
|
|
||||||
for (unsigned char c : out) {
|
|
||||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
|
||||||
(c >= '0' && c <= '9')) {
|
|
||||||
hasAlnum = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (out.empty() || !hasAlnum) {
|
|
||||||
return "capture";
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
BankPaths deriveBankPaths(const std::string& projectDir,
|
|
||||||
const std::string& baseName,
|
|
||||||
const std::string& uniqueTag) {
|
|
||||||
const std::string dir = normalizeSlashes(projectDir);
|
|
||||||
|
|
||||||
std::string stem = sanitizeStem(baseName);
|
|
||||||
if (!uniqueTag.empty()) {
|
|
||||||
stem += "_" + sanitizeStem(uniqueTag);
|
|
||||||
}
|
|
||||||
const std::string fileName = stem + ".wav";
|
|
||||||
|
|
||||||
// Precondition: the capture shell must resolve a non-empty project directory
|
|
||||||
// before calling this function. An empty projectDir would produce a bare
|
|
||||||
// relative "reasampler_bank" path — the silent default-location fallback this
|
|
||||||
// tool explicitly forbids. Assert in debug; leave absoluteDir empty in release
|
|
||||||
// so any caller that ignores the precondition fails loudly at the render/stat
|
|
||||||
// step rather than silently writing to CWD.
|
|
||||||
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
|
|
||||||
|
|
||||||
BankPaths p;
|
|
||||||
p.fileStem = stem; // stem only — REAPER appends extension
|
|
||||||
p.fileName = fileName;
|
|
||||||
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
|
|
||||||
// absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the
|
|
||||||
// directory itself; RENDER_PATTERN supplies the file name separately).
|
|
||||||
// Empty when precondition is violated (dir empty) — caller must not proceed.
|
|
||||||
p.absoluteDir = dir.empty() ? std::string{}
|
|
||||||
: dir + "/" + kBankSubfolder;
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string bankRelativeForName(const std::string& fileName) {
|
|
||||||
if (fileName.empty()) return {};
|
|
||||||
// The SAME expression deriveBankPaths uses for relativePath, kept in one place so
|
|
||||||
// the two spellings can never drift (Phase R spelling-consistency invariant).
|
|
||||||
return std::string(kBankSubfolder) + "/" + fileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string resolveBankFile(const std::string& projectDir,
|
|
||||||
const std::string& relativePath) {
|
|
||||||
// No default-location fallback (CLAUDE.md invariant): an empty project dir or
|
|
||||||
// relative path yields empty, not a bare relative path resolved against CWD.
|
|
||||||
if (projectDir.empty() || relativePath.empty()) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const std::string dir = normalizeSlashes(projectDir);
|
|
||||||
const std::string rel = normalizeSlashes(relativePath);
|
|
||||||
if (dir.empty() || rel.empty()) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return dir + "/" + rel;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string projectDirOfRpp(const std::string& rppPath) {
|
|
||||||
// An unsaved project reports an empty .rpp path; keep it empty so downstream
|
|
||||||
// resolution refuses (no default-location fallback). Mirrors persist.cpp's prior
|
|
||||||
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
|
|
||||||
if (rppPath.empty()) return {};
|
|
||||||
std::string dir = std::filesystem::path(rppPath).parent_path().string();
|
|
||||||
return normalizeSlashes(dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
|
||||||
const std::string& newProjectDir) {
|
|
||||||
BankRelocation r;
|
|
||||||
if (oldProjectDir.empty() || newProjectDir.empty()) {
|
|
||||||
return r; // needed=false, empty dirs — nothing to relocate
|
|
||||||
}
|
|
||||||
const std::string oldDir = normalizeSlashes(oldProjectDir);
|
|
||||||
const std::string newDir = normalizeSlashes(newProjectDir);
|
|
||||||
|
|
||||||
r.oldBankDir = oldDir + "/" + kBankSubfolder;
|
|
||||||
r.newBankDir = newDir + "/" + kBankSubfolder;
|
|
||||||
// A Save (in place) leaves the project dir unchanged — nothing to relocate.
|
|
||||||
// Only a Save-As to a different directory needs the bank moved.
|
|
||||||
r.needed = (oldDir != newDir);
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
ProjectTransition classifyProjectTransition(bool sameProjectObject,
|
|
||||||
const std::string& lastGuid,
|
|
||||||
const std::string& lastPath,
|
|
||||||
const std::string& currentGuid,
|
|
||||||
const std::string& currentPath) {
|
|
||||||
// 1. The GUID is the identity of record and is checked FIRST. A different
|
|
||||||
// stored GUID means a genuinely different project is active — Load ITS index.
|
|
||||||
// This catches the regression that pointer-primary classification missed:
|
|
||||||
// REAPER RECYCLES ReaProject* addresses across close/open, so a reopened /
|
|
||||||
// new project can reuse the previous project's address (sameProjectObject ==
|
|
||||||
// true) while carrying a different stored GUID. Deciding on the pointer alone
|
|
||||||
// then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is
|
|
||||||
// immune to address recycling, so it leads. Also covers new/unsaved<->saved
|
|
||||||
// transitions (one GUID empty, the other not) and switching between two
|
|
||||||
// distinct saved projects.
|
|
||||||
if (currentGuid != lastGuid) {
|
|
||||||
return ProjectTransition::Load;
|
|
||||||
}
|
|
||||||
|
|
||||||
// From here currentGuid == lastGuid (they are equal; both may be empty for
|
|
||||||
// unsaved projects). The pointer now disambiguates the same-GUID case.
|
|
||||||
|
|
||||||
// 2. Same GUID but a DIFFERENT object is a forked sibling: Save-As copied our
|
|
||||||
// GUID onto a distinct project object. Load its (own) index; never relocate.
|
|
||||||
// Two unsaved projects (both GUIDs empty, distinct objects) also land here —
|
|
||||||
// Load, so switching between them installs the right in-memory state.
|
|
||||||
if (!sameProjectObject) {
|
|
||||||
return ProjectTransition::Load;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object
|
|
||||||
// identity is proven and the record identity is unchanged — only the .rpp
|
|
||||||
// moved). Also the first save of an unsaved project (both GUIDs empty, old
|
|
||||||
// path empty): SaveAsRelocate is safe there because deriveRelocationPlan
|
|
||||||
// no-ops on the empty old dir (empty-GUID safety preserved) while poll()
|
|
||||||
// mints a GUID.
|
|
||||||
if (currentPath != lastPath) {
|
|
||||||
return ProjectTransition::SaveAsRelocate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Same object, same GUID, same path — Save in place / idle tick.
|
|
||||||
return ProjectTransition::NoOp;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// capture_paths — the REAPER-free path arithmetic behind offline capture.
|
|
||||||
//
|
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
|
||||||
// vendor/ includes. Standard library only. The capture shell resolves the
|
|
||||||
// current project directory via REAPER APIs, then hands the raw strings here so
|
|
||||||
// the fiddly, easy-to-get-wrong path arithmetic (bank subfolder, unique file
|
|
||||||
// name, absolute render dir, project-relative index path) is unit-tested outside
|
|
||||||
// the DAW.
|
|
||||||
//
|
|
||||||
// Path convention: this module works in forward-slash form and does NOT touch
|
|
||||||
// the filesystem. The bank subfolder name is a fixed constant so the same
|
|
||||||
// project always resolves the same bank location (determinism).
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// The project-relative bank subfolder. All captured wavs live here so the bank
|
|
||||||
// travels with the .rpp (CONTEXT.md §Settled decisions: per-project bank).
|
|
||||||
inline constexpr const char* kBankSubfolder = "reasampler_bank";
|
|
||||||
|
|
||||||
// A resolved pair of paths for one capture: where REAPER must be told to write
|
|
||||||
// (absolute, because RENDER_FILE wants a directory REAPER can create/open) and
|
|
||||||
// what we store in the BankIndex (project-relative, because the index is
|
|
||||||
// relative-paths-only — CLAUDE.md precision invariant).
|
|
||||||
struct BankPaths {
|
|
||||||
std::string absoluteDir; // <projectDir>/reasampler_bank (forward slash)
|
|
||||||
std::string relativePath; // reasampler_bank/<fileName> (index value)
|
|
||||||
std::string fileName; // <stem>.wav (full file name)
|
|
||||||
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data`
|
|
||||||
// and returns it as a 16-character lowercase hex string. Designed to fill
|
|
||||||
// Sample::contentHash so the confirm-on-last-reference guardrail
|
|
||||||
// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this
|
|
||||||
// file" from "another bank holds the same file." An empty buffer returns the bare
|
|
||||||
// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty
|
|
||||||
// files would share, but real WAV files are never empty).
|
|
||||||
std::string hashBytes(const std::uint8_t* data, std::size_t len);
|
|
||||||
|
|
||||||
// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float
|
|
||||||
// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all
|
|
||||||
// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED).
|
|
||||||
//
|
|
||||||
// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a
|
|
||||||
// `bext` chunk containing the origination date/time) even when the format config blob
|
|
||||||
// requests no BWF metadata. Two renders of identical audio therefore differ in those
|
|
||||||
// bytes, making whole-file hashes diverge and preventing dedup collapse.
|
|
||||||
//
|
|
||||||
// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before
|
|
||||||
// the fmt/data bytes are fed in, so a content hash can never equal a whole-file
|
|
||||||
// hashBytes result for a different file of the same size.
|
|
||||||
//
|
|
||||||
// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a
|
|
||||||
// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) —
|
|
||||||
// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an
|
|
||||||
// unrecognized or malformed file still gets a non-empty hash rather than silently
|
|
||||||
// skipping dedup.
|
|
||||||
//
|
|
||||||
// Called by both capture commit paths (offline and realtime) in place of the raw
|
|
||||||
// hashBytes call.
|
|
||||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
|
|
||||||
|
|
||||||
// Normalizes a path to forward slashes and strips any trailing slash. Empty in
|
|
||||||
// -> empty out. Pure string transform (does not consult the filesystem).
|
|
||||||
// Platform case rule: on Windows (_WIN32) the result is also lowercased so that
|
|
||||||
// paths differing only in drive-letter or component casing compare equal (Windows
|
|
||||||
// paths are case-insensitive). On macOS/Linux the case is preserved exactly (those
|
|
||||||
// filesystems are case-sensitive).
|
|
||||||
std::string normalizeSlashes(const std::string& path);
|
|
||||||
|
|
||||||
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
|
|
||||||
// [A-Za-z0-9._-], replaces every other byte (spaces, slashes, quotes, control)
|
|
||||||
// with '_', and collapses to "capture" if nothing usable remains. Deterministic:
|
|
||||||
// the same input always yields the same stem (feeds bit-identical file naming).
|
|
||||||
std::string sanitizeStem(const std::string& baseName);
|
|
||||||
|
|
||||||
// Derives the bank paths for one capture.
|
|
||||||
// projectDir : absolute directory of the current .rpp (any slash style)
|
|
||||||
// baseName : human base for the file stem (sanitized)
|
|
||||||
// uniqueTag : caller-supplied disambiguator appended to the stem (e.g. a
|
|
||||||
// timestamp or counter) so repeated captures do not collide.
|
|
||||||
// Also sanitized. May be empty.
|
|
||||||
// Produces "<stem>[_<tag>].wav". The relativePath is always project-relative and
|
|
||||||
// forward-slashed so it satisfies BankIndex::add's relative-only invariant.
|
|
||||||
BankPaths deriveBankPaths(const std::string& projectDir,
|
|
||||||
const std::string& baseName,
|
|
||||||
const std::string& uniqueTag);
|
|
||||||
|
|
||||||
// The project-relative index spelling for a bank file KNOWN ONLY by its file name —
|
|
||||||
// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder
|
|
||||||
// entry the SAME way deriveBankPaths spelled it at capture time. By construction it
|
|
||||||
// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder +
|
|
||||||
// "/" + fileName), so a file the capture path created and a directory listing of that
|
|
||||||
// same file resolve to the byte-identical relative string — the safety-critical
|
|
||||||
// spelling-consistency the prune core's exact-string match depends on (a divergence
|
|
||||||
// here could make a referenced file look like an orphan). fileName is a bare entry
|
|
||||||
// name (no directory component); the caller supplies forward-slash-free names from the
|
|
||||||
// folder enumeration. Empty in -> empty out.
|
|
||||||
std::string bankRelativeForName(const std::string& fileName);
|
|
||||||
|
|
||||||
// --- Persist-side path arithmetic (M4) --------------------------------------
|
|
||||||
//
|
|
||||||
// The index stores relative paths only; on project load the persist shell must
|
|
||||||
// turn each entry's relativePath back into an absolute path against the CURRENT
|
|
||||||
// project directory (so a project opened from a new location still resolves its
|
|
||||||
// bank). This is the inverse of the relativePath the capture path produced.
|
|
||||||
//
|
|
||||||
// projectDir : absolute directory of the current .rpp (any slash style)
|
|
||||||
// relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav")
|
|
||||||
//
|
|
||||||
// Returns "<projectDir>/<relativePath>" forward-slashed. Returns empty when
|
|
||||||
// either input is empty (no default-location fallback — CLAUDE.md invariant) so
|
|
||||||
// a caller that ignores an unsaved/unset project fails loudly rather than
|
|
||||||
// resolving against CWD.
|
|
||||||
std::string resolveBankFile(const std::string& projectDir,
|
|
||||||
const std::string& relativePath);
|
|
||||||
|
|
||||||
// The project directory that holds a .rpp: its parent directory, forward-slashed,
|
|
||||||
// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty
|
|
||||||
// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the
|
|
||||||
// no-default-location invariant). This is the M4 convention persist uses to place
|
|
||||||
// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves
|
|
||||||
// audio paths the SAME way persist does rather than re-implementing the derivation.
|
|
||||||
std::string projectDirOfRpp(const std::string& rppPath);
|
|
||||||
|
|
||||||
// A relocation plan for the physical bank folder on Save-As to a new project
|
|
||||||
// location. The index's relative paths do NOT change (they are relative to the
|
|
||||||
// project dir, which is what moved with the .rpp), so relocation is purely a
|
|
||||||
// folder move: copy/move the whole bank subfolder from the old project dir to
|
|
||||||
// the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
|
|
||||||
struct BankRelocation {
|
|
||||||
std::string oldBankDir; // <oldProjectDir>/reasampler_bank
|
|
||||||
std::string newBankDir; // <newProjectDir>/reasampler_bank
|
|
||||||
bool needed = false; // false when old==new (Save in place, not Save-As)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Derives the relocation plan from the old and new project directories.
|
|
||||||
// oldProjectDir : project dir the bank currently sits under (any slash style)
|
|
||||||
// newProjectDir : project dir the .rpp was just saved to (any slash style)
|
|
||||||
// `needed` is true iff the normalized dirs differ (a genuine Save-As-to-new-dir).
|
|
||||||
// Returns a plan with empty dirs and needed=false when either input is empty.
|
|
||||||
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
|
||||||
const std::string& newProjectDir);
|
|
||||||
|
|
||||||
// --- Project-identity transition (W12 combined identity fix) -----------------
|
|
||||||
//
|
|
||||||
// What the persist timer must do on each tick. Identity rests on TWO facts,
|
|
||||||
// layered GUID-PRIMARY:
|
|
||||||
// 1. the minted GUID — content-based identity of record, stored in ext state.
|
|
||||||
// It is IMMUNE to REAPER recycling a closed project's ReaProject* address,
|
|
||||||
// so it is checked FIRST.
|
|
||||||
// 2. sameProjectObject — did the same live ReaProject* stay active across the
|
|
||||||
// two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to
|
|
||||||
// disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID
|
|
||||||
// onto a distinct object) vs a genuine Save-As (one object, new path).
|
|
||||||
//
|
|
||||||
// This fix layers both prior designs, GUID-primary. M4 (GUID-only) broke Save-As
|
|
||||||
// forks: Save-As copies the whole .rpp incl. our stored GUID, so a fork and its
|
|
||||||
// parent share a GUID on disk. W10 (pointer-primary, GUID voided) broke pointer
|
|
||||||
// RECYCLING: REAPER reuses a closed project's address, so a reopened/new project
|
|
||||||
// can present the previous project's pointer with a different stored GUID —
|
|
||||||
// pointer-primary read that as NoOp/SaveAsRelocate and the bank never reloaded.
|
|
||||||
// Checking the GUID first catches recycling; the pointer then separates a fork
|
|
||||||
// (same GUID, different object -> Load) from a Save-As (same GUID, same object,
|
|
||||||
// new path -> relocate).
|
|
||||||
//
|
|
||||||
// The load-bearing rule: a DIFFERENT record identity (GUID) is always a Load; a
|
|
||||||
// DIFFERENT project object with the same GUID is a fork Load, never a relocate.
|
|
||||||
enum class ProjectTransition {
|
|
||||||
NoOp, // same object, same GUID, same location — nothing to do
|
|
||||||
Load, // a different project is active — load ITS index from ext state
|
|
||||||
SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank
|
|
||||||
};
|
|
||||||
|
|
||||||
// Classifies what a poll tick observed.
|
|
||||||
// sameProjectObject : true iff the SAME ReaProject* stayed active across the two
|
|
||||||
// ticks (poll() computes `proj == lastProject_`). The pure
|
|
||||||
// classifier takes the bool, not the raw pointer, to stay
|
|
||||||
// REAPER-free and testable.
|
|
||||||
// lastGuid : the GUID of the project persist last acted on ("" if none/unsaved)
|
|
||||||
// lastPath : that project's .rpp path when last seen ("" if unsaved)
|
|
||||||
// currentGuid : the GUID stored in the now-active project's ext state ("" if
|
|
||||||
// unsaved or never written)
|
|
||||||
// currentPath : the now-active project's .rpp path ("" if unsaved)
|
|
||||||
//
|
|
||||||
// Rules (evaluated in EXACTLY this order):
|
|
||||||
// 1. currentGuid != lastGuid -> Load (different record identity:
|
|
||||||
// recycled pointer w/ different GUID,
|
|
||||||
// new/unsaved<->saved, or two distinct
|
|
||||||
// saved projects)
|
|
||||||
// 2. !sameProjectObject -> Load (same GUID, different object:
|
|
||||||
// forked sibling, or two unsaved projects)
|
|
||||||
// 3. currentPath != lastPath -> SaveAsRelocate (same object + same GUID,
|
|
||||||
// new path: genuine Save-As, or first save
|
|
||||||
// of an unsaved project — relocate no-ops
|
|
||||||
// on the empty old dir, poll() mints a GUID)
|
|
||||||
// 4. otherwise -> NoOp (same object, same GUID, same path)
|
|
||||||
//
|
|
||||||
// The GUID (identity of record) leads; the pointer only disambiguates the same-GUID
|
|
||||||
// case (fork-Load in step 2 vs Save-As in step 3). The empty-GUID safety (unsaved
|
|
||||||
// projects never physically relocate) is preserved because an empty old project dir
|
|
||||||
// makes deriveRelocationPlan's `needed` false.
|
|
||||||
ProjectTransition classifyProjectTransition(bool sameProjectObject,
|
|
||||||
const std::string& lastGuid,
|
|
||||||
const std::string& lastPath,
|
|
||||||
const std::string& currentGuid,
|
|
||||||
const std::string& currentPath);
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -1,867 +0,0 @@
|
|||||||
// capture_realtime.cpp — REAPER-facing realtime-record backend (RealtimeRecordBackend).
|
|
||||||
//
|
|
||||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
|
||||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
|
||||||
// pointers; here they are extern (CLAUDE.md §contract).
|
|
||||||
//
|
|
||||||
// Captures the requested scope over the requested range by RECORDING in realtime
|
|
||||||
// (transport-driven) into a hidden temp track, then moves the recorded file into
|
|
||||||
// the bank as a Sample — non-destructively. This increment implements the TRACK
|
|
||||||
// scope only (records the selected track's own output). Item realtime is deferred
|
|
||||||
// (UnsupportedMode) rather than silently half-built.
|
|
||||||
//
|
|
||||||
// ============================================================================
|
|
||||||
// §ASYNC — timer-driven, no UI block (M8 rework — Daniel: "do it right")
|
|
||||||
// ============================================================================
|
|
||||||
// A realtime record takes (end - start) wall-clock seconds. The earlier spike ran a
|
|
||||||
// bounded MAIN-THREAD wait for the transport to reach the range end — which FREEZES
|
|
||||||
// REAPER's UI for the whole record. That is gone. The record is now driven across
|
|
||||||
// timer ticks:
|
|
||||||
// begin() — validate, snapshot ALL state to restore, create the temp track,
|
|
||||||
// route the source-track tap, arm, CSurf_OnRecord, RETURN IMMEDIATELY.
|
|
||||||
// tick() — (from OnTimer, the same tick as session.poll()) read the transport,
|
|
||||||
// and on a terminal verdict stop + finalize/abort + RESTORE everything.
|
|
||||||
// abort() — force-terminate now (shutdown / project switch) + RESTORE everything.
|
|
||||||
//
|
|
||||||
// The snapshot + restore live on RealtimeCaptureState (below), NOT a function-scope
|
|
||||||
// RAII guard — because the record spans ticks, no single stack frame outlives it.
|
|
||||||
// restore() is idempotent (a restored_ latch): every terminal path — normal
|
|
||||||
// completion, user stop, error, second-capture reject, project switch, unload —
|
|
||||||
// funnels through the SAME single restore, safe to call once from whichever fires.
|
|
||||||
// The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the
|
|
||||||
// completion state machine (advanceRecordPhase) all live in realtime_record.{h,cpp}
|
|
||||||
// (unit-tested outside the DAW). This TU owns only the REAPER-bound recipe.
|
|
||||||
//
|
|
||||||
// ============================================================================
|
|
||||||
// §TAP — track-output tap (selected track's own output, PRE-parent)
|
|
||||||
// ============================================================================
|
|
||||||
// The recipe: the hidden temp track RECEIVES a send FROM each selected source track
|
|
||||||
// (CreateTrackSend(source, temp)). The temp track records its OWN output
|
|
||||||
// (I_RECMODE 3/6, latency-compensated) with B_MAINSEND=0 (it does NOT sum back into
|
|
||||||
// the master — no feedback, no monitoring double). Multiple selected tracks each get
|
|
||||||
// a send into the one temp track, so their outputs SUM in the temp track — matching
|
|
||||||
// how offline track scope handles a multi-track selection.
|
|
||||||
//
|
|
||||||
// WHY THIS FAITHFULLY CAPTURES THE TRACK'S OUTPUT — and why NO FxBypassGuard:
|
|
||||||
// A CreateTrackSend defaults to I_SENDMODE=0 (post-fader) with I_SRCCHAN=0
|
|
||||||
// (channel offset 0, (srcchan>>10)==0 => full stereo — SDK ~3302/3304). Post-fader
|
|
||||||
// taps the source track AFTER its own FX and AFTER its own fader/pan — i.e. exactly
|
|
||||||
// the track's OWN OUTPUT — but BEFORE the parent/folder/master sums it. The send is
|
|
||||||
// a branch off the signal at the track's output stage; the parent chain downstream
|
|
||||||
// of that branch is not in the tapped path AT ALL. So the tap is chain-independent
|
|
||||||
// BY CONSTRUCTION: there is nothing to neutralize, and FxBypassGuard (which mutates
|
|
||||||
// the live chain, altering the user's monitoring) is deliberately NOT used. This is
|
|
||||||
// the realtime analogue of offline track scope (item + the track's own FX + its own
|
|
||||||
// fader/pan; parent/folder/master excluded), reached without touching any live FX.
|
|
||||||
//
|
|
||||||
// This ALSO fixes the earlier silent-file bug: that spike sent FROM the master INTO
|
|
||||||
// a temp track, which REAPER refuses to carry (master->track is a feedback loop), so
|
|
||||||
// the temp recorded silence. A regular track->track send has no feedback — it works.
|
|
||||||
//
|
|
||||||
// Non-destructive: the temp track is deleted on teardown, which removes every send we
|
|
||||||
// created INTO it (REAPER cannot leave a send dangling to a deleted destination) — so
|
|
||||||
// NO source track retains any routing change. We never mutate any existing track's
|
|
||||||
// persistent state; we only add sends FROM the source tracks that vanish with the
|
|
||||||
// temp track. The selected source tracks are UNCHANGED after capture.
|
|
||||||
//
|
|
||||||
// Item realtime is deferred (UnsupportedMode): item scope would need per-item take
|
|
||||||
// isolation on top of the tap, which is a separate increment.
|
|
||||||
|
|
||||||
#include "capture.h"
|
|
||||||
|
|
||||||
#include <chrono>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstring>
|
|
||||||
#include <ctime>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <fstream>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "capture_paths.h" // hashBytes, deriveBankPaths
|
|
||||||
#include "peaks.h" // lastFrameAboveThreshold, AudioSample
|
|
||||||
#include "realtime_record.h"
|
|
||||||
#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
|
|
||||||
#include "wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate
|
|
||||||
|
|
||||||
#define REAPERAPI_MINIMAL
|
|
||||||
#define REAPERAPI_WANT_EnumProjects
|
|
||||||
#define REAPERAPI_WANT_Main_SaveProject
|
|
||||||
#define REAPERAPI_WANT_Master_GetTempo
|
|
||||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
|
||||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
|
||||||
#define REAPERAPI_WANT_InsertTrackAtIndex
|
|
||||||
#define REAPERAPI_WANT_DeleteTrack
|
|
||||||
#define REAPERAPI_WANT_CountTracks
|
|
||||||
#define REAPERAPI_WANT_GetTrack
|
|
||||||
#define REAPERAPI_WANT_CreateTrackSend
|
|
||||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
|
||||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
|
||||||
#define REAPERAPI_WANT_GetTrackNumMediaItems
|
|
||||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
|
||||||
#define REAPERAPI_WANT_GetMediaItemTake
|
|
||||||
#define REAPERAPI_WANT_GetMediaItemTake_Source
|
|
||||||
#define REAPERAPI_WANT_GetMediaSourceFileName
|
|
||||||
#define REAPERAPI_WANT_CSurf_OnRecord
|
|
||||||
#define REAPERAPI_WANT_OnStopButtonEx
|
|
||||||
#define REAPERAPI_WANT_GetPlayStateEx
|
|
||||||
#define REAPERAPI_WANT_GetPlayPositionEx
|
|
||||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
|
||||||
#define REAPERAPI_WANT_GetCursorPosition
|
|
||||||
#define REAPERAPI_WANT_SetEditCurPos
|
|
||||||
#define REAPERAPI_WANT_ValidatePtr2
|
|
||||||
#include "reaper_plugin_functions.h"
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// A monotonic, filesystem-safe timestamp tag so repeated captures do not collide.
|
|
||||||
std::string makeUniqueTag() {
|
|
||||||
std::time_t now = std::time(nullptr);
|
|
||||||
return "rt-" + std::to_string(static_cast<long long>(now));
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string normSlashes(std::string s) {
|
|
||||||
for (char& c : s) if (c == '\\') c = '/';
|
|
||||||
if (s.size() > 1 && s.back() == '/') s.pop_back();
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads the ACTIVE project's .rpp path (empty if unsaved). Only needed at begin()
|
|
||||||
// time, when the record's project IS the active project.
|
|
||||||
std::string readRppPath() {
|
|
||||||
std::vector<char> buf(4096, '\0');
|
|
||||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
|
||||||
return std::string(buf.data());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discovers the file REAPER actually recorded onto the temp track: the first media
|
|
||||||
// item's active take's source file. Empty string if nothing was recorded.
|
|
||||||
std::string recordedFilePath(MediaTrack* temp) {
|
|
||||||
if (!temp) return {};
|
|
||||||
if (GetTrackNumMediaItems(temp) <= 0) return {};
|
|
||||||
MediaItem* item = GetTrackMediaItem(temp, 0);
|
|
||||||
if (!item) return {};
|
|
||||||
MediaItem_Take* take = GetMediaItemTake(item, 0);
|
|
||||||
if (!take) return {};
|
|
||||||
PCM_source* src = GetMediaItemTake_Source(take);
|
|
||||||
if (!src) return {};
|
|
||||||
std::vector<char> buf(4096, '\0');
|
|
||||||
GetMediaSourceFileName(src, buf.data(), static_cast<int>(buf.size()));
|
|
||||||
return std::string(buf.data());
|
|
||||||
}
|
|
||||||
|
|
||||||
// The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no
|
|
||||||
// item/take/source, or the file does not exist on disk this tick). Used by the flush
|
|
||||||
// wait to detect stability (size unchanged across a tick) BEFORE moving the file — a
|
|
||||||
// take REAPER is still flushing on the audio thread grows tick over tick.
|
|
||||||
std::int64_t recordedFileSize(MediaTrack* temp) {
|
|
||||||
const std::string path = recordedFilePath(temp);
|
|
||||||
if (path.empty()) return -1;
|
|
||||||
std::error_code ec;
|
|
||||||
const auto sz = std::filesystem::file_size(path, ec);
|
|
||||||
if (ec) return -1;
|
|
||||||
return static_cast<std::int64_t>(sz);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// RealtimeCaptureState — the in-flight snapshot + idempotent restore
|
|
||||||
// ============================================================================
|
|
||||||
// Holds EVERYTHING to restore across the many ticks the record spans (temp track +
|
|
||||||
// its receive-sum sends, other tracks' I_RECARM, transport, edit cursor, time selection),
|
|
||||||
// plus the request echo needed to finalize the Sample. restore() is idempotent
|
|
||||||
// (restored_ latch) and is the single teardown every terminal path calls.
|
|
||||||
class RealtimeCaptureState {
|
|
||||||
public:
|
|
||||||
// Bound at begin(): the record's OWN project (transport reads use *Ex(proj_) so
|
|
||||||
// a project switch mid-record cannot read the wrong transport), the request
|
|
||||||
// echo, and the resolved bank paths + tag for finalize.
|
|
||||||
ReaProject* proj_ = nullptr;
|
|
||||||
CaptureRequest request_;
|
|
||||||
BankPaths paths_;
|
|
||||||
std::string uniqueTag_;
|
|
||||||
|
|
||||||
// The RECORDED window end in project seconds (>= request_.endSeconds). For a tail
|
|
||||||
// mode the transport runs PAST the range end (Auto: +8 s cap; Manual: +the set
|
|
||||||
// length), so this — not request_.endSeconds — is the end the completion state
|
|
||||||
// machine waits for. Equals request_.endSeconds for TailMode::None (exact bounds).
|
|
||||||
double recordWindowEnd_ = 0.0;
|
|
||||||
|
|
||||||
// The transient sink. The sends we create (from each selected source track INTO
|
|
||||||
// temp_) live on those source tracks pointing AT temp_, and are removed automatically
|
|
||||||
// when temp_ is deleted — REAPER cannot leave a send dangling to a deleted
|
|
||||||
// destination. So there is no separate send handle to track here.
|
|
||||||
MediaTrack* temp_ = nullptr;
|
|
||||||
|
|
||||||
// The record phase (pure state machine drives the transition). Starts Recording.
|
|
||||||
RecordPhase phase_ = RecordPhase::Recording;
|
|
||||||
|
|
||||||
// Wall-clock anchors for the pure machine's safety ceilings (a steady clock — not
|
|
||||||
// the play cursor — so a stuck/looping transport is still caught, review §3).
|
|
||||||
// begunAt_ is set at begin(); finalizingAt_ is set on the Recording->Finalizing
|
|
||||||
// edge (the transport stop) so the flush wait is bounded from the stop, not begin.
|
|
||||||
std::chrono::steady_clock::time_point begunAt_{};
|
|
||||||
std::chrono::steady_clock::time_point finalizingAt_{};
|
|
||||||
|
|
||||||
// Deferred-finalize (review §2) flush tracking: the recorded file's size the
|
|
||||||
// previous tick, so "size unchanged across a tick" signals REAPER finished
|
|
||||||
// flushing/closing the take. -1 = not yet seen.
|
|
||||||
std::int64_t lastFileSize_ = -1;
|
|
||||||
|
|
||||||
void markElapsedStart() { begunAt_ = std::chrono::steady_clock::now(); }
|
|
||||||
double elapsedSeconds() const {
|
|
||||||
return std::chrono::duration<double>(
|
|
||||||
std::chrono::steady_clock::now() - begunAt_).count();
|
|
||||||
}
|
|
||||||
// Set the flush-wait anchor once, on the first Finalizing tick.
|
|
||||||
void markFinalizingStartOnce() {
|
|
||||||
if (finalizingAt_.time_since_epoch().count() == 0)
|
|
||||||
finalizingAt_ = std::chrono::steady_clock::now();
|
|
||||||
}
|
|
||||||
double finalizingSeconds() const {
|
|
||||||
if (finalizingAt_.time_since_epoch().count() == 0) return 0.0;
|
|
||||||
return std::chrono::duration<double>(
|
|
||||||
std::chrono::steady_clock::now() - finalizingAt_).count();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot of state to restore. Filled at begin(), replayed once by restore().
|
|
||||||
double curPos_ = 0.0;
|
|
||||||
double tsStart_ = 0.0;
|
|
||||||
double tsEnd_ = 0.0;
|
|
||||||
struct ArmSnap { MediaTrack* track; double recarm; };
|
|
||||||
std::vector<ArmSnap> armSnaps_;
|
|
||||||
|
|
||||||
// Snapshot the transport-adjacent state (cursor + time selection) and every
|
|
||||||
// OTHER track's arm, disarming them so only our sink records. Call ONCE, before
|
|
||||||
// the temp track exists (so the temp track is never in the arm snapshot).
|
|
||||||
void snapshotAndDisarmOthers() {
|
|
||||||
curPos_ = GetCursorPosition();
|
|
||||||
GetSet_LoopTimeRange(false, false, &tsStart_, &tsEnd_, false);
|
|
||||||
const int n = CountTracks(proj_);
|
|
||||||
for (int i = 0; i < n; ++i) {
|
|
||||||
MediaTrack* tr = GetTrack(proj_, i);
|
|
||||||
if (!tr) continue;
|
|
||||||
const double armed = GetMediaTrackInfo_Value(tr, "I_RECARM");
|
|
||||||
if (armed != 0.0) {
|
|
||||||
armSnaps_.push_back({tr, armed});
|
|
||||||
SetMediaTrackInfo_Value(tr, "I_RECARM", 0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The single, idempotent teardown. Called on EVERY terminal path (normal
|
|
||||||
// completion, user stop, error, project switch, unload). Safe to call more than
|
|
||||||
// once — the restored_ latch makes every call after the first a no-op. Order:
|
|
||||||
// 1. stop the transport if anything is still running (we own it),
|
|
||||||
// 2. delete the temp track (drops its receive-sum sends + the recorded item),
|
|
||||||
// 3. restore every other track's arm,
|
|
||||||
// 4. restore the time selection + edit cursor.
|
|
||||||
// Stop the record's OWN project transport if it is still playing/recording. Uses
|
|
||||||
// the project-scoped OnStopButtonEx(proj_) (not the global CSurf_OnStop) so a
|
|
||||||
// project switch mid-record — where proj_ is no longer the ACTIVE project — stops
|
|
||||||
// OUR project's transport, never the foreign now-active one. &1=playing,
|
|
||||||
// &4=recording. Idempotent to call (the playstate guard makes a repeat a no-op).
|
|
||||||
void stopOwnTransport() {
|
|
||||||
if (GetPlayStateEx(proj_) & (1 | 4)) OnStopButtonEx(proj_);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is the captured project STILL OPEN? (review §1 — CRITICAL). If the captured
|
|
||||||
// project was CLOSED mid-record, proj_/temp_ point at freed memory;
|
|
||||||
// touching them (stopOwnTransport, DeleteTrack, arm restore) is a use-after-free.
|
|
||||||
// ValidatePtr2 with a null project validates the ReaProject* itself (the header:
|
|
||||||
// "proj is ignored if pointer is itself a project"). Every teardown that
|
|
||||||
// dereferences a captured REAPER object MUST gate on this first.
|
|
||||||
bool captureProjectStillOpen() const {
|
|
||||||
return proj_ && ValidatePtr2(nullptr, proj_, "ReaProject*");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop the handle WITHOUT touching any REAPER state — for the closed-project case
|
|
||||||
// (review §1). A closed project already reclaimed its temp track, arms, and
|
|
||||||
// transport; there is nothing to restore and the pointers are freed. Latch
|
|
||||||
// restored_ so any later terminal path is a no-op (idempotent), but skip every
|
|
||||||
// REAPER call restore() would make.
|
|
||||||
void dropWithoutRestore() {
|
|
||||||
restored_ = true;
|
|
||||||
temp_ = nullptr;
|
|
||||||
armSnaps_.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
void restore() {
|
|
||||||
if (restored_) return;
|
|
||||||
restored_ = true;
|
|
||||||
|
|
||||||
// 1. Transport: stop OUR project's if still running (usually already stopped
|
|
||||||
// by the terminal path's explicit stop-before-finalize — a safe no-op then).
|
|
||||||
stopOwnTransport();
|
|
||||||
|
|
||||||
// 2. Temp track: deleting it drops the source-track sends (REAPER removes every
|
|
||||||
// send whose destination is deleted — no source track is left mutated) AND the
|
|
||||||
// recorded arrange item in one move — nothing stays behind (load-bearing).
|
|
||||||
if (temp_) { DeleteTrack(temp_); temp_ = nullptr; }
|
|
||||||
|
|
||||||
// 3. Other tracks' record-arm.
|
|
||||||
for (const ArmSnap& s : armSnaps_)
|
|
||||||
SetMediaTrackInfo_Value(s.track, "I_RECARM", s.recarm);
|
|
||||||
armSnaps_.clear();
|
|
||||||
|
|
||||||
// 4. Time selection + edit cursor (no view move, no seek).
|
|
||||||
GetSet_LoopTimeRange(true, false, &tsStart_, &tsEnd_, false);
|
|
||||||
SetEditCurPos(curPos_, false, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool restored() const { return restored_; }
|
|
||||||
bool finalized() const { return finalized_; }
|
|
||||||
void markFinalized() { finalized_ = true; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
bool restored_ = false;
|
|
||||||
bool finalized_ = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Reads the whole file into a byte buffer. Empty vector on any I/O failure — the
|
|
||||||
// caller treats an unreadable file as "skip the trim" (keep the untrimmed window),
|
|
||||||
// never as a corruption of the recorded audio.
|
|
||||||
std::vector<std::uint8_t> readAllBytes(const std::string& path) {
|
|
||||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
|
||||||
if (!f) return {};
|
|
||||||
const std::streamoff size = f.tellg();
|
|
||||||
if (size <= 0) return {};
|
|
||||||
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
|
|
||||||
f.seekg(0);
|
|
||||||
f.read(reinterpret_cast<char*>(bytes.data()), size);
|
|
||||||
if (!f) return {};
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields).
|
|
||||||
void writeU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
|
|
||||||
bytes[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
|
|
||||||
bytes[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
|
||||||
bytes[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
|
||||||
bytes[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime)
|
|
||||||
// ============================================================================
|
|
||||||
// After the recorded file is stable and moved into the bank (the file we OWN — never
|
|
||||||
// the project), Auto mode trims the trailing decay: read the WAV, scan the tail
|
|
||||||
// region (frames AFTER the original range end) backward for the last frame above
|
|
||||||
// -72 dB, and truncate the file there. Rules (spec):
|
|
||||||
// * no frame in the tail window above -72 dB -> trim back to the original range end
|
|
||||||
// * signal never falls below -72 dB in window -> keep the full window (cap did its job)
|
|
||||||
// * otherwise -> trim one frame past the last audible
|
|
||||||
//
|
|
||||||
// Returns the trimmed length in SECONDS (for the Sample), or a negative value to
|
|
||||||
// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and
|
|
||||||
// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window)
|
|
||||||
// rather than risk corrupting the capture — realtime tail is a convenience path.
|
|
||||||
//
|
|
||||||
// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit
|
|
||||||
// float WAV (REAPER project record format — the manual procedure sets it) and is fully
|
|
||||||
// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees
|
|
||||||
// that for the normal path; abort()'s best-effort finalize races it, documented).
|
|
||||||
double trimAutoTailInPlace(const std::string& path,
|
|
||||||
double rangeStartSeconds,
|
|
||||||
double rangeEndSeconds) {
|
|
||||||
constexpr double kNoTrim = -1.0;
|
|
||||||
|
|
||||||
std::vector<std::uint8_t> bytes = readAllBytes(path);
|
|
||||||
if (bytes.empty()) return kNoTrim;
|
|
||||||
|
|
||||||
const reasampler::WavLayout layout = parseWavLayout(bytes);
|
|
||||||
if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim
|
|
||||||
|
|
||||||
const std::size_t totalFrames = layout.frameCount();
|
|
||||||
if (totalFrames == 0) return kNoTrim;
|
|
||||||
|
|
||||||
// The original range end as a frame index within the file (frame 0 == start). Use
|
|
||||||
// the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow
|
|
||||||
// project). Clamp to the file so a rounding overshoot cannot exceed it.
|
|
||||||
const double rangeSeconds = rangeEndSeconds - rangeStartSeconds;
|
|
||||||
if (rangeSeconds <= 0.0) return kNoTrim;
|
|
||||||
std::size_t rangeEndFrame = static_cast<std::size_t>(
|
|
||||||
rangeSeconds * static_cast<double>(layout.sampleRate) + 0.5);
|
|
||||||
if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames;
|
|
||||||
|
|
||||||
// Nothing recorded past the range end (the tail window was empty) -> nothing to
|
|
||||||
// trim; keep as-is. (Shouldn't happen for Auto, but total by construction.)
|
|
||||||
if (rangeEndFrame >= totalFrames) return kNoTrim;
|
|
||||||
|
|
||||||
// Scan ONLY the tail region (frames after the original range end). The trim never
|
|
||||||
// eats into the range body — the scan starts at rangeEndFrame.
|
|
||||||
const std::size_t tailFrames = totalFrames - rangeEndFrame;
|
|
||||||
const std::vector<reasampler::AudioSample> tailPcm =
|
|
||||||
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
|
|
||||||
if (tailPcm.empty()) return kNoTrim;
|
|
||||||
|
|
||||||
const float threshold = static_cast<float>(reasampler::autoTrimEndRatio());
|
|
||||||
const std::size_t lastAbove = reasampler::lastFrameAboveThreshold(
|
|
||||||
tailPcm, layout.channelCount, tailFrames, threshold);
|
|
||||||
|
|
||||||
// keptFrames: the total frame count the trimmed file retains.
|
|
||||||
// no audible tail frame -> trim back to the range end (rangeEndFrame frames)
|
|
||||||
// an audible frame at idx -> keep range body + up to and including that frame
|
|
||||||
// The "signal never falls below threshold" case falls out naturally: lastAbove is
|
|
||||||
// the final tail frame, so keptFrames == totalFrames (the full window is kept).
|
|
||||||
std::size_t keptFrames;
|
|
||||||
if (lastAbove == reasampler::kNoFrameAboveThreshold) {
|
|
||||||
keptFrames = rangeEndFrame;
|
|
||||||
} else {
|
|
||||||
keptFrames = rangeEndFrame + (lastAbove + 1);
|
|
||||||
}
|
|
||||||
if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate
|
|
||||||
|
|
||||||
const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames);
|
|
||||||
if (!plan.valid) return kNoTrim;
|
|
||||||
|
|
||||||
// Patch the RIFF + data size fields in the in-memory buffer so they describe the
|
|
||||||
// kept frame count, then rewrite the file as exactly the first newFileByteLength
|
|
||||||
// bytes (header + patched sizes + retained PCM). A single truncating write is the
|
|
||||||
// simplest correct truncate — no separate resize step, no partial-write window
|
|
||||||
// where the on-disk sizes and length disagree. The result is a valid, playable WAV
|
|
||||||
// of the kept frames (verified by the wav_trim re-parse test).
|
|
||||||
writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
|
|
||||||
writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize);
|
|
||||||
|
|
||||||
// NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full
|
|
||||||
// disk, a yanked drive) would leave a short file while we return kNoTrim, so the
|
|
||||||
// Sample length would overstate the file. Vanishingly unlikely for a just-recorded
|
|
||||||
// local bank file, and realtime tail is a convenience path, so a temp-file+atomic-
|
|
||||||
// rename is not warranted here; flagged rather than built.
|
|
||||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
|
||||||
if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file
|
|
||||||
out.write(reinterpret_cast<const char*>(bytes.data()),
|
|
||||||
static_cast<std::streamsize>(plan.newFileByteLength));
|
|
||||||
if (!out) return kNoTrim;
|
|
||||||
out.close();
|
|
||||||
|
|
||||||
// The trimmed length in seconds for the Sample metadata.
|
|
||||||
return static_cast<double>(keptFrames) / static_cast<double>(layout.sampleRate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Builds a CaptureResult for a finalized recording: discover the recorded file,
|
|
||||||
// move it into the bank, populate the Sample via the pure mapping. Returns Ok +
|
|
||||||
// Sample on success, or a RenderFailed result. Does NOT restore — the caller
|
|
||||||
// restores unconditionally afterward (finalize + restore are separate steps so a
|
|
||||||
// finalize failure still restores).
|
|
||||||
CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
|
||||||
CaptureResult result;
|
|
||||||
|
|
||||||
const std::string recorded = normSlashes(recordedFilePath(st.temp_));
|
|
||||||
if (recorded.empty() || !std::filesystem::exists(recorded)) {
|
|
||||||
result.status = CaptureStatus::RenderFailed;
|
|
||||||
result.message = "Realtime record produced no file (check transport/record "
|
|
||||||
"settings in the DAW).";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::error_code ec;
|
|
||||||
std::filesystem::create_directories(st.paths_.absoluteDir, ec);
|
|
||||||
const std::string destPath = st.paths_.absoluteDir + "/" + st.paths_.fileName;
|
|
||||||
std::filesystem::rename(recorded, destPath, ec);
|
|
||||||
if (ec) {
|
|
||||||
// Cross-volume rename can fail; fall back to copy+remove.
|
|
||||||
ec.clear();
|
|
||||||
std::filesystem::copy_file(
|
|
||||||
recorded, destPath,
|
|
||||||
std::filesystem::copy_options::overwrite_existing, ec);
|
|
||||||
if (ec) {
|
|
||||||
result.status = CaptureStatus::RenderFailed;
|
|
||||||
result.message = "Recorded file could not be moved into the bank: " +
|
|
||||||
ec.message();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
std::error_code rmEc;
|
|
||||||
std::filesystem::remove(recorded, rmEc); // best-effort
|
|
||||||
}
|
|
||||||
|
|
||||||
// TAIL (Auto): trim the trailing decay of the recorded window in place — on the
|
|
||||||
// BANK file we now own (destPath), never the project. Best-effort: an unreadable /
|
|
||||||
// unknown-format / short file skips the trim (keeps the full window) rather than
|
|
||||||
// corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a
|
|
||||||
// fixed window (spec §The realtime path). Returns the trimmed length in seconds,
|
|
||||||
// or < 0 for "no trim applied".
|
|
||||||
double trimmedLenSeconds = -1.0;
|
|
||||||
if (st.request_.tailMode == TailMode::Auto) {
|
|
||||||
trimmedLenSeconds = trimAutoTailInPlace(destPath,
|
|
||||||
st.request_.startSeconds,
|
|
||||||
st.request_.endSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
RecordedCapture cap;
|
|
||||||
cap.relativePath = st.paths_.relativePath;
|
|
||||||
cap.uniqueTag = st.uniqueTag_;
|
|
||||||
cap.sourceMode = SourceMode::Realtime;
|
|
||||||
cap.startSeconds = st.request_.startSeconds;
|
|
||||||
cap.endSeconds = st.request_.endSeconds;
|
|
||||||
cap.wetDry = st.request_.wetDry;
|
|
||||||
cap.displayName = st.request_.baseName;
|
|
||||||
cap.trackGuids = st.request_.trackGuids;
|
|
||||||
cap.channelCount = st.request_.channelCount;
|
|
||||||
cap.sampleRate = (st.request_.sampleRate > 0)
|
|
||||||
? st.request_.sampleRate
|
|
||||||
: static_cast<int>(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false));
|
|
||||||
cap.captureTempo = Master_GetTempo();
|
|
||||||
// Time signature at the record range's START (L7 F1). TimeMap_GetTimeSigAtTime
|
|
||||||
// (reaper_plugin_functions.h:7130) reads the meter effective at that project time;
|
|
||||||
// proj=st.proj_ pins the recording's own project. tempoOut ignored (captureTempo is
|
|
||||||
// the master tempo above). Leaves 0/0 (unstamped) on any failure.
|
|
||||||
{
|
|
||||||
int tsNum = 0, tsDenom = 0;
|
|
||||||
double tsTempo = 0.0;
|
|
||||||
TimeMap_GetTimeSigAtTime(st.proj_, st.request_.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
|
||||||
cap.captureTimeSigNum = tsNum;
|
|
||||||
cap.captureTimeSigDenom = tsDenom;
|
|
||||||
}
|
|
||||||
cap.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
|
||||||
|
|
||||||
result.status = CaptureStatus::Ok;
|
|
||||||
result.sample = sampleFromRecordedCapture(cap);
|
|
||||||
|
|
||||||
// Content hash: WAV-aware FNV-1a over the (possibly trimmed) bank file's fmt+data
|
|
||||||
// chunks so hashReferencedElsewhere can identify copies in other banks and suppress
|
|
||||||
// the last-reference confirm when another bank still holds the same file. Using
|
|
||||||
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
|
|
||||||
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two records of identical
|
|
||||||
// audio collapse to the same hash. Best-effort: an unreadable file leaves
|
|
||||||
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats
|
|
||||||
// "" as non-participating).
|
|
||||||
{
|
|
||||||
const std::vector<std::uint8_t> fileBytes = readAllBytes(destPath);
|
|
||||||
if (!fileBytes.empty()) {
|
|
||||||
result.sample.contentHash = hashWavContent(fileBytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The recorded file's true length differs from the request range when a tail was
|
|
||||||
// recorded, so the Sample length must reflect the FILE, not the range:
|
|
||||||
// Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned.
|
|
||||||
// Auto with no trim, or Manual -> the full recorded window (end - start).
|
|
||||||
// None -> the exact range (unchanged; recordWindowEnd_ == endSeconds).
|
|
||||||
// sampleFromRecordedCapture already set lengthSeconds = end - start; override it
|
|
||||||
// to the recorded/trimmed length so downstream (thumbnail, placement) matches disk.
|
|
||||||
if (trimmedLenSeconds >= 0.0) {
|
|
||||||
result.sample.lengthSeconds = trimmedLenSeconds;
|
|
||||||
} else {
|
|
||||||
result.sample.lengthSeconds =
|
|
||||||
st.recordWindowEnd_ - st.request_.startSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.message = "Realtime-captured [" +
|
|
||||||
std::to_string(st.request_.startSeconds) + "s, " +
|
|
||||||
std::to_string(st.request_.endSeconds) + "s] (recorded " +
|
|
||||||
std::to_string(result.sample.lengthSeconds) + "s) -> " +
|
|
||||||
st.paths_.relativePath;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// begin — start the record, snapshot, return immediately (no UI block)
|
|
||||||
// ============================================================================
|
|
||||||
void RealtimeCaptureStateDeleter::operator()(RealtimeCaptureState* p) const noexcept {
|
|
||||||
delete p; // full type is visible here — keeps capture.h REAPER-free
|
|
||||||
}
|
|
||||||
|
|
||||||
RealtimeCaptureHandle
|
|
||||||
RealtimeRecordBackend::begin(const CaptureRequest& request,
|
|
||||||
const std::vector<MediaTrack*>& sourceTracks,
|
|
||||||
CaptureResult& outFailure) {
|
|
||||||
// Only the track scope is implemented this increment (see §TAP). Item realtime
|
|
||||||
// is deferred — it needs per-item take isolation on top of the track-output tap.
|
|
||||||
if (request.sourceMode != SourceMode::SelectedTracks) {
|
|
||||||
outFailure.status = CaptureStatus::UnsupportedMode;
|
|
||||||
outFailure.message = "RealtimeRecordBackend implements TRACK scope only this "
|
|
||||||
"increment (item realtime is deferred).";
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track scope needs at least one source track to tap. No selection -> refuse
|
|
||||||
// (matching offline track scope's no-op on an empty selection).
|
|
||||||
if (sourceTracks.empty()) {
|
|
||||||
outFailure.status = CaptureStatus::UnsupportedMode;
|
|
||||||
outFailure.message = "No track selected — realtime track capture needs at least "
|
|
||||||
"one selected track to tap.";
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact bounds: refuse an empty/inverted range rather than record silence.
|
|
||||||
if (!(request.endSeconds > request.startSeconds)) {
|
|
||||||
outFailure.status = CaptureStatus::EmptyRange;
|
|
||||||
outFailure.message = "Capture range is empty (end <= start).";
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
|
||||||
if (!proj) {
|
|
||||||
outFailure.status = CaptureStatus::NoProject;
|
|
||||||
outFailure.message = "No active project.";
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refuse if the transport is already playing/recording — we own the transport for
|
|
||||||
// the capture window and must not hijack a user's live take.
|
|
||||||
if (GetPlayStateEx(proj) & (1 | 4)) {
|
|
||||||
outFailure.status = CaptureStatus::TransportBusy;
|
|
||||||
outFailure.message = "Transport is already playing/recording — realtime capture "
|
|
||||||
"refused. Stop the transport first.";
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Saved-project gate (same as offline): the bank folder resolves against the
|
|
||||||
// .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved.
|
|
||||||
std::string rppPath = readRppPath();
|
|
||||||
if (rppPath.empty()) {
|
|
||||||
Main_SaveProject(proj, true); // DAW-only: opens Save-As, blocks (verify)
|
|
||||||
rppPath = readRppPath();
|
|
||||||
}
|
|
||||||
if (rppPath.empty()) {
|
|
||||||
outFailure.status = CaptureStatus::NoProject;
|
|
||||||
outFailure.message = "Project must be saved before capture — nothing captured.";
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
const std::string projectDir =
|
|
||||||
normSlashes(std::filesystem::path(rppPath).parent_path().string());
|
|
||||||
|
|
||||||
// --- Build the in-flight state (owns the snapshot + teardown) ---------------
|
|
||||||
RealtimeCaptureHandle st(new RealtimeCaptureState());
|
|
||||||
st->proj_ = proj;
|
|
||||||
st->request_ = request;
|
|
||||||
st->uniqueTag_ = makeUniqueTag();
|
|
||||||
st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);
|
|
||||||
|
|
||||||
// The recorded window end: extended past the range end for a tail mode (Auto/Manual),
|
|
||||||
// exact for None. This — not request.endSeconds — is what the completion machine
|
|
||||||
// waits for; the extra window past the range end is trimmed later (Auto) or kept
|
|
||||||
// (Manual). Pure mapping (render_settings), shared caps with the offline tail.
|
|
||||||
st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode,
|
|
||||||
request.endSeconds,
|
|
||||||
request.tailMs);
|
|
||||||
|
|
||||||
// DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT
|
|
||||||
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
|
|
||||||
// shells is intentional. This backend fully restores its own state across every
|
|
||||||
// terminal path (the restore() latch); an undo point would surface an internal,
|
|
||||||
// fully-reversed scaffold in the user's undo history for no user-meaningful action.
|
|
||||||
// Snapshot cursor + time selection, and disarm every OTHER track BEFORE the temp
|
|
||||||
// track exists (so it is never in the arm snapshot and keeps the arm we set).
|
|
||||||
st->snapshotAndDisarmOthers();
|
|
||||||
|
|
||||||
// Hidden temp track at the end: no default FX/envelopes (clean sink), hidden from
|
|
||||||
// both panels, B_MAINSEND=0 so it does NOT sum back into the master (monitoring
|
|
||||||
// invariant — it would otherwise double the tapped tracks in the user's monitoring).
|
|
||||||
const int idx = CountTracks(proj);
|
|
||||||
InsertTrackAtIndex(idx, false);
|
|
||||||
st->temp_ = GetTrack(proj, idx);
|
|
||||||
if (!st->temp_) {
|
|
||||||
outFailure.status = CaptureStatus::RenderFailed;
|
|
||||||
outFailure.message = "Could not create the hidden temp record track.";
|
|
||||||
st->restore(); // undo the disarm + cursor/time-sel snapshot
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
SetMediaTrackInfo_Value(st->temp_, "B_SHOWINTCP", 0.0);
|
|
||||||
SetMediaTrackInfo_Value(st->temp_, "B_SHOWINMIXER", 0.0);
|
|
||||||
SetMediaTrackInfo_Value(st->temp_, "B_MAINSEND", 0.0);
|
|
||||||
|
|
||||||
// Route the TRACK-OUTPUT tap: a send FROM each selected source track INTO the temp
|
|
||||||
// track (CreateTrackSend(source, temp)). The temp records its OWN output, so the
|
|
||||||
// sends' outputs SUM in it — multiple selected tracks are captured together (same as
|
|
||||||
// offline track scope). See §TAP for why this faithfully captures each track's own
|
|
||||||
// output and needs no FxBypassGuard.
|
|
||||||
//
|
|
||||||
// Sends default to post-fader (I_SENDMODE 0) and full-stereo (I_SRCCHAN default,
|
|
||||||
// (srcchan>>10)==0 — SDK ~3302/3304): post-fader = after the source track's FX and
|
|
||||||
// fader/pan = the track's OWN output, tapped BEFORE the parent sums it. Left at
|
|
||||||
// defaults deliberately — that IS the track-scope tap point.
|
|
||||||
//
|
|
||||||
// DAW-ONLY ASSUMPTION (flag): that a post-fader track->temp send + output-record
|
|
||||||
// reproduces the track's own output sample-for-sample (latency comp, pan law,
|
|
||||||
// mono/stereo folding) is the crux to verify live.
|
|
||||||
int sendsMade = 0;
|
|
||||||
for (MediaTrack* src : sourceTracks) {
|
|
||||||
if (!src || src == st->temp_) continue;
|
|
||||||
if (CreateTrackSend(src, st->temp_) >= 0) ++sendsMade;
|
|
||||||
}
|
|
||||||
if (sendsMade == 0) {
|
|
||||||
// Every send failed (should not happen for valid selected tracks). Refuse
|
|
||||||
// rather than record a guaranteed-silent file.
|
|
||||||
outFailure.status = CaptureStatus::RenderFailed;
|
|
||||||
outFailure.message = "Could not route any selected track into the record tap — "
|
|
||||||
"nothing to capture.";
|
|
||||||
st->restore(); // deleting the temp track drops any partial sends too
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record-mode values from the pure planner. The temp track records its OWN output;
|
|
||||||
// it has no FX and unity fader, so its post-fader output equals the summed sends.
|
|
||||||
// Track scope is fully wet -> PostFader. (The actual track-scope tap point is the
|
|
||||||
// source sends' default post-fader mode; the temp's recmode only records the sum.)
|
|
||||||
const OutputTap tap = outputTapForWetDry(request.wetDry);
|
|
||||||
const RecordModePlan rec = recordModePlanFor(request.channelCount, tap);
|
|
||||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE", static_cast<double>(rec.recMode));
|
|
||||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMODE_FLAGS",
|
|
||||||
static_cast<double>(rec.recModeFlags));
|
|
||||||
SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink
|
|
||||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring
|
|
||||||
|
|
||||||
// Record range: time selection over [start, recordWindowEnd], play cursor at start.
|
|
||||||
// recordWindowEnd extends past the request's range end for a tail mode so the
|
|
||||||
// transport captures the decaying tail; it equals the range end for None (exact
|
|
||||||
// bounds). Both cursor + time selection were snapshotted and are restored by
|
|
||||||
// restore().
|
|
||||||
double rs = request.startSeconds, re = st->recordWindowEnd_;
|
|
||||||
GetSet_LoopTimeRange(true, false, &rs, &re, false);
|
|
||||||
SetEditCurPos(request.startSeconds, false, false);
|
|
||||||
|
|
||||||
// Start the transport and RETURN. tick() drives the rest across timer ticks.
|
|
||||||
//
|
|
||||||
// DAW-ONLY ASSUMPTION (flag): CSurf_OnRecord starts recording and the exact
|
|
||||||
// range/auto-punch/stop behavior depends on the user's transport settings — not
|
|
||||||
// header-guaranteed. tick() detects completion via the play cursor reaching the
|
|
||||||
// range end (the pure state machine), independent of REAPER's auto-punch.
|
|
||||||
CSurf_OnRecord();
|
|
||||||
|
|
||||||
// Anchor the wall-clock safety ceiling from here (steady clock — independent of the
|
|
||||||
// play cursor, so a transport that starts but never advances is still bounded).
|
|
||||||
st->markElapsedStart();
|
|
||||||
|
|
||||||
return st;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// tick — advance the in-flight record; on terminal, finalize/abort + restore
|
|
||||||
// ============================================================================
|
|
||||||
RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
|
|
||||||
RealtimeTickResult out;
|
|
||||||
|
|
||||||
// If a prior terminal path already tore this down (e.g. abort() then a stray
|
|
||||||
// tick), do nothing — the state is spent.
|
|
||||||
if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }
|
|
||||||
|
|
||||||
const RecordPhase prevPhase = state.phase_;
|
|
||||||
|
|
||||||
// Read the transport bound to the record's OWN project (a project switch cannot
|
|
||||||
// point these reads at the wrong transport). &4 = recording. Gather everything the
|
|
||||||
// pure machine needs (transport + wall-clock ceilings + file-flush readiness).
|
|
||||||
RecordTickInputs inputs;
|
|
||||||
inputs.transport.recording = (GetPlayStateEx(state.proj_) & 4) != 0;
|
|
||||||
inputs.transport.playPosition = GetPlayPositionEx(state.proj_);
|
|
||||||
inputs.elapsedSeconds = state.elapsedSeconds();
|
|
||||||
|
|
||||||
// Deferred-finalize flush check (review §2), only meaningful once stopped. The
|
|
||||||
// recorded file is READY when its size is a valid positive value AND unchanged
|
|
||||||
// from the previous tick — REAPER finished flushing/closing the take on the audio
|
|
||||||
// thread. Comparing across a tick avoids moving a file mid-write (truncated take).
|
|
||||||
if (prevPhase == RecordPhase::Finalizing) {
|
|
||||||
state.markFinalizingStartOnce();
|
|
||||||
inputs.finalizingSeconds = state.finalizingSeconds();
|
|
||||||
const std::int64_t sz = recordedFileSize(state.temp_);
|
|
||||||
inputs.fileReady = (sz > 0 && sz == state.lastFileSize_);
|
|
||||||
state.lastFileSize_ = sz;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the transport to reach the RECORDED window end (extended past the
|
|
||||||
// range end for a tail mode), not the request's range end — the extra tail window
|
|
||||||
// is part of the record. The record safety ceiling scales with it (window - start
|
|
||||||
// + margin) inside the pure machine.
|
|
||||||
state.phase_ = advanceRecordPhase(state.phase_, inputs,
|
|
||||||
state.request_.startSeconds,
|
|
||||||
state.recordWindowEnd_);
|
|
||||||
|
|
||||||
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
|
|
||||||
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
|
|
||||||
// — never the global CSurf_OnStop, which would stop whatever project is ACTIVE (a
|
|
||||||
// foreign one during a project switch), not the record's own. The flush wait then
|
|
||||||
// proceeds across subsequent ticks before the file is moved.
|
|
||||||
if (prevPhase == RecordPhase::Recording &&
|
|
||||||
isStopRequested(state.phase_)) {
|
|
||||||
state.stopOwnTransport();
|
|
||||||
state.markFinalizingStartOnce(); // anchor the flush ceiling from the stop
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isTerminalPhase(state.phase_)) {
|
|
||||||
out.status = RealtimeTickStatus::InProgress;
|
|
||||||
return out; // keep the OnTimer tick fast — recording or flushing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminal (Done: file flushed + stable; Failed: flush ceiling tripped). On Done,
|
|
||||||
// finalize moves the now-stable file into the bank + builds the Sample. On Failed
|
|
||||||
// (the flush timeout) there is nothing usable — report RenderFailed. Then restore
|
|
||||||
// ALL snapshotted state — the non-destructive gate, idempotent + unconditional.
|
|
||||||
CaptureResult res;
|
|
||||||
if (state.phase_ == RecordPhase::Done) {
|
|
||||||
res = finalizeRecording(state);
|
|
||||||
} else {
|
|
||||||
res.status = CaptureStatus::RenderFailed;
|
|
||||||
res.message = "Realtime record timed out waiting for the recorded file to "
|
|
||||||
"flush/close (nothing captured).";
|
|
||||||
}
|
|
||||||
state.markFinalized();
|
|
||||||
state.restore();
|
|
||||||
|
|
||||||
out.result = res;
|
|
||||||
out.status = (res.status == CaptureStatus::Ok)
|
|
||||||
? RealtimeTickStatus::Done
|
|
||||||
: RealtimeTickStatus::Failed;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// abort — force-terminate now (shutdown / project switch) + restore
|
|
||||||
// ============================================================================
|
|
||||||
RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
|
|
||||||
RealtimeTickResult out;
|
|
||||||
|
|
||||||
// Already torn down (idempotent): report Failed and leave it.
|
|
||||||
if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; }
|
|
||||||
|
|
||||||
// CRITICAL (review §1): if the captured project was CLOSED mid-record, proj_ /
|
|
||||||
// temp_ point at freed memory. The closed project already reclaimed its
|
|
||||||
// temp track, arms, and transport — so DROP the handle WITHOUT touching any REAPER
|
|
||||||
// state (no stop, no finalize, no DeleteTrack, no arm restore). Touching those
|
|
||||||
// freed pointers is the use-after-free bug this guard exists to prevent. This is
|
|
||||||
// the ONE terminal path that can run against a possibly-closed project (tick() only
|
|
||||||
// runs while proj_ is the active — hence still-open — project); guarding here covers
|
|
||||||
// both the project-switch and unload callers.
|
|
||||||
if (!state.captureProjectStillOpen()) {
|
|
||||||
state.dropWithoutRestore();
|
|
||||||
out.result.status = CaptureStatus::RenderFailed;
|
|
||||||
out.result.message = "Realtime capture dropped — the captured project was closed "
|
|
||||||
"mid-record (nothing to restore; no capture persisted).";
|
|
||||||
out.status = RealtimeTickStatus::Failed;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The project is still open (a tab-switch, or a clean unload with the project
|
|
||||||
// present): stop the transport, then TRY to finalize whatever was captured so a
|
|
||||||
// near-complete record still keeps the audio; if nothing was recorded (or the file
|
|
||||||
// has not flushed yet), finalize returns RenderFailed and we abort clean.
|
|
||||||
// Project-scoped stop (OnStopButtonEx(proj_)) — on a project switch proj_ is no
|
|
||||||
// longer active, so the global CSurf_OnStop would stop the wrong (foreign) project.
|
|
||||||
//
|
|
||||||
// NOTE (residual timing — DAW-verify): abort is the force-terminate path (unload /
|
|
||||||
// switch); it cannot span ticks to wait for the flush the way tick() does, so its
|
|
||||||
// finalize still races REAPER's audio-thread take close. That is inherent to a
|
|
||||||
// best-effort terminal grab and is acceptable — the normal completion path (tick)
|
|
||||||
// is the one that must be flush-safe.
|
|
||||||
state.stopOwnTransport();
|
|
||||||
|
|
||||||
CaptureResult res = finalizeRecording(state);
|
|
||||||
state.markFinalized();
|
|
||||||
state.restore(); // the non-destructive gate — always runs
|
|
||||||
|
|
||||||
out.result = res;
|
|
||||||
out.status = (res.status == CaptureStatus::Ok)
|
|
||||||
? RealtimeTickStatus::Done
|
|
||||||
: RealtimeTickStatus::Failed;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
-147
@@ -1,147 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// card_drag — the REAPER-free decision logic behind the L7 in-grid reorder drag. Three
|
|
||||||
// pure concerns live here so they are unit-tested outside the DAW (CLAUDE.md §load-bearing
|
|
||||||
// split); the SWELL wiring, SetCursor call, cursor resources, and drop-target draw stay in
|
|
||||||
// the shell (bank_panel.cpp). Mirror of drag_out::decideGesture.
|
|
||||||
//
|
|
||||||
// 1. GESTURE PRECEDENCE (F3 settled). A live drag resolves to exactly one gesture, in a
|
|
||||||
// strict precedence the shell evaluates on every mouse-move / at drop:
|
|
||||||
// (1) pointer LEFT the client rect -> OsDragOut (hand off to the OS)
|
|
||||||
// (2) else drop over a tab / the OTHER bank -> Move | Copy (Ctrl = Copy)
|
|
||||||
// (3) else drop within the SAME bank's grid -> Reorder | Replace
|
|
||||||
// - empty slot -> Reorder (place there)
|
|
||||||
// - occupied slot, no modifier -> Reorder (insert-before-and-shift)
|
|
||||||
// - occupied slot, Alt held -> Replace (Alt-replace-over-occupied)
|
|
||||||
// So leave-client wins first, then other-bank, then same-bank-grid = reorder/replace.
|
|
||||||
// This keeps the reorder gesture from ever stealing a bank-move or OS-drag.
|
|
||||||
//
|
|
||||||
// 2. SLOT HIT-TEST. Which grid SLOT a pointer sits over, sparse-aware: the grid tiles
|
|
||||||
// slots 0..maxSlot including empty ones, so hit-testing maps a point to a slot index
|
|
||||||
// (empty or occupied) or -1 for a miss. The pixel<->slot rect math extends bank_grid's
|
|
||||||
// dense tiling to the gap-preserving slot layout.
|
|
||||||
//
|
|
||||||
// 3. DROP-RESULT -> CURSOR CUE. The resolved gesture maps to a cursor cue enum the shell
|
|
||||||
// turns into a SetCursor call. The cue DECISION is pure (here); the shell owns only
|
|
||||||
// the SetCursor call and the cursor resources. The Replace cue appears ONLY when Alt
|
|
||||||
// is actually held over an occupied slot (precedence rule 3's Alt branch).
|
|
||||||
//
|
|
||||||
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO OS, NO vendor/ includes. Standard
|
|
||||||
// library only. Reuses drag_out's PanelClientRect / DragState and bank_grid's CellRect.
|
|
||||||
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "bank_grid.h" // CellRect
|
|
||||||
#include "drag_out.h" // PanelClientRect, DragState
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// Which drop region the pointer currently sits over WITHIN the client rect. The shell
|
|
||||||
// classifies the live pointer against its own region geometry (tab strip / other bank
|
|
||||||
// region / this bank's own grid) and passes the verdict; card_drag does not know panel
|
|
||||||
// layout, only the precedence over these verdicts. (When the pointer has left the client
|
|
||||||
// rect the shell need not compute this — OsDragOut wins first regardless.)
|
|
||||||
enum class DropRegion {
|
|
||||||
SameBankGrid, // over the dragged samples' OWN bank grid — a reorder/replace target
|
|
||||||
OtherBankOrTab, // over a tab or the other region's bank — a move/copy target
|
|
||||||
DeadSpace, // inside the client but over no drop target (header, footer, gap)
|
|
||||||
};
|
|
||||||
|
|
||||||
// The resolved gesture — one clean outcome the shell acts on and maps to a cursor.
|
|
||||||
enum class CardGesture {
|
|
||||||
None, // no drag under way, or an empty payload — do nothing
|
|
||||||
OsDragOut, // pointer left the client rect — hand off to the native OS drag (drag_out)
|
|
||||||
Move, // drop over another bank/tab, no Ctrl — move the samples there
|
|
||||||
Copy, // drop over another bank/tab, Ctrl held — copy the samples there
|
|
||||||
Reorder, // drop within the same bank grid — reorder to the target slot
|
|
||||||
Replace, // drop within the same bank grid, Alt over an OCCUPIED slot — replace
|
|
||||||
};
|
|
||||||
|
|
||||||
// The live drag inputs the precedence decision needs beyond position + client rect:
|
|
||||||
// region — the shell's verdict on what the pointer sits over (see DropRegion).
|
|
||||||
// targetSlot — the slot the pointer sits over in the same-bank grid, or -1 (used only
|
|
||||||
// when region == SameBankGrid to decide empty-vs-occupied).
|
|
||||||
// slotOccupied — whether targetSlot currently holds a sample (drives Reorder vs Replace).
|
|
||||||
// ctrl — Ctrl held (Copy vs Move over another bank).
|
|
||||||
// alt — Alt held (Replace vs Reorder over an occupied same-bank slot).
|
|
||||||
struct DragModifiers {
|
|
||||||
DropRegion region = DropRegion::DeadSpace;
|
|
||||||
int targetSlot = -1;
|
|
||||||
bool slotOccupied = false;
|
|
||||||
bool ctrl = false;
|
|
||||||
bool alt = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolves the gesture for a drag at pointer (px, py) over `client`, given the drag
|
|
||||||
// `state` and the live `mods`. Precedence exactly as documented above.
|
|
||||||
// * Not dragging / no armed samples: None.
|
|
||||||
// * Pointer OUTSIDE the client rect: OsDragOut (wins first — invariant #4 boundary).
|
|
||||||
// * OtherBankOrTab: Copy if ctrl else Move.
|
|
||||||
// * SameBankGrid: Replace iff (alt AND the target slot is occupied); else Reorder
|
|
||||||
// (whether the slot is empty — place — or occupied without Alt — insert-shift).
|
|
||||||
// * DeadSpace inside the client: None (a drop here is a no-op).
|
|
||||||
CardGesture decideCardGesture(int px, int py, const PanelClientRect& client,
|
|
||||||
const DragState& state, const DragModifiers& mods);
|
|
||||||
|
|
||||||
// The cursor cue the shell should show for a resolved gesture. 1:1 with CardGesture but
|
|
||||||
// named as a cursor concern so the shell maps it to a SetCursor resource. None -> the
|
|
||||||
// default arrow. The Replace cue is produced ONLY for CardGesture::Replace (which itself
|
|
||||||
// requires Alt-over-occupied), satisfying "the replace cursor appears only while Alt is
|
|
||||||
// held over an occupied slot."
|
|
||||||
enum class CursorCue {
|
|
||||||
Default, // arrow — no drag, or dead space
|
|
||||||
Reorder, // within-bank reorder
|
|
||||||
Move, // move to another bank/tab
|
|
||||||
Copy, // copy to another bank/tab
|
|
||||||
OsDragOut, // pointer left the client (the OS drag loop owns the cursor once handed off)
|
|
||||||
Replace, // Alt-replace over an occupied slot
|
|
||||||
};
|
|
||||||
|
|
||||||
// Maps a resolved gesture to its cursor cue (pure — the shell owns SetCursor only).
|
|
||||||
CursorCue cursorForGesture(CardGesture g);
|
|
||||||
|
|
||||||
// --- Sparse-aware slot layout + hit-test --------------------------------------
|
|
||||||
|
|
||||||
// The pixel rect of one grid SLOT (empty or occupied). Distinct from bank_grid's CellRect
|
|
||||||
// only in intent — a SlotCellRect carries the slot index it draws, so the shell can map a
|
|
||||||
// drawn/hit rect back to the model slot without a parallel array. width/height match the
|
|
||||||
// grid spec; (x, y) is the top-left in the region's grid-viewport coordinates (the shell
|
|
||||||
// translates by the grid origin exactly as regionCellRects does today).
|
|
||||||
struct SlotCellRect {
|
|
||||||
int slot = 0; // the model slot this rect represents (0..maxSlot)
|
|
||||||
int x = 0;
|
|
||||||
int y = 0;
|
|
||||||
int width = 0;
|
|
||||||
int height = 0;
|
|
||||||
|
|
||||||
bool operator==(const SlotCellRect& o) const {
|
|
||||||
return slot == o.slot && x == o.x && y == o.y &&
|
|
||||||
width == o.width && height == o.height;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Tiles slots 0..maxSlot (INCLUSIVE) into a panel of the given pixel width, honoring the
|
|
||||||
// grid spec — the sparse-aware sibling of bank_grid::computeCellRects. Every slot in
|
|
||||||
// [0, maxSlot] gets a rect (empty slots included) so a gap draws as an empty cell and a
|
|
||||||
// drop targets it precisely. `maxSlot` < 0 -> empty (no occupied slots). The rects use the
|
|
||||||
// SAME column/row math as computeCellRects (slot index in place of item index), so an
|
|
||||||
// all-dense map (slots 0..N-1) lays out identically to today's grid.
|
|
||||||
std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
|
|
||||||
const GridSpec& spec);
|
|
||||||
|
|
||||||
// Like computeSlotRects but extends one full trailing row of slots beyond maxSlot so a
|
|
||||||
// drop pointer past the last occupied card still resolves to a valid target slot. The
|
|
||||||
// trailing slots (maxSlot+1 .. maxSlot+cols) are empty — a drop on any of them calls
|
|
||||||
// reorderSample with that slot index, which places the card there directly (no shift,
|
|
||||||
// because the slot is empty). Used ONLY for drop hit-testing; the draw path uses
|
|
||||||
// computeSlotRects (no trailing ghost row in the visual).
|
|
||||||
// When maxSlot < 0 the trailing row starts at slot 0 (same as a fresh bank with no cards).
|
|
||||||
std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth,
|
|
||||||
const GridSpec& spec);
|
|
||||||
|
|
||||||
// Hit-tests a point against slot rects (half-open bounds, matching hitTestCell). Returns
|
|
||||||
// the SLOT index (rect.slot) of the first rect containing the point, or -1 on a miss (gap,
|
|
||||||
// margin, below the last row). NOTE the return is the slot index, NOT the vector index —
|
|
||||||
// callers reason in model slots.
|
|
||||||
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects);
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// card_meta — pure implementation. See card_meta.h. NO REAPER / SWELL / LICE / vendor.
|
|
||||||
|
|
||||||
#include "card_meta.h"
|
|
||||||
|
|
||||||
#include <cmath>
|
|
||||||
#include <cstdio>
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
std::string formatBarsBeats(const MusicalLength& m) {
|
|
||||||
// No derivable musical read-out without a positive tempo AND a stamped meter.
|
|
||||||
if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {};
|
|
||||||
|
|
||||||
const double len = m.lengthSeconds > 0.0 ? m.lengthSeconds : 0.0;
|
|
||||||
|
|
||||||
// Total beats in THIS meter. A quarter-note is 60/tempo s; a beat is (4/denom)
|
|
||||||
// quarter-notes, so a beat lasts (60/tempo) * (4/denom) seconds. beats = len / that.
|
|
||||||
const double secondsPerBeat = (60.0 / m.tempoBpm) * (4.0 / m.timeSigDenom);
|
|
||||||
double totalBeats = len / secondsPerBeat;
|
|
||||||
|
|
||||||
// Snap to an exact beat when we are within a hundredth-of-a-beat epsilon of one, so a
|
|
||||||
// bar-aligned capture reads "2.1.00" rather than "1.4.99" from FP error just under the
|
|
||||||
// boundary. The epsilon is well below the .01 display quantum, so it never mis-rounds a
|
|
||||||
// genuinely fractional length.
|
|
||||||
const double snapped = std::floor(totalBeats + 0.5);
|
|
||||||
if (std::fabs(totalBeats - snapped) < 1e-6) totalBeats = snapped;
|
|
||||||
|
|
||||||
// Split into whole beats + a fractional remainder (0..1 of a beat).
|
|
||||||
double wholeBeats = std::floor(totalBeats);
|
|
||||||
double frac = totalBeats - wholeBeats;
|
|
||||||
|
|
||||||
// Bars/beats are 1-based; beat cycles 1..timeSigNum within a bar.
|
|
||||||
const long wb = static_cast<long>(wholeBeats);
|
|
||||||
const long bar = wb / m.timeSigNum + 1; // 1-based bar
|
|
||||||
const long beat = wb % m.timeSigNum + 1; // 1-based beat within the bar
|
|
||||||
|
|
||||||
// Subdivision: hundredths of a beat, floored (0..99). A decorative display quantum.
|
|
||||||
int sub = static_cast<int>(std::floor(frac * 100.0));
|
|
||||||
if (sub < 0) sub = 0;
|
|
||||||
if (sub > 99) sub = 99;
|
|
||||||
|
|
||||||
char buf[48];
|
|
||||||
std::snprintf(buf, sizeof(buf), "%ld.%ld.%02d", bar, beat, sub);
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string formatSecondsMs(double lengthSeconds) {
|
|
||||||
double len = lengthSeconds > 0.0 ? lengthSeconds : 0.0;
|
|
||||||
|
|
||||||
long secs = static_cast<long>(std::floor(len));
|
|
||||||
// Round to the nearest millisecond (not floor): FP error means 62.037 s stores as
|
|
||||||
// 62.0369999... and a raw floor would render "62.036". +0.5 before truncation rounds
|
|
||||||
// to the closest ms, which is what a wall-clock read-out should show.
|
|
||||||
int ms = static_cast<int>((len - static_cast<double>(secs)) * 1000.0 + 0.5);
|
|
||||||
// Rounding can push ms to 1000 at a whole-second boundary; carry into seconds.
|
|
||||||
if (ms >= 1000) { ms -= 1000; ++secs; }
|
|
||||||
if (ms < 0) ms = 0;
|
|
||||||
|
|
||||||
char buf[48];
|
|
||||||
std::snprintf(buf, sizeof(buf), "%ld.%03d", secs, ms);
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// card_meta — pure formatting for the L7 decorative card metadata overlay. Each bank
|
|
||||||
// card overlays capture length as bars.beats.subdivisions (bottom-LEFT, musical) and
|
|
||||||
// seconds.milliseconds (bottom-RIGHT, wall-clock). Both read-outs are DECORATIVE and
|
|
||||||
// non-interactive; the bank_panel draws them via the L1 kit. The formatting itself is
|
|
||||||
// pure string work over the sample's stamped tempo + meter + length, so it is
|
|
||||||
// unit-tested outside the DAW (CLAUDE.md §load-bearing split).
|
|
||||||
//
|
|
||||||
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard
|
|
||||||
// library only. Mirror of tooltip's prefix-strip helper.
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// The musical length inputs, taken straight off a Sample (L7 F1 capture-time stamp):
|
|
||||||
// lengthSeconds — captured length in wall-clock seconds (>= 0).
|
|
||||||
// tempoBpm — project tempo (BPM) at capture (Sample.captureTempo); 0 = unknown.
|
|
||||||
// timeSigNum — meter numerator at capture (Sample.captureTimeSigNum); 0 = unstamped.
|
|
||||||
// timeSigDenom — meter denominator at capture (Sample.captureTimeSigDenom); 0 = unstamped.
|
|
||||||
struct MusicalLength {
|
|
||||||
double lengthSeconds = 0.0;
|
|
||||||
double tempoBpm = 0.0;
|
|
||||||
int timeSigNum = 0;
|
|
||||||
int timeSigDenom = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// bars.beats.subdivisions from a capture-time tempo + meter stamp (musical read-out).
|
|
||||||
//
|
|
||||||
// Derivation: one quarter-note lasts 60 / tempo seconds; a beat in this meter lasts
|
|
||||||
// (4 / timeSigDenom) quarter-notes; a bar holds timeSigNum beats. From lengthSeconds we
|
|
||||||
// get total beats, split into whole bars (÷ timeSigNum) + whole leftover beats + a
|
|
||||||
// subdivision remainder scaled to 1..N of the next beat. The output is 1-BASED and
|
|
||||||
// zero-padded to two subdivision digits: "1.1.00" is exactly one bar-start (a
|
|
||||||
// zero-length or bar-aligned capture), "2.3.50" is 1 bar + 2 beats + half a beat.
|
|
||||||
//
|
|
||||||
// Contract / edge cases (all tested):
|
|
||||||
// * UNSTAMPED meter (timeSigNum <= 0 || timeSigDenom <= 0) OR unknown tempo
|
|
||||||
// (tempoBpm <= 0): returns "" — no musical read-out is derivable (the caller keeps
|
|
||||||
// the s.ms read-out). This is the pre-L7-sample fallback (blank musical read-out).
|
|
||||||
// * zero length: "1.1.00" (bar 1, beat 1, no subdivision) — the musical origin.
|
|
||||||
// * exact bar boundary: the beat rolls to 1 and the bar increments (never "1.5.00"
|
|
||||||
// in 4/4 — that reads as "2.1.00").
|
|
||||||
// * long captures: bars grow without cap ("129.1.00" is fine).
|
|
||||||
// The subdivision is 0..99 (hundredths of a beat), floored — a display quantum, not a
|
|
||||||
// tick-accurate PPQ (the model refuses to invent PPQ; this is a decorative read-out).
|
|
||||||
std::string formatBarsBeats(const MusicalLength& m);
|
|
||||||
|
|
||||||
// seconds.milliseconds from a wall-clock length (always derivable, meter-independent).
|
|
||||||
// * "S.mmm" — integer seconds, a dot, zero-padded 3-digit milliseconds (rounded to nearest ms).
|
|
||||||
// e.g. 0.0 -> "0.000", 1.5 -> "1.500", 62.037 -> "62.037".
|
|
||||||
// * negative length is clamped to "0.000" (a length is never negative; defensive).
|
|
||||||
std::string formatSecondsMs(double lengthSeconds);
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// component_geometry — the REAPER-free, LICE-free geometry + hit-test math for the shared
|
|
||||||
// drawing kit's generic components (Phase L, L1): a button box, a slider's track/handle,
|
|
||||||
// and a list row. These are the kit-level primitives that DON'T already have a pure owner:
|
|
||||||
// bank_grid / mode_switch / tab_strip / prune_button stay the source of truth for the
|
|
||||||
// surfaces THEY own; this module carries only the new, reusable component
|
|
||||||
// shapes the kit's drawButton / drawSlider / drawListRow draw against.
|
|
||||||
//
|
|
||||||
// Why pure (CLAUDE.md §load-bearing split, DS-1 caution): even where the draw shell reuses
|
|
||||||
// a WDL/vwnd drawing idiom, the hit-test geometry stays HERE, unit-tested outside the DAW —
|
|
||||||
// vwnd's retained-mode controls own their hit-test internally, which this deliberately does
|
|
||||||
// NOT import. The shell asks this module where a handle is and whether a point hit a row.
|
|
||||||
//
|
|
||||||
// NAME NOTE (brief §name-collision): the surrounding modules already own ButtonRect /
|
|
||||||
// SegmentRect / CellRect / FooterRect etc. in this namespace, so this module's types are
|
|
||||||
// named KitButtonBox / SliderGeometry / ListRowBox to avoid collision — checked with grep
|
|
||||||
// before minting. They are distinct concepts (kit-generic component boxes vs. a specific
|
|
||||||
// surface's hit rects), so the separate names are correct, not merely non-colliding.
|
|
||||||
//
|
|
||||||
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library
|
|
||||||
// only. Builds and unit-tests without REAPER. Mirror of mode_switch / prune_button.
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// A generic pixel box, top-left origin (SWELL/LICE convention). Shared shape for the kit
|
|
||||||
// component rects below. A zero-area box (empty()) means "nothing to draw / hit" — the
|
|
||||||
// same graceful-suppression convention prune_button uses.
|
|
||||||
struct KitBox {
|
|
||||||
int x = 0;
|
|
||||||
int y = 0;
|
|
||||||
int width = 0;
|
|
||||||
int height = 0;
|
|
||||||
|
|
||||||
bool empty() const { return width <= 0 || height <= 0; }
|
|
||||||
|
|
||||||
bool operator==(const KitBox& o) const {
|
|
||||||
return x == o.x && y == o.y && width == o.width && height == o.height;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// True iff (px, py) falls inside `box`, half-open bounds [x, x+width) x [y, y+height) —
|
|
||||||
// the same discipline as every sibling hit-test so draw and hit-test never double-claim a
|
|
||||||
// pixel. An empty box claims no point (always false).
|
|
||||||
bool hitTestBox(int px, int py, const KitBox& box);
|
|
||||||
|
|
||||||
// --- Button ------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// A button drawn inside a host cell, inset by a uniform padding so it reads as a raised
|
|
||||||
// control rather than a full-bleed fill (the kit's drawButton draws the micro-gradient
|
|
||||||
// surface inside this box). Distinct from prune_button, which owns its OWN placement
|
|
||||||
// within its strip — this is the generic "given a cell, where's the
|
|
||||||
// button" helper for new kit consumers.
|
|
||||||
struct KitButtonBox {
|
|
||||||
KitBox box;
|
|
||||||
|
|
||||||
bool operator==(const KitButtonBox& o) const { return box == o.box; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// The button box inside `cell`, inset uniformly by `padding` on all four sides. Returns an
|
|
||||||
// empty box (suppressed) when the cell is degenerate or the padding would collapse it to
|
|
||||||
// zero-or-negative area — the caller then draws nothing (graceful, mirrors prune_button).
|
|
||||||
// padding < 0 is treated as 0.
|
|
||||||
KitButtonBox computeButtonBox(const KitBox& cell, int padding);
|
|
||||||
|
|
||||||
// --- Slider (horizontal) -----------------------------------------------------
|
|
||||||
//
|
|
||||||
// A horizontal slider: a track spanning the control width (inset at both ends by the
|
|
||||||
// handle's half-width so the handle never clips past the track), and a square handle
|
|
||||||
// centered on the track and positioned by the normalized value. drawSlider draws the
|
|
||||||
// track, the filled portion up to the handle, and the handle. Hit-test is against the
|
|
||||||
// handle (grab) and the track (jump); both are pure here.
|
|
||||||
struct SliderGeometry {
|
|
||||||
KitBox track; // the full track rect (the groove)
|
|
||||||
KitBox filled; // the filled portion from the track's left up to the handle center
|
|
||||||
KitBox handle; // the draggable handle rect
|
|
||||||
|
|
||||||
bool operator==(const SliderGeometry& o) const {
|
|
||||||
return track == o.track && filled == o.filled && handle == o.handle;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lays out a horizontal slider inside `control` for a normalized `value` in [0, 1] with a
|
|
||||||
// square handle of side `handleSize`. The track is vertically centered at a fixed
|
|
||||||
// `trackThickness`, inset horizontally by handleSize/2 at each end so the handle's travel
|
|
||||||
// stays within `control`. value is clamped to [0, 1]; a value of 0 puts the handle flush
|
|
||||||
// left, 1 flush right. Returns all-empty boxes when the control is degenerate or too
|
|
||||||
// small to host the handle (control width < handleSize or height < handleSize) — the
|
|
||||||
// caller draws nothing. handleSize <= 0 or trackThickness <= 0 also yields empty.
|
|
||||||
SliderGeometry computeSlider(const KitBox& control, double value,
|
|
||||||
int handleSize, int trackThickness);
|
|
||||||
|
|
||||||
// The normalized value [0, 1] a click at px maps to, for a slider laid out in `control`
|
|
||||||
// with `handleSize` (the inverse of computeSlider's handle placement — a track jump).
|
|
||||||
// px left of / at the track start yields 0.0, at/right of the track end yields 1.0,
|
|
||||||
// linear in between. Returns 0.0 for a degenerate/too-small control (no travel). py is
|
|
||||||
// unused (a horizontal slider maps X only); the caller gates the whole slider region
|
|
||||||
// with hitTestBox(control) before calling this.
|
|
||||||
double sliderValueAt(int px, const KitBox& control, int handleSize);
|
|
||||||
|
|
||||||
// --- List row ----------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// A single selectable row in a vertical list: full-width, fixed height, stacked from the
|
|
||||||
// list's top by index (no scroll — the caller offsets the list origin for scroll). The
|
|
||||||
// kit's drawListRow draws the row surface (rest/hover/selected/focus) and an optional
|
|
||||||
// leading thumbnail; the panel's waveform cell is a specialization drawn the same way.
|
|
||||||
struct ListRowBox {
|
|
||||||
int index = 0; // the row's index in the caller's list (0-based, top-first)
|
|
||||||
KitBox box;
|
|
||||||
|
|
||||||
bool operator==(const ListRowBox& o) const {
|
|
||||||
return index == o.index && box == o.box;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// The row box for `index` in a list laid out inside `list` at `rowHeight` per row. Rows
|
|
||||||
// stack from list.y; row i spans [list.y + i*rowHeight, +rowHeight). Returns an empty box
|
|
||||||
// when the list is degenerate, rowHeight <= 0, index < 0, or the row would fall entirely
|
|
||||||
// below the list's bottom (fully clipped) — a partially-visible last row IS returned (the
|
|
||||||
// caller clips the draw). This is layout only; the caller decides how many rows exist.
|
|
||||||
ListRowBox computeListRow(const KitBox& list, int index, int rowHeight);
|
|
||||||
|
|
||||||
// The index of the row a point (px, py) lands on, for a list laid out inside `list` at
|
|
||||||
// `rowHeight`. Returns -1 for a miss: outside the list bounds, in the list band but below
|
|
||||||
// the last row of `rowCount` rows (the empty tail), or a degenerate list/rowHeight/count.
|
|
||||||
// rowCount bounds the hit so a click in blank space past the last row is a clean miss, not
|
|
||||||
// a phantom row. Half-open bounds match computeListRow so the hit maps to the drawn row.
|
|
||||||
int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount);
|
|
||||||
|
|
||||||
// --- Waveform column count ---------------------------------------------------
|
|
||||||
//
|
|
||||||
// The number of pixel columns drawWaveform renders inside `box` (its fixed 2px side
|
|
||||||
// insets), never negative. Callers pass this count directly as the `binCount` argument to
|
|
||||||
// peaks::computeEnvelope — one bin per column is the correct resolution, and
|
|
||||||
// peaks::columnMinMax's exact partition makes the render gap-free at any bins-to-pixels
|
|
||||||
// ratio. Overbinning does NOT improve render quality (columnMinMax's frame union is
|
|
||||||
// identical whether bins == columns or bins == k*columns) and wastes memory and CPU.
|
|
||||||
int waveformColumnCount(const KitBox& box);
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -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,13 @@
|
|||||||
|
# src/core/audio — pure audio-data math
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Pure, REAPER-free audio-data math with no dependence on REAPER's own peak-cache
|
||||||
|
API. Currently one module: waveform min/max bin computation from raw PCM. Does
|
||||||
|
**not** include: LICE waveform drawing (`draw_kit`, `shell/panel`), the editor's
|
||||||
|
waveform/marker geometry (`waveform_view`, `core/instrument/ui`), or PCM
|
||||||
|
decoding itself.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
- `peaks` — waveform min/max bin computation from raw PCM; does not depend on REAPER's peak API.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
reasampler_pure_library(peaks SOURCES peaks.cpp)
|
||||||
|
reasampler_test(peaks LINK peaks)
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
#include "peaks.h"
|
#include "core/audio/peaks.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <climits>
|
#include <climits>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
// peaks implementation.
|
// peaks — pure implementation. See peaks.h.
|
||||||
//
|
//
|
||||||
// One linear pass per channel. The frame->bin partition is computed with integer
|
// One linear pass per channel. Frame->bin partition uses integer arithmetic so it's exact for
|
||||||
// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the
|
// any frameCount/binCount pairing: bin b owns [b*frameCount/binCount, (b+1)*frameCount/binCount)
|
||||||
// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That
|
// — earlier bins absorb the remainder, no rounding drift, no dropped tail.
|
||||||
// span formula distributes the remainder deterministically (earlier bins get the
|
|
||||||
// extra frames) with no rounding drift and no dropped tail — the last bin's end is
|
|
||||||
// always exactly frameCount.
|
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler::audio {
|
||||||
|
|
||||||
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
||||||
std::size_t channelCount,
|
std::size_t channelCount,
|
||||||
@@ -22,11 +19,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
|||||||
std::size_t binCount) {
|
std::size_t binCount) {
|
||||||
Envelope envelope(channelCount);
|
Envelope envelope(channelCount);
|
||||||
if (channelCount == 0) {
|
if (channelCount == 0) {
|
||||||
return envelope; // no channels -> no envelopes
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never read past what the buffer actually holds, even if the caller's
|
// Never read past what the buffer actually holds, even if frameCount overstates it.
|
||||||
// frameCount overstates the buffer (defensive: no OOB on a short buffer).
|
|
||||||
const std::size_t availableFrames = interleaved.size() / channelCount;
|
const std::size_t availableFrames = interleaved.size() / channelCount;
|
||||||
const std::size_t frames = std::min(frameCount, availableFrames);
|
const std::size_t frames = std::min(frameCount, availableFrames);
|
||||||
|
|
||||||
@@ -35,14 +31,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
|||||||
bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0}
|
bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0}
|
||||||
|
|
||||||
for (std::size_t b = 0; b < binCount; ++b) {
|
for (std::size_t b = 0; b < binCount; ++b) {
|
||||||
// Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount).
|
// Guard b*frames / (b+1)*frames overflow: binCount is caller-controlled and
|
||||||
// Guard against size_t overflow in b*frames and (b+1)*frames: binCount is
|
// unbounded. Unreachable in practice (would OOM first) but guarded to avoid UB.
|
||||||
// caller-controlled and unbounded, so when b >= SIZE_MAX/frames either
|
|
||||||
// multiplication could wrap. Any such bin is unreachable in practice
|
|
||||||
// (allocating that many MinMax entries would OOM first), but we guard
|
|
||||||
// explicitly to eliminate UB.
|
|
||||||
if (frames > 0 && b >= SIZE_MAX / frames) {
|
if (frames > 0 && b >= SIZE_MAX / frames) {
|
||||||
continue; // b*frames or (b+1)*frames would overflow; span is empty
|
continue;
|
||||||
}
|
}
|
||||||
const std::size_t begin = (b * frames) / binCount;
|
const std::size_t begin = (b * frames) / binCount;
|
||||||
const std::size_t end = ((b + 1) * frames) / binCount;
|
const std::size_t end = ((b + 1) * frames) / binCount;
|
||||||
@@ -69,21 +61,17 @@ MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col) {
|
|||||||
const int nbins = static_cast<int>(bins.size());
|
const int nbins = static_cast<int>(bins.size());
|
||||||
if (columnCount <= 0 || nbins == 0) return MinMax{};
|
if (columnCount <= 0 || nbins == 0) return MinMax{};
|
||||||
|
|
||||||
// Clamp col to [0, columnCount-1].
|
|
||||||
if (col < 0) col = 0;
|
if (col < 0) col = 0;
|
||||||
if (col >= columnCount) col = columnCount - 1;
|
if (col >= columnCount) col = columnCount - 1;
|
||||||
|
|
||||||
// Half-open bin range for this column, mirroring computeEnvelope's exact partition.
|
// Half-open bin range for this column, mirroring computeEnvelope's partition. 64-bit
|
||||||
// 64-bit products: col*nbins can exceed int range for a large oversampled envelope
|
// products: col*nbins can exceed int range for a large oversampled envelope.
|
||||||
// (same overflow discipline as computeEnvelope's frame-span arithmetic above).
|
|
||||||
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
|
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
|
||||||
const std::int64_t end64 =
|
const std::int64_t end64 =
|
||||||
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount;
|
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount;
|
||||||
// col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins.
|
|
||||||
const int colBinBegin = static_cast<int>(begin64);
|
const int colBinBegin = static_cast<int>(begin64);
|
||||||
|
|
||||||
// When the column spans no full bin (more columns than bins), use the enclosing bin
|
// When the column spans no full bin (more columns than bins), use the enclosing bin.
|
||||||
// so no column is left empty.
|
|
||||||
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
|
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
|
||||||
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
|
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
|
||||||
|
|
||||||
@@ -102,14 +90,11 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
|||||||
AudioSample linearThreshold) {
|
AudioSample linearThreshold) {
|
||||||
if (channelCount == 0) return kNoFrameAboveThreshold;
|
if (channelCount == 0) return kNoFrameAboveThreshold;
|
||||||
|
|
||||||
// Clamp to what the buffer actually holds — a caller frameCount that overstates
|
|
||||||
// the buffer must never read past the end (mirror of computeEnvelope's guard).
|
|
||||||
const std::size_t availableFrames = interleaved.size() / channelCount;
|
const std::size_t availableFrames = interleaved.size() / channelCount;
|
||||||
const std::size_t frames = std::min(frameCount, availableFrames);
|
const std::size_t frames = std::min(frameCount, availableFrames);
|
||||||
if (frames == 0) return kNoFrameAboveThreshold;
|
if (frames == 0) return kNoFrameAboveThreshold;
|
||||||
|
|
||||||
// Scan backward: the first frame (from the end) whose loudest channel exceeds the
|
// Scan backward; `f` runs frames..1 so `f-1` never wraps.
|
||||||
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
|
|
||||||
for (std::size_t f = frames; f > 0; --f) {
|
for (std::size_t f = frames; f > 0; --f) {
|
||||||
const std::size_t frame = f - 1;
|
const std::size_t frame = f - 1;
|
||||||
const std::size_t base = frame * channelCount;
|
const std::size_t base = frame * channelCount;
|
||||||
@@ -123,4 +108,4 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
|||||||
return kNoFrameAboveThreshold;
|
return kNoFrameAboveThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler::audio
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#pragma once
|
||||||
|
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved PCM. We compute
|
||||||
|
// our own thumbnails rather than depending on REAPER's peak API: we own the file format, so this
|
||||||
|
// is simpler, testable, and dependency-free.
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace reasampler::audio {
|
||||||
|
|
||||||
|
// REAPER's native audio buffer format (interleaved 32-bit float), consumed directly with no
|
||||||
|
// lossy conversion. Named AudioSample rather than Sample to avoid colliding with bank_model's
|
||||||
|
// metadata struct of the same short name.
|
||||||
|
using AudioSample = float;
|
||||||
|
|
||||||
|
// One bin's extremes across the samples that fell in it. min <= max always; an empty bin
|
||||||
|
// (more bins than frames) is {0, 0}.
|
||||||
|
struct MinMax {
|
||||||
|
AudioSample min = 0.0f;
|
||||||
|
AudioSample max = 0.0f;
|
||||||
|
|
||||||
|
bool operator==(const MinMax& o) const { return min == o.min && max == o.max; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// One channel's envelope: exactly `binCount` bins, in time order.
|
||||||
|
using ChannelEnvelope = std::vector<MinMax>;
|
||||||
|
|
||||||
|
// Per-channel envelopes: outer index is channel (channelCount entries, order preserved — never
|
||||||
|
// mixed or folded), inner is that channel's bins.
|
||||||
|
using Envelope = std::vector<ChannelEnvelope>;
|
||||||
|
|
||||||
|
// Computes a per-channel min/max envelope from interleaved PCM.
|
||||||
|
//
|
||||||
|
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. Size must be
|
||||||
|
// >= frameCount * channelCount; extra is ignored.
|
||||||
|
// channelCount channels per frame (the stride). Each channel is enveloped INDEPENDENTLY — no
|
||||||
|
// averaging, no stereo fold (channel count is preserved end to end).
|
||||||
|
// frameCount frames (samples-per-channel) to consider.
|
||||||
|
// binCount requested bins per channel. Honored exactly for any frameCount.
|
||||||
|
//
|
||||||
|
// Frame->bin partition: frames split into `binCount` contiguous spans as evenly as possible;
|
||||||
|
// when frameCount doesn't divide evenly, the remainder spreads one-frame-per-bin across the
|
||||||
|
// earliest bins, so the tail is never dropped and no bin reads out of bounds.
|
||||||
|
//
|
||||||
|
// Degenerate input (no UB, no throw): binCount == 0 -> empty bin vector per channel;
|
||||||
|
// channelCount == 0 -> empty envelope; frameCount == 0 -> binCount bins, all {0, 0}.
|
||||||
|
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
||||||
|
std::size_t channelCount,
|
||||||
|
std::size_t frameCount,
|
||||||
|
std::size_t binCount);
|
||||||
|
|
||||||
|
// Merged min/max for display column `col` (0-based, of `columnCount` total) of a pre-computed
|
||||||
|
// ChannelEnvelope — the true extremes of every bin projecting to that column. This is the
|
||||||
|
// display-side collapse when the envelope was computed at a higher resolution than the drawn
|
||||||
|
// width, so a steep transient split across adjacent bins (e.g. {0.9,1.0} then {-1.0,-0.9})
|
||||||
|
// renders as one gap-free span instead of two separated dots.
|
||||||
|
//
|
||||||
|
// Bin->column mapping mirrors computeEnvelope's half-open partition: column col owns bins
|
||||||
|
// [col*nbins/columnCount, (col+1)*nbins/columnCount). When that range is empty (more columns
|
||||||
|
// than bins), the enclosing bin fills the column instead. columnCount <= 0 or bins.empty()
|
||||||
|
// returns {0, 0}; col is clamped to [0, columnCount-1].
|
||||||
|
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col);
|
||||||
|
|
||||||
|
// Sentinel for "no frame in the scanned range peaked above threshold". SIZE_MAX is unambiguous
|
||||||
|
// since no real frame index can reach it.
|
||||||
|
inline constexpr std::size_t kNoFrameAboveThreshold =
|
||||||
|
static_cast<std::size_t>(-1);
|
||||||
|
|
||||||
|
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (max |sample| across
|
||||||
|
// all channels of that frame — no stereo fold) exceeds `linearThreshold`. Returns
|
||||||
|
// kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
|
||||||
|
//
|
||||||
|
// This is the boundary primitive behind the realtime tail's decay-scan trim (see
|
||||||
|
// docs/product/capture-tail.md): the recorded tail is scanned back from the end for the last
|
||||||
|
// frame still above -72 dB, and the file truncated one frame past it. Deliberately separate from
|
||||||
|
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), this answers
|
||||||
|
// "the last frame above a level" (a boundary); bending a bin-oriented envelope to a frame-exact
|
||||||
|
// question is a worse fit.
|
||||||
|
//
|
||||||
|
// linearThreshold a LINEAR amplitude ratio (e.g. the -72 dB ratio from
|
||||||
|
// render_settings::autoTrimEndRatio), NOT dB. A frame counts as above when
|
||||||
|
// its peak is STRICTLY greater than this.
|
||||||
|
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
||||||
|
std::size_t channelCount,
|
||||||
|
std::size_t frameCount,
|
||||||
|
AudioSample linearThreshold);
|
||||||
|
|
||||||
|
} // namespace reasampler::audio
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# src/core/capture — pure logic behind the capture pillar
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Pure, REAPER-free logic behind the capture pillar: path arithmetic, the RIFF/WAV
|
||||||
|
codec, render-settings/FX-scope/tail-mode mapping, `InsertMedia` mode-bit
|
||||||
|
computation, the realtime-record state machine, and batch-capture planning.
|
||||||
|
Does **not** include: the REAPER-bound capture backends themselves
|
||||||
|
(`shell/capture`), the docked panel's tail-toggle window/click-handling
|
||||||
|
(`shell/panel`), or the `InsertMedia` call/undo-block mechanics
|
||||||
|
(`shell/capture`'s `insert.cpp`).
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
The repo-wide precision invariants (null test, bit-identical repeats,
|
||||||
|
non-destructive, exact bounds, relative-paths-only, capture FX scope) are
|
||||||
|
authoritative in root `CLAUDE.md` — reference them, don't re-copy them.
|
||||||
|
Detail specific to these pure modules:
|
||||||
|
|
||||||
|
- **No silent time-stretch, made checkable.** `insert_plan` never sets the &4
|
||||||
|
("stretch/loop to fit time sel") bit; `kStretchToTimeSelBit` is exposed
|
||||||
|
precisely so a test can assert it is never present in any computed
|
||||||
|
`InsertMedia` mode.
|
||||||
|
- **Tail is a three-state mode (`docs/product/capture-tail.md`), not a
|
||||||
|
per-action variant:** None (exact bounds, byte-identical, the only mode for
|
||||||
|
null-test/verify captures), Auto (generous 8 s tail then trim trailing
|
||||||
|
silence to -72 dB surgical normalize), Manual (fixed length, clamped to the 8
|
||||||
|
s cap, no trim). `render_settings` owns the offline RENDER_* mapping;
|
||||||
|
`tail_control` owns the panel-facing toggle/cycle/clamp/label logic sharing
|
||||||
|
the same `TailMode` enum and the same 8 s / -72 dB constants (single source
|
||||||
|
of truth — do not hardcode a second copy in either module).
|
||||||
|
- **Capture FX scope is enforced via FX-bypass + gain-neutralize, not a render
|
||||||
|
bit.** `render_settings::fxBypassPlanFor` selects which tracks (self /
|
||||||
|
ancestors / master) get their FX bypassed for a given `CaptureScope`; there
|
||||||
|
is no master capture scope (to capture the master, render a track instead).
|
||||||
|
- **Relative paths only, by construction.** `capture_paths::BankPaths`
|
||||||
|
separates the absolute render directory REAPER needs from the
|
||||||
|
project-relative path the `BankIndex` stores; `bankRelativeForName` spells an
|
||||||
|
enumerated folder entry the identical way `deriveBankPaths` spelled it at
|
||||||
|
capture time, so the prune core's exact-string match cannot drift.
|
||||||
|
- **Project-identity transition is GUID-primary.** `capture_paths`'s
|
||||||
|
`classifyProjectTransition` checks the minted GUID before the live
|
||||||
|
`ReaProject*` object, specifically because REAPER can recycle a closed
|
||||||
|
project's pointer address onto an unrelated project.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
- `render_settings`'s `RENDER_SETTINGS`/`RENDER_NORMALIZE`/`RENDER_TAILFLAG`/
|
||||||
|
`RENDER_TRIMEND` bit values are transcribed verbatim from the SDK header
|
||||||
|
(`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)
|
||||||
|
— don't conflate them when touching either.
|
||||||
@@ -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)
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
// batch_capture.cpp — pure logic for M11 batch capture. See header.
|
// batch_capture.cpp — pure logic for batch capture. See header.
|
||||||
// NO REAPER types; unit-tested by tests/test_batch_capture.cpp.
|
// Unit-tested by tests/test_batch_capture.cpp.
|
||||||
|
|
||||||
#include "batch_capture.h"
|
#include "core/capture/batch_capture.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges) {
|
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges) {
|
||||||
std::vector<CaptureUnit> units;
|
std::vector<CaptureUnit> units;
|
||||||
units.reserve(ranges.size());
|
units.reserve(ranges.size());
|
||||||
int ordinal = 0;
|
int ordinal = 0;
|
||||||
for (const BatchRange& r : ranges) {
|
for (const BatchRange& r : ranges) {
|
||||||
// Drop empty/inverted ranges — the offline backend refuses end<=start too, so
|
// Drop empty/inverted ranges — the offline backend refuses end<=start too.
|
||||||
// planning one would only manufacture a guaranteed per-unit failure. Ordinals
|
|
||||||
// count kept units so the reported numbering is contiguous.
|
|
||||||
if (!(r.endSeconds > r.startSeconds)) continue;
|
if (!(r.endSeconds > r.startSeconds)) continue;
|
||||||
++ordinal;
|
++ordinal;
|
||||||
units.push_back({ordinal, r.startSeconds, r.endSeconds});
|
units.push_back({ordinal, r.startSeconds, r.endSeconds});
|
||||||
@@ -73,4 +71,4 @@ std::string BatchOutcome::summaryLine(const std::string& noun) const {
|
|||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#pragma once
|
||||||
|
// batch_capture — the REAPER-free logic behind batch capture (one action fires N
|
||||||
|
// captures: one bank sample per selected item / per razor area).
|
||||||
|
//
|
||||||
|
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||||
|
// only. The batch shell reads the DAW state (selected items -> exact bounds;
|
||||||
|
// each track's P_RAZOREDITS -> areas) and hands the raw ranges here:
|
||||||
|
//
|
||||||
|
// 1. planCaptureUnits: an ordered list of (start,end) ranges -> an ordered
|
||||||
|
// list of CaptureUnit, each with a 1-based ordinal and validated bounds.
|
||||||
|
// Empty/inverted ranges are dropped (mirrors the offline backend's own
|
||||||
|
// end>start guard); ordinals count only the kept units, so three valid
|
||||||
|
// items yield 1,2,3 regardless of dropped neighbors.
|
||||||
|
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a
|
||||||
|
// summary (succeeded/failed counts + ordered failures) for one console
|
||||||
|
// line with no partial-corruption ambiguity.
|
||||||
|
//
|
||||||
|
// Range is the only thing that varies per unit here. FX scope (item vs track) is
|
||||||
|
// a per-action constant the shell already owns; item-batch uses item scope,
|
||||||
|
// razor-batch uses track scope, passed through unchanged from the single-capture
|
||||||
|
// path.
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
// One capture in a batch: an exact source range plus its 1-based ordinal within
|
||||||
|
// the kept set. The ordinal disambiguates per-unit file stems (the offline
|
||||||
|
// backend's unique tag is 1-second-granular, so a fast batch could otherwise
|
||||||
|
// collide N files onto one name) and labels a failure in the summary.
|
||||||
|
struct CaptureUnit {
|
||||||
|
int ordinal = 0; // 1-based, counts kept units only
|
||||||
|
double startSeconds = 0.0; // exact — no rounding
|
||||||
|
double endSeconds = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A source range handed in by the shell (a selected item's [pos, pos+len] or one
|
||||||
|
// razor area's [start, end]). Named BatchRange (not SourceRange) to avoid
|
||||||
|
// collision with bank_model's SourceRange, which carries PPQ fields this planner
|
||||||
|
// doesn't need.
|
||||||
|
struct BatchRange {
|
||||||
|
double startSeconds = 0.0;
|
||||||
|
double endSeconds = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validates + orders a batch's source ranges into capture units. Preserves input
|
||||||
|
// order; drops every range with end <= start; assigns 1-based ordinals over the
|
||||||
|
// kept units. An empty input yields an empty plan — the shell reports "nothing
|
||||||
|
// to batch" and writes nothing.
|
||||||
|
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges);
|
||||||
|
|
||||||
|
// The per-unit verdict the shell records after each render attempt, in unit order.
|
||||||
|
struct BatchUnitResult {
|
||||||
|
int ordinal = 0; // the CaptureUnit's ordinal this result is for
|
||||||
|
bool ok = false; // true iff the render + bank-add succeeded
|
||||||
|
std::string detail; // failure reason (empty on success) — for the summary
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order-preserving aggregation of a batch's per-unit results. Built incrementally
|
||||||
|
// by the shell (record() after each unit) so a mid-batch failure doesn't abort
|
||||||
|
// the remaining units — each unit is independent.
|
||||||
|
class BatchOutcome {
|
||||||
|
public:
|
||||||
|
// Records one unit's verdict. Order of calls IS the reported order.
|
||||||
|
void record(int ordinal, bool ok, std::string detail = {});
|
||||||
|
|
||||||
|
std::size_t total() const { return results_.size(); }
|
||||||
|
std::size_t succeeded() const;
|
||||||
|
std::size_t failed() const;
|
||||||
|
|
||||||
|
const std::vector<BatchUnitResult>& results() const { return results_; }
|
||||||
|
|
||||||
|
// The ordered subset of results that failed (ok == false). For the summary line.
|
||||||
|
std::vector<BatchUnitResult> failures() const;
|
||||||
|
|
||||||
|
// A single human summary line for the console (explicit-action response — allowed
|
||||||
|
// by the console policy; a batch-completion summary with failure counts qualifies,
|
||||||
|
// per-unit success spam does not). `noun` is the unit word ("item" / "razor area").
|
||||||
|
// Examples:
|
||||||
|
// all-success, 3 items : "ReaSampler batch capture: 3 items captured."
|
||||||
|
// partial, 3 of 5 : "ReaSampler batch capture: 3 of 5 items captured "
|
||||||
|
// "(2 failed: #2, #4)."
|
||||||
|
// empty plan : "ReaSampler batch capture: nothing to capture."
|
||||||
|
std::string summaryLine(const std::string& noun) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<BatchUnitResult> results_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// capture_name — pure implementation. See the header.
|
||||||
|
|
||||||
|
#include "core/capture/capture_name.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// A track name padded with spaces would render ragged in the label and as underscores in
|
||||||
|
// the stem, so both ends are trimmed before anything else looks at it.
|
||||||
|
std::string trimmed(const std::string& s) {
|
||||||
|
std::size_t b = 0;
|
||||||
|
std::size_t e = s.size();
|
||||||
|
auto isSpace = [](unsigned char c) {
|
||||||
|
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
||||||
|
};
|
||||||
|
while (b < e && isSpace(static_cast<unsigned char>(s[b]))) ++b;
|
||||||
|
while (e > b && isSpace(static_cast<unsigned char>(s[e - 1]))) --e;
|
||||||
|
return s.substr(b, e - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncating mid-sequence would put invalid UTF-8 into the persisted label, so the cut
|
||||||
|
// backs off over continuation bytes (10xxxxxx). The stem does not care — sanitizeStem
|
||||||
|
// replaces every non-ASCII byte anyway — but one rule for both keeps them the same name.
|
||||||
|
std::string truncateUtf8(const std::string& s, std::size_t maxBytes) {
|
||||||
|
if (s.size() <= maxBytes) return s;
|
||||||
|
std::size_t cut = maxBytes;
|
||||||
|
while (cut > 0 && (static_cast<unsigned char>(s[cut]) & 0xC0) == 0x80) --cut;
|
||||||
|
return s.substr(0, cut);
|
||||||
|
}
|
||||||
|
|
||||||
|
int clampTo(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string formatCaptureStamp(const CaptureStamp& stamp) {
|
||||||
|
if (stamp.month < 1 || stamp.day < 1) return {};
|
||||||
|
char buf[24];
|
||||||
|
std::snprintf(buf, sizeof(buf), "%02d-%02d %02d%02d",
|
||||||
|
clampTo(stamp.month, 1, 12), clampTo(stamp.day, 1, 31),
|
||||||
|
clampTo(stamp.hour, 0, 23), clampTo(stamp.minute, 0, 59));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureName composeCaptureName(const CaptureNameInputs& in) {
|
||||||
|
std::string base;
|
||||||
|
int named = 0;
|
||||||
|
for (const std::string& raw : in.sourceNames) {
|
||||||
|
const std::string n = trimmed(raw);
|
||||||
|
if (n.empty()) continue;
|
||||||
|
if (base.empty()) base = n;
|
||||||
|
++named;
|
||||||
|
}
|
||||||
|
if (base.empty()) base = trimmed(in.fallback);
|
||||||
|
if (base.empty()) base = "capture";
|
||||||
|
base = truncateUtf8(base, kMaxSourceNameBytes);
|
||||||
|
// truncateUtf8 backs off over continuation bytes, so a name whose first kMaxSourceNameBytes
|
||||||
|
// bytes are ALL continuation bytes (0x80-0xBF) backs off to nothing — re-apply the "never an
|
||||||
|
// empty label" fallback after truncation, not just before it.
|
||||||
|
if (base.empty()) base = "capture";
|
||||||
|
|
||||||
|
CaptureName out;
|
||||||
|
out.label = base;
|
||||||
|
out.stemBase = base;
|
||||||
|
|
||||||
|
// Several sources collapse onto the first one's name plus a count of the rest — the
|
||||||
|
// alternative (joining every name) produces a stem no one can read and a label that
|
||||||
|
// no longer fits a card.
|
||||||
|
if (named > 1) {
|
||||||
|
const std::string extra = std::to_string(named - 1);
|
||||||
|
out.label += " +" + extra;
|
||||||
|
out.stemBase += "+" + extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in.ordinal > 0) {
|
||||||
|
const std::string ord = std::to_string(in.ordinal);
|
||||||
|
out.label += " #" + ord;
|
||||||
|
out.stemBase += "-" + ord;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string stamp = formatCaptureStamp(in.stamp);
|
||||||
|
if (!stamp.empty()) out.label += " " + stamp;
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
// capture_name — the REAPER-free composition of one capture's label and file-stem base
|
||||||
|
// from its source-track name(s), a local-calendar discriminator, and an optional batch
|
||||||
|
// ordinal. The shell reads the names and the clock; the SHAPE of a capture's name is
|
||||||
|
// decided here so it is testable without a DAW.
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
// The capture's own moment, already broken down into LOCAL calendar fields by the shell.
|
||||||
|
// Passing fields rather than an epoch is what keeps the format deterministic under test:
|
||||||
|
// an epoch would render differently per machine timezone. month < 1 or day < 1 means
|
||||||
|
// "no stamp" and suppresses the discriminator entirely.
|
||||||
|
struct CaptureStamp {
|
||||||
|
int month = 0; // 1-12
|
||||||
|
int day = 0; // 1-31
|
||||||
|
int hour = 0; // 0-23
|
||||||
|
int minute = 0; // 0-59
|
||||||
|
};
|
||||||
|
|
||||||
|
// Longest source-name prefix kept in either the label or the stem. Real track names sit
|
||||||
|
// far under it; the bound exists so a pathological name cannot push the rendered file
|
||||||
|
// path toward the platform's limit, and so a label and its file still read as the same
|
||||||
|
// name.
|
||||||
|
inline constexpr std::size_t kMaxSourceNameBytes = 64;
|
||||||
|
|
||||||
|
struct CaptureNameInputs {
|
||||||
|
// Source-track names in source order — the first non-empty one names the capture,
|
||||||
|
// the rest only contribute the "+N" multi-source marker.
|
||||||
|
std::vector<std::string> sourceNames;
|
||||||
|
|
||||||
|
CaptureStamp stamp;
|
||||||
|
|
||||||
|
// Batch unit ordinal; <= 0 for a single capture.
|
||||||
|
int ordinal = 0;
|
||||||
|
|
||||||
|
// The scope literal ("item"/"track"/"realtime"), used ONLY when no source name
|
||||||
|
// resolved at all — otherwise the source name wins.
|
||||||
|
std::string fallback = "capture";
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CaptureName {
|
||||||
|
// Sample::displayName. Legible, carries the source name verbatim, and is explicitly
|
||||||
|
// NOT unique (core/model/CLAUDE.md §resample_name) — the stamp serves the eye.
|
||||||
|
std::string label;
|
||||||
|
|
||||||
|
// deriveBankPaths' baseName. Still passes through sanitizeStem, and stem uniqueness
|
||||||
|
// is still entirely makeUniqueTag's job.
|
||||||
|
std::string stemBase;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "MM-DD HHMM" (e.g. "08-01 1432"); empty when the stamp carries no calendar date.
|
||||||
|
// Year is deliberately omitted: the card and the browse list are narrow, and Sample
|
||||||
|
// carries the full createdTimestamp for anything needing the exact moment.
|
||||||
|
std::string formatCaptureStamp(const CaptureStamp& stamp);
|
||||||
|
|
||||||
|
CaptureName composeCaptureName(const CaptureNameInputs& in);
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#include "core/capture/capture_paths.h"
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <cctype>
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
std::string normalizeSlashes(const std::string& path) {
|
||||||
|
std::string out = path;
|
||||||
|
for (char& c : out) {
|
||||||
|
if (c == '\\') c = '/';
|
||||||
|
}
|
||||||
|
// Strip a trailing slash but preserve a lone "/" (root).
|
||||||
|
if (out.size() > 1 && out.back() == '/') {
|
||||||
|
out.pop_back();
|
||||||
|
}
|
||||||
|
#ifdef _WIN32
|
||||||
|
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
#endif
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string sanitizeStem(const std::string& baseName) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(baseName.size());
|
||||||
|
for (unsigned char c : baseName) {
|
||||||
|
const bool keep = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||||
|
(c >= '0' && c <= '9') || c == '.' || c == '_' ||
|
||||||
|
c == '-';
|
||||||
|
out.push_back(keep ? static_cast<char>(c) : '_');
|
||||||
|
}
|
||||||
|
// Collapse to a stable default if nothing alnum survived.
|
||||||
|
bool hasAlnum = false;
|
||||||
|
for (unsigned char c : out) {
|
||||||
|
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||||
|
(c >= '0' && c <= '9')) {
|
||||||
|
hasAlnum = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.empty() || !hasAlnum) {
|
||||||
|
return "capture";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
BankPaths deriveBankPaths(const std::string& projectDir,
|
||||||
|
const std::string& baseName,
|
||||||
|
const std::string& uniqueTag) {
|
||||||
|
const std::string dir = normalizeSlashes(projectDir);
|
||||||
|
|
||||||
|
std::string stem = sanitizeStem(baseName);
|
||||||
|
if (!uniqueTag.empty()) {
|
||||||
|
stem += "_" + sanitizeStem(uniqueTag);
|
||||||
|
}
|
||||||
|
const std::string fileName = stem + ".wav";
|
||||||
|
|
||||||
|
// Precondition: caller must resolve a non-empty project directory — an
|
||||||
|
// empty one would otherwise fall back to a bare relative path (forbidden).
|
||||||
|
// Assert in debug; leave absoluteDir empty in release so a caller that
|
||||||
|
// ignores it fails at the render/stat step, not silently onto CWD.
|
||||||
|
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
|
||||||
|
|
||||||
|
BankPaths p;
|
||||||
|
p.fileStem = stem; // stem only — REAPER appends extension
|
||||||
|
p.fileName = fileName;
|
||||||
|
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
|
||||||
|
p.absoluteDir = dir.empty() ? std::string{}
|
||||||
|
: dir + "/" + kBankSubfolder;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string bankRelativeForName(const std::string& fileName) {
|
||||||
|
if (fileName.empty()) return {};
|
||||||
|
// Same expression deriveBankPaths uses, so the two spellings can't drift.
|
||||||
|
return std::string(kBankSubfolder) + "/" + fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string resolveBankFile(const std::string& projectDir,
|
||||||
|
const std::string& relativePath) {
|
||||||
|
if (projectDir.empty() || relativePath.empty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const std::string dir = normalizeSlashes(projectDir);
|
||||||
|
const std::string rel = normalizeSlashes(relativePath);
|
||||||
|
if (dir.empty() || rel.empty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return dir + "/" + rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string projectDirOfRpp(const std::string& rppPath) {
|
||||||
|
if (rppPath.empty()) return {}; // unsaved project: keep empty, no fallback
|
||||||
|
std::string dir = std::filesystem::path(rppPath).parent_path().string();
|
||||||
|
return normalizeSlashes(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
||||||
|
const std::string& newProjectDir) {
|
||||||
|
BankRelocation r;
|
||||||
|
if (oldProjectDir.empty() || newProjectDir.empty()) {
|
||||||
|
return r; // needed=false, empty dirs — nothing to relocate
|
||||||
|
}
|
||||||
|
const std::string oldDir = normalizeSlashes(oldProjectDir);
|
||||||
|
const std::string newDir = normalizeSlashes(newProjectDir);
|
||||||
|
|
||||||
|
r.oldBankDir = oldDir + "/" + kBankSubfolder;
|
||||||
|
r.newBankDir = newDir + "/" + kBankSubfolder;
|
||||||
|
r.needed = (oldDir != newDir); // Save-in-place leaves the dir unchanged
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectTransition classifyProjectTransition(bool sameProjectObject,
|
||||||
|
const std::string& lastGuid,
|
||||||
|
const std::string& lastPath,
|
||||||
|
const std::string& currentGuid,
|
||||||
|
const std::string& currentPath) {
|
||||||
|
// See capture_paths.h for the GUID-primary rationale and rule order.
|
||||||
|
if (currentGuid != lastGuid) {
|
||||||
|
return ProjectTransition::Load;
|
||||||
|
}
|
||||||
|
if (!sameProjectObject) {
|
||||||
|
return ProjectTransition::Load; // forked sibling: same GUID, different object
|
||||||
|
}
|
||||||
|
if (currentPath != lastPath) {
|
||||||
|
return ProjectTransition::SaveAsRelocate;
|
||||||
|
}
|
||||||
|
return ProjectTransition::NoOp;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#pragma once
|
||||||
|
// capture_paths — the REAPER-free path arithmetic behind offline capture. The
|
||||||
|
// capture shell resolves the current project directory via REAPER APIs, then
|
||||||
|
// hands the raw strings here. Forward-slash form throughout, no filesystem
|
||||||
|
// access; the bank subfolder name is a fixed constant.
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
// The project-relative bank subfolder. All captured wavs live here so the bank
|
||||||
|
// travels with the .rpp.
|
||||||
|
inline constexpr const char* kBankSubfolder = "reasampler_bank";
|
||||||
|
|
||||||
|
// A resolved pair of paths for one capture: where REAPER must be told to write
|
||||||
|
// (absolute, because RENDER_FILE wants a directory REAPER can create/open) and
|
||||||
|
// what we store in the BankModel (project-relative, because the index is
|
||||||
|
// relative-paths-only — CLAUDE.md precision invariant).
|
||||||
|
struct BankPaths {
|
||||||
|
std::string absoluteDir; // <projectDir>/reasampler_bank (forward slash)
|
||||||
|
std::string relativePath; // reasampler_bank/<fileName> (index value)
|
||||||
|
std::string fileName; // <stem>.wav (full file name)
|
||||||
|
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalizes a path to forward slashes and strips any trailing slash (does not
|
||||||
|
// consult the filesystem). On Windows (_WIN32) also lowercases the result so
|
||||||
|
// paths differing only in casing compare equal; macOS/Linux preserve case.
|
||||||
|
std::string normalizeSlashes(const std::string& path);
|
||||||
|
|
||||||
|
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
|
||||||
|
// [A-Za-z0-9._-], replaces every other byte with '_', and collapses to
|
||||||
|
// "capture" if nothing usable remains. Deterministic.
|
||||||
|
std::string sanitizeStem(const std::string& baseName);
|
||||||
|
|
||||||
|
// Derives the bank paths for one capture: baseName is the sanitized file-stem
|
||||||
|
// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so
|
||||||
|
// repeated captures don't collide. Produces "<stem>[_<tag>].wav".
|
||||||
|
BankPaths deriveBankPaths(const std::string& projectDir,
|
||||||
|
const std::string& baseName,
|
||||||
|
const std::string& uniqueTag);
|
||||||
|
|
||||||
|
// The project-relative index spelling for a bank file known only by its file
|
||||||
|
// name (bare entry, no directory) — the prune shell uses this to spell an
|
||||||
|
// enumerated folder entry the SAME way deriveBankPaths spelled it at capture
|
||||||
|
// time; a divergence here could make a referenced file look like an orphan.
|
||||||
|
std::string bankRelativeForName(const std::string& fileName);
|
||||||
|
|
||||||
|
// --- Persist-side path arithmetic -------------------------------------------
|
||||||
|
//
|
||||||
|
// The index stores relative paths only; on project load the persist shell
|
||||||
|
// turns each relativePath back into an absolute path against the current
|
||||||
|
// project directory — the inverse of deriveBankPaths.
|
||||||
|
|
||||||
|
// Returns "<projectDir>/<relativePath>" forward-slashed, or empty if either
|
||||||
|
// input is empty (no default-location fallback — an unsaved/unset project
|
||||||
|
// fails loudly rather than resolving against CWD).
|
||||||
|
std::string resolveBankFile(const std::string& projectDir,
|
||||||
|
const std::string& relativePath);
|
||||||
|
|
||||||
|
// The project directory that holds a .rpp: parent directory, forward-slashed,
|
||||||
|
// trailing slash stripped. Empty in -> empty out (an unsaved project reports
|
||||||
|
// an empty .rpp path). Pure so the VST3 instrument resolves audio paths the
|
||||||
|
// same way persist does.
|
||||||
|
std::string projectDirOfRpp(const std::string& rppPath);
|
||||||
|
|
||||||
|
// A relocation plan for the physical bank folder on Save-As to a new project
|
||||||
|
// location. The index's relative paths do NOT change (they are relative to the
|
||||||
|
// project dir, which moved with the .rpp), so relocation is purely a folder
|
||||||
|
// move. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
|
||||||
|
struct BankRelocation {
|
||||||
|
std::string oldBankDir; // <oldProjectDir>/reasampler_bank
|
||||||
|
std::string newBankDir; // <newProjectDir>/reasampler_bank
|
||||||
|
bool needed = false; // false when old==new (Save in place, not Save-As)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derives the relocation plan: `needed` is true iff the normalized old/new
|
||||||
|
// project dirs differ (a genuine Save-As-to-new-dir); empty dirs/needed=false
|
||||||
|
// when either input is empty.
|
||||||
|
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
||||||
|
const std::string& newProjectDir);
|
||||||
|
|
||||||
|
// --- Project-identity transition ---------------------------------------------
|
||||||
|
//
|
||||||
|
// What the persist timer must do on each tick. GUID is checked FIRST because
|
||||||
|
// two prior pointer-primary/GUID-only designs each broke a real case: a
|
||||||
|
// GUID-only check misreads a Save-As fork as the same project (fork and
|
||||||
|
// parent share a GUID on disk); a pointer-primary check misreads REAPER
|
||||||
|
// recycling a closed project's ReaProject* address onto an unrelated project
|
||||||
|
// (a different project, same recycled pointer, read as NoOp/SaveAsRelocate —
|
||||||
|
// the bank never reloads). Checking GUID first catches recycling; the pointer
|
||||||
|
// (sameProjectObject) then separates a forked sibling (Load) from a genuine
|
||||||
|
// Save-As (SaveAsRelocate).
|
||||||
|
enum class ProjectTransition {
|
||||||
|
NoOp, // same object, same GUID, same location — nothing to do
|
||||||
|
Load, // a different project is active — load ITS index from ext state
|
||||||
|
SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank
|
||||||
|
};
|
||||||
|
|
||||||
|
// Classifies what a poll tick observed. sameProjectObject is passed as a bool
|
||||||
|
// (not the raw pointer) to keep the classifier REAPER-free and testable;
|
||||||
|
// lastGuid/lastPath is the project persist last acted on, currentGuid/
|
||||||
|
// currentPath the now-active project (both "" if unsaved/unwritten).
|
||||||
|
// Evaluated in order: currentGuid!=lastGuid -> Load; !sameProjectObject ->
|
||||||
|
// Load (forked sibling); currentPath!=lastPath -> SaveAsRelocate (also covers
|
||||||
|
// first save of an unsaved project); else NoOp.
|
||||||
|
ProjectTransition classifyProjectTransition(bool sameProjectObject,
|
||||||
|
const std::string& lastGuid,
|
||||||
|
const std::string& lastPath,
|
||||||
|
const std::string& currentGuid,
|
||||||
|
const std::string& currentPath);
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// capture_realtime.cpp — pure logic for the realtime-record backend. See header.
|
||||||
|
// Unit-tested by tests/test_capture_realtime.cpp.
|
||||||
|
|
||||||
|
#include "core/capture/capture_realtime.h"
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
|
||||||
|
RecordModePlan p;
|
||||||
|
|
||||||
|
// REAPER's output-record modes are mono/stereo only; >2 channels still
|
||||||
|
// records stereo-out (a >2-channel realtime capture is out of scope).
|
||||||
|
p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp
|
||||||
|
: kRecModeStereoOutLatComp;
|
||||||
|
|
||||||
|
switch (tap) {
|
||||||
|
case OutputTap::PostFader: p.recModeFlags = kRecOutPostFader; break;
|
||||||
|
case OutputTap::PreFx: p.recModeFlags = kRecOutPreFx; break;
|
||||||
|
case OutputTap::PostFxPreFader: p.recModeFlags = kRecOutPostFxPreFader; break;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
OutputTap outputTapForWetDry(double wetDry) {
|
||||||
|
return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
|
||||||
|
Sample s;
|
||||||
|
// Relative path tail included so two same-tag captures (shouldn't happen) still differ.
|
||||||
|
s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath;
|
||||||
|
s.displayName = cap.displayName;
|
||||||
|
s.relativePath = cap.relativePath;
|
||||||
|
s.sourceMode = cap.sourceMode;
|
||||||
|
s.sourceRange.startSeconds = cap.startSeconds;
|
||||||
|
s.sourceRange.endSeconds = cap.endSeconds;
|
||||||
|
// PPQ/beats deferred (musical-placement concern), as offline.
|
||||||
|
s.wetDry = cap.wetDry;
|
||||||
|
s.trackGuids = cap.trackGuids;
|
||||||
|
s.channelCount = cap.channelCount;
|
||||||
|
s.sampleRate = cap.sampleRate; // 0 when project rate was unknown
|
||||||
|
s.lengthSeconds = cap.endSeconds - cap.startSeconds;
|
||||||
|
s.captureTempo = cap.captureTempo;
|
||||||
|
s.captureTimeSigNum = cap.captureTimeSigNum; // 0/0 = unstamped
|
||||||
|
s.captureTimeSigDenom = cap.captureTimeSigDenom;
|
||||||
|
s.tier = Tier::Scratch;
|
||||||
|
// contentHash is left empty: this mapping runs before the file exists on
|
||||||
|
// disk; the shell patches the hash in after the move+trim.
|
||||||
|
// rootNote/loop left empty: a realtime record of wet output isn't a single
|
||||||
|
// played note, so no root note is derivable; loop points are a later action.
|
||||||
|
s.createdTimestamp = cap.createdTimestamp;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordPhase advanceRecordPhase(RecordPhase current,
|
||||||
|
const RecordTickInputs& inputs,
|
||||||
|
double rangeStartSeconds,
|
||||||
|
double rangeEndSeconds) {
|
||||||
|
switch (current) {
|
||||||
|
case RecordPhase::Recording: {
|
||||||
|
// Stopped early (user or REAPER) -> finalize what was captured so far.
|
||||||
|
if (!inputs.transport.recording) return RecordPhase::Finalizing;
|
||||||
|
|
||||||
|
// >= (not >): a cursor landing exactly on the end completes.
|
||||||
|
if (inputs.transport.playPosition >= rangeEndSeconds)
|
||||||
|
return RecordPhase::Finalizing;
|
||||||
|
|
||||||
|
// Self-defense: a stuck/looping transport that never reaches end would
|
||||||
|
// otherwise stay in Recording forever, leaking the temp track + armed sink.
|
||||||
|
const double ceiling =
|
||||||
|
(rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds;
|
||||||
|
if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing;
|
||||||
|
|
||||||
|
return RecordPhase::Recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
case RecordPhase::Finalizing: {
|
||||||
|
// Moving the file before it's stable would race REAPER's flush and
|
||||||
|
// yield a truncated/missing capture.
|
||||||
|
if (inputs.fileReady) return RecordPhase::Done;
|
||||||
|
|
||||||
|
if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds)
|
||||||
|
return RecordPhase::Failed;
|
||||||
|
|
||||||
|
return RecordPhase::Finalizing;
|
||||||
|
}
|
||||||
|
|
||||||
|
case RecordPhase::Done:
|
||||||
|
case RecordPhase::Failed:
|
||||||
|
default:
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isStopRequested(RecordPhase phase) {
|
||||||
|
return phase != RecordPhase::Recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isTerminalPhase(RecordPhase phase) {
|
||||||
|
return phase == RecordPhase::Done || phase == RecordPhase::Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
#pragma once
|
||||||
|
// capture_realtime — the REAPER-free logic behind the realtime-record backend.
|
||||||
|
// The shell drives the transport, temp track, send routing, and file move; the
|
||||||
|
// pure pieces split out here and unit-tested outside the DAW are: (1) record-
|
||||||
|
// mode bookkeeping — scope + FX-tap point -> I_RECMODE/I_RECMODE_FLAGS values
|
||||||
|
// (bit MEANINGS transcribed verbatim from reaper_plugin_functions.h ~2197-2198;
|
||||||
|
// the CHOICE of value per scope is this module's tested logic) — and (2) the
|
||||||
|
// recorded-file -> Sample mapping (mirrors OfflineRenderBackend's population).
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/model/bank_model.h" // Sample, SourceMode (pure)
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
using model::Sample;
|
||||||
|
using model::Tier;
|
||||||
|
using model::SourceMode;
|
||||||
|
|
||||||
|
// I_RECMODE (verbatim from SDK header ~2197): 0=input, 1=stereo out, 2=none,
|
||||||
|
// 3=stereo out w/latency comp, 4=midi output, 5=mono out, 6=mono out w/latency
|
||||||
|
// comp, 7=midi overdub, 8=midi replace. We record a track's OUTPUT, latency-
|
||||||
|
// compensated, so the recorded file lines up sample-accurately with the source.
|
||||||
|
inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp
|
||||||
|
inline constexpr int kRecModeMonoOutLatComp = 6; // mono out w/latency comp
|
||||||
|
|
||||||
|
// I_RECMODE_FLAGS (verbatim from SDK header ~2198): &3=output recording mode
|
||||||
|
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). This is the only documented
|
||||||
|
// pre-FX tap in the SDK — offline render has no pre-FX bit — so the realtime
|
||||||
|
// backend is the true pre-FX "dry" path.
|
||||||
|
inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet)
|
||||||
|
inline constexpr int kRecOutPreFx = 1; // &3==1: pre-FX (true dry)
|
||||||
|
inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader
|
||||||
|
|
||||||
|
// The tap point on the source track's output the temp track records from.
|
||||||
|
// Orthogonal to the record mode (stereo/mono); this only sets the &3 flags bits.
|
||||||
|
enum class OutputTap {
|
||||||
|
PostFader, // fully wet, after this track's fader (kRecOutPostFader)
|
||||||
|
PreFx, // true dry, before this track's FX (kRecOutPreFx)
|
||||||
|
PostFxPreFader, // wet FX, before the fader (kRecOutPostFxPreFader)
|
||||||
|
};
|
||||||
|
|
||||||
|
// The concrete record-mode values a temp track must carry to capture the scoped
|
||||||
|
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated);
|
||||||
|
// `recModeFlags` sets the &3 output-recording tap bits (we only own those bits).
|
||||||
|
struct RecordModePlan {
|
||||||
|
int recMode = kRecModeStereoOutLatComp;
|
||||||
|
int recModeFlags = kRecOutPostFader;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maps (channelCount, tap) to the record-mode values: channelCount <= 1 ->
|
||||||
|
// mono-out latency-comp, else stereo-out; tap -> the &3 bits. The shell applies
|
||||||
|
// these via SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
|
||||||
|
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap);
|
||||||
|
|
||||||
|
// Maps a wetDry value to the output tap point: 1.0 (fully wet) -> PostFader,
|
||||||
|
// anything less -> PreFx (true dry — the realtime backend's distinguishing
|
||||||
|
// capability over offline render). PostFxPreFader is not reachable from wetDry.
|
||||||
|
OutputTap outputTapForWetDry(double wetDry);
|
||||||
|
|
||||||
|
// --- Recorded-file -> Sample mapping ----------------------------------------
|
||||||
|
|
||||||
|
// The inputs a finished realtime capture yields, gathered by the shell into a
|
||||||
|
// pure struct so Sample population is a single tested transform (mirrors the
|
||||||
|
// inline population in OfflineRenderBackend::capture).
|
||||||
|
struct RecordedCapture {
|
||||||
|
// Project-relative path of the recorded file (the shell resolves REAPER's
|
||||||
|
// absolute path back to project-relative).
|
||||||
|
std::string relativePath;
|
||||||
|
|
||||||
|
// The disambiguating tag that named the file (feeds the Sample id).
|
||||||
|
std::string uniqueTag;
|
||||||
|
|
||||||
|
// Echoed from the request (exact bounds — no re-measuring the file).
|
||||||
|
SourceMode sourceMode = SourceMode::Realtime;
|
||||||
|
double startSeconds = 0.0;
|
||||||
|
double endSeconds = 0.0;
|
||||||
|
double wetDry = 1.0;
|
||||||
|
std::string displayName;
|
||||||
|
std::vector<std::string> trackGuids;
|
||||||
|
|
||||||
|
int channelCount = 0;
|
||||||
|
|
||||||
|
// Left at defaults here — capture_realtime_finalize.cpp calls
|
||||||
|
// stampCaptureSample(result.sample, ...) afterward, overwriting these five
|
||||||
|
// from the live project. Kept because the pure unit tests still assert them.
|
||||||
|
int sampleRate = 0; // 0 when the project rate was unknown (as offline)
|
||||||
|
double captureTempo = 0.0; // BPM at capture time
|
||||||
|
int captureTimeSigNum = 0; // 0/0 = unstamped
|
||||||
|
int captureTimeSigDenom = 0;
|
||||||
|
std::int64_t createdTimestamp = 0; // unix epoch seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
// Builds the Sample for a finished realtime capture: exact request bounds,
|
||||||
|
// scratch tier, empty content hash, lengthSeconds = end - start. PPQ/beats
|
||||||
|
// left 0 (deferred, as offline).
|
||||||
|
Sample sampleFromRecordedCapture(const RecordedCapture& cap);
|
||||||
|
|
||||||
|
// --- Async record-phase state machine ----------------------------------------
|
||||||
|
//
|
||||||
|
// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport
|
||||||
|
// on REAPER's audio thread and returns immediately — it does not block until the
|
||||||
|
// range completes). The completion decision — keep waiting, stop-and-flush,
|
||||||
|
// finalize, or give up — is pure and unit-tested without a DAW; the shell only
|
||||||
|
// reads the transport/clock/file and applies the verdict.
|
||||||
|
//
|
||||||
|
// Two waits, not one:
|
||||||
|
// 1. RECORD wait (Recording): transport running; wait for the play cursor to
|
||||||
|
// reach the range end, OR the user stops early, OR a wall-clock safety
|
||||||
|
// ceiling trips (a started-but-never-advancing transport).
|
||||||
|
// 2. FLUSH wait (Finalizing): transport stopped but REAPER closes/flushes the
|
||||||
|
// recorded take on the audio thread — the file may lag a tick or two.
|
||||||
|
// Defer the move until the file exists AND is stable, bounded by a flush
|
||||||
|
// ceiling so a file that never appears fails cleanly instead of hanging.
|
||||||
|
|
||||||
|
// Where an in-progress capture is in its lifecycle: Recording (live, transport
|
||||||
|
// running) and Finalizing (live-but-stopped, waiting for flush) are the two
|
||||||
|
// waits above; Done/Failed are terminal — the shell's verdict to act on.
|
||||||
|
enum class RecordPhase {
|
||||||
|
Recording,
|
||||||
|
Finalizing,
|
||||||
|
Done,
|
||||||
|
Failed
|
||||||
|
};
|
||||||
|
|
||||||
|
// A distilled transport reading so the state machine never touches a REAPER
|
||||||
|
// type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` is
|
||||||
|
// GetPlayPositionEx (latency-compensated).
|
||||||
|
struct TransportReading {
|
||||||
|
bool recording = false;
|
||||||
|
double playPosition = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything the pure transition needs beyond the current phase, gathered by
|
||||||
|
// the shell each tick (the shell only reads and reports; never decides).
|
||||||
|
struct RecordTickInputs {
|
||||||
|
TransportReading transport;
|
||||||
|
double elapsedSeconds = 0.0; // wall-clock since begin() — record ceiling
|
||||||
|
double finalizingSeconds = 0.0; // wall-clock in Finalizing — flush ceiling
|
||||||
|
bool fileReady = false; // recorded file exists+stable (Finalizing only)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Record ceiling margin added to nominal duration: generous enough that
|
||||||
|
// pre-roll/count-in/latency never trips it, tight enough a stuck transport is
|
||||||
|
// force-terminated within seconds.
|
||||||
|
inline constexpr double kRecordMarginSeconds = 5.0;
|
||||||
|
|
||||||
|
// Max wall-clock Finalizing waits for the file to flush/stabilize before
|
||||||
|
// giving up (REAPER closes the take within a tick or two in practice).
|
||||||
|
inline constexpr double kFinalizeFlushCeilingSeconds = 5.0;
|
||||||
|
|
||||||
|
// The pure transition (total + deterministic). Done/Failed are sticky — a late
|
||||||
|
// tick before teardown finishes cannot flip the verdict (the idempotence the
|
||||||
|
// shell's single-restore relies on).
|
||||||
|
RecordPhase advanceRecordPhase(RecordPhase current,
|
||||||
|
const RecordTickInputs& inputs,
|
||||||
|
double rangeStartSeconds,
|
||||||
|
double rangeEndSeconds);
|
||||||
|
|
||||||
|
// True once the shell must STOP the transport and begin the flush wait — i.e. the
|
||||||
|
// phase has left Recording (Finalizing/Done/Failed). Used by the shell to fire the
|
||||||
|
// (idempotent) transport stop exactly on the Recording -> Finalizing edge.
|
||||||
|
bool isStopRequested(RecordPhase phase);
|
||||||
|
|
||||||
|
// True for the phases the shell must ACT on to conclude (finalize-or-fail + restore).
|
||||||
|
// Only Done and Failed are terminal; Recording and Finalizing are live.
|
||||||
|
bool isTerminalPhase(RecordPhase phase);
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
// insert_plan.cpp — see insert_plan.h. Pure InsertMedia mode-bit arithmetic.
|
// insert_plan.cpp — see insert_plan.h. Pure InsertMedia mode-bit arithmetic.
|
||||||
|
|
||||||
#include "insert_plan.h"
|
#include "core/capture/insert_plan.h"
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Base target bits (mode&3). We use only 0 (current track) and 1 (new track).
|
// Base target bits (mode&3). We use only 0 (current track) and 1 (new track).
|
||||||
constexpr int kBaseCurrentTrack = 0; // add to current track
|
constexpr int kBaseCurrentTrack = 0;
|
||||||
constexpr int kBaseNewTrack = 1; // add new track
|
constexpr int kBaseNewTrack = 1;
|
||||||
|
|
||||||
// Tempo-conform bits, verbatim from the header doc-comment.
|
// Tempo-conform bits, verbatim from the header doc-comment.
|
||||||
constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x
|
constexpr int kMatchTempo1x = 8;
|
||||||
constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x
|
constexpr int kMatchTempoHalf = 16;
|
||||||
constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x
|
constexpr int kMatchTempoDbl = 32;
|
||||||
constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo
|
constexpr int kDontPreservePitch = 64;
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@@ -24,8 +24,7 @@ int computeInsertMode(const InsertOptions& opts) {
|
|||||||
|
|
||||||
switch (opts.conform) {
|
switch (opts.conform) {
|
||||||
case TempoConform::None:
|
case TempoConform::None:
|
||||||
// No tempo bits: native length, no stretch. (Also never &4.)
|
return mode; // native length, no stretch; never &4
|
||||||
return mode;
|
|
||||||
case TempoConform::Ratio1x:
|
case TempoConform::Ratio1x:
|
||||||
mode |= kMatchTempo1x;
|
mode |= kMatchTempo1x;
|
||||||
break;
|
break;
|
||||||
@@ -37,13 +36,11 @@ int computeInsertMode(const InsertOptions& opts) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tempo bits are set (conform != None). Add the pitch-shift bit only when the
|
// Reached only when a tempo bit is set (None already returned above).
|
||||||
// caller asked NOT to preserve pitch. When conform == None we already returned
|
|
||||||
// above, so this can never fire without a tempo bit present.
|
|
||||||
if (!opts.preservePitch)
|
if (!opts.preservePitch)
|
||||||
mode |= kDontPreservePitch;
|
mode |= kDontPreservePitch;
|
||||||
|
|
||||||
return mode;
|
return mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler::capture
|
||||||
@@ -1,34 +1,32 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing
|
// insert_plan — the REAPER-free logic behind the `insert` shell: computing the
|
||||||
// the InsertMedia `mode` bitmask from a small options struct.
|
// InsertMedia `mode` bitmask from a small options struct.
|
||||||
//
|
//
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||||
// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW
|
// only. The InsertMedia bitfield is easy to get wrong and its bits are
|
||||||
// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to
|
// load-bearing for the "no silent time-stretch" invariant, so it's factored here
|
||||||
// get wrong and its bits are load-bearing for the "no silent time-stretch"
|
// and unit-tested. The REAPER-bound placement (InsertMedia call, edit-cursor
|
||||||
// invariant, so it is factored here and unit-tested. The REAPER-bound placement
|
// movement, undo block) lives in insert.cpp and is DAW-verified.
|
||||||
// (InsertMedia call, edit-cursor movement, undo block) lives in insert.cpp and is
|
|
||||||
// DAW-verified.
|
|
||||||
//
|
//
|
||||||
// The bit meanings below are transcribed VERBATIM from the authoritative header
|
// Bit meanings below are transcribed VERBATIM from the authoritative header
|
||||||
// doc-comment (vendor/reaper-sdk/sdk/reaper_plugin_functions.h, InsertMedia):
|
// doc-comment (vendor/reaper-sdk/sdk/reaper_plugin_functions.h, InsertMedia):
|
||||||
// mode: 0=add to current track, 1=add new track, 3=add to selected items as
|
// mode: 0=add to current track, 1=add new track, 3=add to selected items as
|
||||||
// takes, &4=stretch/loop to fit time sel, &8=try to match tempo 1x,
|
// takes, &4=stretch/loop to fit time sel, &8=try to match tempo 1x,
|
||||||
// &16=try to match tempo 0.5x, &32=try to match tempo 2x,
|
// &16=try to match tempo 0.5x, &32=try to match tempo 2x,
|
||||||
// &64=don't preserve pitch when matching tempo, ...
|
// &64=don't preserve pitch when matching tempo, ...
|
||||||
// We intentionally use only the base target (0/1) and the tempo-conform bits
|
// We use only the base target (0/1) and the tempo-conform bits (&8/&16/&32/&64).
|
||||||
// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is
|
// We NEVER set &4 (stretch/loop to fit time selection) — the silent-time-stretch
|
||||||
// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals).
|
// path the tool forbids.
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
// Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3).
|
// Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3).
|
||||||
// We expose only the two placement targets M6 needs; "add as takes" (3) is a
|
// We expose only the two placement targets needed here; "add as takes" (3) is
|
||||||
// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's
|
// out of scope. Both insert at the edit cursor — REAPER's convention for base
|
||||||
// convention for base modes 0/1 (the header names no explicit edit-cursor bit;
|
// modes 0/1 (the header names no explicit edit-cursor bit; see the flagged
|
||||||
// see the flagged runtime assumption in insert.cpp).
|
// runtime assumption in insert.cpp).
|
||||||
enum class InsertTarget {
|
enum class InsertTarget {
|
||||||
NewTrack, // mode base 1: add a new track for the item
|
NewTrack, // mode base 1: add a new track for the item
|
||||||
CurrentTrack, // mode base 0: add to the current/selected track
|
CurrentTrack, // mode base 0: add to the current/selected track
|
||||||
@@ -72,4 +70,4 @@ int computeInsertMode(const InsertOptions& opts);
|
|||||||
// any computed mode (the "no silent time-stretch" invariant, made checkable).
|
// any computed mode (the "no silent time-stretch" invariant, made checkable).
|
||||||
inline constexpr int kStretchToTimeSelBit = 4;
|
inline constexpr int kStretchToTimeSelBit = 4;
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler::capture
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
// render_settings.cpp — pure logic for the three-scope capture action family. See header.
|
// render_settings.cpp — pure logic for the three-scope capture action family. See header.
|
||||||
// NO REAPER types; unit-tested by tests/test_render_settings.cpp.
|
// NO REAPER types; unit-tested by tests/test_render_settings.cpp.
|
||||||
|
|
||||||
#include "render_settings.h"
|
#include "core/capture/render_settings.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
double autoTrimEndRatio() {
|
double autoTrimEndRatio() {
|
||||||
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is
|
// Amplitude ratio = 10^(dB/20) (header ~3062). For -72 dB this is ~0.00025119.
|
||||||
// the single source of truth (header ~3062: RENDER_TRIMEND is an amplitude ratio,
|
|
||||||
// "0.5 means -6.02 dB"). For -72 dB this is ~= 0.00025119.
|
|
||||||
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
|
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,8 +18,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
|||||||
TailRenderSettings t;
|
TailRenderSettings t;
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case TailMode::None:
|
case TailMode::None:
|
||||||
// Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off,
|
// Exact bounds — byte-identical to the pre-tail capture.
|
||||||
// disable-all normalize (the current default), no trim.
|
|
||||||
t.tailFlag = kTailFlagNone;
|
t.tailFlag = kTailFlagNone;
|
||||||
t.tailMs = 0.0;
|
t.tailMs = 0.0;
|
||||||
t.normalize = kNormalizeDisableAll;
|
t.normalize = kNormalizeDisableAll;
|
||||||
@@ -29,12 +26,10 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
|||||||
return t;
|
return t;
|
||||||
|
|
||||||
case TailMode::Auto:
|
case TailMode::Auto:
|
||||||
// Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence
|
// Surgical normalize: only the trim-ending-silence bit set, every other
|
||||||
// bit (32768) — every other postprocessing bit clear. A fixed-threshold
|
// postprocessing bit clear. A fixed-threshold trim scales/limits/fades
|
||||||
// trailing-silence trim is a pure boundary decision (it scales/limits/fades
|
// nothing, so identical requests trim at the identical sample -> holds
|
||||||
// nothing), so it re-introduces none of the coloring the disable-all bit
|
// the bit-identical-repeats invariant.
|
||||||
// guarded against, and two identical requests trim at the identical sample
|
|
||||||
// -> bit-identical repeats hold (spec §surgical normalize).
|
|
||||||
t.tailFlag = kTailFlagCustomBounds;
|
t.tailFlag = kTailFlagCustomBounds;
|
||||||
t.tailMs = kMaxTailMs;
|
t.tailMs = kMaxTailMs;
|
||||||
t.normalize = kNormalizeTrimEnd;
|
t.normalize = kNormalizeTrimEnd;
|
||||||
@@ -42,10 +37,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
|||||||
return t;
|
return t;
|
||||||
|
|
||||||
case TailMode::Manual:
|
case TailMode::Manual:
|
||||||
// Fixed tail, no trim -> keep the disable-all normalize exactly as the
|
// Clamped to the cap regardless of source; negative floors to 0.
|
||||||
// no-tail path does. Clamp to the 8 s cap even here: the runaway guard
|
|
||||||
// applies whether the length came from the Auto default or an explicit
|
|
||||||
// request (spec §Manual override). Negative requests floor to 0.
|
|
||||||
t.tailFlag = kTailFlagCustomBounds;
|
t.tailFlag = kTailFlagCustomBounds;
|
||||||
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
|
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
|
||||||
t.normalize = kNormalizeDisableAll;
|
t.normalize = kNormalizeDisableAll;
|
||||||
@@ -60,14 +52,10 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
|||||||
double manualTailMs) {
|
double manualTailMs) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case TailMode::None:
|
case TailMode::None:
|
||||||
// Exact — no extra recording (byte-identical to today's realtime capture).
|
return rangeEndSeconds; // exact, no extra recording
|
||||||
return rangeEndSeconds;
|
|
||||||
case TailMode::Auto:
|
case TailMode::Auto:
|
||||||
// The 8 s runaway cap past the range end; the decay-trim shortens it later.
|
return rangeEndSeconds + kMaxTailSeconds; // runaway cap; decay-trim shortens later
|
||||||
return rangeEndSeconds + kMaxTailSeconds;
|
|
||||||
case TailMode::Manual:
|
case TailMode::Manual:
|
||||||
// Fixed window: range + the set length, clamped to the 8 s cap (the same
|
|
||||||
// runaway guard the offline Manual path applies). Negative floors to 0.
|
|
||||||
return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
|
return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
|
||||||
}
|
}
|
||||||
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
|
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
|
||||||
@@ -75,42 +63,36 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
|||||||
}
|
}
|
||||||
|
|
||||||
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
||||||
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
|
// wetDry doesn't affect this mapping (seam for future dry work); FX scoping
|
||||||
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
|
// is handled by fxBypassPlanFor, not by these render bits.
|
||||||
// handled by fxBypassPlanFor, not by these render bits.
|
|
||||||
RenderSettingsChoice c;
|
RenderSettingsChoice c;
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case SourceMode::MasterMix:
|
case SourceMode::MasterMix:
|
||||||
case SourceMode::TimeSelection:
|
case SourceMode::TimeSelection:
|
||||||
// Master IS the mix — wet-only; &(1|2)==0, no source bits.
|
c.settings = kRenderMasterMix; // wet-only, no source bits
|
||||||
c.settings = kRenderMasterMix;
|
|
||||||
c.supported = true;
|
c.supported = true;
|
||||||
return c;
|
return c;
|
||||||
|
|
||||||
case SourceMode::SelectedTracks:
|
case SourceMode::SelectedTracks:
|
||||||
// Selected tracks via master (&128) — wet (post-FX). Header ~3041.
|
|
||||||
c.settings = kRenderSelTracksViaMaster;
|
c.settings = kRenderSelTracksViaMaster;
|
||||||
c.supported = true;
|
c.supported = true;
|
||||||
return c;
|
return c;
|
||||||
|
|
||||||
case SourceMode::SelectedItems:
|
case SourceMode::SelectedItems:
|
||||||
// Selected media items, rendered to ONE file (single-file bit) so a
|
// Single-file bit so a multi-item selection yields one bank entry.
|
||||||
// multi-item selection yields a single bank entry, not N wavs.
|
|
||||||
c.settings = kRenderSelItems | kRenderSingleFile;
|
c.settings = kRenderSelItems | kRenderSingleFile;
|
||||||
c.supported = true;
|
c.supported = true;
|
||||||
return c;
|
return c;
|
||||||
|
|
||||||
case SourceMode::RazorArea:
|
case SourceMode::RazorArea:
|
||||||
// Render razor edits to ONE file (same single-file rationale as items).
|
|
||||||
c.settings = kRenderRazorEdits | kRenderSingleFile;
|
c.settings = kRenderRazorEdits | kRenderSingleFile;
|
||||||
c.supported = true;
|
c.supported = true;
|
||||||
return c;
|
return c;
|
||||||
|
|
||||||
case SourceMode::Realtime:
|
case SourceMode::Realtime:
|
||||||
// Not an offline-render source — the realtime backend (M8) owns it.
|
|
||||||
c.settings = kRenderMasterMix;
|
c.settings = kRenderMasterMix;
|
||||||
c.supported = false;
|
c.supported = false; // not an offline-render source
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
// Unreachable for a valid enum; fail closed (unsupported) rather than render.
|
// Unreachable for a valid enum; fail closed (unsupported) rather than render.
|
||||||
@@ -118,12 +100,44 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
SourceMode sourceModeForScope(CaptureScope scope) {
|
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) {
|
||||||
switch (scope) {
|
switch (scope) {
|
||||||
case CaptureScope::Item: return SourceMode::SelectedItems;
|
case CaptureScope::Item:
|
||||||
|
return itemExtentIsWindow ? SourceMode::SelectedItems
|
||||||
|
: SourceMode::SelectedTracks;
|
||||||
case CaptureScope::Track: return 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) {
|
RangeSource inferRangeSource(bool hasRazorArea) {
|
||||||
@@ -135,17 +149,13 @@ FxBypassPlan fxBypassPlanFor(CaptureScope scope) {
|
|||||||
FxBypassPlan p;
|
FxBypassPlan p;
|
||||||
switch (scope) {
|
switch (scope) {
|
||||||
case CaptureScope::Item:
|
case CaptureScope::Item:
|
||||||
// Item = take/item FX ONLY. Bypass the item's own track FX, every
|
// Take FX live in the item and are always rendered — bypass everything else.
|
||||||
// ancestor's FX, and the master's FX. (Take FX live in the item and
|
|
||||||
// are always rendered — there is no track to bypass them from.)
|
|
||||||
p.bypassSelfFx = true;
|
p.bypassSelfFx = true;
|
||||||
p.bypassAncestorFx = true;
|
p.bypassAncestorFx = true;
|
||||||
p.bypassMaster = true;
|
p.bypassMaster = true;
|
||||||
return p;
|
return p;
|
||||||
case CaptureScope::Track:
|
case CaptureScope::Track:
|
||||||
// Track = item FX + the selected track's OWN FX. Keep self FX; bypass
|
// Keep self FX; bypass every ancestor (parent/folder) and the master.
|
||||||
// every ancestor (parent/folder) and the master. Parent/master GAIN
|
|
||||||
// still applies (I_FXEN is FX-only) — documented boundary.
|
|
||||||
p.bypassSelfFx = false;
|
p.bypassSelfFx = false;
|
||||||
p.bypassAncestorFx = true;
|
p.bypassAncestorFx = true;
|
||||||
p.bypassMaster = true;
|
p.bypassMaster = true;
|
||||||
@@ -158,24 +168,19 @@ std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
|
|||||||
std::vector<RazorRange> ranges;
|
std::vector<RazorRange> ranges;
|
||||||
std::istringstream in(razorString);
|
std::istringstream in(razorString);
|
||||||
|
|
||||||
// The string is space-separated TRIPLES: <start> <end> <envGuidString>.
|
|
||||||
// A track-audio area's third token is the literal two-char string `""`; an
|
|
||||||
// envelope-lane area's is a GUID `{…}`. We keep only track-audio triples.
|
|
||||||
std::string startTok, endTok, guidTok;
|
std::string startTok, endTok, guidTok;
|
||||||
while (in >> startTok >> endTok >> guidTok) {
|
while (in >> startTok >> endTok >> guidTok) {
|
||||||
// Envelope-lane areas carry a real GUID; skip them (razor captures track audio only).
|
// Skip envelope-lane areas (real GUID); keep only track-audio (`""`).
|
||||||
// A track-audio area's GUID token is the empty quoted string `""`.
|
|
||||||
if (guidTok != "\"\"") continue;
|
if (guidTok != "\"\"") continue;
|
||||||
|
|
||||||
// Parse the two time tokens. std::stod throws on garbage — guard so one
|
// std::stod throws on garbage — guard so one malformed triple doesn't
|
||||||
// malformed triple does not abort the whole parse.
|
// abort the whole parse.
|
||||||
double start = 0.0, end = 0.0;
|
double start = 0.0, end = 0.0;
|
||||||
try {
|
try {
|
||||||
std::size_t sp = 0, ep = 0;
|
std::size_t sp = 0, ep = 0;
|
||||||
start = std::stod(startTok, &sp);
|
start = std::stod(startTok, &sp);
|
||||||
end = std::stod(endTok, &ep);
|
end = std::stod(endTok, &ep);
|
||||||
// Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse
|
// Reject trailing garbage (e.g. "1.0x") — a partial parse is malformed.
|
||||||
// is a malformed area, not a valid range.
|
|
||||||
if (sp != startTok.size() || ep != endTok.size()) continue;
|
if (sp != startTok.size() || ep != endTok.size()) continue;
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
continue;
|
continue;
|
||||||
@@ -197,23 +202,13 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const std::vector<CaptureActionDef>& captureActionTable() {
|
const std::vector<CaptureActionDef>& captureActionTable() {
|
||||||
// Built once (function-local static): two SCOPE actions, item + track. Both
|
// FOREVER-STABLE ids — never edit a shipped string. No master capture
|
||||||
// exact bounds by default; the tail mode a capture applies is read from the
|
// action (its id was retired; do not reintroduce it).
|
||||||
// docked-panel setting at fire time (tail_control + bank_panel), so tail is NOT
|
|
||||||
// a per-action variant. Ids are FOREVER-STABLE — never edit a shipped string.
|
|
||||||
// Each action infers its range (razor-else-time) at fire time and enforces its
|
|
||||||
// FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET /
|
|
||||||
// CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in
|
|
||||||
// main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise
|
|
||||||
// mirror-unregistered) — to capture the master you render a track.
|
|
||||||
static const std::vector<CaptureActionDef> table = {
|
static const std::vector<CaptureActionDef> table = {
|
||||||
// Item scope — item/take FX only. Suffix + phrase are channel-agnostic; the shell
|
|
||||||
// composes the FOREVER-STABLE id (prefix + "CAPTURE_ITEM") and the display name.
|
|
||||||
{"CAPTURE_ITEM",
|
{"CAPTURE_ITEM",
|
||||||
"capture selected item(s)", "item",
|
"capture selected item(s)", "item",
|
||||||
CaptureScope::Item},
|
CaptureScope::Item},
|
||||||
|
|
||||||
// Track scope — item FX + the track's own FX.
|
|
||||||
{"CAPTURE_TRACK",
|
{"CAPTURE_TRACK",
|
||||||
"capture selected track(s)", "track",
|
"capture selected track(s)", "track",
|
||||||
CaptureScope::Track},
|
CaptureScope::Track},
|
||||||
@@ -221,4 +216,4 @@ const std::vector<CaptureActionDef>& captureActionTable() {
|
|||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
#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, 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>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/model/bank_model.h" // SourceMode (pure enum)
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
using model::SourceMode;
|
||||||
|
|
||||||
|
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) --
|
||||||
|
inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no source bits
|
||||||
|
inline constexpr int kRenderSelItems = 32; // &32 selected media items
|
||||||
|
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
|
||||||
|
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
|
||||||
|
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
|
||||||
|
// kRenderPreFaderStems (&8192) is deliberately NOT used — REAPER offline render
|
||||||
|
// has no true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
|
||||||
|
// mechanism (see fxBypassPlan below), not by any render bit. All capture actions
|
||||||
|
// render wet; the scope decides which FX remain enabled.
|
||||||
|
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
|
||||||
|
|
||||||
|
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
|
||||||
|
//
|
||||||
|
// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG,
|
||||||
|
// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE
|
||||||
|
// (verbatim, header ~3051): &32768 = trim ending silence (Auto path);
|
||||||
|
// &(4<<16) = disable all render postprocessing (None/Manual path).
|
||||||
|
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
|
||||||
|
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
|
||||||
|
|
||||||
|
inline constexpr int kTailFlagNone = 0;
|
||||||
|
inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047
|
||||||
|
|
||||||
|
// Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND
|
||||||
|
// ratio derives from this dB, never the reverse). Daniel-set.
|
||||||
|
inline constexpr double kAutoTrimThresholdDb = -72.0;
|
||||||
|
|
||||||
|
// Runaway guard: max tail rendered past the range end, so a non-decaying or
|
||||||
|
// looping signal doesn't render forever. Daniel-set; shared by offline+realtime.
|
||||||
|
inline constexpr double kMaxTailSeconds = 8.0;
|
||||||
|
inline constexpr double kMaxTailMs = 8000.0;
|
||||||
|
|
||||||
|
// Derived linear amplitude ratio for RENDER_TRIMEND (header ~3062: an amplitude
|
||||||
|
// ratio, "0.5 means -6.02 dB", i.e. 10^(dB/20)) from kAutoTrimThresholdDb.
|
||||||
|
// Function not constant: std::pow isn't constexpr before C++26.
|
||||||
|
double autoTrimEndRatio();
|
||||||
|
|
||||||
|
// The three tail states — see src/core/capture/CLAUDE.md.
|
||||||
|
enum class TailMode {
|
||||||
|
None,
|
||||||
|
Auto,
|
||||||
|
Manual,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS
|
||||||
|
// the backend already sets. `trimEnd` is meaningful only when the trim-end
|
||||||
|
// normalize bit is set (Auto). The backend reads these straight onto
|
||||||
|
// GetSetProjectInfo.
|
||||||
|
struct TailRenderSettings {
|
||||||
|
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
|
||||||
|
double tailMs = 0.0; // RENDER_TAILMS
|
||||||
|
int normalize = kNormalizeDisableAll; // RENDER_NORMALIZE
|
||||||
|
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maps a tail mode (+ requested manual tail ms, used only for Manual) to its
|
||||||
|
// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source.
|
||||||
|
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
|
||||||
|
|
||||||
|
// The realtime record-window end (project seconds): realtime does NOT drive
|
||||||
|
// RENDER_*, it records a generous window and trims later, so this is where the
|
||||||
|
// transport actually stops. None -> exact rangeEndSeconds; Auto -> +8s runaway
|
||||||
|
// cap; Manual -> + clamp(manualTailMs, kMaxTailMs)/1000.
|
||||||
|
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
||||||
|
double manualTailMs);
|
||||||
|
|
||||||
|
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
|
||||||
|
// for SourceMode::Realtime (that backend doesn't use offline render).
|
||||||
|
struct RenderSettingsChoice {
|
||||||
|
int settings = kRenderMasterMix;
|
||||||
|
bool supported = true; // false => not an offline-render source (e.g. Realtime)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maps a source mode to its RENDER_SETTINGS value (which content the render
|
||||||
|
// covers); FX scoping is orthogonal (done by fxBypassPlan). `wetDry` is
|
||||||
|
// accepted but ignored — retained as the seam for future dry work. CONFIRMED
|
||||||
|
// (SDK header ~3041): MasterMix/TimeSelection -> 0; SelectedTracks -> &128;
|
||||||
|
// SelectedItems -> &32|single-file; RazorArea -> &4096|single-file.
|
||||||
|
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
|
||||||
|
|
||||||
|
// --- Capture scope: the FX-scope invariant ------------------------------------
|
||||||
|
//
|
||||||
|
// See src/core/capture/CLAUDE.md for the scope contract. There is NO master
|
||||||
|
// scope; the master track's FX/gain/pan are still NEUTRALIZED as part of the
|
||||||
|
// out-of-scope chain (bypassMaster below) — master is a bypass target only.
|
||||||
|
enum class CaptureScope {
|
||||||
|
Item,
|
||||||
|
Track,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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) -------------------
|
||||||
|
//
|
||||||
|
// Razor-present -> razor union; otherwise time selection. Razor is a range
|
||||||
|
// source, not a capture mode.
|
||||||
|
enum class RangeSource {
|
||||||
|
Razor, // a razor area is present -> use its union bound
|
||||||
|
TimeSelection, // no razor -> use the time selection
|
||||||
|
};
|
||||||
|
|
||||||
|
// Picks the range source. Pure so "razor wins when present" is tested without
|
||||||
|
// a DAW; the shell supplies whether any razor area was found.
|
||||||
|
RangeSource inferRangeSource(bool hasRazorArea);
|
||||||
|
|
||||||
|
// --- FX-bypass plan: which tracks' FX to bypass for a scope -------------------
|
||||||
|
//
|
||||||
|
// Given a CaptureScope, returns three boolean flags: bypass (a) the captured
|
||||||
|
// track's OWN FX, (b) every ancestor (parent/folder) track's FX, (c) the
|
||||||
|
// master FX. The caller (FxBypassGuard, shell) walks the ancestor chain via
|
||||||
|
// GetParentTrack, clears I_FXEN on each flagged track (RAII restore), and also
|
||||||
|
// neutralizes D_VOL/D_PAN/D_WIDTH/D_PANLAW/I_PANMODE to unity/center on the
|
||||||
|
// same set (I_PANMODE is load-bearing: in pan mode 6, D_PAN/D_WIDTH are
|
||||||
|
// ignored entirely, so forcing it is what makes the other neutralizations
|
||||||
|
// take effect) — I_FXEN alone doesn't touch a track's volume/pan. This plan
|
||||||
|
// selects the set; the guard applies both the FX bypass and the neutralize.
|
||||||
|
struct FxBypassPlan {
|
||||||
|
bool bypassSelfFx = false; // the captured track's own FX
|
||||||
|
bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX
|
||||||
|
bool bypassMaster = false; // the master track's FX
|
||||||
|
};
|
||||||
|
|
||||||
|
FxBypassPlan fxBypassPlanFor(CaptureScope scope);
|
||||||
|
|
||||||
|
// A single razor-edit area: a time range on one track (envelope GUID ignored —
|
||||||
|
// razor captures target track-audio areas, not envelope lanes).
|
||||||
|
struct RazorRange {
|
||||||
|
double startSeconds = 0.0;
|
||||||
|
double endSeconds = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
|
||||||
|
// TRIPLES of <start> <end> <envGuidString>, envGuid == `""` for a track-audio
|
||||||
|
// area vs a GUID for an envelope-lane area. Returns only track-audio ranges
|
||||||
|
// (envelope-lane triples skipped); malformed trailing tokens are ignored, not
|
||||||
|
// fatal; a range with end <= start is dropped.
|
||||||
|
std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
|
||||||
|
|
||||||
|
// The union bound (min start, max end) of a set of razor ranges — the exact
|
||||||
|
// window the offline render must cover. {0,0} for empty input ("no razor area").
|
||||||
|
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
|
||||||
|
|
||||||
|
// --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
|
||||||
|
//
|
||||||
|
// One row per bindable scope action (item/track); range inference and tail
|
||||||
|
// mode are read at fire time, not baked into the row. The row stores only the
|
||||||
|
// channel-agnostic command-id SUFFIX + description PHRASE; the registering
|
||||||
|
// shell composes the full channel-qualified id/name via app_version.
|
||||||
|
//
|
||||||
|
// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id).
|
||||||
|
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 FALLBACK; the source track normally names the capture
|
||||||
|
CaptureScope scope; // FX scope (item / track)
|
||||||
|
};
|
||||||
|
|
||||||
|
// The capture-action table. Iterated by main.cpp to register the family and
|
||||||
|
// route each fired command back to its definition.
|
||||||
|
//
|
||||||
|
// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to
|
||||||
|
// capture the master you render a track.
|
||||||
|
const std::vector<CaptureActionDef>& captureActionTable();
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -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,119 @@
|
|||||||
|
// tail_control — pure implementation. See tail_control.h.
|
||||||
|
|
||||||
|
#include "core/capture/tail_control.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#include "core/json/json.h"
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
TailMode cycleTailMode(TailMode current) {
|
||||||
|
switch (current) {
|
||||||
|
case TailMode::None: return TailMode::Auto;
|
||||||
|
case TailMode::Auto: return TailMode::Manual;
|
||||||
|
case TailMode::Manual: return TailMode::None;
|
||||||
|
}
|
||||||
|
return TailMode::None; // unreachable for a valid enum; fail to the safe default
|
||||||
|
}
|
||||||
|
|
||||||
|
double clampManualMs(double manualMs) {
|
||||||
|
return std::clamp(manualMs, 0.0, kMaxTailMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
double adjustManualMs(double current, int notches, double stepMs) {
|
||||||
|
return clampManualMs(current + notches * stepMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string tailToggleLabel(const TailSetting& setting) {
|
||||||
|
switch (setting.mode) {
|
||||||
|
case TailMode::None: return "Tail: Off";
|
||||||
|
case TailMode::Auto: return "Tail: Auto";
|
||||||
|
case TailMode::Manual: {
|
||||||
|
// Clamped so the readout can't show an over-cap value even if
|
||||||
|
// manualMs was stored past the cap.
|
||||||
|
const double seconds = clampManualMs(setting.manualMs) / 1000.0;
|
||||||
|
char buf[32];
|
||||||
|
std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds);
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON round-trip ---------------------------------------------------------
|
||||||
|
// manualMs round-trips exactly (json::numToStr uses the shortest %.17g-class
|
||||||
|
// form for doubles); deserialize returns nullopt on any parse failure.
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// The persisted integer for a mode. Stable forever (stored in the .rpp): never
|
||||||
|
// renumber these values or an already-saved project reads back the wrong mode.
|
||||||
|
int modeToInt(TailMode m) {
|
||||||
|
switch (m) {
|
||||||
|
case TailMode::None: return 0;
|
||||||
|
case TailMode::Auto: return 1;
|
||||||
|
case TailMode::Manual: return 2;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<TailMode> modeFromInt(int v) {
|
||||||
|
switch (v) {
|
||||||
|
case 0: return TailMode::None;
|
||||||
|
case 1: return TailMode::Auto;
|
||||||
|
case 2: return TailMode::Manual;
|
||||||
|
default: return std::nullopt; // unknown enumerant -> malformed -> default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string serializeTailSetting(const TailSetting& setting) {
|
||||||
|
// Byte-identical to the former snprintf writer: {"mode":%d,"manualMs":%.17g}.
|
||||||
|
std::string out;
|
||||||
|
{
|
||||||
|
json::Writer w(out);
|
||||||
|
w.keyRaw("mode", json::numToStr(modeToInt(setting.mode)));
|
||||||
|
w.keyRaw("manualMs", json::numToStr(setting.manualMs));
|
||||||
|
} // Writer closes the object here (see bank_model's NRVO note)
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
|
||||||
|
json::Reader r(blob);
|
||||||
|
if (!r.consume('{')) return std::nullopt;
|
||||||
|
|
||||||
|
int modeInt = 0;
|
||||||
|
double ms = 0.0;
|
||||||
|
bool haveMode = false, haveMs = false;
|
||||||
|
r.skipWs();
|
||||||
|
if (!r.consume('}')) {
|
||||||
|
do {
|
||||||
|
std::string key;
|
||||||
|
if (!r.parseKey(key)) return std::nullopt;
|
||||||
|
if (key == "mode") {
|
||||||
|
if (!r.parseInt(modeInt)) return std::nullopt;
|
||||||
|
haveMode = true;
|
||||||
|
} else if (key == "manualMs") {
|
||||||
|
if (!r.parseDouble(ms)) return std::nullopt;
|
||||||
|
haveMs = true;
|
||||||
|
} else {
|
||||||
|
if (!r.skipValue()) return std::nullopt; // forward-compat
|
||||||
|
}
|
||||||
|
} while (r.consume(','));
|
||||||
|
if (!r.consume('}')) return std::nullopt;
|
||||||
|
}
|
||||||
|
if (!haveMode || !haveMs) return std::nullopt; // absent key -> malformed -> default
|
||||||
|
|
||||||
|
const std::optional<TailMode> mode = modeFromInt(modeInt);
|
||||||
|
if (!mode) return std::nullopt;
|
||||||
|
|
||||||
|
TailSetting out;
|
||||||
|
out.mode = *mode;
|
||||||
|
out.manualMs = ms;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#pragma once
|
||||||
|
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode
|
||||||
|
// toggle. The panel shell owns the SWELL window, LICE drawing, and click
|
||||||
|
// hit-testing; the cycle order, manual-length clamp, and label text live here.
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/capture/render_settings.h" // TailMode (pure enum) — the three-state tail contract
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
// The Manual-mode starting length: 2s, a musically useful default (a bar of
|
||||||
|
// reverb throw at moderate tempo), well under the 8s cap. Also the fallback
|
||||||
|
// for a project with no stored tail setting.
|
||||||
|
inline constexpr double kDefaultManualTailMs = 2000.0;
|
||||||
|
|
||||||
|
// Fine-adjust step per scroll-wheel notch in Manual mode. Daniel-set.
|
||||||
|
inline constexpr double kManualStepMs = 250.0;
|
||||||
|
|
||||||
|
// The panel's current tail setting: mode + the length used only when Manual.
|
||||||
|
// Default None so a capture with no explicit choice stays exact-bounds.
|
||||||
|
// `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest.
|
||||||
|
struct TailSetting {
|
||||||
|
TailMode mode = TailMode::None;
|
||||||
|
double manualMs = kDefaultManualTailMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cycles the tail mode: None -> Auto -> Manual -> None.
|
||||||
|
TailMode cycleTailMode(TailMode current);
|
||||||
|
|
||||||
|
// The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs].
|
||||||
|
// Exposed so the panel can show the clamped value. Meaningful only for Manual.
|
||||||
|
double clampManualMs(double manualMs);
|
||||||
|
|
||||||
|
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped
|
||||||
|
// to [0, kMaxTailMs]. Meaningful only for TailMode::Manual.
|
||||||
|
double adjustManualMs(double current, int notches, double stepMs);
|
||||||
|
|
||||||
|
// The toggle's label, e.g. "Tail: Off", "Tail: Auto", or (Manual, clamped
|
||||||
|
// length to one decimal) "Tail: Manual 2.0s".
|
||||||
|
std::string tailToggleLabel(const TailSetting& setting);
|
||||||
|
|
||||||
|
// JSON round-trip of a TailSetting, for persist to store per-project. Pure/
|
||||||
|
// testable here, mirroring bank_model's serialize/deserialize; deserialize
|
||||||
|
// returns nullopt on malformed input so the caller falls back to a default.
|
||||||
|
std::string serializeTailSetting(const TailSetting& setting);
|
||||||
|
std::optional<TailSetting> deserializeTailSetting(const std::string& json);
|
||||||
|
|
||||||
|
} // 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
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
// wav_codec — pure implementation. See wav_codec.h. The one RIFF chunk
|
||||||
|
// traversal lives here (nextWavChunk); layout parse and content hash both
|
||||||
|
// walk with it, so their view of the container cannot drift.
|
||||||
|
|
||||||
|
#include "core/capture/wav_codec.h"
|
||||||
|
|
||||||
|
#include <cstdio> // std::snprintf (hash hex render)
|
||||||
|
#include <cstring> // std::memcpy, std::memcmp
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Little-endian readers. Caller checks bounds before each read (off + N <= size).
|
||||||
|
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
|
||||||
|
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
|
||||||
|
}
|
||||||
|
std::uint32_t readU32LE(const std::vector<std::uint8_t>& b, std::size_t off) {
|
||||||
|
return static_cast<std::uint32_t>(b[off]) |
|
||||||
|
(static_cast<std::uint32_t>(b[off + 1]) << 8) |
|
||||||
|
(static_cast<std::uint32_t>(b[off + 2]) << 16) |
|
||||||
|
(static_cast<std::uint32_t>(b[off + 3]) << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char* tag) {
|
||||||
|
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAVE format tags we accept as 32-bit float (see wav_codec.h).
|
||||||
|
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
|
||||||
|
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
|
||||||
|
|
||||||
|
// FNV-1a 64-bit constants (http://www.isthe.com/chongo/tech/comp/fnv/).
|
||||||
|
constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL;
|
||||||
|
constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
|
||||||
|
|
||||||
|
std::string fnvHex(std::uint64_t h) {
|
||||||
|
char buf[17]; // 16 hex digits, zero-padded
|
||||||
|
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h));
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The one RIFF chunk traversal --------------------------------------------
|
||||||
|
//
|
||||||
|
// One sub-chunk of a RIFF/WAVE container: header at `headerOffset` (id(4) +
|
||||||
|
// size(4)), body at `bodyOffset`/`bodySize`. `bodyInBounds` false means the
|
||||||
|
// declared body runs past the buffer — still reported, but must not be read.
|
||||||
|
struct WavChunkView {
|
||||||
|
std::size_t headerOffset = 0;
|
||||||
|
std::size_t bodyOffset = 0;
|
||||||
|
std::uint32_t bodySize = 0;
|
||||||
|
bool bodyInBounds = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call
|
||||||
|
// fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte
|
||||||
|
// padding. Returns false when no further chunk header fits. If the padded advance
|
||||||
|
// would overrun the buffer, the chunk is still reported (return true) and `pos`
|
||||||
|
// is parked past the end so the next call returns false.
|
||||||
|
bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos,
|
||||||
|
WavChunkView& out) {
|
||||||
|
if (pos + 8 > bytes.size()) return false;
|
||||||
|
|
||||||
|
out.headerOffset = pos;
|
||||||
|
out.bodyOffset = pos + 8;
|
||||||
|
out.bodySize = readU32LE(bytes, pos + 4);
|
||||||
|
out.bodyInBounds = (out.bodyOffset + out.bodySize <= bytes.size());
|
||||||
|
|
||||||
|
std::size_t advance = out.bodySize;
|
||||||
|
if (advance & 1u) ++advance; // RIFF pad byte
|
||||||
|
if (advance > bytes.size() - out.bodyOffset) {
|
||||||
|
pos = bytes.size(); // overrun -> this is the last reported chunk
|
||||||
|
} else {
|
||||||
|
pos = out.bodyOffset + advance;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isRiffWave(const std::vector<std::uint8_t>& bytes) {
|
||||||
|
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
|
||||||
|
return bytes.size() >= 12 && tagEquals(bytes, 0, "RIFF") &&
|
||||||
|
tagEquals(bytes, 8, "WAVE");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
||||||
|
WavLayout out;
|
||||||
|
|
||||||
|
if (!isRiffWave(bytes)) return out;
|
||||||
|
|
||||||
|
bool haveFmt = false;
|
||||||
|
std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0;
|
||||||
|
std::uint32_t sampleRate = 0;
|
||||||
|
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
|
||||||
|
|
||||||
|
// Walk the sub-chunks after "WAVE" (offset 12). A malformed/truncated file
|
||||||
|
// is "invalid", never an OOB read.
|
||||||
|
std::size_t pos = 12;
|
||||||
|
WavChunkView c;
|
||||||
|
while (nextWavChunk(bytes, pos, c)) {
|
||||||
|
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
|
||||||
|
// fmt body: at least 16 bytes (PCM/float common fields).
|
||||||
|
if (c.bodyOffset + 16 > bytes.size() || c.bodySize < 16) return out;
|
||||||
|
fmtTag = readU16LE(bytes, c.bodyOffset + 0);
|
||||||
|
channels = readU16LE(bytes, c.bodyOffset + 2);
|
||||||
|
sampleRate = readU32LE(bytes, c.bodyOffset + 4);
|
||||||
|
bitsPerSample = readU16LE(bytes, c.bodyOffset + 14);
|
||||||
|
// WAVE_FORMAT_EXTENSIBLE: the real format lives in the SubFormat GUID's
|
||||||
|
// leading 2-byte tag at body offset 24, not in fmtTag itself. Body must
|
||||||
|
// reach offset 24+16; otherwise leave the tag at 0 (rejected).
|
||||||
|
if (fmtTag == kWaveFormatExtensible) {
|
||||||
|
if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) {
|
||||||
|
extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
haveFmt = true;
|
||||||
|
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
||||||
|
// Reject if the declared body runs past the buffer (truncated/lying
|
||||||
|
// header), or if data arrived before fmt.
|
||||||
|
if (!c.bodyInBounds) return out;
|
||||||
|
if (!haveFmt) return out;
|
||||||
|
|
||||||
|
// Extensible tag (0xFFFE) is float only when its SubFormat sub-tag is
|
||||||
|
// also IEEE-float (0x0003) — PCM-integer-in-extensible must be rejected.
|
||||||
|
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
|
||||||
|
(fmtTag == kWaveFormatExtensible &&
|
||||||
|
extensibleSubFormatTag == kWaveFormatIeeeFloat);
|
||||||
|
if (!floatTag || bitsPerSample != 32 || channels == 0) return out;
|
||||||
|
|
||||||
|
out.valid = true;
|
||||||
|
out.channelCount = channels;
|
||||||
|
out.sampleRate = sampleRate;
|
||||||
|
out.dataByteOffset = c.bodyOffset;
|
||||||
|
out.dataByteLength = c.bodySize;
|
||||||
|
out.riffSizeFieldOffset = 4;
|
||||||
|
out.dataSizeFieldOffset = c.headerOffset + 4; // the `data` size field (LE uint32)
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out; // no data chunk found -> invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
|
||||||
|
const WavLayout& layout,
|
||||||
|
std::size_t startFrame,
|
||||||
|
std::size_t frameCount) {
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
if (!layout.valid) return out;
|
||||||
|
|
||||||
|
const std::size_t bytesPerFrame =
|
||||||
|
static_cast<std::size_t>(layout.channelCount) * 4u;
|
||||||
|
const std::size_t totalFrames = layout.frameCount();
|
||||||
|
if (startFrame >= totalFrames) return out;
|
||||||
|
|
||||||
|
// Clamp the requested span to the frames that actually exist.
|
||||||
|
const std::size_t avail = totalFrames - startFrame;
|
||||||
|
const std::size_t frames = (frameCount < avail) ? frameCount : avail;
|
||||||
|
if (frames == 0) return out;
|
||||||
|
|
||||||
|
const std::size_t firstByte =
|
||||||
|
layout.dataByteOffset + startFrame * bytesPerFrame;
|
||||||
|
out.resize(frames * layout.channelCount);
|
||||||
|
// memcpy each float (LE on target hosts — see header's byte-order note).
|
||||||
|
for (std::size_t i = 0; i < out.size(); ++i) {
|
||||||
|
float f = 0.0f;
|
||||||
|
std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u);
|
||||||
|
out[i] = f;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) {
|
||||||
|
WavTruncatePlan plan;
|
||||||
|
if (!layout.valid) return plan;
|
||||||
|
|
||||||
|
const std::size_t totalFrames = layout.frameCount();
|
||||||
|
if (keptFrames > totalFrames) return plan; // never grow
|
||||||
|
|
||||||
|
const std::size_t bytesPerFrame =
|
||||||
|
static_cast<std::size_t>(layout.channelCount) * 4u;
|
||||||
|
const std::size_t keptDataBytes = keptFrames * bytesPerFrame;
|
||||||
|
|
||||||
|
plan.valid = true;
|
||||||
|
plan.newFileByteLength = layout.dataByteOffset + keptDataBytes;
|
||||||
|
plan.dataSizeFieldOffset = layout.dataSizeFieldOffset;
|
||||||
|
plan.newDataSize = static_cast<std::uint32_t>(keptDataBytes);
|
||||||
|
plan.riffSizeFieldOffset = layout.riffSizeFieldOffset;
|
||||||
|
// RIFF size counts everything after the 8-byte "RIFF"+size prefix.
|
||||||
|
plan.newRiffSize = static_cast<std::uint32_t>(plan.newFileByteLength - 8);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
|
||||||
|
bytes[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||||
|
bytes[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||||
|
bytes[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||||
|
bytes[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||||
|
std::size_t frameCount,
|
||||||
|
const std::vector<double>& interleaved) {
|
||||||
|
const std::size_t sampleCount = frameCount * static_cast<std::size_t>(nch);
|
||||||
|
const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32
|
||||||
|
|
||||||
|
// The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload.
|
||||||
|
// Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount.
|
||||||
|
const std::uint32_t riffSize =
|
||||||
|
static_cast<std::uint32_t>(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> out;
|
||||||
|
out.reserve(44u + dataBytesCount);
|
||||||
|
|
||||||
|
auto putU16 = [&](std::uint16_t v) {
|
||||||
|
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||||
|
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||||
|
};
|
||||||
|
auto putU32 = [&](std::uint32_t v) {
|
||||||
|
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||||
|
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||||
|
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||||
|
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||||
|
};
|
||||||
|
auto putTag = [&](const char* t) {
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
out.push_back(static_cast<std::uint8_t>(t[i]));
|
||||||
|
};
|
||||||
|
auto putF32 = [&](float f) {
|
||||||
|
std::uint8_t tmp[4];
|
||||||
|
std::memcpy(tmp, &f, 4);
|
||||||
|
for (int i = 0; i < 4; ++i) out.push_back(tmp[i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// RIFF header
|
||||||
|
putTag("RIFF");
|
||||||
|
putU32(riffSize);
|
||||||
|
putTag("WAVE");
|
||||||
|
|
||||||
|
// fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003)
|
||||||
|
putTag("fmt ");
|
||||||
|
putU32(16u); // chunk body size
|
||||||
|
putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT
|
||||||
|
putU16(static_cast<std::uint16_t>(nch));
|
||||||
|
putU32(rate);
|
||||||
|
putU32(rate * static_cast<std::uint32_t>(nch) * 4u); // avgBytesPerSec
|
||||||
|
putU16(static_cast<std::uint16_t>(nch * 4)); // blockAlign
|
||||||
|
putU16(32u); // bitsPerSample
|
||||||
|
|
||||||
|
// data chunk
|
||||||
|
putTag("data");
|
||||||
|
putU32(static_cast<std::uint32_t>(dataBytesCount));
|
||||||
|
for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i)
|
||||||
|
putF32(static_cast<float>(interleaved[i]));
|
||||||
|
|
||||||
|
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;
|
||||||
|
for (std::size_t i = 0; i < len; ++i) {
|
||||||
|
h ^= static_cast<std::uint64_t>(data[i]);
|
||||||
|
h *= kFnvPrime;
|
||||||
|
}
|
||||||
|
return fnvHex(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
||||||
|
if (isRiffWave(bytes)) {
|
||||||
|
std::uint64_t h = kFnvOffsetBasis;
|
||||||
|
auto feedByte = [&](std::uint8_t b) {
|
||||||
|
h ^= static_cast<std::uint64_t>(b);
|
||||||
|
h *= kFnvPrime;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool haveFmt = false;
|
||||||
|
bool haveData = false;
|
||||||
|
|
||||||
|
feedByte(static_cast<std::uint8_t>('W')); // domain-separation prefix
|
||||||
|
|
||||||
|
std::size_t pos = 12;
|
||||||
|
WavChunkView c;
|
||||||
|
while (nextWavChunk(bytes, pos, c)) {
|
||||||
|
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
|
||||||
|
if (c.bodyInBounds) {
|
||||||
|
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
||||||
|
feedByte(bytes[c.bodyOffset + i]);
|
||||||
|
haveFmt = true;
|
||||||
|
}
|
||||||
|
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
||||||
|
if (c.bodyInBounds) {
|
||||||
|
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
||||||
|
feedByte(bytes[c.bodyOffset + i]);
|
||||||
|
haveData = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haveFmt && haveData) return fnvHex(h);
|
||||||
|
// Falls through to whole-file fallback if chunks were missing/malformed.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: not a parseable RIFF/WAVE — hash the whole file (identical to
|
||||||
|
// hashBytes(data, size); no prefix tag).
|
||||||
|
return hashBytes(bytes.data(), bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
#pragma once
|
||||||
|
// wav_codec — the pure owner of the WAV/RIFF byte format: chunk walker, layout
|
||||||
|
// parse, float32 build, size-field patch, and the WAV-aware content hash — one
|
||||||
|
// chunk traversal shared by all of them so hashing and decoding cannot desync.
|
||||||
|
// Handles 32-bit float WAV only (RIFF/WAVE, `fmt ` tag 3 or 0xFFFE-extensible
|
||||||
|
// w/ float subformat, float32 `data`); anything else parses as invalid.
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/audio/peaks.h" // AudioSample (float)
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
using audio::AudioSample;
|
||||||
|
|
||||||
|
// --- Layout parse ------------------------------------------------------------
|
||||||
|
|
||||||
|
// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the
|
||||||
|
// bytes are not a WAV we can safely trim; every other field is meaningful only
|
||||||
|
// when valid.
|
||||||
|
struct WavLayout {
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
|
std::uint16_t channelCount = 0; // from `fmt ` (interleave stride)
|
||||||
|
std::uint32_t sampleRate = 0;
|
||||||
|
|
||||||
|
// The `data` chunk: PCM byte offset + declared length.
|
||||||
|
// frameCount = dataByteLength / (channelCount * 4).
|
||||||
|
std::size_t dataByteOffset = 0;
|
||||||
|
std::size_t dataByteLength = 0;
|
||||||
|
|
||||||
|
// Offsets of the two LE uint32 size fields the truncate patch rewrites.
|
||||||
|
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
|
||||||
|
std::size_t dataSizeFieldOffset = 0;
|
||||||
|
|
||||||
|
std::size_t frameCount() const {
|
||||||
|
const std::size_t bytesPerFrame = static_cast<std::size_t>(channelCount) * 4u;
|
||||||
|
return bytesPerFrame ? dataByteLength / bytesPerFrame : 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parses a WAV byte buffer's header geometry; {valid=false} for anything not a
|
||||||
|
// canonical float32 RIFF/WAVE, or a `data` length running past the buffer.
|
||||||
|
// Does not copy PCM, only locates it. Pure + total (no throw, no UB).
|
||||||
|
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes);
|
||||||
|
|
||||||
|
// Copies `frameCount` interleaved float frames starting at `startFrame` out of
|
||||||
|
// the WAV's `data` region into a flat [f0c0,f0c1,...] buffer, clamped to frames
|
||||||
|
// actually present; never reads past `data`. Reads little-endian via memcpy —
|
||||||
|
// target is x86/ARM-LE only, no big-endian byte-swap.
|
||||||
|
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
|
||||||
|
const WavLayout& layout,
|
||||||
|
std::size_t startFrame,
|
||||||
|
std::size_t frameCount);
|
||||||
|
|
||||||
|
// --- Truncate plan + size-field patch ---------------------------------------
|
||||||
|
|
||||||
|
// The plan to truncate a parsed WAV to `keptFrames` frames. `valid` is false if
|
||||||
|
// the layout is invalid or keptFrames exceeds the file's frames (never grow).
|
||||||
|
struct WavTruncatePlan {
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
|
std::size_t newFileByteLength = 0; // truncate the file to exactly this length
|
||||||
|
std::size_t dataSizeFieldOffset = 0; // where to write newDataSize (LE uint32)
|
||||||
|
std::uint32_t newDataSize = 0; // kept PCM byte length
|
||||||
|
std::size_t riffSizeFieldOffset = 4; // where to write newRiffSize (LE uint32)
|
||||||
|
std::uint32_t newRiffSize = 0; // newFileByteLength - 8 (RIFF size excludes
|
||||||
|
// the 8-byte "RIFF"+size prefix)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computes the truncate plan to keep exactly `keptFrames` frames. The shell
|
||||||
|
// applies it: patch the two size fields (patchU32LE), then truncate to
|
||||||
|
// newFileByteLength.
|
||||||
|
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
|
||||||
|
|
||||||
|
// Patches a little-endian uint32 into a byte buffer at `off`. Caller guarantees
|
||||||
|
// off + 4 <= bytes.size() (the plan's offsets came from a valid parse of the same
|
||||||
|
// buffer).
|
||||||
|
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v);
|
||||||
|
|
||||||
|
// --- Float32 WAV build -------------------------------------------------------
|
||||||
|
|
||||||
|
// Builds a minimal canonical float32 RIFF/WAVE byte buffer from interleaved
|
||||||
|
// double samples (narrowed to float by cast). Round-trips through
|
||||||
|
// parseWavLayout/extractFloatFrames.
|
||||||
|
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
|
||||||
|
// hex. Fills Sample::contentHash for the confirm-on-last-reference dedup guardrail.
|
||||||
|
std::string hashBytes(const std::uint8_t* data, std::size_t len);
|
||||||
|
|
||||||
|
// WAV-aware content hash: hashes only the `fmt ` body + `data` payload, skipping
|
||||||
|
// other chunks. WHY: REAPER's offline renderer embeds a render-varying `bext`
|
||||||
|
// timestamp chunk even with no BWF metadata requested, so two renders of
|
||||||
|
// identical audio would otherwise hash differently and never dedup. Prefixed
|
||||||
|
// with tag byte 'W' so it can't collide with a same-size hashBytes result.
|
||||||
|
// Falls back to whole-file hashBytes (no prefix) for a file that doesn't parse.
|
||||||
|
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
# 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 five
|
||||||
|
subdirectories:
|
||||||
|
|
||||||
|
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
|
||||||
|
velocity curve, and master-gain taper math.
|
||||||
|
- **`map/`** — the capture resolution + `SampleData` build, the cross-artifact
|
||||||
|
`ComponentState` codec, and the small pure helpers the engine/shell share
|
||||||
|
(bank-generation sync, bridge-read marshalling, note-name parsing, 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
|
||||||
|
|
||||||
|
### The three locked decisions this spec assumes (settled 2026-07-26)
|
||||||
|
|
||||||
|
- **D1 — native VST3.** Not JSFX. Full sampler sophistication, clean integration, and
|
||||||
|
access to the REAPER VST-host bridge.
|
||||||
|
- **D5 — Windows-only, VST3-only, REAPER-only.** No cross-platform DSP/build/signing
|
||||||
|
matrix, no multi-format wrapper, no standalone-in-other-hosts concern.
|
||||||
|
- **D6 — two products, tightly integrated.** A separate artifact, but not a divorced
|
||||||
|
file-only companion: via the VST-host bridge it reads the live `"reasampler"` project
|
||||||
|
ext-state and is project-aware. (The bridge mechanism itself is documented in
|
||||||
|
`src/core/wire/CLAUDE.md`.)
|
||||||
|
|
||||||
|
### The two seams (audio via files, mapping via live state)
|
||||||
|
|
||||||
|
- **File seam (audio, permanent).** The sample **audio** is the on-disk 32-bit-float
|
||||||
|
WAVs — project-relative, travelling with the `.rpp`. The instrument resolves those
|
||||||
|
paths the same way `persist` does (a shared convention, not a re-implementation).
|
||||||
|
There is no live PCM stream across the bridge, by design.
|
||||||
|
- **Live-state seam (the mapping, via the bridge).** For everything that is not raw
|
||||||
|
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:
|
||||||
|
|
||||||
|
- **Bank intrinsics (facts about the captured file) live on `Sample`.** Root note (the
|
||||||
|
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`).
|
||||||
|
- **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, 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.
|
||||||
|
|
||||||
|
- **The bank is one source; the instrument is another view of it (never a fork).** The
|
||||||
|
instrument is a pure consumer of the bank — it does not copy samples, does not own a
|
||||||
|
private sample store, and does not mutate the bank.
|
||||||
|
- **`Sample` field additions are additive and lossless.** No existing `Sample` field
|
||||||
|
changes; no `BankIndex` behavior changes.
|
||||||
|
- **Relative-paths-only survives.** The instrument resolves audio via the project-relative
|
||||||
|
machinery; it introduces no absolute paths.
|
||||||
|
|
||||||
|
### Channel mode — current reality
|
||||||
|
|
||||||
|
**Current reality (root `CLAUDE.md`, GA post-launch pass): the output bus is
|
||||||
|
permanently stereo.** `ChannelMode` is decode-only; the dynamic mono↔stereo bus
|
||||||
|
renegotiation (`setBusArrangements` per-instance toggle) has been deleted. Channel mode
|
||||||
|
auto-defaults from the loaded capture's channel count via a pure `channelModeFor` helper,
|
||||||
|
gated by a persisted `channelModeExplicit` flag (`ComponentState` v9). Mono source +
|
||||||
|
stereo mode → dual-mono (same signal both channels, centered); stereo source + mono mode
|
||||||
|
→ downmix (existing decode-side policy).
|
||||||
|
|
||||||
|
> **Superseded design, do not reintroduce:** an earlier "Channel mode — mono |
|
||||||
|
> stereo (D-E)" design specified a per-instance toggle that **dynamically
|
||||||
|
> renegotiates the REAPER audio bus** via `setBusArrangements`/`getBusArrangement`
|
||||||
|
> (the instrument reporting mono or stereo per instance and REAPER's routing
|
||||||
|
> following). That dynamic-bus-negotiation design was superseded by the GA fix
|
||||||
|
> above; root `CLAUDE.md` is current and wins.
|
||||||
|
|
||||||
|
### Sampling modes — Gate vs Trigger, pitch engine, pitch envelope (S15/S16 — settled, landed)
|
||||||
|
|
||||||
|
Daniel's directive (2026-07-26, verbatim): *"Sampling mode: Trigger vs Gate. Gate has an
|
||||||
|
AHDSR envelope. Trigger has fade in, % length, and fade out. Both modes have modifiable
|
||||||
|
start point, Gate has modifiable loop points too. In addition to amp env, there will be a
|
||||||
|
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, 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; note-off is ignored (the voice plays through, no sustain loop). Frame span
|
||||||
|
`[startFrame, playEnd)` where `playEnd = startFrame +
|
||||||
|
round(lengthFraction·(frames − startFrame))`. The amplitude over that span is the staged
|
||||||
|
**AHD** (below), not a fade pair. **Note-off in Trigger is a no-op** — choke-on-note-off
|
||||||
|
is held/out of scope (fork S15-F1).
|
||||||
|
|
||||||
|
> **Superseded, do not reintroduce:** Trigger's amplitude was once a fade-in/unity/
|
||||||
|
> fade-out shape with its own equal-power curve and its own `fadeInFrames`/`fadeOutFrames`
|
||||||
|
> pair, clamped so the two fades fit the span. That is retired — one staged-envelope
|
||||||
|
> design now covers what were two mechanisms. A saved instance's fades lift onto the AHD
|
||||||
|
> at the codec boundary (attack ← fade-in, decay ← fade-out, hold ← the remainder).
|
||||||
|
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
|
||||||
|
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none.
|
||||||
|
- **Pitch engine — Varispeed vs Preserve (S16).** Varispeed (current/
|
||||||
|
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
|
||||||
|
source rate while a pitch shifter transposes the output — musically right for
|
||||||
|
tempo-locked loops/phrases; **the engine default leans Preserve** (fork S16-F1).
|
||||||
|
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 — 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
|
||||||
|
accepted property, not a defect." Root `CLAUDE.md`'s GA2 pass **eliminated** that onset
|
||||||
|
latency (ring buffer primed with the actual upcoming source at note-on instead of
|
||||||
|
zero-filled, so Preserve now speaks on frame 0, matching Varispeed) — a
|
||||||
|
cold-started/un-pre-warmed shifter producing a click or smear remains a bug.
|
||||||
|
- **S15/S16 stay channel-count-agnostic.** The mode/envelope logic is per-frame amplitude
|
||||||
|
and read-rate, independent of the stereo channel dimension — any S15/S16 code that
|
||||||
|
assumes a fixed (mono) channel count rather than operating per-frame pre-mix is a bug.
|
||||||
|
- **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
|
||||||
|
add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
|
||||||
|
- **The pure core stays REAPER-free *and* VST3-free.** Any VST3 or REAPER type leaking
|
||||||
|
into the voice engine / envelope / keymap / repitch module is a bug (the D3 split).
|
||||||
|
- **Channel mode is a performance choice, not a bank fact.** Never written to `Sample` or
|
||||||
|
the bank.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### The envelope overlay — one graphical surface, every envelope (S-VIEW, extended)
|
||||||
|
|
||||||
|
The overlay draws ONE envelope over the Sample view's hero waveform, and WHICH one is a
|
||||||
|
transient editor choice: each envelope deck (amp, pitch, filter) carries a corner radio, at
|
||||||
|
most one is overlay-active, and **none is a valid resting state — the editor opens there.**
|
||||||
|
Never persisted; it selects what is drawn, not what is played.
|
||||||
|
|
||||||
|
**The overlay is directly editable — draggable nodes (SETTLED, S-VIEW-F2), plus a round
|
||||||
|
mid-segment knot per sloped stage that sets that stage's curve exponent.** A node drag, a
|
||||||
|
knot drag and the deck knobs are surfaces onto ONE model: all three read/write the same
|
||||||
|
fields of the one parameter set, so an edit on any of them re-lays the others — one source
|
||||||
|
of truth, structural (re-read-every-paint), never a listener chain. Every drag is
|
||||||
|
range-clamped to the same per-param min/max the knobs enforce, so no drag can produce a
|
||||||
|
param a knob couldn't. Two pure modules split the forward (draw) and inverse (edit) maps —
|
||||||
|
see `envelope_overlay` and `envelope_edit` in Modules below.
|
||||||
|
|
||||||
|
**Every envelope is EITHER staged or drawn, and both states persist.** Each of the three
|
||||||
|
(amp, pitch, filter) carries a `SplineEnv` — a mode plus a contour over NORMALIZED sample time —
|
||||||
|
beside its staged parameters. Switching modes converts and discards nothing: the inactive state
|
||||||
|
stays saved but inert, and round-tripping restores the other mode's shape untouched. The
|
||||||
|
consequences, each with one home:
|
||||||
|
|
||||||
|
- **Gate is unavailable while any EG is drawn.** A contour is a pure time function over the full
|
||||||
|
sample length, which IS the Trigger/one-shot model. `splineActive` (`play_params.h`) is the
|
||||||
|
predicate; `enforceGateUnavailableWhileDrawn` (`play_params.h`) is the one enforcement of it,
|
||||||
|
called by both `resolvePlay` (`sample_map.cpp`) on the way to the engine and the editor's
|
||||||
|
`applyControl`, so the two callers cannot drift. The editor's Gate segment refuses and paints
|
||||||
|
Disabled off the same `splineActive` predicate.
|
||||||
|
- **A drawn envelope's staged segment knobs go inert** — drawn-but-dead, never removed, never
|
||||||
|
hidden — including their inner curve dials, which are reached through their outer cell.
|
||||||
|
`deckKnobInert` (`ui/deck_groups`) is the one place that list lives. The DEPTH knobs (pitch
|
||||||
|
peak, filter mod amount) stay live: they scale whichever shape is active.
|
||||||
|
- **Normalized is what makes a contour length-independent.** There are no stored seconds to
|
||||||
|
rescale, so a different-length capture replays the same shape proportionally.
|
||||||
|
- **The contours sit on `PlayParams`/`PlaySeconds` directly, not inside the three envelope
|
||||||
|
structs.** Those are copied whole into the live block, which must stay trivially copyable
|
||||||
|
(`live_params.h`) — and a contour is not a live control anyway: like the velocity curves it
|
||||||
|
travels by reload.
|
||||||
|
|
||||||
|
**Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:**
|
||||||
|
pitch is always AHD; amp and filter are AHDSR in Gate and AHD in Trigger. Both mode shapes
|
||||||
|
are STORED per envelope, so flipping modes cannot lose either mode's dialled values (the
|
||||||
|
migration case forces it: an old instance carries both its AHDSR values and its Trigger
|
||||||
|
fades, and one shared set could not preserve both modes' prior sound).
|
||||||
|
|
||||||
|
**And which LAYOUT an envelope takes follows from whether it has a sustain stage** — the
|
||||||
|
same rule, applied once: an AHDSR right-anchors its release (the end point is fixed at the
|
||||||
|
canvas edge and release is dragged from its top node), a sustain-less AHD maps 1:1 onto the
|
||||||
|
waveform's time axis. The two policies coexist rather than merge; the 1:1 mapping only means
|
||||||
|
anything for a trigger shape.
|
||||||
|
|
||||||
|
### Parameter ownership and persistence (D-B)
|
||||||
|
|
||||||
|
- **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 curves** — three of them (amp, pitch, filter), all per-instance, edited from ONE
|
||||||
|
deck group. The amp curve is the one non-back-compat surface in S-VIEW: an already-saved
|
||||||
|
instance with no stored curve now plays every velocity at unity under the flat-default
|
||||||
|
(Option A), not bit-identical to the old linear `velocity/127` mapping — a deliberate,
|
||||||
|
Daniel-approved behavior change. The pitch and filter curves are bipolar and off by default
|
||||||
|
(see `velocity_curve` in Modules).
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
### `engine/`
|
||||||
|
|
||||||
|
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||||
|
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. 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` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
|
||||||
|
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
||||||
|
|
||||||
|
### `map/`
|
||||||
|
|
||||||
|
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
||||||
|
- `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping.
|
||||||
|
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v14), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
|
||||||
|
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
|
||||||
|
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
|
||||||
|
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
|
||||||
|
- `trigger_seam` — the shared Trigger play-SPAN formula: how a %-length becomes the source-frame span the overlay draws over and the bake's window holds, threading `startFrame` correctly and clamping the fraction the same way `Voice::start` does (the engine evaluates the same formula inline rather than depending on `map/`). The spline fold every consumer must apply first — `effectiveLengthFraction` — is `play_params.h`'s, beside the rest of that rule family. (Its fade frames↔fraction converters retired with the fade pair itself.)
|
||||||
|
|
||||||
|
### `ui/`
|
||||||
|
|
||||||
|
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
||||||
|
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||||
|
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — 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. 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 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
|
||||||
|
|
||||||
|
- **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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user