Compare commits
383 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3eae942625 | |||
| 03f7fb5081 | |||
| ab14ae769f | |||
| 2269fd80b6 | |||
| 8c06383fe7 | |||
| 8a7056d19d | |||
| 13c824c63d | |||
| bd86ede5b3 | |||
| 4b306dd436 | |||
| 5376ab085c | |||
| 67524206fb | |||
| 203961f41c | |||
| 2f96dd3da5 | |||
| 098727e464 | |||
| 0e6a0d3618 | |||
| 8487833342 | |||
| d22446ef7d | |||
| 4b19556513 | |||
| a5febbd6aa | |||
| 51c7505dc0 | |||
| b71a05fbef | |||
| aa87d81f1f | |||
| 70bd29dc89 | |||
| 84afd1b545 | |||
| f91054276b | |||
| fcd1ed022c | |||
| 9599e96e0c | |||
| 761125d0fe | |||
| 5042e2709b | |||
| d5280687f3 | |||
| b7b7e88195 | |||
| 59837abd00 | |||
| cca7d1a538 | |||
| 6e2128e937 | |||
| 32c7e959ae | |||
| 170b2e4994 | |||
| 1159d364c2 | |||
| 93d6fd0271 | |||
| d16df67eed | |||
| c8e08a7670 | |||
| c9af821eae | |||
| d278c38370 | |||
| 78e5f06928 | |||
| 07b51dcabf | |||
| 84905e6bd1 | |||
| 3137c05961 | |||
| 73c69f689c | |||
| 7169d7f22b | |||
| dd067a192e | |||
| e714df80f8 | |||
| 450559f155 | |||
| a3853231a7 | |||
| 4320bfdb08 | |||
| 82a8d51004 | |||
| a4a1c3860f | |||
| 0eb2c67875 | |||
| 3376f3eaa1 | |||
| 8c1fccf450 | |||
| cbc4a4c53e | |||
| 27712b7573 | |||
| 2ee984ed16 | |||
| 77eb7c6680 | |||
| 53e7d35178 | |||
| 3313ac6b96 | |||
| a3698972db | |||
| 0d316b7b9e | |||
| b584fa4da9 | |||
| 00afa9ba2e | |||
| a9c166b8c2 | |||
| d3f5e6ec17 | |||
| de34fbafdb | |||
| 4c6545e0d3 | |||
| bd7d6857e5 | |||
| 430faff230 | |||
| b5a820ed8a | |||
| 037707e517 | |||
| 6787e1725c | |||
| 056c60c8e1 | |||
| d5ed4f6e53 | |||
| 90b9d4527c | |||
| 5f6efb7cc3 | |||
| 425b9f708c | |||
| 02319041e3 | |||
| 9b5393098b | |||
| 4231b2321c | |||
| 163c091f55 | |||
| beaf527bc6 | |||
| fa69b6c547 | |||
| f33ef9af37 | |||
| a2ee5ff69d | |||
| 24569956cf | |||
| c0c56ab2a7 | |||
| b99027bc4c | |||
| 3fd3214ff8 | |||
| 1005c943a2 | |||
| 9521b5339f | |||
| 2069ae8086 | |||
| f9b191bd36 | |||
| 01481350e6 | |||
| f8dde16a7e | |||
| 33ea95078d | |||
| 454f67b3bc | |||
| a927dad2f4 | |||
| 752d3413f8 | |||
| 081b6f1028 | |||
| f87ea3d3d7 | |||
| f188846370 | |||
| ff65371eb6 | |||
| 181b4f2edb | |||
| 655159ceac | |||
| a197ff7d68 | |||
| 3909b1072c | |||
| edfd7ead4d | |||
| 35b2a3a151 | |||
| 1aebf51938 | |||
| 41a3016e63 | |||
| e0b4ec2e21 | |||
| 043558a54d | |||
| 1fd38bbd57 | |||
| 09a9ef838f | |||
| ec95aac274 | |||
| 4c7e0507a1 | |||
| 8331df2e91 | |||
| d85e50c143 | |||
| de5654fb6f | |||
| bcdf97d6c4 | |||
| 5f971e60cd | |||
| bfaa0f2614 | |||
| 51b13304ee | |||
| b400384a4f | |||
| 7c43e55435 | |||
| d7e5c59547 | |||
| 5c0f5f1591 | |||
| a0fd931dcb | |||
| 0ab4673887 | |||
| 292d14d14c | |||
| c7afa3a80f | |||
| e87d044042 | |||
| 5236c58838 | |||
| 7c473bce73 | |||
| da14509ab5 | |||
| 5c6525fb91 | |||
| 4b0b03d8d5 | |||
| b956fe0d5a | |||
| 41876674e4 | |||
| df10ddacc2 | |||
| 0627398bbb | |||
| f60c05c042 | |||
| 1490c25250 | |||
| bf7840020e | |||
| e2981e83ee | |||
| 782f50c859 | |||
| d4acb3feb5 | |||
| 1b4d0e67b7 | |||
| 56bf26d8b6 | |||
| a7c3c7a828 | |||
| 35ab59d7f9 | |||
| fefd169d5b | |||
| cbe2369037 | |||
| ef59265e7a | |||
| f1168e16eb | |||
| 248f2f3842 | |||
| d35a55ec8e | |||
| 91bd6f51a2 | |||
| cc4967d21d | |||
| 3e4ba628c3 | |||
| 048673eb75 | |||
| e7d7e70201 | |||
| 79189bd316 | |||
| 9228e93750 | |||
| 163ab11e05 | |||
| 334022c0f1 | |||
| 38337229d4 | |||
| 93230208ff | |||
| f8e57a3cbd | |||
| 91c1b78d5e | |||
| 6232851c6b | |||
| 0612abbddb | |||
| 3baf4ee50b | |||
| 4fa021edae | |||
| 20d86d7900 | |||
| abace156a5 | |||
| ab3548dced | |||
| f39fb1b145 | |||
| ac653aa581 | |||
| 6f39d0137a | |||
| a1b42ed1a8 | |||
| ae59e9b70d | |||
| 748aad3538 | |||
| ee8a956fbd | |||
| 589a8e078b | |||
| 3eb72d01c4 | |||
| ae54ca8128 | |||
| 69e2f1d3e3 | |||
| abb27f08f2 | |||
| e589addc54 | |||
| 1c8709e82d | |||
| 25390d5253 | |||
| 2cb0b52884 | |||
| 0afb885987 | |||
| 1800bd64c3 | |||
| 6cabbd0dd4 | |||
| 2f8e88880e | |||
| 4abeace925 | |||
| 41ca833b86 | |||
| 0511d16d4f | |||
| ef0da893b5 | |||
| c037aa1cb6 | |||
| 734672ba0e | |||
| 2005f90c66 | |||
| 61e90af547 | |||
| 461351ee02 | |||
| 52394fa08f | |||
| 7f3b00a646 | |||
| 7f040dd934 | |||
| 47065a19bb | |||
| b1726b0ad3 | |||
| f57bf4fb6d | |||
| 962ab64ef0 | |||
| a91df760cc | |||
| 86c3c7f3b8 | |||
| 893455594f | |||
| 5455b1047b | |||
| 6e937b9c61 | |||
| 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 |
@@ -0,0 +1 @@
|
||||
*.rsbank binary
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
/build/
|
||||
/build-beta/
|
||||
/.claude/worktrees/
|
||||
/.idea/ctestState.xml
|
||||
/.idea/
|
||||
*.dll
|
||||
*.dylib
|
||||
*.so
|
||||
*.o
|
||||
*.obj
|
||||
.DS_Store
|
||||
/AGENTS.md
|
||||
cmake-build-*/
|
||||
|
||||
Generated
-10
@@ -1,10 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
-1
@@ -1 +0,0 @@
|
||||
reaper_mpeview
|
||||
Generated
-350
@@ -1,350 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="BackendCodeEditorSettings">
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CDeclarationWithImplicitIntType/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CommentTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=ConstevalIfIsAlwaysConstant/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractClassWithoutSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractVirtualFunctionCallInCtor/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAccessSpecifierWithNoDeclarations/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAwaiterTypeIsNotClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBooleanIncrementExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatBadCode/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatLegacyCode/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatMixedArgs/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooFewArgs/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCompileTimeConstantCanBeReplacedWithBooleanConstant/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConceptNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConditionalExpressionCanBeSimplified/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstValueFunctionReturnType/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstexprIfDiscardedBranch/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCoroutineCallResolveError/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAArrayIndexOutOfBounds/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantConditions/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantFunctionResult/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantParameter/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFADeletedPointer/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAEndlessLoop/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInfiniteRecursion/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInvalidatedMemory/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesFunction/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesScope/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALoopConditionNotUpdated/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAMemoryLeak/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANotInitializedField/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANullDereference/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFATimeOver/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableCode/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableFunctionCall/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreadVariable/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnusedValue/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesLocal/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesUncapturedLocal/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationSpecifierWithoutDeclarators/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorDisambiguatedAsFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorUsedBeforeInitialization/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultCaseNotHandledInSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefinitionsOrder/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedEntity/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedOverridenMethod/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedRegisterStorageClassSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDereferenceOperatorLimitExceeded/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDiscardedPostfixOperatorResult/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenSyntaxError/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUndocumentedParameter/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUnresolvedReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEmptyDeclaration/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersOrder/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersPlacement/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceDoStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceForStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceFunctionDeclarationStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceIfStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceNestedNamespacesStyle/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingDestructorStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingFunctionStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceTypeAliasCodeStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceWhileStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityAssignedButNoRead/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityUsedOnlyInUnevaluatedContext/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnumeratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEqualOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEvaluationFailure/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExplicitSpecializationInNonNamespaceScope/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExpressionWithoutSideEffects/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalFunctionInFinalClass/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalNonOverridingVirtualFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForLoopCanBeReplacedWithWhile/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForwardEnumDeclarationWithoutUnderlyingType/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionDoesntReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionResultShouldBeUsed/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionalStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHeaderHasBeenAlreadyIncluded/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHiddenFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHidingFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIdenticalOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIfCanBeReplacedByConstexprIf/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppImplicitDefaultConstructorNotAvailable/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompatiblePointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompleteSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInconsistentNaming/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIntegralToPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInvalidLineContinuation/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppJoinDeclarationAndAssignment/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLambdaCaptureNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableWithNonTrivialDtorIsNeverUsed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLongFloat/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeConst/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeStatic/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberInitializersOrder/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMismatchedClassTags/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingIncludeGuard/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingKeywordThrow/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppModulePartitionWithSeveralPartitionUnits/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtAddressOfClassRValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtBindingRValueToLvalueReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtCopyElisionInCopyInitDeclarator/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtDoubleUserConversionInCopyInit/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtNotInitializedStaticConstLocalVar/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtReinterpretCastFromNullptr/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterLiteral/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterWideLiteral/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMustBePublicVirtualToImplementInterface/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMutableSpecifierOnReferenceMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNoDiscardExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNodiscardFunctionWithoutReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExceptionSafeResourceAcquisition/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConversionOperator/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConvertingConstructor/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineFunctionDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineVariableDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNotAllPathsReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppObjectMemberMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppOutParameterMustBeWritten/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppOverrideWithDifferentVisibility/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConstPtrOrRef/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNamesMismatch/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPassValueParameterByConstReference/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerConversionDropsQualifiers/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerToIntegralConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPolymorphicClassWithNonVirtualPublicDestructor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyErroneousEmptyStatements/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUnintendedObjectSlicing/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderIsNotIncluded/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderNotFound/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfBadFormat/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfExtraArg/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfMissedArg/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfRiskyFormat/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrivateSpecialMemberFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRangeBasedForIncompatibleReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedefinitionOfDefaultArgumentInOverrideFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBooleanExpressionArgument/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantCastExpression/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantComplexityInComparison/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConditionalExpression/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConstSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantControlFlowJump/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantDereferencingAndTakingAddress/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElaboratedTypeSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeyword/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeywordInsideCompoundStatement/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyDeclaration/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyStatement/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantExportKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantFwdClassOrEnumSpecifier/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantInlineSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantLambdaParameterList/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantMemberInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantNamespaceDefinition/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantParentheses/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifierADL/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnMemberAllocationFunction/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnThreadLocalLocalVariable/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateArguments/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantVoidArgumentList/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantZeroInitializerInAggregateInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReferenceToOverriddenVirtualFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReinterpretCastFromVoidPtr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRemoveRedundantBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceMemsetWithZeroInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceTieWithStructuredBinding/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReturnNoValueInNonVoidFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSmartPointerVsMakeFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSomeObjectMembersMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSpecialFunctionWithoutNoexceptSpecification/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticAssertFailure/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticDataMemberInUnnamedStruct/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticSpecifierOnAnonymousNamespaceMember/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStringLiteralToCharPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTabsAreDisallowed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateArgumentsCanBeDeduced/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterShadowing/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppThrowExpressionCanBeReplacedWithRethrow/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScope/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScopeInitStatement/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTypeAliasNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedDependentBaseClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedNonStaticDataMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnionMemberOfReferenceType/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaEndRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnamedNamespaceInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnecessaryWhitespace/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnsignedZeroComparison/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnusedIncludeDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAlgorithmWithCount/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAssociativeContains/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAuto/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAutoForNumeric/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseDesignatedInitializers/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseElementsView/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseEraseAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseFamiliarTemplateSyntaxForGenericLambdas/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseInternalLinkage/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseRangeAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStdSize/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStructuredBinding/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseTypeTraitAlias/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUserDefinedLiteralSuffixDoesNotStartWithUnderscore/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUsingResultOfAssignmentAsCondition/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVariableCanBeMadeConstexpr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionCallInsideCtor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionInFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVolatileParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWarningDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongIncludesOrder/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongSlashesInIncludeDirective/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroConstantCanBeReplacedWithNullptr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroValuedExpressionUsedAsNullPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IdentifierTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IfStdIsConstantEvaluatedCanBeReplaced/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StdIsConstantEvaluatedWillAlwaysEvaluateToConstant/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppClangFormat/EnableClangFormatSupport/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_ARGUMENT/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_BINARY_EXPRESSIONS_CHAIN/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_CALLS_CHAIN/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_EXPRESSION/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_EXTENDS_LIST/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_FOR_STMT/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_PARAMETER/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_TYPE_ARGUMENT/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_TYPE_PARAMETER/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTIPLE_DECLARATION/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_TERNARY/@EntryValue" value="ALIGN_ALL" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/ANONYMOUS_METHOD_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_CLASS_DEFINITION/@EntryValue" value="1" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_DECLARATIONS/@EntryValue" value="0" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_FUNCTION_DECLARATION/@EntryValue" value="1" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BLANK_LINES_AROUND_FUNCTION_DEFINITION/@EntryValue" value="1" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/BREAK_TEMPLATE_DECLARATION/@EntryValue" value="LINE_BREAK" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/CASE_BLOCK_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/CONTINUOUS_LINE_INDENT/@EntryValue" value="Double" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_ACCESS_SPECIFIERS_FROM_CLASS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_CASE_FROM_SWITCH/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_CLASS_MEMBERS_FROM_ACCESS_SPECIFIERS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_COMMENT/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_SIZE/@EntryValue" value="4" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INDENT_STYLE/@EntryValue" value="Space" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INITIALIZER_BRACES/@EntryValue" value="END_OF_LINE_NO_SPACE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INT_ALIGN_EQ/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/INVOCABLE_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/KEEP_BLANK_LINES_IN_CODE/@EntryValue" value="2" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/KEEP_BLANK_LINES_IN_DECLARATIONS/@EntryValue" value="2" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/KEEP_USER_LINEBREAKS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/LINE_BREAK_AFTER_COLON_IN_MEMBER_INITIALIZER_LISTS/@EntryValue" value="ON_SINGLE_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/MEMBER_INITIALIZER_LIST_STYLE/@EntryValue" value="DO_NOT_CHANGE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/NAMESPACE_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/NAMESPACE_INDENTATION/@EntryValue" value="All" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/OTHER_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_CATCH_ON_NEW_LINE/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_ELSE_ON_NEW_LINE/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_NAMESPACE_DEFINITIONS_ON_SAME_LINE/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/PLACE_WHILE_ON_NEW_LINE/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SIMPLE_BLOCK_STYLE/@EntryValue" value="DO_NOT_CHANGE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_CAST_EXPRESSION_PARENTHESES/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_COLON_IN_BITFIELD_DECLARATOR/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_COMMA_IN_TEMPLATE_ARGS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_COMMA_IN_TEMPLATE_PARAMS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_EXTENDS_COLON/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_FOR_COLON/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_FOR_SEMICOLON/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_DATA_MEMBER/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_DATA_MEMBERS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_METHOD/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_PTR_IN_NESTED_DECLARATOR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_REF_IN_DATA_MEMBER/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_REF_IN_DATA_MEMBERS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_REF_IN_METHOD/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_AFTER_UNARY_OPERATOR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_COLON_IN_BITFIELD_DECLARATOR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_EXTENDS_COLON/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_FOR_COLON/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_FOR_SEMICOLON/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_ABSTRACT_DECL/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_DATA_MEMBER/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_DATA_MEMBERS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_PTR_IN_METHOD/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_ABSTRACT_DECL/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_DATA_MEMBER/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_DATA_MEMBERS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_REF_IN_METHOD/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_TEMPLATE_ARGS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_TEMPLATE_PARAMS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BETWEEN_CLOSING_ANGLE_BRACKETS_IN_TEMPLATE_ARGS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_ARRAY_ACCESS_BRACKETS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_CAST_EXPRESSION_PARENTHESES/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_DECLARATION_PARENTHESES/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_BLOCKS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_INITIALIZER_BRACES/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_METHOD_PARENTHESES/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_EMPTY_TEMPLATE_PARAMS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_INITIALIZER_BRACES/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_TEMPLATE_ARGS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_WITHIN_TEMPLATE_PARAMS/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/SPECIAL_ELSE_IF_TREATMENT/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/TAB_WIDTH/@EntryValue" value="4" type="int" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/TYPE_DECLARATION_BRACES/@EntryValue" value="END_OF_LINE" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_AFTER_BINARY_OPSIGN/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_AFTER_DECLARATION_LPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_AFTER_INVOCATION_LPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_ARGUMENTS_STYLE/@EntryValue" value="WRAP_IF_LONG" type="string" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_DECLARATION_LPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_DECLARATION_RPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_INVOCATION_LPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_INVOCATION_RPAR/@EntryValue" value="false" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_BEFORE_TERNARY_OPSIGNS/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_PARAMETERS_STYLE/@EntryValue" value="WRAP_IF_LONG" type="string" />
|
||||
<option name="/Default/CodeStyle/EditorConfig/EnableClangFormatSupport/@EntryValue" value="false" type="bool" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-10
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="userId" value="-2857a757:19fa27c75be:-7fff" />
|
||||
</MTProjectMetadataState>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-7
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CMakePythonSetting">
|
||||
<option name="pythonIntegrationState" value="YES" />
|
||||
</component>
|
||||
<component name="CMakeWorkspace" PROJECT_DIR="$PROJECT_DIR$" />
|
||||
</project>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/reasampler.iml" filepath="$PROJECT_DIR$/.idea/reasampler.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-2
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module classpath="CIDR" type="CPP_MODULE" version="4" />
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/vendor/WDL" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/vendor/reaper-sdk" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
+52
-1294
File diff suppressed because it is too large
Load Diff
-3258
File diff suppressed because it is too large
Load Diff
-1230
File diff suppressed because it is too large
Load Diff
-2779
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,619 +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.
|
||||
>
|
||||
> **Q-W0 SIGN-OFF: COMPLETE (Daniel, 2026-07-28).** The audit ran as four parallel tracks (T1 DSP,
|
||||
> T2 architecture, T3 env-coupled constants, T4 sizing/placement — **59 findings**; report
|
||||
> `docs/product/code-quality-audit.md`, appendices `docs/product/audit-notes/`), and **all 59
|
||||
> findings' dispositions are approved as proposed.** The Q-W1 sub-gate is satisfied **once the six
|
||||
> approved fix-now remediations land** (in flight on branch `pq-w0-fixes`, Q-W0-scoped): T1-01,
|
||||
> T1-03, T1-09, T2-01(a), T3-01, T3-03. The audit's §3 plan reshape is **folded into the waves
|
||||
> below** (Q-W2 6→8 seams; NEW wave **Q-W2v** parallel with Q-W2; Q-W3 3→4 hoists + riders; Q-W5
|
||||
> + the ext-state-loop dedupe), and its §4 decision list is settled — see the settlement block
|
||||
> below. The Q-11 question is answered by the audit: the SOLA pitch engine is **sound — no
|
||||
> technique replacement warranted**; every pitch finding is a bounded in-technique fix or a
|
||||
> documented operating limit.
|
||||
>
|
||||
> **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; **+
|
||||
> `Q-W2v`**, the VST god-module wave added at the Q-W0 sign-off, 2026-07-28).
|
||||
> **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).**
|
||||
>
|
||||
> **SETTLED (Daniel, 2026-07-28 — with the Q-W0 sign-off; the REC record above kept as history):**
|
||||
> **Q-5 SETTLED** — split to **seams-by-responsibility with the T4 seam lists adopted**
|
||||
> (`bank_panel` 6→8 seams adding `panel_layout` + `panel_drag`, T4-01; `capture_orchestrator`
|
||||
> further split with `capture_batch`, T4-02), and the **~600-line file ceiling is an acceptance
|
||||
> criterion on every split wave** — seams are the method, the ceiling is the bar; arbitrary
|
||||
> bisection to hit the number is rejected. **Q-6 SETTLED: in scope, last wave, as planned.**
|
||||
> **Q-8 SETTLED: both renames** — `BankIndex`→`BankModel` (W1) and the JSON parser minted as
|
||||
> `json::Reader`/`json::Writer` (W1); additionally from the audit, **`ICaptureBackend` is deleted
|
||||
> in Q-W3** (T4-26 — one deriver, zero polymorphic call sites; the CLAUDE.md/CONTEXT "two
|
||||
> backends behind one interface" correction **rides Q-W3 itself**, recorded as a rider — the docs
|
||||
> are not edited before that wave). **Q-9 SETTLED: yes** — align to stem `capture_realtime`,
|
||||
> shell suffixed, during W3. **VST placement (audit §4a) SETTLED: T4-18** — `src/vst/` integrates
|
||||
> into the single `core/`/`shell/` top split as `core/instrument/{engine,map,ui}` +
|
||||
> `shell/instrument/` (Q-3 directory map updated in CONTEXT.md §Phase Q). **WAV/RIFF
|
||||
> consolidation (audit §4e) SETTLED:** a named rider on **Q-W3** — one pure **`wav_codec`** owner
|
||||
> (walker + layout + build + patch), absorbing the T4-10 ingest extraction. **Q-W2v scheduling
|
||||
> (audit §4f) SETTLED: parallel with Q-W2** (different artifact, zero file overlap; the serial
|
||||
> "Q-W7" alternative set aside).
|
||||
>
|
||||
> **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.*
|
||||
>
|
||||
> **STRUCTURAL HEURISTICS (Daniel, 2026-07-28 — acceptance criteria on every wave; these
|
||||
> *generalize* the three-hot-path guardrail above, they do not replace it):**
|
||||
> (1) **More directories is a must, more files is good, ~600-line file ceiling** — SRP applies to
|
||||
> namespaces, encapsulation, and file organization alike. The ceiling is the *bar*, the audit's
|
||||
> named seams are the *method*: a file landing over ~600 needs a responsibility seam, not an
|
||||
> arbitrary bisection; a documented hot-path exception (`sampler_core.cpp`, T4-14/T4-27) is
|
||||
> legitimate, silent overshoot is not.
|
||||
> (2) **Templates are the right tool for compile-time dedup — use them where earned** (the LE
|
||||
> byte codec `putLE`/`readLE`, T4-20), not for name-only unification (the rect family is one
|
||||
> **concrete** `ui::Rect`, NOT a template — T4-21's ruling).
|
||||
> (3) **SOLID is great but saved CPU is better** — no dispatch-stack blowouts *anywhere*, not
|
||||
> just the three named hot paths; prefer static polymorphism where types are compile-time-known
|
||||
> (T4-27's warning is the canonical case: a by-class `sampler_core` split would put virtual
|
||||
> envelope `tick()`s on the per-voice-per-sample path).
|
||||
>
|
||||
> **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/W2v–W5 god-module splits → W6 OCP finish).
|
||||
>
|
||||
> **PHASE STATUS (2026-07-29): all seven waves (Q-W0..Q-W6 incl. Q-W2v) are structurally
|
||||
> COMPLETE.** Remaining before the phase closes and merges to `dev`: (1) Daniel's in-DAW
|
||||
> verification batch — the full deferred list across all waves (panel parity, editor/processor
|
||||
> parity, stereo Preserve listening, null test, bit-identical repeats, capture flows, action
|
||||
> families, one-op-one-Ctrl-Z, prune fail-safes, save/load/relocation) — now unblocked since the
|
||||
> tree is stable; (2) the phase-close CLAUDE.md architecture refresh (module map still describes
|
||||
> some pre-Q homes); (3) the phase-q → dev merge on Daniel's sign-off. See `COMPLETED.md` for
|
||||
> each wave's full landed narrative.
|
||||
|
||||
## Q-W0 — pre-restructure functional + DSP quality audit (runs FIRST; gates Q-W1)
|
||||
**STATUS (2026-07-29): audit COMPLETE, triage COMPLETE, sign-off COMPLETE, fix-now
|
||||
remediations LANDED — Q-W0 is fully closed.** The findings report is committed
|
||||
(`docs/product/code-quality-audit.md`; track appendices in `docs/product/audit-notes/` — T1
|
||||
DSP, T2 architecture, T3 env-constants, T4 sizing/placement; 59 findings). Daniel approved
|
||||
every disposition 2026-07-28. The six approved fix-now remediations plus seven review riders
|
||||
landed 2026-07-28 (merge `546927e`) — see `COMPLETED.md`. **Q-W1 has since landed on top of
|
||||
this closure** (see `COMPLETED.md`).
|
||||
**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. **DONE (Daniel, 2026-07-28): all 59 dispositions approved as
|
||||
proposed; the §3 plan reshape and §4 decisions are folded into Q-W1..Q-W6 + Q-W2v below.**
|
||||
|
||||
## Q-W1 — safe opener: extract `core/json` + impose the directory/namespace layout on clean modules
|
||||
|
||||
> **Landed on `phase-q` (2026-07-29). Integrated suite 60/60 green.** `core/json`
|
||||
> (`json::Reader`/`json::Writer`) extracted; the five hand-rolled JSON decoders (incl.
|
||||
> `tail_control`'s) deleted; the wire-`Cursor` family collapsed into `core/wire`; the shared
|
||||
> `readFileBytes` helper added; ~50 clean modules relocated into `core/{model,view,capture,
|
||||
> audio,ui,reclaim,version,json,util,wire}/`, `core/instrument/{engine,map,ui}/`,
|
||||
> `shell/{capture,panel,view,persist,actions,instrument}/`, `app/main.cpp`; sub-namespaces
|
||||
> applied; one concrete `ui::Rect` + aliases (LTRB fork + `footer_bar` NAME NOTE retired);
|
||||
> `slot_map` extracted from `bank_book`; `clamp01` deduped; `BankIndex`→`BankModel`;
|
||||
> `reasampler_uid.h` relocated to `core/wire/`. See `COMPLETED.md` for the full narrative.
|
||||
>
|
||||
> **Skipped/deferred riders:** T4-22 (`hitIndex` hit-test template) — not trivial, deferred as
|
||||
> an opportunistic follow-on once the rect unification is in use downstream; T4-06
|
||||
> (`view_mode_model` planner split) — optional, deferred; T4-09 (`view_lanes` split) —
|
||||
> deferred (in scope only if a later wave touches `view.cpp` anyway).
|
||||
>
|
||||
> **Open residual — `bank_book.cpp` still 737 LOC.** The serialize/deserialize seam is
|
||||
> identified but blocked on a `nameKey` linkage design decision, escalated to Daniel and
|
||||
> **pending** as of 2026-07-29. Downstream waves touching `bank_book` should check this
|
||||
> residual before assuming the split is finished.
|
||||
>
|
||||
> An interim `core/namespaces.h` shim covers the six not-yet-split god TUs; each downstream
|
||||
> split wave (Q-W2 onward) retires its own includes of it as that module splits.
|
||||
|
||||
## Q-W2 — split `bank_panel.cpp` (the biggest extension god-module — 3459 LOC at the Q-W0 census)
|
||||
|
||||
> **Landed on `phase-q` (2026-07-29, merge of `pq-w2-panel`). Integrated suite 61/61 green,
|
||||
> reviewed-approved.** `bank_panel.cpp` (3459 LOC) split into eight TUs under `shell/panel/`:
|
||||
> `panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` /
|
||||
> `panel_window` / `panel_layout` / `panel_drag`, plus per-seam public headers and internal
|
||||
> `panel_state.h`; audition stays a direct call-through; the one-bank-op-one-undo invariant is
|
||||
> preserved; ~50 TU-private helpers wrapped in anonymous namespaces (a review follow-up). See
|
||||
> `COMPLETED.md` for the full narrative.
|
||||
>
|
||||
> **Recorded ceiling overages (reviewer-endorsed, preserved as a durable record per CONTEXT's
|
||||
> "silent overshoot is not legitimate" rule):** `panel_input.cpp` 636, `panel_render.cpp` 613,
|
||||
> `panel_state.h` 608 — the overage is comment volume; non-comment lines are ~322–369 per file;
|
||||
> no honest seam remains; bisection was rejected.
|
||||
>
|
||||
> **Review note for the Q-W4 planning record:** `panel_bank_ops`'s verbs still embed
|
||||
> prompts/panel-state nudges — Q-W4's dedupe needs promptless inner verbs (`renameBank(id,name)`
|
||||
> etc.), not a call-site swap; `promptText`/`mintBankId` are byte-identical twins with
|
||||
> `actions.cpp` and are the cheapest first dedupe.
|
||||
>
|
||||
> **In-DAW verification (panel parity) is PENDING on `phase-q`** — deferred by design, not yet
|
||||
> performed.
|
||||
|
||||
## Q-W2v — split the VST god-modules (NEW wave — Q-W0 T4 §1.5; runs parallel with Q-W2)
|
||||
|
||||
> **Landed on `phase-q` (2026-07-29, merge of `pq-w2v-vst`). Integrated suite 61/61 green,
|
||||
> reviewed-approved.** `reasampler_editor.cpp` (3084 LOC, the largest file in the repo) split
|
||||
> into eight face-axis TUs under `shell/instrument/`, with pure layout hoisted into
|
||||
> `core/instrument/ui/editor_geometry` (discharges T2-06, newly tested); `reasampler_processor.cpp`
|
||||
> split into `processor_state` / `processor_reload` / lifecycle+`process()` kept whole (no
|
||||
> virtual seam, T4-29); `sample_map` split into a resolution core + `component_state_io` codec
|
||||
> (the extension preset path no longer links the voice engine — link-proven; T4-13 ≡ T2-07);
|
||||
> `sampler_core.cpp` stays whole with the documented hot-path exception comment (T4-14/T4-27);
|
||||
> `zone_params.h` split out; `core/wire/bytes.h` (`putLE`/`ByteReader`) lands (T4-20);
|
||||
> `ThumbnailKey` adopted (T2-10); a golden full-blob v11 fixture pins the codec bytes. The
|
||||
> `src/vst/` directory is gone. See `COMPLETED.md` for the full narrative.
|
||||
>
|
||||
> **Deferred/known:** `component_state_io.h` still includes `sample_map.h`→`sampler_core.h`
|
||||
> transitively (T2-07's header half — future work); the `engine` namespace is deferred
|
||||
> (`sampler_core` stays flat `reasampler`); capture-side LE rewires are left for the capture
|
||||
> family.
|
||||
>
|
||||
> **In-DAW verification (editor/processor parity) is PENDING on `phase-q`** — deferred by
|
||||
> design, not yet performed.
|
||||
|
||||
## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch)
|
||||
|
||||
> **Landed on `phase-q` (2026-07-29, merge of `pq-w3-main`). Integrated suite 61/61 green,
|
||||
> reviewed-approved.** `app/main.cpp` reduced 1897 → 653 LOC (pointers + entry + dispatch; the
|
||||
> remaining bulk is the registration residue Q-W6 dissolves) via four hoists into
|
||||
> `shell/capture/`: `capture_orchestrator`, `capture_batch`, `scope_resolve`,
|
||||
> `realtime_lifecycle`; `FxBypassGuard` moved intact as a stack RAII object; the realtime idle
|
||||
> tick stays a single pointer test; `ICaptureBackend` deleted (T4-26) with the
|
||||
> CLAUDE.md/CONTEXT-ARCHIVE corrections landed in the same commit; the Q-9 rename done (pure
|
||||
> `core/capture/capture_realtime`, shell `capture_realtime_shell` + `capture_realtime_finalize`
|
||||
> split, T4-08); `stampCaptureSample` dedupe (T2-09, divergent time-sig behavior preserved via
|
||||
> caller arg); `makeUniqueTag` gains a per-session monotonic counter (T1-11 behavior fix — stems
|
||||
> now `<epoch>-<n>` / `rt-<epoch>-<n>`; the per-process residual is documented in-code); one pure
|
||||
> `wav_codec` RIFF owner absorbs `wav_trim` + `ingest`'s WAV build + content hashes, with golden
|
||||
> hash literals pinned (`wav_codec_tests` replaces `wav_trim_tests`; `capture_realtime_tests`
|
||||
> replaces `realtime_record_tests`). See `COMPLETED.md` for the full narrative.
|
||||
>
|
||||
> **Known open:** `wav_trim.h`'s transitional forwarding shim still has three live includers
|
||||
> (`sample_map.h`, `editor_session.cpp`, `processor_reload.cpp`) — repoint-and-retire is a named
|
||||
> follow-up; `ingest.cpp` is trimmed to 567 LOC but keeps the `namespaces.h` shim (`ingest` +
|
||||
> `view` remain the shim's unowned consumers).
|
||||
>
|
||||
> **In-DAW verification (null test, bit-identical repeats) is PENDING on `phase-q`** — deferred
|
||||
> by design, not yet performed.
|
||||
|
||||
## Q-W4 — split `actions.cpp` + dedupe bank verbs against `panel_bank_ops`
|
||||
|
||||
> **Landed on `phase-q` (2026-07-29, merge of `pq-w4-actions`). Integrated suite 61/61 green,
|
||||
> reviewed-approved.** `actions.cpp` (1019 LOC) split into `design_view_actions` / `bank_actions`
|
||||
> / `prune_action`, plus a fourth shared `action_registry` TU, all under `shell/actions/`;
|
||||
> `promptText`/`mintBankId` deduped against `panel_bank_ops`; bank verbs reshaped to promptless
|
||||
> inner verbs (one mutation home, two UX skins — panel and actions each keep their exact prior
|
||||
> UX); command-id suffixes/display phrases verified byte-identical in review; `prune_action`
|
||||
> stays a clean deletion-authority isolate (no `Undo_*`, no ext-state writes);
|
||||
> `persistBankOp`/`persistBook` gain null-session guards; `promptText` renamed `promptBankName`.
|
||||
> See `COMPLETED.md` for the full narrative.
|
||||
>
|
||||
> **Review note (🟡, resolved in Q-W6):** two session pointers / a null-session-as-model-rejection
|
||||
> misreport (unreachable today) was resolved by Q-W6's `bank_ops` lift.
|
||||
>
|
||||
> **In-DAW verification (action families, one-op-one-Ctrl-Z, prune fail-safes) is PENDING on
|
||||
> `phase-q`** — deferred by design, not yet performed.
|
||||
|
||||
## Q-W5 — split `persist.cpp` (isolate the single file-deletion authority into `prune_fs`)
|
||||
|
||||
> **Landed on `phase-q` (2026-07-29, merge of `pq-w5-persist`). Integrated suite 61/61 green,
|
||||
> reviewed-approved.** `persist.cpp` (853 LOC) split into `session` / `ext_state_io` / `prune_fs`
|
||||
> under `shell/persist/` + `persist_internal.h`; the file-deletion authority is concentrated —
|
||||
> `SHFileOperationW`/orphan-remove lives in exactly one anonymous-namespace function in
|
||||
> `prune_fs.cpp`, verified tree-wide; the prune fail-safe chain stays byte-intact. T2-04's
|
||||
> `GetProjExtState` grow-loop is unified as a header-only template, with all three hand-rolled
|
||||
> copies rewired (`usage_scan`'s start cap raised 4KB→64KB, allocation-only, verified
|
||||
> equivalent). The Q-W1 `bank_book_json` residual lands via a private static `nameKey`
|
||||
> (Daniel-approved option a) — `bank_book.cpp` is now ~462 LOC. `persist.h` is kept as a compat
|
||||
> umbrella for parallel safety (retired in Q-W6); deletion-authority wording is scoped precisely
|
||||
> in headers; the grow-loop gains a defensive NUL. See `COMPLETED.md` for the full narrative.
|
||||
>
|
||||
> **In-DAW verification (save/load/undo-reload, ext-state round-trip, folder relocation, prune
|
||||
> deletion) is PENDING on `phase-q`** — deferred by design, not yet performed.
|
||||
|
||||
## Q-W6 — OCP registration-table + residual fat-header (I) splits
|
||||
|
||||
> **Landed on `phase-q` (2026-07-29). Integrated suite 61/61 green, reviewed-approved.** Action
|
||||
> registration/gaccel/hookcommand-dispatch/mirror-unregister all iterate one `ActionTableRow`
|
||||
> table (flat function-pointer dispatch, no `std::function`/virtual); adding a new action now
|
||||
> touches one table row only; `main.cpp` shrinks 653→404. FOREVER-STABLE suffixes/phrases/
|
||||
> retired-ids verified byte-identical row-by-row in review; capture rows derive their
|
||||
> suffix+phrase from the pure `captureActionTable()` (the parallel-list risk is gone by
|
||||
> construction). See `COMPLETED.md` for the full narrative.
|
||||
>
|
||||
> **Phase-end cleanup riders (landed in this wave):** `bankOp*` verbs + `persistBankOp` lifted to
|
||||
> new `shell/bank_ops` taking `ReaSamplerSession&` (dissolves the Q-W4 🟡 review note);
|
||||
> `persist.h` umbrella retired (13 callers repointed); `capture.h`'s realtime seam moved to
|
||||
> `capture_realtime_shell.h`; the `wav_trim.h` shim + its INTERFACE target deleted;
|
||||
> **`core/namespaces.h` DELETED** (the interim Q-W1 shim's contract fulfilled — ~26 includers
|
||||
> rewired); the grow-loop rehomed to `core/wire/ext_state_read.h`; a stale-comment sweep
|
||||
> (`persist.cpp`/`bank_panel.cpp` refs); CLAUDE.md's persist/bank_book/actions/wav_codec bullets
|
||||
> corrected in-wave.
|
||||
>
|
||||
> **Review-noted follow-on (not landed, deferred):** extending the table pattern to the
|
||||
> design_view/bank/ingest families' hand-registration; `channelIdFor`'s shared string-store scan
|
||||
> is correct-by-prefix-disjointness — a suffix-keyed map would make it structural, but isn't
|
||||
> required; `view_mode_model.h` (748 LOC) remains the largest header (T4-06's planner split
|
||||
> stays optional/deferred).
|
||||
>
|
||||
> **In-DAW verification (all action families, registration/fire/unregister parity) is PENDING on
|
||||
> `phase-q`** — deferred by design, not yet performed.
|
||||
|
||||
## 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 (audit + triage + report — COMPLETE; all 59 dispositions signed off 2026-07-28;
|
||||
│ fix-now remediations LANDED 2026-07-28)
|
||||
▼
|
||||
Q-W1 (safe opener: core/json ×5 + wire codec + rect unification + relocation incl. ~20 VST
|
||||
│ pure libs under core/instrument/{engine,map,ui} + riders — LANDED 2026-07-29)
|
||||
├─► Q-W2 (split bank_panel — 8 seams — LANDED 2026-07-29)
|
||||
│ └─► Q-W4 (split actions + dedupe vs panel_bank_ops — LANDED 2026-07-29)
|
||||
├─► Q-W2v (VST god-modules — editor 8 TUs / processor 3 TUs / component_state_io;
|
||||
│ sampler_core TU whole — documented exception — LANDED 2026-07-29)
|
||||
│ [parallel with Q-W2: zero overlap]
|
||||
├─► Q-W3 (split main — 4 hoists incl. capture_batch; + wav_codec, ICaptureBackend deletion,
|
||||
│ stamp dedupe, T1-11, capture_realtime_finalize — LANDED 2026-07-29)
|
||||
│ └─► Q-W6 (OCP registration-table — LANDED 2026-07-29)
|
||||
└─► Q-W5 (split persist; + ext-state-loop dedupe — LANDED 2026-07-29) [best after Q-W4]
|
||||
|
||||
STATUS (2026-07-29): all seven waves (Q-W0..Q-W6 incl. Q-W2v) structurally COMPLETE, 61/61
|
||||
integrated suite green. Remaining: Daniel's in-DAW verification batch, the phase-close
|
||||
CLAUDE.md architecture refresh, and the phase-q → dev merge on sign-off.
|
||||
```
|
||||
Q-W0 ran and closed 2026-07-28 (its six fix-now remediations landed the same day). W1 was the
|
||||
safe, high-leverage structural opener (all later waves assumed the layout — including the T4-18
|
||||
`instrument/` placement — it establishes). The god-module splits (W2, W2v, W3, W5) were
|
||||
risk-ordered and mostly parallel-safe; **Q-W2v ran parallel with Q-W2** (different artifact, zero
|
||||
file overlap — audit §4f SETTLED); W4 depended on W2's `panel_bank_ops`, W6 depended on W3's
|
||||
isolated registration code. Big-bang was rejected — every wave landed independently,
|
||||
CTest-green throughout. **All seven waves landed on `phase-q` by 2026-07-29 — Phase Q is
|
||||
structurally complete** (see the phase preamble's PHASE STATUS block for what remains before the
|
||||
phase closes and merges to `dev`).
|
||||
|
||||
## 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`.) **Status 2026-07-28: triage + sign-off
|
||||
COMPLETE (all 59 dispositions); the sub-gate closes when the six fix-now remediations land
|
||||
(`pq-w0-fixes`).**
|
||||
- **~600-line ceiling on every split wave** — every TU a split wave ships lands under ~600 LOC,
|
||||
with `sampler_core.cpp` the single documented exception (T4-14/T4-27). Seams are the method,
|
||||
the ceiling is the bar; arbitrary bisection to hit the number is rejected (Q-5 settlement,
|
||||
2026-07-28).
|
||||
- **No dispatch-stack blowouts anywhere** — heuristic (3) generalizes the hot-path guardrail
|
||||
beyond the three named paths: prefer static polymorphism where types are compile-time-known;
|
||||
templates only where earned for compile-time dedup (T4-20 yes; T4-21's rect NO-template
|
||||
ruling).
|
||||
- **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
|
||||
|
||||
A native C++ REAPER extension that captures any arbitrary audio source into a
|
||||
per-project **sample bank** (cached files + a docked grid), decoupled from the
|
||||
arrange view, with keyboard/MIDI-bindable capture and placement. Built as a
|
||||
precision tool: deterministic, non-destructive, no clutter.
|
||||
Version 1.6.0 · License: GNU AGPL v3 (see `LICENSE`)
|
||||
|
||||
A per-project audio sample-bank capture tool for REAPER, built as two artifacts: a
|
||||
native C++ REAPER extension (`reaper_reasampler`) and a Windows-only VST3 sampler
|
||||
instrument (`reasampler_9000.vst3`, ReaSampler 9000). It captures any arbitrary audio
|
||||
source into a per-project **sample bank** (cached files + a docked grid), decoupled
|
||||
from the arrange view, with keyboard/MIDI-bindable capture and placement. It is
|
||||
designed as a precision tool: deterministic captures, non-destructive by
|
||||
construction, no auto-inserted clutter.
|
||||
|
||||
## Status
|
||||
|
||||
Under active development, not yet formally released.
|
||||
|
||||
## The two artifacts
|
||||
|
||||
**`reaper_reasampler`** — the REAPER extension. Captures audio into a named,
|
||||
per-project sample bank, displays it in a docked panel, and provides bindable
|
||||
actions for capture and placement.
|
||||
per-project sample bank and provides bindable actions across the workflow: offline
|
||||
and realtime capture (with cancel), batch capture (per selected item or per razor
|
||||
area), recapture-from-source, capture-and-assign to a live instrument instance,
|
||||
insert with an opt-in tempo-conform variant, the resample-bake landing action,
|
||||
multi-bank management (pool, activate, evacuate, move, copy, delete), prune/reclaim,
|
||||
provenance and lineage tracking, Design View, and ingest via drag-out, arrange-drop,
|
||||
and instrument-drop. All of it surfaces in a docked bank panel.
|
||||
|
||||
**ReaSampler 9000** (`reasampler_9000.vst3`) — a Windows-only VST3 sampler
|
||||
instrument that plays bank captures back across a MIDI keyboard. The VST3 target
|
||||
(`reasampler_vst`) is gated on the vendored `vendor/vst3sdk` slice; configure
|
||||
quietly omits it if the slice is absent.
|
||||
instrument that plays bank captures back across a MIDI keyboard. Since
|
||||
`ComponentState` v10 it is self-contained: it decodes samples from its own
|
||||
persisted references and plays with the extension absent, treating the bank as a
|
||||
browsing source rather than a runtime dependency. The VST3 target
|
||||
(`reasampler_vst`) is gated on `WIN32 AND EXISTS .../pluginfactory.cpp` — omitted
|
||||
on macOS/Linux even when the vendored `vendor/vst3sdk` slice is present, and
|
||||
quietly omitted anywhere the slice itself is absent.
|
||||
|
||||
A **beta channel** build is available via `-DREASAMPLER_CHANNEL=beta` at configure
|
||||
time, producing `reaper_reasampler_beta` and `reasampler_9000_beta.vst3`. The two
|
||||
@@ -27,8 +45,35 @@ and adds an index entry. It **never** puts an item in the arrange view. Placemen
|
||||
is a distinct, on-demand action. Any code path that auto-inserts a capture into the
|
||||
timeline violates the purpose of the tool.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- CMake ≥ 3.19
|
||||
- A C++17 compiler
|
||||
- Windows + MSVC to build the VST3 target (`reasampler_vst`) — the REAPER extension
|
||||
itself is cross-platform
|
||||
- PHP, to run the SWELL resource-generation step on macOS/Linux (below)
|
||||
|
||||
## Platform support
|
||||
|
||||
The REAPER extension targets Windows, macOS, and Linux; ReaSampler 9000 (the VST3
|
||||
instrument) is Windows-only.
|
||||
|
||||
| Platform | Extension | VST3 instrument |
|
||||
|---|---|---|
|
||||
| Windows | Builds with no extra steps | Builds when `vendor/vst3sdk` is present |
|
||||
| macOS | Builds, but dialogs need the manual SWELL resgen step below plus hand-uncommenting the `APPLE` `target_sources` block in `src/app/CMakeLists.txt` | Not built (Windows-only gate) |
|
||||
| Linux | Same manual resgen + hand-uncomment requirement as macOS, against its own commented block | Not built (Windows-only gate) |
|
||||
|
||||
A macOS/Linux build that skips the resgen-and-uncomment step compiles cleanly
|
||||
without dialogs and without a warning — this is expected, not a bug.
|
||||
|
||||
## One-time setup
|
||||
|
||||
A plain `git clone --recursive` also works, but it pulls every nested submodule of
|
||||
`vendor/vst3sdk` (including `vstgui4`, `tutorials`, `doc`, `cmake` — none of which
|
||||
this project links against). The steps below pull only the three submodules
|
||||
actually needed:
|
||||
|
||||
git submodule update --init
|
||||
|
||||
Vendors three submodules:
|
||||
@@ -43,10 +88,25 @@ Vendors three submodules:
|
||||
|
||||
cmake -B build -S .
|
||||
cmake --build build
|
||||
ctest --test-dir build
|
||||
ctest --test-dir build -C Debug
|
||||
|
||||
Pure modules have `<module>_tests` targets that run without REAPER or a DAW.
|
||||
`CMakeLists.txt` is the authoritative list of all targets.
|
||||
On a multi-config generator (Visual Studio, Xcode), `cmake --build build` with no
|
||||
`--config` builds **Debug** — nothing in this build sets `CMAKE_BUILD_TYPE` or an
|
||||
optimization flag, so that's the default. The `-C Debug` above is required on a
|
||||
multi-config generator too: without it, `ctest` silently reports every test as "Not
|
||||
Run" instead of running them. Single-config generators (Ninja, Make) need neither
|
||||
flag.
|
||||
|
||||
Pure `core/` modules each have a corresponding `<module>_tests` executable target
|
||||
that runs without REAPER or a DAW. Targets are declared per-directory — each
|
||||
`src/**/CMakeLists.txt` owns its own libraries and test targets, pulled in via
|
||||
`add_subdirectory` from the root `CMakeLists.txt`, which itself declares no targets
|
||||
directly.
|
||||
|
||||
Installing or judging performance requires the Release config explicitly:
|
||||
|
||||
cmake --build build --config Release
|
||||
ctest --test-dir build -C Release
|
||||
|
||||
### Beta channel
|
||||
|
||||
@@ -60,33 +120,59 @@ Pure modules have `<module>_tests` targets that run without REAPER or a DAW.
|
||||
php vendor/WDL/WDL/swell/swell_resgen.php src/resource.rc # macOS; Linux reuses the output
|
||||
|
||||
Add the generated file to the appropriate `APPLE` / Linux `target_sources` block in
|
||||
CMakeLists.txt. The SWS extension build is the canonical reference for this step.
|
||||
`src/app/CMakeLists.txt` (both are commented out by default). The SWS extension
|
||||
build is the canonical reference for this step.
|
||||
|
||||
## Install
|
||||
|
||||
Copy the built binary into REAPER's `UserPlugins/` folder
|
||||
(Options → Show REAPER resource path), then **restart REAPER**. Extensions load at
|
||||
startup only; there is no hot reload.
|
||||
There is no hot reload; REAPER loads extensions at startup only.
|
||||
|
||||
**Extension** — copy the **Release** build's `reaper_reasampler` binary
|
||||
(`build/Release/` on a multi-config generator — not the default `Debug/` output)
|
||||
into REAPER's `UserPlugins/` folder (Options → Show REAPER resource path), then
|
||||
restart REAPER.
|
||||
|
||||
**VST3 instrument** — copy the **Release** build's `reasampler_9000.vst3` into the
|
||||
system VST3 folder (`C:\Program Files\Common Files\VST3` on Windows) — a different
|
||||
destination from the extension, not `UserPlugins/`. REAPER picks it up on its next
|
||||
plugin rescan.
|
||||
|
||||
## Repo layout
|
||||
|
||||
The codebase is organized around one discipline: **pure, REAPER-free testable core
|
||||
split from REAPER-facing shells**.
|
||||
The codebase is organized around one discipline: **pure, REAPER/VST3-SDK-free
|
||||
testable core, split from the REAPER- and VST3-facing shells that touch those host
|
||||
types.**
|
||||
|
||||
- `src/` — pure core modules (no REAPER types, unit-testable outside the DAW) and
|
||||
REAPER-facing shells (extension entry point, panel, actions, capture backends, etc.)
|
||||
- `src/vst/` — the VST3 instrument: pure voice engine + zone payload + editor UI
|
||||
pure modules, and the VST3 shells
|
||||
- `tests/` — unit tests for the pure core modules
|
||||
- `src/app/` — the REAPER extension's entry point (`main.cpp` only)
|
||||
- `src/core/` — pure modules, no REAPER or VST3 SDK types, each with a
|
||||
`<module>_tests` target: `audio/`, `capture/`, `instrument/` (further split into
|
||||
`bake/`, `engine/filter/`, `engine/loop/`, `map/`, `note/`, `ui/`), `json/`,
|
||||
`model/`, `reclaim/`, `tracking/`, `ui/`, `util/`, `version/`, `view/`, `wire/`
|
||||
- `src/shell/` — REAPER/VST3-facing shells: `actions/`, `bank_ops/`, `capture/`,
|
||||
`instrument/` (the ReaSampler 9000 VST3 shells), `panel/`, `persist/`, `view/`
|
||||
- `src/resource.rc`, `src/resource.h`, `src/ext_keys.h` — root-level build inputs
|
||||
not claimed by any one subdirectory
|
||||
- `tests/` — unit test sources for the pure `core/` modules
|
||||
- `cmake/` — shared CMake target-declaration helpers
|
||||
- `docs/` — plan-style docs and product-design docs (see Further reading, below)
|
||||
- `vendor/` — git submodules
|
||||
|
||||
See `CLAUDE.md` for the full module inventory, architectural contracts, and the
|
||||
precise boundary between pure core and REAPER-facing shells.
|
||||
precise boundary between pure core and REAPER/VST3-facing shells; each `src/**/`
|
||||
directory also carries its own `CLAUDE.md` with that area's own module list and
|
||||
invariants.
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3, copyright Daniel Harvey. See `LICENSE`.
|
||||
|
||||
## Further reading
|
||||
|
||||
- `CLAUDE.md` — architecture, module inventory, and build/API contracts
|
||||
- `CONTEXT.md` — the authoritative spec (large: ~186k — grep the relevant section rather than reading whole)
|
||||
- `CONTEXT-ARCHIVE.md` — build detail for landed work
|
||||
- `PLAN.md` — roadmap
|
||||
- `COMPLETED.md` — landed milestones
|
||||
- `docs/PLAN.md` — the active roadmap
|
||||
- `docs/COMPLETED.md` — landed milestones for the current (1.x) cycle
|
||||
- `docs/TODO.md` — deferred follow-ups, with the reason each was deferred
|
||||
- `docs/TODO-1.0.md` — the raw 1.x work list this cycle's plan was structured from
|
||||
- `docs/ARCHIVE.md` — pre-1.0 history
|
||||
- `docs/cmake-cheatsheet.md` — a standalone build-system reference
|
||||
- `docs/product/` — the product-design reasoning behind each phase
|
||||
|
||||
@@ -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,35 @@
|
||||
# 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()
|
||||
# A default-less switch missing an enumerator: MSVC's C4062 is off by its /W1 default;
|
||||
# GCC/Clang's -Wswitch is on by default but only warns without -Werror, and this repo
|
||||
# sets no -Wall/-Werror/-W4/-WX anywhere. Promoted to an error only here, on our own
|
||||
# pure libraries, so a deliberately default-less switch (e.g. deckParamCommit,
|
||||
# deck_groups.cpp) is a compile error on every toolchain. NOT C4061 (fires even with
|
||||
# a default: present) — that would light up every defensive switch in the tree.
|
||||
if(MSVC)
|
||||
target_compile_options(${name} PRIVATE /we4062)
|
||||
elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(${name} PRIVATE -Werror=switch)
|
||||
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
+1662
File diff suppressed because it is too large
Load Diff
+2303
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,14 @@
|
||||
# TODO-1.0
|
||||
|
||||
> **`docs/PLAN.md` is now the roadmap.** All seventeen items below have been
|
||||
> consolidated into areas and sequenced into the Phase → Wave → Track hierarchy in
|
||||
> `docs/PLAN.md`; that file is what implementation specialists are dispatched against,
|
||||
> and each of its tracks is self-sufficient for a brief. **This file is retained as the
|
||||
> verbatim-provenance spec appendix** — Daniel's raw asks and every answer round,
|
||||
> unedited, are the source of truth behind PLAN.md's compressed behavior bullets. Its
|
||||
> traceability table maps each item number below onto the track that owns it. Nothing in
|
||||
> this file changes as work lands; PLAN.md points move to `docs/COMPLETED.md`.
|
||||
|
||||
Post-1.0 queue for ReaSampler — chiefly the 9000 instrument, plus two
|
||||
extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering
|
||||
(2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day);
|
||||
+908
@@ -0,0 +1,908 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
|
||||
**RESOLVED — Γ-W2-T2 (`loop-crossfade-ux`), incidentally.** Giving every mark the cap-grip the crossfade already had is what closed it: the start marker now carries an 11x10 cap in the overlay's top strip, whose nominal area (110) is smaller than the node's fixed pick box (169), so the cap wins the coincident pixel and the marker is reachable again. No priority rule was added and `resolveWaveformClaim` is byte-for-byte unchanged — but the cap slot's own nominal area DID move, from the old clipped-actual measure (60 at frame 0) to the new nominal 110 every cap now feeds it (`markerHandleRect`'s own unclipped area). That move leaves the `cap < node < column` ordering unchanged only because 110 is still under the node's fixed 169 — the outcome held, not the area. Below the cap strip the node keeps the click, which is correct: that is where the node is actually drawn for any non-degenerate envelope. Pinned by `testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip` (`tests/test_spline_edit.cpp`). `Origin` was not touched and `isDraggable`'s shape rules are unchanged.
|
||||
|
||||
## 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.
|
||||
|
||||
## The editor's drag state machine has no seam, and `reasampler_editor.h` is near the ceiling
|
||||
|
||||
**Context (Γ-W3, meter re-review).** `reasampler_editor.h` stands at **564 lines** against the
|
||||
~600-line ceiling — 36 lines of margin — and it keeps growing because every new surface on the
|
||||
Sample face adds its transient state there. The obvious seam is the drag state machine: `drag_`
|
||||
plus the per-gesture anchors it is read against.
|
||||
|
||||
**Why it was declined rather than taken.** `drag_` has **42 references across 13 shell TUs**
|
||||
(measured over `src/shell/instrument/*.cpp`; the declaration in the header is additional). Of
|
||||
the six input TUs, three write it and branch on it (`editor_input`, `_waveform`, `_curve`) and
|
||||
three only write it (`_chrome`, `_browse`, `_deck`) — which is what makes the anchor invariant
|
||||
observed rather than enforced. Extracting it is a real refactor of the editor's input half, not
|
||||
a header move — and doing it inside a wave whose subject is the MASTER deck would have put an
|
||||
unrelated high-blast-radius change in the same diff. Declining was right; leaving it unrecorded
|
||||
was not.
|
||||
|
||||
**The shape a fix would take.** A `DragState` type owning the kind plus its anchor payload,
|
||||
with the input TUs mutating it through named transitions rather than assigning `drag_` and its
|
||||
anchors independently — which is also what would let the invariant "an anchor is only readable
|
||||
while its own `DragKind` is in flight" be enforced rather than observed. `editor_interaction.h`
|
||||
already holds the `DragKind` vocabulary and is the natural home.
|
||||
|
||||
**Priority / risk.** Low, but the margin is the clock: the next surface that adds two members to
|
||||
the header takes it over the ceiling, and at that point the seam gets chosen under time pressure
|
||||
by whoever is unlucky. Take it before that, not after.
|
||||
|
||||
**Done looks like.** `reasampler_editor.h` is back under the ceiling with room; no TU assigns
|
||||
`drag_` and an anchor as two independent writes; and the transitions are named where the
|
||||
`DragKind` catalogue already lives.
|
||||
|
||||
## `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.
|
||||
|
||||
**Re-confirmed still accurate (2026-08-02), after the mono-collapse landing touched
|
||||
`wav_codec` adjacent to this site.** `applyMonoCollapse` operates on the staged bytes in
|
||||
`bake_landing.cpp`'s `prepareLanding`, upstream of and unrelated to `runBake`'s
|
||||
`std::vector<double>` copy in `instrument_bake.cpp`; `buildFloat32Wav`'s signature is
|
||||
unchanged. The wart stands exactly as described above.
|
||||
|
||||
**Current blocker.** Not taken this wave because `instrument_bake.cpp` is being edited
|
||||
by a live VST3-parameter track.
|
||||
|
||||
**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 1198 × 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`'s `ingestRegisterActions` 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`
|
||||
(`test_render_settings.cpp`'s `testMultiTrackStemRenderIsNamedForRefusal`), 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.
|
||||
|
||||
## A 0-byte render can still pass every gate under Auto/Manual tail (closed)
|
||||
|
||||
**Context (surfaced by Ψ-W3 review).** `OfflineRenderBackend::capture`'s exists-check
|
||||
passes for a 0-byte file, and the bounds gate used to fire only when `expectedFrames > 0`
|
||||
— an invalid/empty layout read `expectedFrames == 0` and skipped the gate rather than
|
||||
refusing, so a 0-byte render reached `stampCaptureSample` and landed as
|
||||
`CaptureStatus::Ok` with an empty `contentHash` and `channelCount == 0`.
|
||||
|
||||
**Narrowed, then reopened as an asymmetry.** `shell/capture/render_bounds_gate` was
|
||||
first changed to refuse an unmeasurable render (invalid layout, or a layout declaring no
|
||||
sample rate) instead of skipping it — but that gate only ever judges `TailMode::None`,
|
||||
so a 0-byte render under Auto/Manual still landed as `Ok`, while `None` now refused and
|
||||
quarantined the identical file. The two tail modes disagreed on a defect neither should
|
||||
accept.
|
||||
|
||||
**Closed.** `capture.cpp` now checks `checkRenderedFileNotEmpty` right after the
|
||||
exists-check, on every tail mode, before the `TailMode::None`-only bounds gate runs —
|
||||
a 0-byte render is refused and quarantined identically regardless of tail mode. The
|
||||
refusal reuses `CaptureStatus::BoundsMismatch` rather than minting its own status; the
|
||||
earlier note here preferred a distinct status, and that preference is unresolved, not
|
||||
withdrawn.
|
||||
|
||||
## `renderHonoredBounds`'s one-frame tolerance is empirical, not proven
|
||||
|
||||
**Context.** The millisecond-floor defect that motivated this gate is closed
|
||||
(`docs/COMPLETED.md`), but the gate itself — `render_window.h`'s
|
||||
`renderHonoredBounds` — carries a one-frame tolerance that carried through the fix
|
||||
unchanged and was never itself proven.
|
||||
|
||||
**The wart.** A renderer that resolves the window's two edges by DIFFERENT
|
||||
conventions can sit two frames from `frameCountFor`'s answer on a
|
||||
correctly-honored render. That cannot account for the 8- and 38-frame shortfalls
|
||||
the floor produced (`docs/COMPLETED.md`), so it was not the cause of those
|
||||
refusals — but it means a future one- or two-frame refusal may be the gate's own
|
||||
edge convention rather than a real defect.
|
||||
|
||||
**Intended fix.** Not proposed. Widening the tolerance is a precision-invariant
|
||||
decision, not a bug fix, and was deliberately not taken on speculation.
|
||||
|
||||
**Priority / risk.** Low. Nothing to date implicates the tolerance itself;
|
||||
recorded so a future narrow refusal is investigated rather than assumed to be
|
||||
the same floor.
|
||||
|
||||
**Done looks like.** Either the tolerance is confirmed correct by a DAW
|
||||
observation that isolates edge-convention behavior from bounds-floor behavior,
|
||||
or it is widened with the reasoning recorded.
|
||||
|
||||
## `TailMode::Auto` and `Manual` have no automatic bounds observation
|
||||
|
||||
**Context.** `render_bounds_gate.cpp`'s `checkRenderedBounds` returns early for
|
||||
anything but `TailMode::None`, so the millisecond-floor fix (`docs/COMPLETED.md`)
|
||||
was measured only against `TailMode::None` — Auto and Manual were never
|
||||
observed, before the fix or after it.
|
||||
|
||||
**The wart.** The inference that Auto/Manual are fixed too is sound — same
|
||||
bounds path, same floor, same fix — but it is an inference, not a measurement.
|
||||
`checkRenderedFileNotEmpty` runs on every tail mode and still catches a 0-byte
|
||||
render, but that is the ONLY automatic bounds signal Auto/Manual get; a
|
||||
floored or otherwise short-but-nonzero render under either mode would land as
|
||||
`Ok` with nothing to catch it.
|
||||
|
||||
**Intended fix.** Not a code change — a DAW observation. `docs/VERIFICATION.md`'s
|
||||
"Capture range and bounds" section already carries the manual check: repeat an
|
||||
off-grid-start capture at Manual over a source loud to the window's end and
|
||||
check the landed frame count against window + `tailMs`; Auto can't be checked
|
||||
by count (it trims trailing silence) and needs the null test by ear/inversion
|
||||
instead.
|
||||
|
||||
**Priority / risk.** Low. Both modes share the same bounds path as the
|
||||
now-fixed `TailMode::None`, so nothing suggests they still floor — but nothing
|
||||
confirms it either.
|
||||
|
||||
**Done looks like.** A DAW-observed Auto and Manual capture, each landing the
|
||||
window as requested, closes the inference into fact — or surfaces a
|
||||
mode-specific divergence this entry does not currently know about.
|
||||
|
||||
## Floor, ceil and round are not the identity on a millisecond grid point in binary double (caution, not an open question)
|
||||
|
||||
A discarded compensation design for the millisecond-floor defect
|
||||
(`docs/COMPLETED.md`) rested on the premise that a grid-aligned value survives a
|
||||
bare floor/ceil/round unchanged. That is false in binary double: `1.007 * 1000
|
||||
== 1006.9999999999999` (floors to 1006, not 1007), and `4.068 * 1000 ==
|
||||
4067.9999999999995` (floors to 4067, not 4068). The compensation this premise
|
||||
would have supported is no longer needed — the fix moved the render to a bounds
|
||||
mode that does not floor at all — so this is not a live open question. Recorded
|
||||
because it would bite any future millisecond-grid arithmetic that assumes an
|
||||
on-grid value is safe from a bare floor: `render_window.h`'s own
|
||||
`isOnMillisecondGrid`/`msFlooredEndFrameCount` already carry the nanosecond
|
||||
tolerance that handles it correctly on this codebase's side of the boundary; the
|
||||
trap is for whoever writes the next piece of grid arithmetic without that guard.
|
||||
|
||||
## `capture.cpp` is over the ~600-line ceiling — the seam is identified, taking it is blocked
|
||||
|
||||
**Context.** Removing the settled bounds experiment's instrumentation (the console
|
||||
verdict and the three-checkpoint `RENDER_STARTPOS`/`ENDPOS` read-back) brought the file
|
||||
from 697 to **620 measured lines**, against root `CLAUDE.md`'s ~600-line ceiling. The
|
||||
seam that entry originally named is gone with the instrumentation; nothing left in the
|
||||
file is bisectable without cutting load-bearing why.
|
||||
|
||||
**The remaining seam is a real responsibility boundary**, and the file header already
|
||||
names it as two things: `OfflineRenderBackend::capture` (the offline render driver)
|
||||
versus the four helpers BOTH backends share — `makeUniqueTag`, `captureNameFor`,
|
||||
`collapseCapturedFileToMono`, `stampCaptureSample` — consumed by `capture_batch`,
|
||||
`capture_orchestrator`, `capture_realtime_shell`, `capture_realtime_finalize` and
|
||||
`render_in_place`. Lifting those four into their own TU takes the driver under the
|
||||
ceiling and gives the cross-backend steps their own home.
|
||||
|
||||
**Why not taken.** `src/shell/capture/` has no `CMakeLists.txt` of its own — its sources
|
||||
are listed in `src/app/CMakeLists.txt`, so a new TU needs an edit there. Forcing the
|
||||
four helpers into an existing TU instead (orchestrator, realtime finalize) would put
|
||||
them in a wrong home to dodge one build-file line, which is worse than the overshoot.
|
||||
|
||||
## bext TimeReference read-back is not a floor detector (dead end, recorded so it is not re-litigated)
|
||||
|
||||
Idea considered and dropped: read a captured file's `BWF:TimeReference` tag back as
|
||||
independent evidence on the START-edge millisecond-floor question above. `WDL/metadata.h`'s
|
||||
`WriteMetadataPrefPos` only writes it past its `prefpos > 0.0` guard (`:1301`) — that guard
|
||||
alone is enough to rule the approach out. One nuance worth recording separately: the
|
||||
millisecond quantization at `:1382-1383` (`AddMexMetadata`'s `ParseUInt64(val)/1000.0`)
|
||||
belongs to the MEX caller, not proven to be `WriteMetadataPrefPos`'s own behavior or the
|
||||
renderer's direct call into it — so even without the guard, a floored bext tag would show
|
||||
that MEX quantizes, not that the render engine does.
|
||||
|
||||
## Split `render_bounds_gate` on the verdict/message vs. filesystem seam
|
||||
|
||||
**Context (Ψ-W3 round-two review).** `render_bounds_gate.cpp` mixes pure verdict
|
||||
composition (frame-count comparison, message text) with filesystem I/O
|
||||
(`retainRefusedRender`'s `fs::create_directories`/`fs::rename`) in one shell TU. The
|
||||
verdict half has no REAPER dependency and no filesystem dependency either — it could be
|
||||
`core/capture`, unit-tested directly instead of only through the pure `render_window`
|
||||
functions it calls. The reviewer's suggested split: verdict + message composition pure
|
||||
and testable in `core/capture`, leaving only `retainRefusedRender` (and the two thin
|
||||
`checkRendered*` entry points that call it) in `shell/capture`.
|
||||
|
||||
**Why deferred.** Out of scope for the dispatch that surfaced it — a structural split,
|
||||
not the bug fix in front of it.
|
||||
|
||||
**Filed also because it's already slightly wrong today.** `render_bounds_gate.cpp`
|
||||
touches no REAPER API (it is `<filesystem>` + the pure `core/capture` modules only), so
|
||||
`src/shell/capture/CLAUDE.md`'s "this directory is the REAPER API surface only" scope
|
||||
line no longer describes it — one more small argument for eventually moving the
|
||||
REAPER-free half to `core/capture`, separate from the untested-filesystem-code gap
|
||||
above.
|
||||
|
||||
**Done looks like.** `core/capture` owns a pure `checkRenderedBoundsVerdict`-shaped
|
||||
function under a `<module>_tests` target with no REAPER, no VST3 SDK, and no
|
||||
filesystem includes; `shell/capture/render_bounds_gate` shrinks to the file-move and
|
||||
the two callers' plumbing.
|
||||
|
||||
## The capture path ignores `saveToActiveProject`'s return at four sites
|
||||
|
||||
**Context.** `saveToActiveProject()` returns false for exactly two reasons — no active
|
||||
project, or an unsaved one — and in both cases NOTHING was written. Four capture sites
|
||||
discard that return outright: `capture_orchestrator.cpp`'s `RunCapture`, `capture_batch.cpp`'s
|
||||
`RunBatchCaptureItems` and `RunBatchCaptureRazor`, and `realtime_lifecycle.cpp`'s `CommitRealtimeResult`.
|
||||
|
||||
**The wart.** A capture on an unsaved project renders the file into the bank folder, adds
|
||||
the `Sample` to the in-memory book, records a birth record in memory — and loses all three
|
||||
on reload. The bytes stay on disk with no index entry and no persisted ledger record, so
|
||||
they are a foreign file prune will never reclaim (an unrecorded file is untouchable by
|
||||
design — `core/tracking/CLAUDE.md`). Nothing is printed. The bank-op family already reads
|
||||
this return and discards its undo point on a false; the capture family does not read it at
|
||||
all.
|
||||
|
||||
**Why filed, not fixed.** Pre-existing, and the right answer is a product decision this
|
||||
dispatch had no mandate for: refuse the capture up front, keep it and warn, or prompt for
|
||||
a Save-As (the bank ops chose "quiet persist by design, deliberately no Save-As prompt" —
|
||||
whether capture should follow is a separate call).
|
||||
|
||||
**Done looks like.** A capture attempted with no saved project either does not write bytes
|
||||
at all, or writes them and says so in a sentence naming what will not survive a reload —
|
||||
and the choice between those two is recorded rather than implicit.
|
||||
|
||||
## `panel_input`'s wheel handler persists the whole book per wheel message
|
||||
|
||||
**Context.** `panel_input.cpp` — `handleWheel` calls `markTailDirty()` on every wheel
|
||||
message that actually moves `manualMs`, while the pointer is over the footer in Manual
|
||||
mode. (It coalesces sub-notch deltas within ONE message and no-ops at a bound, so the
|
||||
count is wheel messages that changed the value, not raw notches.)
|
||||
|
||||
**The wart.** `markTailDirty` is `saveToActiveProject()` — a full `BankBook` serialize plus
|
||||
six ext-state value writes on the UI thread — for a setting that is one number. A flick
|
||||
over the footer is a dozen of them in a few hundred milliseconds. Disproportionate rather
|
||||
than incorrect: no guardrail is violated (this is nowhere near the two named hot paths),
|
||||
and the writes are idempotent.
|
||||
|
||||
**Intended fix.** Coalesce: mark dirty and let one timer tick flush, the same shape the
|
||||
panel already uses elsewhere for repaint batching.
|
||||
|
||||
**Done looks like.** A continuous wheel gesture over the footer produces one persist, and
|
||||
the value that lands is the gesture's final one.
|
||||
|
||||
## `RunCaptureItemAssign`'s undo point does not follow the pattern its comment claims
|
||||
|
||||
**Context.** `capture_orchestrator.cpp`'s `RunCaptureItemAssign` states that the action follows
|
||||
the bank-op family's discard-on-unsaved pattern.
|
||||
|
||||
**The wart.** It does not: `RunCaptureItemAssign` records the undo point unconditionally whenever
|
||||
`sampleId` is non-empty, and never consults the persist's return at all. So on an unsaved
|
||||
project it records an undo point for ext-state that was never written — the empty
|
||||
no-effect entry `persistBankOp`'s guardrail exists to avoid. The comment describes the
|
||||
intended behavior, not the code.
|
||||
|
||||
**Why filed, not fixed.** It is one instance of the capture-family gap filed above, and
|
||||
fixing it alone would leave the other four sites divergent. Fix them together, or explain
|
||||
in one place why capture differs from bank ops.
|
||||
|
||||
**Done looks like.** The comment and the code agree, and the whole capture family answers
|
||||
the unsaved-project case one way.
|
||||
|
||||
## `core/tracking/CLAUDE.md`'s untracked-file enumeration says "reaches the `.rpp`" too loosely
|
||||
|
||||
**Context.** `src/core/tracking/CLAUDE.md` §"Invariants" — "No silent gaps — in memory at
|
||||
creation, on disk at the next save" — enumerates how a created file can stay
|
||||
untracked, and describes the ledger as reaching the `.rpp` at the following
|
||||
`saveToActiveProject()`.
|
||||
|
||||
**The wart.** `saveToActiveProject()` writes REAPER's IN-MEMORY project state and marks the
|
||||
project dirty; REAPER writes the `.rpp` on the project's own save, which may be much later
|
||||
or never. The sentence was already loose before this branch and is not made wrong by it —
|
||||
but it is the same over-claim ("a write reached the file on disk") the bake's reporting
|
||||
pass spent several rounds removing from its own sentences, so it should read the same way.
|
||||
|
||||
**Why filed, not fixed.** Editing another layer's own CLAUDE.md from a persist-and-report
|
||||
dispatch is exactly the boundary crossing the per-directory docs exist to prevent. It is a
|
||||
doc-keeper edit.
|
||||
|
||||
**Done looks like.** The enumeration distinguishes "in the project's state" from "on disk
|
||||
in the `.rpp`", and does not gain a second home for the distinction.
|
||||
|
||||
## `view_mode_model.cpp` is over the ~600-line structural bar, and `view.cpp` is close behind
|
||||
|
||||
**Context (surfaced by the FX-GUID keying track).** Root `CLAUDE.md`'s structural
|
||||
heuristics put an ~600-line ceiling on any one file, with a documented responsibility
|
||||
seam as the required method for splitting it, not an arbitrary bisection.
|
||||
`src/core/view/view_mode_model.cpp` measures **815 lines** (verified this pass),
|
||||
up from 715 before the FX-GUID keying track's v2 schema addition made it worse.
|
||||
|
||||
**The named seam.** The JSON codec — `serialize()`/`deserialize()` — wants its own
|
||||
`view_state_codec` TU in `src/core/view/`.
|
||||
|
||||
**Why it was deferred, and this reasoning should survive.** `serialize()` is a
|
||||
`ViewModeModel` member and `deserialize()` a static factory (confirmed:
|
||||
`std::string ViewModeModel::serialize() const` and
|
||||
`std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string&)`), both
|
||||
reaching private state — so extraction needs either a friend declaration or a new
|
||||
public accessor surface. Doing that in the same commit that changed the byte format
|
||||
the golden test literals pin would roll a format change and a codec extraction
|
||||
together, which is the riskier order.
|
||||
|
||||
**`src/shell/view/view.cpp` is no longer over the bar.** It measures **594 lines**
|
||||
(`wc -l`, re-measured after the undo collapse moved one track's whole park/restore —
|
||||
snapshot, flags and per-FX offline — into `view_fx_park`'s `parkTrack`/
|
||||
`restoreTrack`). Recorded because the entry above used to name it as a second
|
||||
over-ceiling file: if it grows again, note that a further seam there is blocked
|
||||
not by a private-state/friend question but by a build file another team owns —
|
||||
`src/shell/view/` has no `CMakeLists.txt` of its own today, so a new TU costs one
|
||||
`target_sources` line in `src/app/CMakeLists.txt` instead.
|
||||
|
||||
**Priority / risk.** Not stated.
|
||||
|
||||
**Done looks like.** `view_mode_model.cpp`'s JSON codec is extracted into its own
|
||||
`view_state_codec` TU (with the friend/accessor question resolved deliberately, not
|
||||
sidestepped), dropping the file under the ~600-line ceiling.
|
||||
|
||||
## FX-GUID stability for the park's restore is unverified in the DAW
|
||||
|
||||
**Context.** The Design View park/restore FX keying (`restoreTrack`,
|
||||
`src/shell/view/view_fx_park.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that
|
||||
survives a chain reorder while a track is parked. SWS issue #802 reports that after
|
||||
`SNM_MoveOrRemoveTrackFX` reorders a chain, the FXID lines do not follow the plugin
|
||||
(`SNM_PreObjectState()` → `RemoveAllIds()`) — if that still holds, an SWS-driven
|
||||
reorder while parked produces wrong-plugin restores or mass drops, which is the exact
|
||||
operation this keying targets.
|
||||
|
||||
**What must be checked.** Native drag-reorder, an SWS move, save/reload, and two live
|
||||
instances of the same plugin.
|
||||
|
||||
**Already flagged in code — this entry is the tracked home, not a restatement.**
|
||||
There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view_fx_park.cpp` and
|
||||
a note in `src/shell/view/CLAUDE.md`'s Gotchas; point at them rather than restating
|
||||
them in full.
|
||||
|
||||
**Priority / risk.** Not stated.
|
||||
|
||||
**Done looks like.** Native reorder, SWS reorder, save/reload, and a
|
||||
two-instance-of-the-same-plugin case are each observed in a live REAPER session, and
|
||||
either the identity is confirmed to survive all four, or a degradation is found and
|
||||
the keying is amended.
|
||||
@@ -0,0 +1,226 @@
|
||||
# DAW verification — post-1.0 work on `dev`
|
||||
|
||||
Checks for Θ, Ξ, Ψ, Ε, Ρ, Γ, and Ω work that no unit test can close. Build **Release**, install into
|
||||
`UserPlugins/`, restart REAPER. Panel tail toggle = **None**, project rate 48000, unless a check says otherwise.
|
||||
|
||||
## Precision invariants
|
||||
|
||||
- [ ] Dry offline item capture of a 2 s range, re-inserted at its source position, inverted against the source — reads silence (`CLAUDE.md` §"Precision invariants" — "Null test")
|
||||
- [ ] Run the identical offline capture request twice — the two files are byte-identical on disk (`CLAUDE.md` §"Precision invariants" — "Bit-identical repeats")
|
||||
- [ ] After any capture, source items and tracks are unchanged: fader, pan, mute, FX bypass, selection (`CLAUDE.md` §"Precision invariants" — "Non-destructive")
|
||||
- [ ] After a realtime capture, the temp track is gone and every source track's routing is back as it was (`CLAUDE.md` §"Precision invariants" — "Non-destructive")
|
||||
- [ ] Capture 10.000–12.000 s — card reads 2.000 s / 96000 frames, no leading or trailing silence (`CLAUDE.md` §"Precision invariants" — "Exact bounds")
|
||||
- [ ] With an FX on the source track: item scope does NOT carry it, track scope does (`CLAUDE.md` §"Precision invariants" — "Capture FX scope")
|
||||
- [ ] Track scope on a child track with FX, gain, and pan set on the parent and master — neither colors the capture (`CLAUDE.md` §"Precision invariants" — "Capture FX scope")
|
||||
- [ ] Save, move the whole project folder elsewhere, reopen — every card still resolves and auditions (`CLAUDE.md` §"Precision invariants" — "Relative paths only")
|
||||
|
||||
## The decisive observation
|
||||
|
||||
- [ ] **Run first.** `docs/verify-track-scope-multitrack.md` §3 by hand, and count the files REAPER writes (`docs/TODO.md` §"The `&128` multi-track output shape is still DAW-unobserved", `docs/COMPLETED.md` §"Ψ-W3-T1", `docs/COMPLETED.md` §"None of the seven is DAW-verified")
|
||||
- [ ] Two files confirms Ψ-W1-T1 and Ψ-W3-T1 at once; **one summed file invalidates both** — stop and report, the refusal is costing a capture 1.0.0 accepted (`docs/COMPLETED.md` §"None of the seven is DAW-verified")
|
||||
- [ ] Then walk the rest of `docs/verify-track-scope-multitrack.md` (§1–§2, §4–§7) for the multi-track refusal itself (`docs/COMPLETED.md` §"Ψ-W3-T1")
|
||||
|
||||
## Capture range and bounds
|
||||
|
||||
- [ ] Over an item much longer than the selection: item scope × time selection, and item scope × razor — each lands exactly the window, not the whole item (`docs/COMPLETED.md` §"Ψ-W1-T1")
|
||||
- [ ] Same source: track scope × time selection, and track scope × razor — same exact window (`docs/COMPLETED.md` §"Ψ-W1-T1")
|
||||
- [ ] One razor-union case (two disjoint areas, one track) — lands the requested window, no `ReaSampler capture failed:` line (`docs/COMPLETED.md` §"Ψ-W1-T1")
|
||||
- [ ] Capture an item whose extent already equals the window — still lands, unchanged (the byte-identity regression floor) (`docs/COMPLETED.md` §"Ψ-W1-T1")
|
||||
- [ ] **The millisecond floor — SETTLED, nothing to re-run for `TailMode::None`.** The floor lives in the custom-time-bounds field (`RENDER_BOUNDSFLAG=0`), not in the render engine. Two live 48 kHz `TailMode::None` renders on `RENDER_BOUNDSFLAG=2` (time selection, handed over via `GetSet_LoopTimeRange`) came back exact — 97627 frames against 97627 — the second over a window whose START carried a sub-millisecond remainder, with no floored model of that window able to reproduce the count. Time selection is now the only bounds mode a capture can reach; the console verdict line and the `RENDER_STARTPOS`/`ENDPOS` read-back probe that answered this are gone. Full observation: `src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection`
|
||||
- [ ] **Still open — Auto and Manual tail.** `checkRenderedBounds` judges `TailMode::None` only (Auto/Manual add frames by design), so the settled result covers those two by INFERENCE, not observation, and the inference rests on an unverified PREMISE too: that the (retired) floor applied to the bounds identically across all three tail modes, and that all three now hand the window over the same way. Neither is measured — both live short renders that settled the bounds mode were `TailMode::None`; no Auto or Manual capture has been observed at all. **On Auto/Manual, the ONLY automatic check left is the 0-byte gate (`checkRenderedFileNotEmpty`)** — there is no automatic bounds signal for those two modes at all until this bullet is closed by hand. What would establish it: repeat an off-grid-start capture at **Manual** over a source that is loud right to the window's end, and check the landed file's frames against window + `tailMs` — a floored edge shows up in that count. **Auto** cannot be checked by count (it trims trailing silence), so it needs the null test by ear/inversion against the source instead
|
||||
- [ ] `[verify — DAW]` A tail is assumed to render PAST the window end — the SDK header (`:3048`) confirms only that `RENDER_TAILMS` is a length in ms, not that it extends past the end. If that assumption is wrong, a tail capture is silently SHORTER than its window with no detector at all. Report whether either tail capture comes up short against the source
|
||||
- [ ] A refused render is kept for diagnosis at `<project folder>/reasampler_refused/` (the refusal line names the path; a failed move leaves it unindexed in the bank folder and says so). Delete the folder when done — nothing in the bank references it
|
||||
- [ ] **If a capture is refused for a short render**, report the refusal line verbatim. A message naming `floored to the millisecond` means the floor is back on a mode measured escaping it; a shortfall of one or two frames with no such sentence may be the gate's own edge-convention tolerance rather than the render (`render_window.h`'s `renderHonoredBounds`)
|
||||
|
||||
## Names and channels
|
||||
|
||||
- [ ] Capture from a named track — the card reads `<Track> MM-DD HHMM`; capture again the same minute and the second carries an ordinal (`docs/COMPLETED.md` §"Ψ-W2-T1")
|
||||
- [ ] Capture from an unnamed track, and from a multi-item selection — both readable, `+N` present on the multi (`docs/COMPLETED.md` §"Ψ-W2-T1")
|
||||
- [ ] Load a named capture into ReaSampler 9000 — the same name shows there (`docs/COMPLETED.md` §"Ψ-W2-T1")
|
||||
- [ ] The card label stays legible over its scrim at every card size (`docs/COMPLETED.md` §"Ψ-W2-T1")
|
||||
- [ ] Capture a dead-center mono source — the `.wav` is roughly half the size of the equivalent stereo capture (`docs/COMPLETED.md` §"Ψ-W2-T2")
|
||||
- [ ] Insert that collapsed file on a stereo track and null it against the source — confirms REAPER sums a 1-channel item at unity (`docs/COMPLETED.md` §"Ψ-W2-T2")
|
||||
- [ ] Capture a true-stereo source — stays 2-channel, and both it and the collapsed file load into the instrument correctly (`docs/COMPLETED.md` §"Ψ-W2-T2")
|
||||
|
||||
## Mode switching
|
||||
|
||||
- [ ] Solo tracks in Arrange, switch to Design, solo different tracks, switch back — each mode restores its own solo set verbatim (`docs/COMPLETED.md` §"Ψ-W1-T2")
|
||||
- [ ] Attempt a mode switch while the transport is playing, then while recording — both refuse, visibly (`docs/COMPLETED.md` §"Ψ-W1-T2")
|
||||
- [ ] Click the footer mode segment, save, reopen the project — the mode persisted (`docs/COMPLETED.md` §"Ψ-W1-T2")
|
||||
- [ ] `[verify — DAW]` Reproduce the strand: project with FX in both modes, switch to Design, save, close, reopen, toggle to Arrange. Toggle back and forth several more times — the arrangement's FX must NOT converge on permanently offline/hidden. A track the tool refuses to park is NAMED in the console, and the same refusal is not reprinted on a later reapply unless the refused set changed (`src/shell/view/view_fx_park.h`'s `decidePark` / `reportRefusedParks`)
|
||||
- [ ] `[verify — DAW]` **ONE switch, ONE point.** In a project with several FX across several tracks, switch modes once and count the undo points REAPER shows (Ctrl-Z tooltip / undo history): expect exactly ONE, `ReaSampler: activate <mode> view` — never two, and never one per FX. Then press Ctrl-Z once: the flags unpark and EVERY FX comes back online, in that single step. Verify the EFFECT, not merely that one entry exists — a mask that failed to pick up `UNDO_STATE_FX` would still show one correct-looking entry while leaving every plugin offline, which reads as a pass and isn't. Then Ctrl-Y: the redo re-parks all of it, also in one step (`src/shell/view/CLAUDE.md` §Invariants — "ONE mode switch is ONE undo point")
|
||||
- [ ] `[verify — DAW]` **Where the `view_state` lands on undo AND redo, and whether the redo strands a track.** `persistViewState` writes the ext state AFTER `applyMode`'s block closes, so the point never saw the new `view_state` — the undo direction is expected to come out right by position, the redo direction is the open one. Use a project with FX on the tracks that park. After the Ctrl-Z above, read the footer segment: it must show the mode the user came FROM, and a further switch must bank solos under that mode id. Then Ctrl-Y and read it again: if the tracks re-park while the footer still shows the pre-switch mode, the redo restored the stale ext state and model-vs-project is out of step. **Then switch modes ONCE MORE and read the console** — the hypothesised consequence is a `left unparked` refusal line naming those tracks, because the redo-restored model carries no snapshot for a chain that reads parked. A clean switch with no refusal line kills the hypothesis; the line appearing confirms a NEW refusal route and the persist position has to be reopened. Report which, and the footer state at each step. Do NOT fix by moving `persistViewState` inside the block — it can open a modal Save-As (`src/shell/view/CLAUDE.md` §Gotchas)
|
||||
- [ ] `[verify — DAW]` **No point on open.** Open a project saved in Design mode with every inactive leaf already fully parked (flags AND FX), and check the undo history: the load-tick reapply writes nothing, so it must add NO undo point at all — the `("", 0)` discard form. This only holds when the saved state really was fully parked; a first open after tagging a new leaf, an FX added to a parked track, or a plugin hand-onlined since the last save all have real state to write and legitimately mint one correct point. An entry appearing on a project that WAS fully parked means the discard is not working — and since `mintManagedLanes`' no-op path rests on the same idiom, check that alongside it (`src/shell/view/view.h`'s `applyMintsUndoPoint`)
|
||||
- [ ] `[verify — DAW]` **The hitch now sits on the switch.** On a project with heavy plugins (convolution, a loaded sampler) across several parked tracks, time the mode toggle: by design the action does not return until every plugin has unloaded/reloaded. Report roughly how long, and whether the UI recovers cleanly afterwards — the whole write phase runs under one `PreventUIRefresh(1)` hold (`src/shell/view/CLAUDE.md` §Invariants — the documented caveat)
|
||||
- [ ] `[verify — DAW]` **Nested blocks around the FX writes.** Two directions, both undocumented in the SDK header. (a) `render_in_place` calls `applyMode` inside its OWN undo block, so its single `ReaSampler: render selected track to a new track` point should now absorb the reapply's FX moves — run that verb from Design mode over a track with FX and confirm ONE point, not two, and that Ctrl-Z reverses all of it. (b) The inverse: fire a mode-switch action WHILE a switch's plugin loads are in flight (a heavy chain widens the window) — `applyMode` is latched, so the expected outcome is that the second switch is DROPPED entirely and silently; the history must show one point, and the footer must still read the first switch's target. Re-firing it afterwards must work normally. (c) The same window, but let `OnTimer` -> `bankPanelRefresh` -> `mintManagedLanes` run into it: that path is NOT latched and opens a block of its own, so check the history for a split or misordered point (`src/shell/view/CLAUDE.md` §Gotchas)
|
||||
- [ ] `[verify — DAW]` **The load reapply is never lost to the latch.** Open a project saved in Design mode with heavy plugins on parked tracks (so an apply is long) while another apply can overlap — e.g. trigger a project open from an action fired during a switch. The saved mode must still be applied: the load glue tests `modeApplyInProgress` before spending its one-shot signal and retries on the next timer tick, so a delayed-by-a-tick reapply is a pass and a never-applied one is a failure (`src/app/main.cpp`'s load glue, `src/shell/view/view.h`'s `modeApplyInProgress`)
|
||||
- [ ] `[verify — DAW]` **A tab switch mid-apply cannot split the block.** `applyMode` pins the project once at entry and threads it through `Undo_BeginBlock2`/`Undo_EndBlock2`/`ValidatePtr2`. With a long switch in flight, switch project tabs (the pump premise makes this reachable) and confirm the undo point lands on the project the switch started in, with nothing written into the other tab (`src/shell/view/view.cpp`'s `applyMode`)
|
||||
- [ ] `[verify — DAW]` **A→B→A costs two full cycles.** Toggle Arrange→Design→Arrange quickly on a project with heavy plugins: every plugin unloads and reloads twice, where the retired intent queue made a fast double-toggle free. Confirm the END STATE is still correct (every FX online, every driven flag back at its captured value) and report whether the doubled cost is tolerable in practice (`src/shell/view/CLAUDE.md` §Invariants — the A→B→A bullet)
|
||||
- [ ] `[verify — DAW]` For a track the console names as left unparked, follow the message's recovery IN FULL: re-enable its TCP/mixer visibility, main send and FX enable, **and set every FX in its chain online** (FX enable is the chain bypass — it does not bring an individually offlined FX back). Then switch modes twice: it parks and restores normally, with no further console line. Do the flag half ONLY on a second such track and confirm it is still refused — the FX half of the trust test, and the reason the message spells the step out (unit-tested only as a decision, never against a live chain)
|
||||
|
||||
## Actions and drops
|
||||
|
||||
- [ ] Add the import action to a Media Explorer toolbar and fire it from there — it imports (`docs/COMPLETED.md` §"Ψ-W1-T3")
|
||||
- [ ] Fire the existing Main-section import binding — still works (`docs/COMPLETED.md` §"Ψ-W1-T3")
|
||||
- [ ] Unload/reload (restart REAPER) — no duplicate Media Explorer entry in the action list (`docs/COMPLETED.md` §"Ψ-W1-T3")
|
||||
- [ ] Drag one card across the arrange, over an FX window, over the TCP/MCP and back — cue changes per surface, every transition reverses (`docs/COMPLETED.md` §"Ψ-W1-T4")
|
||||
- [ ] Drag fast, and drag onto a narrow TCP — target class still resolves; no release anywhere in REAPER is a silent no-op (`docs/COMPLETED.md` §"Ψ-W1-T4")
|
||||
- [ ] Drop a single card into the arrange — an item lands at the pointer's track and time (`docs/COMPLETED.md` §"Ψ-W1-T4")
|
||||
- [ ] Drag-out to an external app twenty-plus times in a row — audio arrives every time; this is a soak, a single pass is not a gate (`docs/COMPLETED.md` §"Θ-W1-T2" — "Neither acceptance criterion has actually been met yet")
|
||||
- [ ] Drop a capture onto an FX container — the instrument loads with that capture (`docs/COMPLETED.md` §"Θ-W1-T2" — "Neither acceptance criterion has actually been met yet")
|
||||
|
||||
## Bank packages
|
||||
|
||||
- [ ] **Run in full.** `docs/verify-package-transfer.md` — the whole cross-machine
|
||||
export/import round trip: writes-one-file, the transfer itself, re-importing the
|
||||
same file never overwrites, the round trip back to the source, the too-new /
|
||||
truncated / mid-payload-corruption refusals (each an exact string), the
|
||||
unsaved-project refusals, and drag-and-drop (`docs/COMPLETED.md` §"Ε-W3-T1")
|
||||
- [ ] Force a degraded tracking ledger and confirm the import refuses **before the
|
||||
file picker opens**: save a project with a bank, close REAPER, edit the saved
|
||||
`.rpp`'s `owned_files` ext-state value inside its `<REASAMPLER ...>` block — corrupt
|
||||
the JSON for the `Unreadable` case, or bump `"v":2` to `"v":3` for the
|
||||
`FutureVersion` case — reopen the project, then run *ReaSampler: import bank
|
||||
package (.rsbank)*. Read off: the console prints the ledger-refusal block and no
|
||||
file dialog ever appears (`origin_ledger.h`'s `LedgerStatus` and `ledgerDegraded`,
|
||||
`package_import_action.cpp`'s `ledgerPermits`)
|
||||
- [ ] Export dialog: type a destination name with no extension, then again over a
|
||||
name that already carries a different one (e.g. `mybank.bak`) — read off whether
|
||||
`GetUserFileName` appended `.rsbank` itself or ReaSampler's own re-append produced
|
||||
the double-extension result (`mybank.bak.rsbank`) the code expects
|
||||
(`src/shell/package/CLAUDE.md` §"Gotchas" — "The re-append is suffix-blind")
|
||||
- [ ] Both the export and the import file dialogs open in front of REAPER's main
|
||||
window, not behind it — `GetUserFileName` takes no owner window
|
||||
(`src/shell/package/CLAUDE.md` §"Gotchas" — "`GetUserFileName` also takes no owner window")
|
||||
- [ ] With a ReaSampler 9000 instance's editor open on the destination project
|
||||
(Browse view visible), import a `.rsbank` from the docked panel — the browser
|
||||
reflects the new bank without closing or reopening the editor (the bank-generation
|
||||
bump, `session.h`'s `bumpBankGeneration`, polled by the instrument at
|
||||
`processor_reload.cpp`'s `pollBankSync`)
|
||||
- [ ] Drag two or more `.rsbank` files onto the docked panel in one drop — each lands
|
||||
as its OWN new bank, never merged into one, and if the tracking ledger is degraded
|
||||
the refusal prints ONCE for the whole drop rather than once per file
|
||||
(`panel_window.cpp`'s `handleDropFiles`)
|
||||
- [ ] Kill REAPER (or the process) partway through an import so a partial bank file
|
||||
is stranded under its real name in the bank folder, then re-run the same import
|
||||
into the same project — read off what happens. Whether the import verb should
|
||||
pre-clean that stale debris is an open question, not yet decided
|
||||
(`src/shell/package/CLAUDE.md` §"Gotchas" — "A crash mid-export strands the `.rsbanktmp` sibling")
|
||||
|
||||
## The resample bake
|
||||
|
||||
- [ ] Bake a dialed sound — the banked file sounds like what the editor was playing (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
|
||||
- [ ] Bake the result twice more — iteration composes, nothing is lost per pass (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
|
||||
- [ ] Save and reopen after a bake — the instance still points at the baked capture (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
|
||||
- [ ] Confirm no bake put an item in the arrange, and the superseded file is still on disk (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
|
||||
- [ ] Bake from an instance in a background project tab — refuses rather than writing into the wrong bank (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Two behaviors worth recording")
|
||||
- [ ] Load the VST with the extension not installed — the resample affordance reads unavailable, not silently lossy (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Two behaviors worth recording")
|
||||
- [ ] After a bake: instance is in Trigger with start point reset, channel mode and preview velocity survived (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Reset-scope classifications made at review")
|
||||
- [ ] Gate mode + active sustain loop — "Bake Hold" appears within ~500 ms, its label fits its cell, its travel is duration-ordered (`docs/COMPLETED.md` §"Ξ-W3-T1")
|
||||
|
||||
## Render in place
|
||||
|
||||
- [ ] Fire *Render track in place* over a track with a range selected — solo the source
|
||||
and the new sibling track, invert one track's polarity, and confirm silence. This is
|
||||
Ρ's own trust anchor: the placement is the null test performed automatically
|
||||
(`docs/product/render-in-place.md` §"DAW-verification obligations",
|
||||
`docs/COMPLETED.md` §"Phase Ρ — Render in place")
|
||||
- [ ] Render in place from three source positions in turn — a normal mid-folder track, a
|
||||
track that is last in its folder, and a folder-parent track — each time confirm the new
|
||||
sibling track lands at the same nesting level as the source and that the folder bus
|
||||
feeds (or bypasses) it correctly. Then, to settle whether `TrackList_AdjustWindows(false)`
|
||||
is actually needed: comment out that call in `render_in_place.cpp`'s
|
||||
`RunRenderTrackInPlace` (it was added preemptively, answering a question the spec had
|
||||
left open rather than one the code confirmed), rebuild, and repeat the folder-parent
|
||||
case — if nesting still displays correctly with the call removed, it can be dropped in a
|
||||
follow-up (`docs/product/render-in-place.md` §"DAW-verification obligations")
|
||||
- [ ] Render in place from a dead-centre (channel-identical) source — confirm the placed
|
||||
item is mono, and confirm it plays back at the same perceived level the stereo source
|
||||
did before the render. This is root `CLAUDE.md`'s existing mono-summing
|
||||
`[verify — DAW]`, promoted to load-bearing because Ρ is the first path that places a
|
||||
collapsed render into the mix automatically (`docs/product/render-in-place.md`
|
||||
§"DAW-verification obligations")
|
||||
- [ ] Fire Render track in place once from Arrange and once from Design. From Arrange:
|
||||
confirm the source track parks and the result track is visible and in the mix. From
|
||||
Design: confirm the source stays on the bench, the result track is parked too, then
|
||||
switch to Arrange and confirm the result track appears in the source's place. **In
|
||||
both cases wait out at least one panel timer tick before checking membership** — that
|
||||
is the check that catches a missing explicit-tag-wins filter or an untagged item,
|
||||
either of which silently reverses the ruling that the result track is always an
|
||||
Arrange member (`docs/product/render-in-place.md` §"Mode transitions — the source
|
||||
parks, the result goes to Arrange")
|
||||
- [ ] Render in place, then press Ctrl-Z once — confirm the new track and its item are
|
||||
both gone, the source track's folder depth is restored, the rendered file itself is
|
||||
still on disk, and the source track is still tagged Design (`docs/product/render-in-place.md`
|
||||
§"DAW-verification obligations")
|
||||
- [ ] Render in place from a named source, then run it again over the resulting (already
|
||||
`Capture `-prefixed) track — confirm the name does not stack a second prefix, and
|
||||
confirm the new sibling's colour matches the source's (the colour clone has no unit
|
||||
coverage at all). Repeat once from an unnamed source and confirm the result reads
|
||||
`Capture Track N` (`docs/product/render-in-place.md` §"DAW-verification obligations";
|
||||
the name-composition logic itself — apart from the live colour clone and the real
|
||||
`GetTrackName`/`P_NAME` round trip — is unit-tested in `tests/test_capture_name.cpp`)
|
||||
- [ ] Save a project into a folder whose recording path is set away from the default
|
||||
(Project Settings → Media → Path), then Render track in place — confirm the rendered
|
||||
file lands in that configured recording path, not the project folder itself
|
||||
(`render_in_place.cpp`'s `RunRenderTrackInPlace`, `GetProjectPathEx`)
|
||||
- [ ] Render in place, save the project, and reopen it — confirm the result track (which
|
||||
carries an explicit `kArrangeModeId` membership record, unlike the shipped
|
||||
tag-selected-tracks action which never writes one) behaves identically, in every
|
||||
mode-switch and visibility check, to an ordinary untagged Arrange track. The JSON
|
||||
round-trip itself is unit-tested (`tests/test_view_mode_model.cpp`); this is the
|
||||
live-view half that isn't (`docs/product/render-in-place.md` §"Mode transitions — the
|
||||
source parks, the result goes to Arrange")
|
||||
|
||||
## Instrument migration
|
||||
|
||||
- [ ] Open a project saved before the zone retirement — the instance reopens on its first zone and sounds the same (`docs/COMPLETED.md` §"Θ-W1-T1")
|
||||
- [ ] Such an instance with implicit channel mode + a stereo capture reopens **Stereo** — confirm that is acceptable by ear (`docs/COMPLETED.md` §"Θ-W1-T1")
|
||||
|
||||
## Waveform marker zero-crossing snap (Ω-W2-T5)
|
||||
|
||||
- [ ] `[verify — DAW]` Drag a Gate loop mark on a single-cycle capture — the felt radius the mark
|
||||
may be pulled to reach a crossing genuinely reads as imperceptible, i.e. a displacement inside
|
||||
the pointer's own grab neighbourhood is not one the eye catches (`src/core/instrument/ui/waveform_view.h`'s
|
||||
`kZeroCrossingSnapPx` comment)
|
||||
- [ ] `[verify — DAW]` Hold Ctrl, then drag a waveform marker (start/loop start/loop end) off a
|
||||
zero crossing — confirm it lands exactly where dropped, with no snap, in a live editor window
|
||||
(`editor_input_waveform.cpp`'s `dragWaveform`, `GetKeyState`-driven, no CI test target)
|
||||
- [ ] `[verify — DAW]` Press and hold Ctrl BEFORE mouse-down on a mark that coincides with a
|
||||
contour node or another mark's cap — confirm the mark is grabbed (not the node dragged, not a
|
||||
hard/smooth toggle fired), matching the fix in `resolveWaveformClaim`
|
||||
(`src/core/instrument/ui/spline_edit.cpp`)
|
||||
|
||||
## Look and feel
|
||||
|
||||
- [ ] Sign off by eye in a live editor window: knob arcs, needles, envelope splines, waveform outline (`docs/COMPLETED.md` §"Θ-W7-T1", `docs/COMPLETED.md` §"Θ-W6-T1" — "Antialiasing pass")
|
||||
- [ ] Same pass for legibility: text sizes, arc weight, and whether the waveform stroke thickens the docked panel (`docs/COMPLETED.md` §"Θ-W6-T1", `docs/COMPLETED.md` §"Θ-W6-T1" — "Sizing")
|
||||
- [ ] Piano strip at the 840 px default — keys tile uniformly, the 37 px end gutters read as acceptable (`docs/COMPLETED.md` §"Θ-W2-T3")
|
||||
- [ ] Resize the editor across several widths — gutters stay symmetric, no key width jumps (`docs/COMPLETED.md` §"Θ-W2-T3")
|
||||
- [ ] Set host/OS scaling to 150% then 200% — record how the strip and the AA strokes actually look (`docs/TODO.md` §"High-DPI host scaling is unverified (distinct from the antialiasing audit)", `docs/COMPLETED.md` §"Θ-W2-T3" — "Width uniformity is guaranteed in client pixels only")
|
||||
|
||||
## The VST3 parameter surface
|
||||
|
||||
- [ ] `[verify — DAW]` Whether REAPER renders `ParameterInfo::units` beside the string
|
||||
`getParamStringByValue` returns, or shows the string alone. We ship the SDK's own
|
||||
convention (digits in the string, unit carried separately); if REAPER shows no unit at
|
||||
all, the fallback is a one-line change to the single formatter, touching neither the
|
||||
frozen id table nor the editor (`docs/COMPLETED.md` §"Γ-W4-T1")
|
||||
- [ ] Whether REAPER's own per-parameter MIDI learn covers what a shipped `IMidiMapping`
|
||||
CC table would have — the decision to ship no default map rests on it; if learn does not
|
||||
reach these parameters, a CC table is additive and frozen by nothing (`docs/COMPLETED.md`
|
||||
§"Γ-W4-T1")
|
||||
- [ ] The three migration round trips: a pre-parameter project opens with every parameter
|
||||
reading the blob's value and sounds identical; a project saved by this build restores
|
||||
fully in an older binary; a project with automation drawn, saved and reopened, replays
|
||||
against the same plain values (`docs/COMPLETED.md` §"Γ-W4-T1")
|
||||
- [ ] `[verify — DAW]` **Whether an offline render replays automation at all** — the
|
||||
sharpest case for the audio-side drain, because the host drives `process()` and may
|
||||
never touch the controller (`docs/COMPLETED.md` §"Γ-W4-T1")
|
||||
- [ ] `[verify — DAW]` **Whether REAPER restores instance state through `setState`, not
|
||||
`setComponentState`** — the entry-point half of the original bundled `[verify, FIRST]`
|
||||
that an earlier pass closed only the delivery-channel half of. The evidence short of a
|
||||
DAW is strong but is inference: `vstsinglecomponenteffect.h:41-47` collapses the two
|
||||
names on a single-component plug-in, and the blob has round-tripped through payload
|
||||
v1…v16 in real projects. Exercising it costs one save/reopen (`docs/COMPLETED.md`
|
||||
§"Γ-W4-T1")
|
||||
@@ -0,0 +1,813 @@
|
||||
# Λ-W1 Track 1 — Linux build/toolchain audit (build-system lens)
|
||||
|
||||
Static analysis of the build surface only — the 23 `CMakeLists.txt` files, `cmake/`,
|
||||
`src/resource.rc`/`src/resource.h` + the SWELL resgen pipeline, the three vendored submodules
|
||||
as *build inputs*, the `ctest` harness, toolchain-level flags, and the packaging story in
|
||||
`docs/product/versioning-and-release.md`. 2026-08-02, branch `pl-w1-t1-build-toolchain-audit`
|
||||
(cut from `dev` at 6e937b9).
|
||||
|
||||
**Source portability is explicitly NOT audited here** — no Win32/GDI/LICE API usage, path
|
||||
handling, threading, drag/drop, dialog code, fonts, or instrument runtime code. A parallel
|
||||
track (T2) owns that; what I noticed in passing is listed under "Referred to T2" at the end
|
||||
and nowhere else.
|
||||
|
||||
**Verification posture.** I am on Windows and cannot build or run anything on Linux. Every
|
||||
claim below is grounded in a file I opened in this worktree — the tree's own CMake, or a
|
||||
vendored header/source under `vendor/` — not asserted from memory of similar SDKs. Where a
|
||||
claim depends on runtime behaviour I cannot observe (does REAPER's Linux build call
|
||||
`SWELL_dllMain`? does GCC accept this TU?), it is marked `[verify — Linux]` with the exact
|
||||
check, and it is **not** stated as fact. A subsequent citation pass caught several
|
||||
line-number and attribution errors in this draft (corrected in place, disclosed at the point
|
||||
each occurs) — grounded-in-a-file is a floor, not a guarantee against a mis-copied line range.
|
||||
To get the vendored citations I ran `git submodule update --init` for all three submodules
|
||||
inside this worktree; nothing in the tracked tree was modified.
|
||||
|
||||
Severity vocabulary, per the dispatch: **Blocker** = the Linux build cannot produce a loadable
|
||||
artifact; **Major** = it builds but is degraded or unshippable; **Minor** = ergonomics,
|
||||
warnings, CI polish. Effort bands are S (< half a day), M (1–3 days), L (a week+), and each
|
||||
names the reason for the band rather than asserting a number.
|
||||
|
||||
---
|
||||
|
||||
## 1. Baseline — what the build does today, per platform
|
||||
|
||||
**Two loadable modules, 84 pure libraries declared under `src/core/`, 91 CTest targets,
|
||||
three vendored submodules.** (84 = the `reasampler_pure_library` + bare `add_library` count,
|
||||
grep-verified library-by-library across the 18 `src/core/**` CMake files that declare any —
|
||||
`src/core/CMakeLists.txt` and `src/core/instrument/CMakeLists.txt` declare none, only
|
||||
`add_subdirectory` calls (plus one test in the latter); there are 20 CMake files under
|
||||
`src/core/**` total, matching the 19 figure in §1d below, which counts *test* declarations
|
||||
across the whole tree, not `src/core/**` alone. 46 of the 84 are on the extension's link
|
||||
line.)
|
||||
|
||||
- Root `CMakeLists.txt` is 95 lines and declares **no targets at all**: it owns the version
|
||||
string (`:24`), the channel fork (`:40–54`), the `configure_file` for
|
||||
`version_generated.h` (`:58–61`), the four vendor path variables (`:65–68`), the
|
||||
four-TU `LICE_SRC` list (`:78–83`), `enable_testing()` (`:90`), and three
|
||||
`add_subdirectory` calls (`:92–94`).
|
||||
- Language/toolchain settings are exactly three lines: `CMAKE_CXX_STANDARD 17`,
|
||||
`CMAKE_CXX_STANDARD_REQUIRED ON`, `CMAKE_POSITION_INDEPENDENT_CODE ON`
|
||||
(`CMakeLists.txt:28–30`). There is **no** `CMAKE_BUILD_TYPE`, no `CMAKE_CXX_FLAGS`, no
|
||||
`CMAKE_CXX_EXTENSIONS`, no IPO/LTO setting, and no `target_compile_options` anywhere in
|
||||
the tree — I grepped all 23 CMake files for `compile_options`, `find_package`,
|
||||
`pkg_check`, `link_directories`, `target_link_options`, and `install(` and got zero hits
|
||||
outside the two files named below.
|
||||
- **Platform conditionals exist in exactly two files.** Grepping all 23 `CMakeLists.txt`
|
||||
for `WIN32|APPLE|UNIX|LINUX|MSVC|GNU|Clang|CMAKE_SYSTEM` returns hits only in
|
||||
`src/app/CMakeLists.txt` (`:71` `if(WIN32)`, `:76` `elseif(APPLE)`, `:88` `else()`) and
|
||||
`src/shell/instrument/CMakeLists.txt` (`:9` `if(WIN32 AND EXISTS ...)`). Every other
|
||||
CMake file is platform-neutral by construction: they call the two helpers in
|
||||
`cmake/reasampler_targets.cmake` (`reasampler_pure_library` at `:8–15`,
|
||||
`reasampler_test` at `:19–24`) and nothing else.
|
||||
|
||||
### 1a. The REAPER extension (`reaper_reasampler`)
|
||||
|
||||
Declared as a `MODULE` library over 39 listed TUs (38 shell TUs + `app/main.cpp` itself)
|
||||
plus `${LICE_SRC}` (`src/app/CMakeLists.txt:8–51`), linking 46 pure static libraries (`:52`) with
|
||||
`${SDK_INC} ${WDL_INC}` on the include path (`:56`). Output naming is
|
||||
`PREFIX ""` + channel-derived `OUTPUT_NAME`, pinned to the top of the build tree by
|
||||
`LIBRARY_OUTPUT_DIRECTORY`/`ARCHIVE_OUTPUT_DIRECTORY` (`:65–69`).
|
||||
|
||||
The three-way platform block (`:71–98`) is:
|
||||
|
||||
| Branch | State | What it does |
|
||||
|---|---|---|
|
||||
| `if(WIN32)` `:71–74` | **Live and shipped** | adds `src/resource.rc` for the RC compiler |
|
||||
| `elseif(APPLE)` `:76–86` | **Partly aspirational** | adds `swell-modstub.mm`, defines `SWELL_PROVIDED_BY_APP`, links `-framework AppKit`, forces `.dylib`. The resgen `target_sources` line at `:86` is **commented out**. |
|
||||
| `else()` (Linux) `:88–97` | **Partly aspirational** | adds `swell-modstub-generic.cpp`, defines `SWELL_PROVIDED_BY_APP`, forces `.so`. The resgen `target_sources` line at `:97` is **commented out**. |
|
||||
|
||||
So on Linux the build system today produces `reaper_reasampler.so` with the SWELL modstub
|
||||
compiled in and **no dialog resource of any kind**. `README.md:67–68` states this is
|
||||
"expected, not a bug" — the tree is honest about it.
|
||||
|
||||
**Open question, macOS-only, unresolved by this doc.** The APPLE branch adds
|
||||
`${SWELL}/swell-modstub.mm` (`:79`), an Objective-C++ TU, but `CMakeLists.txt:26` is
|
||||
`project(reaper_reasampler VERSION 1.4.0 LANGUAGES CXX)` — OBJCXX is never enabled anywhere
|
||||
in the tree. Whether CMake resolves a `.mm` source against a plain CXX-only project (some
|
||||
Apple-toolchain configurations infer Objective-C++ from the extension regardless; others do
|
||||
not) is unverified from this tree and untestable without a macOS box. `[verify — macOS]`.
|
||||
|
||||
Three build-input facts I verified in `vendor/` and that hold in the extension's favour:
|
||||
|
||||
- `vendor/WDL/WDL/swell/swell-modstub-generic.cpp` is entirely inside
|
||||
`#ifdef SWELL_PROVIDED_BY_APP` (`:21`, closed at `:150`). Since
|
||||
`SWELL_LOAD_SWELL_DYLIB` is not defined anywhere in this build, the `#else` branch at
|
||||
`:133` applies: the file's whole contribution is the three
|
||||
`SWELL_curmodule_*resource_head` globals (`:31–33`), the API function-pointer table
|
||||
built from `swell.h` (`:37–49`), and the `SWELL_dllMain` export (`:135`) that resolves
|
||||
every SWELL symbol from a host-supplied `GetFunc`. That is the correct stub for a
|
||||
REAPER extension, and the comment at `src/app/CMakeLists.txt:90` ("With
|
||||
SWELL_PROVIDED_BY_APP you can drop pkg-config / -lX11 entirely") is **correct**: no SWELL
|
||||
implementation TU, no X11, no GDK is compiled or linked into the extension.
|
||||
- The four-TU LICE slice is self-contained on Linux. `lice.cpp` reaches SWELL only through
|
||||
`SWELL_DeleteGfxContext` (`:153`, `:218`), `SWELL_GetCtxFrameBuffer` (`:182`, `:226`) and
|
||||
`SWELL_CreateMemContext` (`:224`); `lice_textnew.cpp` reaches it through
|
||||
`SWELL_PushClipRegion`/`SWELL_SetClipRegion` (`:1027–1028`) and `SWELL_PopClipRegion`
|
||||
(`:1048`). All six are entries in SWELL's API table
|
||||
(`vendor/WDL/WDL/swell/swell-functions.h:857`, `:863`, `:877`, `:885–887`), so the
|
||||
modstub resolves them from the host. No fifth LICE TU is implied: the `_LICE_RGB2HSV_invtab`
|
||||
table lives inside `lice.cpp` itself (`:3040`), and the one non-Win32-only helper
|
||||
`utf8makechar` is a file-static defined at `lice_textnew.cpp:26` and used at `:241–242`.
|
||||
This is a link-success inference, not a from-first-principles proof — the actual proof is
|
||||
V1, a successful build. One loose end: SWELL's own `Makefile:106–107` lists
|
||||
`lice_colorspace.o` among the LICE objects *its* build needs — a fifth TU we don't compile.
|
||||
It stays unreconciled but harmless: `lice_colorspace.cpp` defines the real `LICE_RGB2HSV`
|
||||
function (declared `lice.h:577`, distinct from the `_invtab` table above), and grepping our
|
||||
four LICE_SRC TUs and all of `src/` for a call to it returns nothing — SWELL needs the TU
|
||||
for its own generic-GDK backend, which our four-TU slice never invokes into.
|
||||
- `CMAKE_POSITION_INDEPENDENT_CODE ON` (`CMakeLists.txt:30`) is already set. This is the
|
||||
single most commonly missed setting for a `MODULE` target fed by `STATIC` libraries on
|
||||
Linux, and it is correct today.
|
||||
|
||||
### 1b. The VST3 instrument (`reasampler_vst`)
|
||||
|
||||
Hard-gated to Windows: `if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")`
|
||||
(`src/shell/instrument/CMakeLists.txt:9`). The gate is a conjunction — even with the
|
||||
`vendor/vst3sdk` slice fully initialised, a Linux configure silently omits the target.
|
||||
`README.md:33–35` states this explicitly. Two module-level `CLAUDE.md` files record the
|
||||
underlying product decision (D5) as *settled*, with "a mac/Linux build" named as a
|
||||
non-goal: `src/core/instrument/CLAUDE.md` (Non-goals/guardrails) and
|
||||
`src/shell/instrument/CLAUDE.md` (same). `src/shell/panel/CLAUDE.md` carries a third
|
||||
instance ("Windows-only (D5) — font/GDI/HFONT choices assume Windows").
|
||||
|
||||
Inside the gate: a 20-source `vst3_sdk` static library (`:16–38`, grep-counted; lines 28/29/37
|
||||
are comments) pinned to SDK tag
|
||||
`v3.7.9_build_61` (confirmed by `git describe --tags` in `vendor/vst3sdk`), and the
|
||||
`reasampler_vst` MODULE (`:44–81`) which additionally compiles `dllmain.cpp` and
|
||||
`moduleinit.cpp` directly (`:78–79`, with the linker-stripping rationale at `:75–77`) plus
|
||||
`${LICE_SRC}` (`:80`). Output is a single **file** named `<name>.vst3` (`:106–111`).
|
||||
|
||||
### 1c. Resources
|
||||
|
||||
`src/resource.rc` declares exactly one dialog — `IDD_BANK_PANEL`, a bare `WS_CHILD`
|
||||
`DIALOG` with an empty `BEGIN`/`END` body and no controls (`:18–22`). `src/resource.h`
|
||||
defines the single id `IDD_BANK_PANEL 1000` (`:8`). It is consumed by
|
||||
`CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), …)` at
|
||||
`src/shell/panel/panel_window.cpp:135`.
|
||||
|
||||
### 1d. Test harness
|
||||
|
||||
91 test declarations across 19 `CMakeLists.txt` files (grep count), matching 91 files in
|
||||
`tests/`. 90 go through `reasampler_test` (`cmake/reasampler_targets.cmake:19–24`, which is
|
||||
`add_executable` + `target_link_libraries` + `add_test`); one is longhand —
|
||||
`app_version_padding_tests` (`src/core/version/CMakeLists.txt:37–42`), which recompiles
|
||||
`app_version.cpp` against a second `configure_file` output so the include-dir substitution
|
||||
works. Neither shape carries anything platform-specific.
|
||||
|
||||
Only two test files mention a platform at all: `tests/test_capture_paths.cpp` (five
|
||||
`#ifdef _WIN32` blocks at `:19`, `:41`, `:87`, `:152`, `:205`, each with a POSIX `#else`
|
||||
branch already written — e.g. `:158–168` asserts `resolveBankFile("/home/d/proj", …)`), and
|
||||
`tests/test_pitch_shift.cpp:3`, where the string is inside a comment explaining why the
|
||||
target links nothing that would drag `<windows.h>` in. No test includes `reaper_plugin.h`,
|
||||
`windows.h`, or any SWELL header. **The test harness is the healthiest part of this
|
||||
surface**; see §3 for the one generator-shape caveat.
|
||||
|
||||
### 1e. Packaging / release, as already committed
|
||||
|
||||
`docs/product/versioning-and-release.md` already commits to Linux as a first-class target:
|
||||
|
||||
- `:361–363` — "**Artifact per platform.** The binary is `reaper_*.dll|.dylib|.so`; the
|
||||
macOS/Linux builds need the SWELL resgen step (CLAUDE.md §SWELL dialog resources) baked
|
||||
into the pipeline. Three platform artifacts per channel per release."
|
||||
- `:307–310` — two named artifacts (stable + beta) per platform, "three platform artifacts
|
||||
each", explicitly handed off to dev-ops.
|
||||
- `:364–366` — "Install is copy-in + restart… there is no server-side rollout."
|
||||
|
||||
What that document does **not** contain: any mention of code signing, macOS notarization, a
|
||||
CI runner, or a Linux install path. I grepped it for `sign|notariz|CI|platform|linux` — one
|
||||
hit is not cited above (`:393`, "artifacts (stable + beta) per platform", the same dev-ops
|
||||
handoff as `:307–310`); the rest are the lines above. The substantive conclusion — no
|
||||
signing/notarization/CI/Linux-install-path mention anywhere — holds regardless. There is no
|
||||
CI configuration anywhere in the repo (`ls -a` on
|
||||
the root shows `.git`, `.gitignore`, `.gitmodules`, `CLAUDE.md`, `CMakeLists.txt`, `LICENSE`,
|
||||
`README.md`, `cmake`, `docs`, `src`, `tests`, `vendor` — no `.github`, no `.gitlab-ci.yml`),
|
||||
and no `install()` rule in any of the 23 CMake files. So "the pipeline" is entirely notional
|
||||
today, on every platform.
|
||||
|
||||
---
|
||||
|
||||
## 2. Findings
|
||||
|
||||
### Λ-01 — the Linux/macOS dialog-resource path is broken in three independent ways, and the fix may not need resgen at all
|
||||
**Severity: Major. Effort: S (mechanical) to M (if the resgen route is taken and automated).**
|
||||
|
||||
**Citations.** `src/app/CMakeLists.txt:94–97` (the Linux comment block) and `:83–86` (the
|
||||
identical macOS one); `vendor/WDL/WDL/swell/swell_resgen.php:313–314`;
|
||||
`vendor/WDL/WDL/swell/swell-dlggen.h:22`; `vendor/WDL/WDL/swell/sample_project/myapp.cpp:192–193`;
|
||||
`vendor/WDL/WDL/swell/swell-dlggen.h:214–218`; `vendor/WDL/WDL/swell/swell-modstub-generic.cpp:31–33`;
|
||||
`vendor/WDL/WDL/swell/swell-functions.h:612–619`; `src/resource.rc:18–22`;
|
||||
`src/shell/panel/panel_window.cpp:135`.
|
||||
|
||||
**What breaks.** Three defects stack, and each alone would sink the step:
|
||||
|
||||
1. **The named script does not exist.** Both comment blocks say
|
||||
`php ${WDL_INC}/swell/mac_resgen.php src/resource.rc` (`:85` and `:96`). Listing
|
||||
`vendor/WDL/WDL/swell/` shows `swell_resgen.php`, `swell_resgen.pl`, and
|
||||
`swell_resgen.sh` — there is no `mac_resgen.php`. `CLAUDE.md` and `README.md:120` both
|
||||
name the correct script; only the CMake comments are wrong, which is exactly the file an
|
||||
implementer will be reading when they do the work.
|
||||
2. **The named output file is not what the generator writes.** Both commented
|
||||
`target_sources` lines (`:86`, `:97`) reference
|
||||
`${REASAMPLER_SRC_DIR}/resource.rc_mac_dlg.h`. `swell_resgen.php:313–314` composes its
|
||||
output names as `$srcfn . "_mac_menu"` and `$srcfn . "_mac_dlg"` — so the actual outputs
|
||||
are `src/resource.rc_mac_dlg` and `src/resource.rc_mac_menu`, with **no** `.h` suffix.
|
||||
3. **Even the correct filename cannot be a `target_sources` entry.** `swell-dlggen.h:22`
|
||||
states the contract outright: "Use swell_resgen.pl or swell_resgen.php on the .rc file,
|
||||
which will produce .rc_mac_dlg, **include that**." The generated body is a run of
|
||||
`SWELL_DEFINE_DIALOG_RESOURCE_BEGIN(...)` macro invocations
|
||||
(`swell_resgen.php:117–129`) that only expand after `swell-dlggen.h` has been included —
|
||||
`swell-dlggen.h:214–218` shows the macro emitting a static `SWELL_DialogRegHelper` that
|
||||
registers into `SWELL_curmodule_dialogresource_head`. The canonical consumption pattern
|
||||
is the SDK's own sample: `sample_project/myapp.cpp:192–193` does
|
||||
`#include "../WDL/swell/swell-dlggen.h"` then `#include "res.rc_mac_dlg"`. A CMake
|
||||
`target_sources` entry for an extensionless file would not even get a language assigned.
|
||||
|
||||
**The runtime consequence, and why this is Major rather than Blocker.** The `.so` still
|
||||
builds and still loads: the extension's whole action family, capture pipeline, and persist
|
||||
layer are untouched. What fails is the dock panel. `swell-functions.h:615` defines
|
||||
`CreateDialog(hinst,resid,par,dlgproc)` as
|
||||
`SWELL_CreateDialog(SWELL_curmodule_dialogresource_head,(resid),par,dlgproc,0)`.
|
||||
`swell-modstub-generic.cpp:32` *defines* that head but nothing ever populates it, because
|
||||
the resgen'd registration TU does not exist — so it stays null and
|
||||
`panel_window.cpp:135`'s `CreateDialogParam` gets a null resource list. The panel is the
|
||||
product's primary surface, so a Linux build without it is not shippable; it is not a
|
||||
Blocker only because the binary loads and the actions work.
|
||||
|
||||
**The route worth considering first.** `swell-functions.h:606–608` documents an escape
|
||||
hatch: *"If you are using CreateDialog() and creating a child window, you can use a resource
|
||||
ID of 0, which creates an opaque child window. Instead of passing a DLGPROC, you should pass
|
||||
a (WNDPROC) routine that returns LRESULT (and cast it to DLGPROC)."* The header comment is
|
||||
not just documentation — the implementation confirms both halves of it:
|
||||
`swell-dlg-generic.cpp:291–292` is `SWELL_DialogResourceIndex *p=resById(reshead,resid); if
|
||||
(!p&&resid) return 0;`, and `resById` (`:34–43`) walks from a (here, null) `reshead` and
|
||||
returns 0 on a miss. That confirms the graceful-failure claim behind "Major rather than
|
||||
Blocker" above (a null `reshead` plus a non-zero `resid` returns 0, not a crash — matching
|
||||
`panel_window.cpp:137`'s `if (!g_panel.hwnd) return;`), **and** that `resid == 0` skips the
|
||||
lookup entirely (`&&resid` short-circuits), which is the escape hatch this paragraph is about.
|
||||
`src/resource.rc:18–22`
|
||||
shows `IDD_BANK_PANEL` is precisely that: a `WS_CHILD` dialog with an empty body and zero
|
||||
controls, whose comment at `:8–10` says "the bank_panel shell owns every pixel and draws the
|
||||
sample grid with LICE in `WM_PAINT`". If the panel is created with resource id 0 on
|
||||
non-Windows, **the entire resgen pipeline — the PHP dependency (`README.md:54`), the
|
||||
generated-file-in-source-tree problem, and the per-platform regeneration story — disappears
|
||||
from the Linux and macOS builds.** The cost is a source change in `panel_window.cpp` and the
|
||||
DLGPROC→WNDPROC return-type contract; that half is T2's call, not mine, and I am naming the
|
||||
option rather than choosing it. This is a shared macOS/Linux fix either way.
|
||||
|
||||
**If the resgen route is taken instead**, the build work is: a `find_program(PHP …)`, an
|
||||
`add_custom_command` generating into `${CMAKE_CURRENT_BINARY_DIR}` (not the source tree —
|
||||
`.gitignore` has no pattern matching `resource.rc_mac_dlg`, so today's manual step would
|
||||
leave an untracked generated file sitting in `src/`), a new one-line shim TU that includes
|
||||
`swell-dlggen.h` then the generated file, and that shim added to `target_sources`. That is
|
||||
the M-effort half of the band.
|
||||
|
||||
---
|
||||
|
||||
### Λ-02 — `--config Release` is a silent no-op on Linux, so the documented ship command produces an unoptimized binary
|
||||
**Severity: Major. Effort: S — one `if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)` block plus a doc correction.**
|
||||
|
||||
**Citations.** `CMakeLists.txt:28–30` (the complete list of language settings — I read the
|
||||
whole 95-line file and there is no `CMAKE_BUILD_TYPE` anywhere); `README.md:106–109`;
|
||||
`CLAUDE.md` §"Build and test" and §"Install / reload".
|
||||
|
||||
**What breaks.** Both docs prescribe `cmake --build build --config Release` as the
|
||||
optimize-and-ship incantation, and `README.md:130–131` tells the user to copy from
|
||||
`build/Release/`. `--config` is a multi-config-generator flag; on Ninja or Unix Makefiles it
|
||||
is accepted and ignored. Because no `CMAKE_BUILD_TYPE` default is set, the resulting build
|
||||
passes **no `-O` flag at all** to GCC/Clang — not `-O0` explicitly, just the compiler's
|
||||
unoptimized default. The failure is silent in both directions: the command succeeds, and the
|
||||
artifact lands at `build/reaper_reasampler.so` — a path the docs never state for a
|
||||
single-config generator (a gap, not an error; see Λ-08).
|
||||
|
||||
This matters more here than the usual "forgot to set the build type" — but only for `peaks`.
|
||||
`CLAUDE.md`'s §"Build and test" (not §"Performance guardrails") states, at `CLAUDE.md:54`,
|
||||
that the header-inline hot paths "presume an **optimizing** build", with no LTO configured to
|
||||
recover it if they don't. `peaks`' `computeEnvelope`/`lastFrameAboveThreshold` is one of
|
||||
those paths and IS linked into the extension (`src/app/CMakeLists.txt:56`), so the finding
|
||||
holds for Fork A as stated. It does **not** extend to `Voice::advanceFrame` or the three
|
||||
`envelopes.h` evaluators (named at `src/core/instrument/CLAUDE.md:378`, not in root
|
||||
`CLAUDE.md`) — those belong to `sampler_core`, which `src/app/CMakeLists.txt:57–59` states is
|
||||
deliberately NOT linked into the extension and which only enters a build at all inside the
|
||||
Windows-gated `reasampler_vst` target (§1b). On Fork A that code never compiles into
|
||||
`reaper_reasampler` in the first place; the unoptimized-build risk to it is a Fork B concern,
|
||||
not this extension-only one. A Linux user following the README's `--config Release` would
|
||||
ship an extension binary whose `peaks` hot path did not inline.
|
||||
|
||||
**Done when:** `cmake --build build` on Linux (Ninja/Make, no `--config`) produces a build
|
||||
whose compile commands (`compile_commands.json` or verbose build log) show an explicit `-O`
|
||||
flag, and the README/CLAUDE.md ship instruction names the single-config-generator path.
|
||||
|
||||
**Sequencing note.** This is not strictly Linux-specific — Ninja on Windows has the same
|
||||
shape — but it becomes load-bearing the moment Linux is a shipped platform, because Makefiles
|
||||
and Ninja are the default there.
|
||||
|
||||
---
|
||||
|
||||
### Λ-03 — no symbol-visibility control, and two modules in one process both export a full copy of LICE
|
||||
**Severity: Major. Effort: S — one `CXX_VISIBILITY_PRESET hidden` + `VISIBILITY_INLINES_HIDDEN` pair, plus a `[verify — Linux]` load test.**
|
||||
|
||||
**Citations.** `CMakeLists.txt:78–83` (`LICE_SRC`); `src/app/CMakeLists.txt:35` and
|
||||
`src/shell/instrument/CMakeLists.txt:80` (the same four LICE TUs compiled into *both*
|
||||
modules — the rationale for the duplication is at `CMakeLists.txt:75–77`);
|
||||
`vendor/reaper-sdk/sdk/reaper_plugin.h:52`; `src/app/main.cpp:251`;
|
||||
`vendor/vst3sdk/pluginterfaces/base/fplatform.h:164`;
|
||||
`vendor/WDL/WDL/swell/Makefile:20`.
|
||||
|
||||
**What breaks.** Nothing in the build sets a visibility preset, so GCC/Clang default to
|
||||
`-fvisibility=default` and every non-static symbol in both modules — all of LICE, all the
|
||||
WDL helpers, every pure library's exported functions, and the whole C++ standard-library
|
||||
instantiation set — lands in the module's dynamic symbol table. On Windows this is a
|
||||
non-issue: each DLL has its own symbol table and per-module resolution. `[verify — Linux]`
|
||||
on ELF, symbols from `dlopen`'d objects CAN participate in global interposition — but whether
|
||||
they actually do here depends on REAPER's own `dlopen` flags (`RTLD_GLOBAL` vs `RTLD_LOCAL`)
|
||||
and whether REAPER's executable is linked `--export-dynamic`, neither of which this tree can
|
||||
answer. The scenario this doc originally named — a hypothetical `reasampler_9000.so` — does
|
||||
not exist on Fork A: per Λ-10 and `src/shell/instrument/CMakeLists.txt:9`, the VST3 target is
|
||||
gated `WIN32 AND EXISTS ...` and is never built on Linux unless D1 selects Fork B. The
|
||||
Fork-A-real risk is different: any **second REAPER extension** loaded in the same REAPER
|
||||
process (a third-party `.so` in `UserPlugins/`) that links its own copy of LICE/WDL and also
|
||||
defaults to `-fvisibility=default` is a candidate to interpose with `reaper_reasampler.so`'s
|
||||
`LICE_FillRect` and the rest of the exported LICE/WDL symbol set. That is a class of bug that
|
||||
presents as "one extension's LICE draws with another extension's LICE build" and is invisible
|
||||
on Windows, where each DLL keeps its own symbol table.
|
||||
|
||||
**Why hiding is safe here.** The two symbols that genuinely must be exported already say so
|
||||
explicitly: `reaper_plugin.h:52` defines
|
||||
`REAPER_PLUGIN_DLL_EXPORT __attribute__((visibility("default")))` on non-Windows, and
|
||||
`main.cpp:251` uses it (`extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(...)`).
|
||||
On the VST3 side, `fplatform.h:164` defines `SMTG_EXPORT_SYMBOL` the same way. SWELL's own
|
||||
build already uses `-fvisibility=hidden` (`Makefile:20`), which is the precedent. The one
|
||||
thing to confirm on a Linux box is that `swell-modstub-generic.cpp:135`'s `SWELL_dllMain`
|
||||
survives — it carries its own `__attribute__ ((visibility ("default")))` at that line, so it
|
||||
should, but the modstub is compiled by *our* build, not SWELL's.
|
||||
|
||||
*Shared macOS/Linux — the visibility default and the LICE-TU duplication (`CMakeLists.txt:75–77`)
|
||||
apply the same way under the APPLE branch's Mach-O two-level namespace, though the concrete
|
||||
Fork-A-real interposition mechanism above is ELF-specific; macOS's own dynamic-linker symbol
|
||||
resolution is a separate `[verify]` this doc does not attempt.*
|
||||
|
||||
---
|
||||
|
||||
### Λ-04 — nothing links a thread library, and `<pthread.h>` is in the extension's include graph
|
||||
**Severity: Major (VST3 target) / Minor (extension). Effort: S — `find_package(Threads)` + `Threads::Threads`.**
|
||||
|
||||
**Citations.** `src/shell/panel/panel_state.h:20–24`;
|
||||
`vendor/vst3sdk/base/thread/source/flock.cpp:71–133`;
|
||||
`src/shell/instrument/CMakeLists.txt:15–39`; `vendor/WDL/WDL/swell/Makefile:90`.
|
||||
|
||||
**What breaks.** No CMake file in the tree calls `find_package(Threads)` or links
|
||||
`Threads::Threads`/`-lpthread`/`-ldl` (grep-verified across all 23). Two separate exposures:
|
||||
|
||||
- **Extension.** `panel_state.h:23` includes `<pthread.h>` on non-Win32 — and it is not the
|
||||
only entry point: `vendor/reaper-sdk/sdk/reaper_plugin.h:50` includes `<pthread.h>`
|
||||
unconditionally in its non-Windows branch, so it is in **every** shell TU's include graph
|
||||
(every `.cpp` that includes `reaper_plugin.h`/`reaper_plugin_functions.h`), not just the
|
||||
one panel file. This strengthens rather than changes the finding: I still did not find a
|
||||
pthread *call* in `src/shell/`'s own code, so this may be an include-only dependency that
|
||||
needs no link flag on a modern glibc (where the pthread symbols are in libc proper). Stays
|
||||
Minor, and `[verify — Linux]`.
|
||||
- **VST3 target.** `flock.cpp` is unambiguous: on non-Windows it calls
|
||||
`pthread_mutexattr_init`/`_settype`/`_destroy` (`:71–76`), `pthread_mutex_init` (`:74`),
|
||||
`pthread_mutex_destroy` (`:89`), `_lock` (`:105`), `_unlock` (`:121`) and `_trylock`
|
||||
(`:133`). It is unconditionally in the `vst3_sdk` source list
|
||||
(`src/shell/instrument/CMakeLists.txt:27`). Any Linux VST3 target must link a thread
|
||||
library. SWELL's own Makefile uses `-lpthread -ldl` (`:90`) for the same reason.
|
||||
|
||||
Adding `find_package(Threads REQUIRED)` + `target_link_libraries(... Threads::Threads)` is
|
||||
correct on all three platforms and costs nothing on Windows.
|
||||
|
||||
**Done when:** the extension links with `Threads::Threads` removed (proving the include-only
|
||||
half needs no flag, or naming the symbol that forces it — see V5), and, if Fork B is taken,
|
||||
`reasampler_vst` links clean with `Threads::Threads` present.
|
||||
|
||||
*Shared macOS/Linux — `reaper_plugin.h:50`'s non-Windows `<pthread.h>` include and
|
||||
`flock.cpp`'s non-Windows pthread calls apply identically to the APPLE branch.*
|
||||
|
||||
---
|
||||
|
||||
### Λ-05 — `reaper_plugin.h`'s SWELL include resolves only by coincidence of the `-I` line
|
||||
**Severity: Minor. Effort: S — a comment, or an `INTERFACE` target that carries both include dirs as one unit.**
|
||||
|
||||
**Citations.** `vendor/reaper-sdk/sdk/reaper_plugin.h:42–54`; `CMakeLists.txt:65–66`;
|
||||
`src/app/CMakeLists.txt:56`; `src/shell/instrument/CMakeLists.txt:97`.
|
||||
|
||||
**What breaks — and, today, does not.** On non-Windows, `reaper_plugin.h:49` does
|
||||
`#include "../WDL/swell/swell.h"`. Relative to the including file's own directory
|
||||
(`vendor/reaper-sdk/sdk/`) that resolves to `vendor/reaper-sdk/WDL/swell/swell.h`, which does
|
||||
not exist — listing `vendor/reaper-sdk/` shows only `README`, `reaper-plugins`, and `sdk`.
|
||||
The include is nevertheless satisfiable, because the quoted-include fallback searches the
|
||||
`-I` path, and `WDL_INC` is `${CMAKE_CURRENT_SOURCE_DIR}/vendor/WDL/WDL` (`CMakeLists.txt:66`) —
|
||||
so `WDL_INC` + `../WDL/swell/swell.h` normalizes to `vendor/WDL/WDL/swell/swell.h`, which
|
||||
exists. Both module targets pass `${WDL_INC}` (`src/app:56`, `src/shell/instrument:97`), so it
|
||||
holds today.
|
||||
|
||||
The hazard is that this is a load-bearing coincidence with no comment anywhere, and it is
|
||||
**invisible on Windows** — `reaper_plugin.h:43` takes `<windows.h>` instead, so dropping
|
||||
`${WDL_INC}` from any target that includes `reaper_plugin.h` would pass CI on Windows and
|
||||
fail only on Linux/macOS. Worth pinning before more targets are added.
|
||||
|
||||
---
|
||||
|
||||
### Λ-06 — the build has no platform partition of its source list, so every T2 finding lands here as a CMake change
|
||||
**Severity: Minor (structural). Effort: S per TU once T2 says which ones need it.**
|
||||
|
||||
**Citations.** `src/app/CMakeLists.txt:8–51` and `:71–98`;
|
||||
`src/shell/actions/drag_out_win.cpp:8`, `:249`, `:282`.
|
||||
|
||||
**Mechanism.** All 38 shell TUs (plus `app/main.cpp`, 39 listed TUs total) are listed unconditionally (`:8–51`); the platform block at
|
||||
`:71–98` only *adds* sources, never removes or substitutes any. Every TU therefore enters a
|
||||
Linux compile, and the only thing standing between the build and a compile error is each
|
||||
file's own `#ifdef` discipline. Today that discipline is uneven in a way worth recording:
|
||||
|
||||
- `drag_out_win.cpp` is the model — `#ifdef _WIN32` at `:8` with a real
|
||||
`// ---- macOS / Linux (SWELL)` branch at `:249` and `#endif` at `:282`.
|
||||
- `arrange_drop_win.cpp` and `instrument_drop_win.cpp` contain **zero** occurrences of
|
||||
`_WIN32` (grep count 0 each) and include no `<windows.h>` — they are `_win`-suffixed for
|
||||
the surface they serve, not for a platform dependency, and appear portable by inspection.
|
||||
|
||||
This is not itself a defect; it is the reason the ordering sketch in §5 puts "read T2's
|
||||
output, then partition the source list" as a distinct step rather than folding it into the
|
||||
resgen work.
|
||||
|
||||
---
|
||||
|
||||
### Λ-07 — `std::filesystem` in a pure library, with no stated minimum compiler
|
||||
**Severity: Minor. Effort: S — a `cmake_minimum_required`-adjacent compiler check or a documented floor.**
|
||||
|
||||
**Citations.** `src/core/capture/capture_paths.cpp:5` (`#include <filesystem>`) and `:95`
|
||||
(`std::filesystem::path(rppPath).parent_path().string()`); `README.md:50–54`.
|
||||
|
||||
**What breaks.** `capture_paths` is a *pure* library with its own CTest target
|
||||
(`src/core/capture/CMakeLists.txt:1–2`), so this is on the test-harness path as well as the
|
||||
extension's. GCC 8 requires an explicit `-lstdc++fs` for `std::filesystem`; GCC ≥ 9 and
|
||||
libstdc++ ≥ 9 do not. `README.md:51` states only "A C++17 compiler" as the prerequisite, and
|
||||
`cmake_minimum_required(VERSION 3.19)` (`CMakeLists.txt:1`) says nothing about the compiler.
|
||||
On any current distro toolchain this is a non-issue; it becomes one on an older CI image.
|
||||
Twelve other files in `src/shell/` also use `<filesystem>` (grep-verified), but one —
|
||||
`src/shell/instrument/instrument_bake.cpp` — is in the `reasampler_vst` target, not the
|
||||
extension: eleven in `reaper_reasampler`, sharing the fix below; the twelfth shares it only
|
||||
if/when Fork B builds that target on Linux.
|
||||
|
||||
**Done when:** `capture_paths_tests` links on the oldest toolchain the phase targets without
|
||||
`-lstdc++fs` added explicitly, or that flag is added and documented as the floor.
|
||||
|
||||
*Shared macOS/Linux — `capture_paths` and the eleven extension-side `<filesystem>` users
|
||||
compile identically under the APPLE branch; whether Apple's libc++ needs the same kind of
|
||||
minimum-toolchain floor as libstdc++ does is a separate `[verify]` this doc has not checked,
|
||||
but the coverage-hole shape (undocumented compiler floor) is identical either way.*
|
||||
|
||||
---
|
||||
|
||||
### Λ-08 — the install instruction is silent about single-config generators, and no path is documented for Linux at all
|
||||
**Severity: Minor. Effort: S — a doc fix, or an `install()` rule if packaging is ever automated.**
|
||||
|
||||
**Citations.** `src/app/CMakeLists.txt:68` (`LIBRARY_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}"`);
|
||||
`README.md:130–131`; `CLAUDE.md` §"Install / reload";
|
||||
`docs/product/versioning-and-release.md:364–366`.
|
||||
|
||||
**What breaks — a gap, not an error.** `README.md:130–131` and `CLAUDE.md` §"Install /
|
||||
reload" both already carry the multi-config qualifier verbatim ("`build/Release/` on a
|
||||
multi-config generator — not the default `Debug/` output") — so the instruction is not
|
||||
wrong, it is simply silent about the other case. On a multi-config generator CMake appends
|
||||
the config directory, giving the documented `build/Release/`. On Ninja/Make the module lands
|
||||
directly at `build/reaper_reasampler.so`, and neither doc names that path. Compounding it,
|
||||
there is no `install()` rule anywhere in the 23 CMake files, and no target directory is
|
||||
documented for Linux at all — `CLAUDE.md` and `README.md:132` say "REAPER's `UserPlugins/`
|
||||
folder (Options → Show REAPER resource path)", which is a correct user-facing instruction but
|
||||
not a path a pipeline can use. I could not confirm REAPER's Linux resource-path root from the
|
||||
SDK: grepping `vendor/reaper-sdk/sdk/` for `UserPlugins` and `reaper_*.so` returns nothing —
|
||||
the `reaper_*.dll|dylib|so` filename convention stated in `CLAUDE.md` is not documented in the
|
||||
vendored headers. `[verify — Linux]`.
|
||||
|
||||
---
|
||||
|
||||
### Λ-09 — no warning flags anywhere, so the first GCC/Clang build surfaces a diagnostic set nobody has ever seen
|
||||
**Severity: Minor. Effort: S to add flags; **unknowable** to fix what they reveal — that is the honest band.**
|
||||
|
||||
**Citations.** grep for `target_compile_options` across all 23 CMake files: zero hits (the
|
||||
only `compile_definitions` hits are `SWELL_PROVIDED_BY_APP` at `src/app/CMakeLists.txt:80`,
|
||||
`:92` and the SDK's `DEVELOPMENT`/`RELEASE` selector at
|
||||
`src/shell/instrument/CMakeLists.txt:42`). `CMakeLists.txt:28–30` for the absence of
|
||||
`CMAKE_CXX_EXTENSIONS`.
|
||||
|
||||
**Mechanism.** The codebase has been compiled by exactly one compiler family. MSVC's default
|
||||
is `/W1`; GCC/Clang's default is essentially silent. Adding `-Wall -Wextra` on the first
|
||||
Linux build is the right move, but the size of the resulting diagnostic set over ~39k LOC is
|
||||
not estimable from Windows, which is why the effort band is split. Do **not** add `-Werror`
|
||||
in the same change.
|
||||
|
||||
Secondary, in the same family: `CMAKE_CXX_EXTENSIONS` is unset, so it defaults to `ON` and
|
||||
GCC/Clang compile as `-std=gnu++17` rather than `-std=c++17`. Not a bug; a hygiene knob
|
||||
worth flipping while the flags are being set, since it is the difference between "portable
|
||||
C++17" and "whatever GNU extensions happened to be used".
|
||||
|
||||
**Coverage hole: the link-succeeds-but-load-fails asymmetry is never stated.** GNU `ld` does
|
||||
not error on undefined symbols in a shared object by default — MSVC's linker does. For a
|
||||
`MODULE` REAPER `dlopen()`s, that means a Linux build can link `reaper_reasampler.so` clean
|
||||
and only discover a missing symbol when REAPER tries to load it (`dlopen` failing, or a
|
||||
partially-resolved symbol table if a satisfying-by-coincidence symbol exists elsewhere in the
|
||||
process — a real risk given Λ-03's global-interposition finding above). Neither `Threads`
|
||||
(Λ-04) nor visibility (Λ-03) named this asymmetry, and it undercuts §5's framing of V1
|
||||
(`cmake --build build`) as "the single highest-information action available" — a clean build
|
||||
on Linux does not carry the same guarantee a clean Windows build does. `-Wl,--no-undefined`
|
||||
(GNU ld) would restore the MSVC-equivalent fail-at-link-time behavior; whether it is needed
|
||||
here specifically depends on whether `SWELL_PROVIDED_BY_APP`'s function-pointer design (§1a)
|
||||
already accounts for every symbol the modstub doesn't itself define — that is `[verify —
|
||||
Linux]`, since it requires an actual GCC/Clang link to observe.
|
||||
|
||||
**Done when:** the Linux build either adds `-Wl,--no-undefined` (or the equivalent) so an
|
||||
undefined-symbol build failure surfaces at link time, or a stated reason is recorded for why
|
||||
`SWELL_PROVIDED_BY_APP` makes the gap moot.
|
||||
|
||||
*Shared macOS/Linux — `-Wall -Wextra`, `CMAKE_CXX_EXTENSIONS OFF`, and the GNU-ld-vs-MSVC-ld
|
||||
asymmetry (macOS's linker also does not default to erroring on undefined symbols) apply
|
||||
identically to the APPLE branch.*
|
||||
|
||||
---
|
||||
|
||||
### Λ-10 — the submodule story costs a Linux developer a large download for a target that is gated off
|
||||
**Severity: Minor. Effort: S — a doc paragraph.**
|
||||
|
||||
**Citations.** `.gitmodules` (three entries, no `shallow`, no `branch`);
|
||||
`README.md:70–86`; `src/shell/instrument/CMakeLists.txt:9`; `CLAUDE.md` §"One-time submodule setup".
|
||||
|
||||
**Mechanism.** `README.md:72–75` already warns that `git clone --recursive` pulls all of
|
||||
`vendor/vst3sdk`'s nested submodules (`vstgui4`, `tutorials`, `doc`, `cmake`) that this
|
||||
project never links, and prescribes the narrow init instead. What neither the README nor
|
||||
`CLAUDE.md` says is that on Linux the `vendor/vst3sdk` submodule is **pure cost**: the
|
||||
`WIN32 AND EXISTS` gate at `src/shell/instrument/CMakeLists.txt:9` omits the target
|
||||
regardless, so `git submodule update --init vendor/reaper-sdk vendor/WDL` is the complete
|
||||
Linux prerequisite. One sentence in the platform-support table would save every Linux
|
||||
contributor the download.
|
||||
|
||||
---
|
||||
|
||||
## 3. Surfaces checked and found clean
|
||||
|
||||
Recorded so the plan author does not re-spend effort here. Each was read or grepped in this
|
||||
pass.
|
||||
|
||||
- **All 23 `CMakeLists.txt` files were read in full**, not sampled: root; `src/app`;
|
||||
`src/core`; `src/core/{audio,capture,instrument,json,model,reclaim,tracking,ui,util,version,view,wire}`;
|
||||
`src/core/instrument/{bake,engine,map,note,ui}`; `src/core/instrument/engine/{filter,loop}`;
|
||||
`src/shell/instrument`. Plus `cmake/reasampler_targets.cmake`. Of these, 21 are entirely
|
||||
platform-neutral — they declare libraries and tests through the two helpers and nothing
|
||||
else.
|
||||
- **`CMAKE_POSITION_INDEPENDENT_CODE ON`** (`CMakeLists.txt:30`) — the usual Linux
|
||||
`MODULE`-fed-by-`STATIC` trap, already handled.
|
||||
- **`.so` naming.** `PREFIX ""` (`src/app/CMakeLists.txt:66`) + `SUFFIX ".so"` (`:93`) +
|
||||
channel-derived `OUTPUT_NAME` (`:67`) yields `reaper_reasampler.so` /
|
||||
`reaper_reasampler_beta.so`, matching the `reaper_*` convention. `ARCHIVE_OUTPUT_DIRECTORY`
|
||||
(`:69`) is a Windows import-lib concern and harmlessly inert on Linux.
|
||||
- **The channel fork is platform-independent.** `REASAMPLER_CHANNEL` (`CMakeLists.txt:40–54`)
|
||||
threads through `configure_file` (`:58–61`) into names only; nothing in it is
|
||||
Windows-specific, so beta builds fork identically on Linux.
|
||||
- **The version padding canary** (`src/core/version/CMakeLists.txt:21–42`) uses a
|
||||
function-scoped `set()` + second `configure_file` + longhand `add_executable`. Portable;
|
||||
no platform assumption.
|
||||
- **The whole test corpus is platform-neutral.** 91 files in `tests/`, 91 declarations in
|
||||
CMake. Only `test_capture_paths.cpp` branches on platform and it already has POSIX
|
||||
expectations written; only `test_pitch_shift.cpp` mentions `windows.h`, in a comment.
|
||||
- **Generator-shape handling in the docs is already correct.** `README.md:93–98` explains
|
||||
that `-C Debug` is required on multi-config and unnecessary on Ninja/Make. `ctest --test-dir
|
||||
build` works bare on Linux; the `-C Debug` in the README's example is harmless there.
|
||||
- **SWELL API coverage for the LICE slice** — all six SWELL GDI entry points LICE needs are
|
||||
in the modstub-resolved table (`swell-functions.h:857`, `:863`, `:877`, `:885–887`).
|
||||
- **The LICE slice needs no fifth TU on Linux** (`lice.cpp:3040` `_LICE_RGB2HSV_invtab`
|
||||
table, `lice_textnew.cpp:26` file-static `utf8makechar`) — an inference from V1's link
|
||||
success plus these two spot checks, not exhaustive; see §1a's fuller caveat, including the
|
||||
unreconciled `lice_colorspace.o` in SWELL's own `Makefile:106–107`.
|
||||
- **`swell-modstub-generic.cpp` compiles against its own includes** — it uses `printf`
|
||||
(`:61`), which arrives via `swell.h` → `swell-types.h:29` (`#include <stdio.h>`). The
|
||||
`dlopen`/`readlink` block (`:69–131`) is behind `SWELL_LOAD_SWELL_DYLIB`, which this build
|
||||
never defines.
|
||||
- **The Linux run-loop IIDs are already in the vendored VST3 slice.**
|
||||
`public.sdk/source/common/commoniids.cpp:50–54` defines `Linux::IEventHandler`,
|
||||
`Linux::ITimerHandler` and `Linux::IRunLoop` under `#if SMTG_OS_LINUX`, and that file is
|
||||
already in the `vst3_sdk` source list (`src/shell/instrument/CMakeLists.txt:36`). A Linux
|
||||
VST3 port needs no additional SDK translation unit for the run loop.
|
||||
- **`SMTG_OS_LINUX` / `SMTG_EXPORT_SYMBOL` are correct in the pinned SDK**
|
||||
(`pluginterfaces/base/fplatform.h:132`, `:164`).
|
||||
- **The MSVC-ABI caveat is Windows-scoped.** `reaper_plugin.h:22` warns that the C++
|
||||
interfaces "require MSVC on win32, or at least the MSVC-compatible C++ ABI" — that is a
|
||||
constraint on Windows toolchains (mingw), not on Linux, where REAPER and the extension both
|
||||
use the Itanium ABI.
|
||||
|
||||
---
|
||||
|
||||
## 4. The VST3-on-Linux question, stated as a fork
|
||||
|
||||
Daniel rules on this. Below is the cost of each side with the evidence, and nothing else.
|
||||
|
||||
### Fork A — extension only on Linux
|
||||
|
||||
Scope: everything in §2 (Λ-01 through Λ-10). The VST3 gate at
|
||||
`src/shell/instrument/CMakeLists.txt:9` stays exactly as it is; a Linux configure omits the
|
||||
target and the `vendor/vst3sdk` submodule becomes optional there (Λ-10).
|
||||
|
||||
**Cost profile:** one Major that must be solved (Λ-01, the panel), two Majors that are
|
||||
one-line-ish but load-bearing (Λ-02 optimization, Λ-03 visibility), one Major/Minor
|
||||
(Λ-04 threads), and six Minors. No new third-party dependency. No new artifact format. No
|
||||
change to any product decision on record.
|
||||
|
||||
**The unknown that dominates the band:** none of the extension's source portability is
|
||||
audited here. The build-system work is small; whether the 38 shell TUs *compile and behave*
|
||||
against SWELL's Win32 subset is T2's answer, and it, not this list, sets the phase's size.
|
||||
|
||||
### Fork B — extension + ReaSampler 9000 on Linux
|
||||
|
||||
Everything in Fork A, plus the following. Three of five are Blockers under the severity
|
||||
vocabulary defined above ("the Linux build cannot produce a loadable artifact"): B1, B2, B3.
|
||||
B4 downgrades to Major — the module still loads; only the editor fails to attach without it.
|
||||
B5 is not a technical blocker at all — it is a documentation act, though one that must
|
||||
precede the others in sequence (see §6 Stage 5).
|
||||
|
||||
- **B1 — the entry point is the wrong one, and the right one is already vendored.**
|
||||
`src/shell/instrument/CMakeLists.txt:78` compiles
|
||||
`public.sdk/source/main/dllmain.cpp` into the module. That file does
|
||||
`#include <windows.h>` at `:41` with no `SMTG_OS_*` guard — grepping the file for
|
||||
`SMTG_OS` returns nothing, and its only conditionals are
|
||||
`#if defined(_MSC_VER) && defined(DEVELOPMENT)` (`:43`, `:96`) and `#ifdef __cplusplus`
|
||||
(`:58`, `:87`). Linux needs
|
||||
`public.sdk/source/main/linuxmain.cpp`, which is present in the slice and exports
|
||||
`ModuleEntry`/`ModuleExit` (`extern "C"` declarations `linuxmain.cpp:47–52`; definitions
|
||||
`:59–67` and `:72–82` respectively). Both are **mandatory** — the
|
||||
SDK's own loader refuses the module without either
|
||||
(`public.sdk/source/vst/hosting/module_linux.cpp:189–204`, error strings "The shared
|
||||
library does not export the required 'ModuleEntry' function" and the same for `ModuleExit`).
|
||||
`GetPluginFactory` is checked at `:205`. **Effort S** — a `CMakeLists.txt` source swap
|
||||
(`dllmain.cpp` → `linuxmain.cpp`) plus the platform `if()`; both files are already vendored,
|
||||
nothing to write from scratch.
|
||||
|
||||
- **B2 — the artifact is a directory bundle, not a file.** Today the target sets
|
||||
`SUFFIX ".vst3"` on a MODULE (`src/shell/instrument/CMakeLists.txt:106–111`), producing a
|
||||
single file. `module_linux.cpp:146–170` (`getSOPath`) shows what a Linux host actually
|
||||
opens: the path must be a **directory**, containing `Contents/`, containing
|
||||
`<machine>-linux/` where `<machine>` is `uname().machine` (`:158–163`, helper at `:88–97`),
|
||||
containing `<stem>.so` (`:167–168`). So `reasampler_9000.vst3/Contents/x86_64-linux/reasampler_9000.so`.
|
||||
That is `add_custom_command(POST_BUILD)` work plus an install rule. Install roots are
|
||||
`$HOME/.vst3/`, `/usr/lib/vst3/`, `/usr/local/lib/vst3/` and `$APPFOLDER/vst3/`
|
||||
(`module_linux.cpp:282–302`). `Contents/Resources/moduleinfo.json` is **optional** —
|
||||
`getModuleInfoPath` (`:356–365`) returns empty when the file is absent rather than failing.
|
||||
**Effort M** — mechanical, but it is a new artifact shape with an install story and a
|
||||
per-architecture directory name.
|
||||
|
||||
- **B3 — the drawing stack is the real cost, and it is where Fork B stops being a build task.**
|
||||
The VST3 module compiles the same four LICE TUs (`src/shell/instrument/CMakeLists.txt:80`),
|
||||
and on non-Win32 LICE resolves its GDI through SWELL (§1a). But **a VST3 plugin is not a
|
||||
REAPER extension**: `swell-modstub-generic.cpp`'s default branch exports `SWELL_dllMain`
|
||||
(`:135`), which only REAPER's *extension* loader calls. Nothing hands a VST3 plugin the
|
||||
SWELL function table. That leaves two routes:
|
||||
- **Route B3a — `SWELL_LOAD_SWELL_DYLIB`** (`swell-modstub-generic.cpp:69–131`): the stub
|
||||
reads `/proc/self/exe`, strips to the directory, and `dlopen`s `libSwell.so` beside the
|
||||
host binary (`:81–98`), then calls `SWELL_set_app_main(SWELLAppMain)` (`:112–114`). That
|
||||
reference makes `SWELLAppMain` a **required definition in the plugin** — it is declared at
|
||||
`swell-types.h:137` as "to be implemented by app (if using swellappmain.mm)". Whether
|
||||
REAPER's Linux install ships a
|
||||
`libSwell.so` beside its executable is `[verify — Linux]`, and this route makes the VST3
|
||||
load-bearing on being hosted by REAPER specifically, which is consistent with D5's
|
||||
REAPER-only stance but is a new runtime coupling to state explicitly.
|
||||
- **Route B3b — build SWELL into the module.** `vendor/WDL/WDL/swell/Makefile:100–104`
|
||||
enumerates the 14 SWELL TUs (`swell.o swell-ini.o swell-miscdlg-generic.o
|
||||
swell-wnd-generic.o swell-menu-generic.o swell-kb-generic.o swell-dlg-generic.o
|
||||
swell-gdi-generic.o swell-misc-generic.o swell-gdi-lice.o swell-generic-headless.o
|
||||
swell-generic-gdk.o swell-appstub-generic.o swell-modstub-generic.o`) and `:106–107` the 8
|
||||
LICE TUs it needs alongside. Link requirements at `:90` (`-lpthread -ldl`), `:128–130`
|
||||
(pkg-config `gtk+-3.0` or `gdk-3.0`), `:145–151` (`freetype2` + `-lfontconfig`) and
|
||||
`:154` (`-lGL`).
|
||||
**This is a wholly new third-party dependency surface** — GDK/GTK3, FreeType, Fontconfig,
|
||||
OpenGL — that the extension deliberately avoids via `SWELL_PROVIDED_BY_APP`. Flagging it
|
||||
as a scope expansion, per the dispatch's constraint; it is not something to slip in.
|
||||
**Effort L either way**, and B3b is the larger of the two by a wide margin.
|
||||
|
||||
- **B4 — the editor's platform contract changes.** The view must report
|
||||
`kPlatformTypeX11EmbedWindowID` (`pluginterfaces/gui/iplugview.h:79`) instead of
|
||||
`kPlatformTypeHWND` (`:62`), and timers/file-descriptors must go through
|
||||
`Steinberg::Linux::IRunLoop` (`iplugview.h:267–279`) with `IEventHandler` (`:223–229`) and
|
||||
`ITimerHandler` (`:239–245`) — the header states the reason at `:255–256`: "On Linux the
|
||||
host has to provide this interface to the plug-in as there's no global event run loop
|
||||
defined as on other platforms." The whole `namespace Linux` block is `#if SMTG_OS_LINUX`
|
||||
(`:209`, closed `:284`), so it compiles in automatically. The **build-side** cost is nil
|
||||
(the IIDs are already vendored — see §3); the **source-side** cost is `editor_platform.cpp`
|
||||
and every timer in the editor, which is T2's surface. **Effort: build S, source L** — build
|
||||
is S because no new CMake source is needed (the SDK block compiles in automatically); source
|
||||
is L because it is a genuine window/run-loop rewrite (an X11 embed contract with no Win32
|
||||
analogue, not a mechanical port) and outside this doc's own portability scope to size
|
||||
further.
|
||||
|
||||
- **B5 — a settled product decision has to be reversed, in writing.** D5 ("Windows-only,
|
||||
VST3-only, REAPER-only") is recorded as a locked decision with "a mac/Linux build" named
|
||||
as a non-goal in `src/core/instrument/CLAUDE.md` (Invariants → the three locked decisions;
|
||||
and again under Non-goals/guardrails) and `src/shell/instrument/CLAUDE.md`
|
||||
(Non-goals/guardrails). `src/shell/panel/CLAUDE.md` carries a third dependent statement.
|
||||
Fork B is not just more work — it invalidates three recorded invariants and needs Daniel's
|
||||
explicit reversal before any code moves. **Effort S** — no code or CMake change of its own,
|
||||
a rewrite of the three `CLAUDE.md` passages naming D5 plus Daniel's sign-off; the L-sized
|
||||
cost sits in B3/B4, not here.
|
||||
|
||||
**The asymmetry worth naming.** Fork A's build work is small and its unknown is source
|
||||
portability. Fork B's build work is genuinely large (B2, B3) *and* carries an unbounded
|
||||
source-portability tail (B3b's dependency surface, B4's window/run-loop rewrite) *and*
|
||||
requires reversing a settled decision. The two are not "the same phase, one bigger" — they
|
||||
are different-shaped commitments.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions
|
||||
|
||||
### `[verify — Linux]` — answerable by trying it on a Linux box
|
||||
|
||||
| # | Question | Exact check |
|
||||
|---|---|---|
|
||||
| V1 | Does the extension compile at all under GCC/Clang? | `cmake -B build -S . -G Ninja && cmake --build build 2>&1 \| tee build.log` — the single highest-information action available and gates most of T2's list too, with one caveat (Λ-09): a clean link is not the same guarantee here as on MSVC, since GNU `ld` does not error on undefined symbols in a `MODULE` by default — V1 answers "does it compile and link", not "does it load". |
|
||||
| V2 | Does `ctest` pass all 91 targets? | `cmake -B build -S . -G Ninja && cmake --build build && ctest --test-dir build --output-on-failure` (no `-C` needed on Ninja). |
|
||||
| V3 | Does REAPER's Linux build call `SWELL_dllMain` in a `reaper_*.so`, populating the API table? | Drop the built `.so` in `~/.config/REAPER/UserPlugins/`, restart, and check the extension's actions appear. A null table shows as immediate crash or dead actions. |
|
||||
| V4 | Where exactly is `UserPlugins/` on Linux, and is `reaper_*.so` the right filename glob? | Options → Show REAPER resource path in a Linux REAPER. The SDK headers do not document either — grep of `vendor/reaper-sdk/sdk/` for `UserPlugins` and `reaper_*.so` returns nothing. |
|
||||
| V5 | Does `panel_state.h:23`'s `<pthread.h>` actually require a link flag on the target glibc? | Build without `Threads::Threads`; if it links, it does not. |
|
||||
| V6 | Does `-fvisibility=hidden` leave `ReaperPluginEntry` and `SWELL_dllMain` exported? | `nm -D --defined-only reaper_reasampler.so \| grep -E 'ReaperPluginEntry\|SWELL_dllMain'` after adding the preset. |
|
||||
| V7 | With Λ-01 fixed by the resource-id-0 route, does `SWELL_CreateDialog` return a usable child HWND for the docker? | Panel toggle action in a Linux REAPER. |
|
||||
| V8 | Does `std::filesystem` need `-lstdc++fs` on the chosen toolchain? | Build `capture_paths_tests` alone; the link error names it. |
|
||||
| V9 | Does REAPER's Linux install ship a `libSwell.so` beside its executable (Fork B, route B3a)? | `ls $(dirname $(readlink -f $(which reaper)))/libSwell.so`. |
|
||||
| V10 | Which `uname -m` values must the VST3 bundle carry (Fork B, B2)? | `uname -m` on each target. `module_linux.cpp:158–163` keys the bundle directory off exactly this string (tree-grounded and verified); which architectures REAPER itself ships for Linux (x86_64, aarch64, armv7l is the claim) is an external fact with no source in this tree — `[verify]` against REAPER's own download page before relying on it. |
|
||||
|
||||
### `[Daniel]` — genuine product/scope calls
|
||||
|
||||
| # | Question | Why it is not answerable by reading code |
|
||||
|---|---|---|
|
||||
| D1 | **Fork A or Fork B?** Extension only, or extension + ReaSampler 9000 on Linux? | §4 states both costs. The choice is scope, and D5 makes it a reversal of a recorded decision, not a technical lookup. |
|
||||
| D2 | If Fork B: **route B3a (dlopen the host's `libSwell.so`, requires defining `SWELLAppMain`) or B3b (vendor and build full SWELL, adds GDK/GTK3 + FreeType + Fontconfig + OpenGL)?** | B3b is a dependency-surface expansion; the dispatch requires that be flagged rather than chosen. B3a is cheaper but couples the plugin's UI to being hosted by REAPER specifically. |
|
||||
| D3 | Is **macOS in or out** of the same phase? | Λ-01, Λ-03, Λ-04, Λ-07 and Λ-09 are all marked *shared macOS/Linux* at their own sections above (Λ-01: identical broken comment block, `src/app/CMakeLists.txt:83–86` vs `:94–97`, and shared `swell_resgen.php` output per `README.md:120`; Λ-03/04/07/09: same mechanism under the APPLE branch, detailed at each). Doing both at once is cheaper than doing them serially — but that is a scope call, and macOS additionally implies signing/notarization (not mentioned anywhere in `versioning-and-release.md`) plus its own open question this doc does not resolve: whether `swell-modstub.mm` (`src/app/CMakeLists.txt:79`) even compiles under a CXX-only `project()` (`CMakeLists.txt:26`, no `OBJCXX`) — see §1a. |
|
||||
| D4 | Does **CI get built in this phase**, and on what runner? | `versioning-and-release.md:307–311` and `:361–363` hand "the pipeline" to dev-ops and commit to three platform artifacts per channel, but no CI exists in the repo on any platform. Adding it is infrastructure and needs an explicit ask. |
|
||||
| D6 | Is the Linux artifact **shipped or developer-only** for 1.x? | Determines whether Λ-02 (Release build) and Λ-08 (install path) are must-fix or nice-to-have, and whether a Linux `install()` rule is worth authoring. (Numbered D6, not D5, to avoid colliding with the settled product decision D5 — "Windows-only, VST3-only, REAPER-only" — cited above at §1b and B5.) |
|
||||
| D7 | Λ-01's fork: **resource-id-0 escape hatch, or the resgen route?** §6 Stage 2 gates on T2's read of `panel_window.cpp`, and V7 presupposes the route is already picked. | It is a source-code + build-pipeline tradeoff (a `panel_window.cpp` DLGPROC→WNDPROC change vs. a PHP build step + generated-file handling) with no code-only right answer — needs Daniel's call once T2 reports, same as D1/D2. |
|
||||
|
||||
Deliberately **not** listed as `[Daniel]`, because reading more would settle them: whether
|
||||
`arrange_drop_win.cpp`/`instrument_drop_win.cpp` need platform gating (read the files — T2);
|
||||
whether the SDK's `moduleinfo.json` is required (it is not — `module_linux.cpp:362–364`);
|
||||
whether the Linux run-loop IIDs need a new SDK TU (they do not — `commoniids.cpp:50–54`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Ordering sketch
|
||||
|
||||
Dependency order only — this is sequencing input for a plan author, not the plan.
|
||||
|
||||
**Stage 0 — the one action that reprices everything.** Run V1 and V2 on a Linux box against
|
||||
the tree as-is. Configure will succeed (nothing in the CMake requires Windows); the compile
|
||||
will produce a diagnostic set that is the actual size of this phase. Every band below is
|
||||
provisional until this is done. Λ-10's doc fix rides along free, since the person doing this
|
||||
will hit it first.
|
||||
|
||||
**Stage 1 — toolchain floor, no source dependency.** Λ-02 (`CMAKE_BUILD_TYPE` default),
|
||||
Λ-03 (visibility presets), Λ-04 (`Threads::Threads`), Λ-09 (warning flags + `CXX_EXTENSIONS
|
||||
OFF`), Λ-05 (pin the `WDL_INC` coincidence), Λ-07 (compiler floor). All are root- or
|
||||
target-property edits with no ordering constraint among them and none touching source. Doing
|
||||
them *before* Stage 2 means the resource work is done under warnings rather than retrofitted
|
||||
into them. Ship as one change.
|
||||
|
||||
**Stage 2 — the panel resource decision.** Λ-01. This forks on the resource-id-0 question
|
||||
(§2, Λ-01) and therefore needs T2's read on `panel_window.cpp` first — so it is gated on T2
|
||||
output, not on Stage 1. Whichever route wins, it is a shared macOS/Linux fix (see D3).
|
||||
|
||||
**Stage 3 — source partition.** Λ-06. Once T2 names which TUs need a platform branch or a
|
||||
non-Windows sibling, the source list in `src/app/CMakeLists.txt:8–51` gains its
|
||||
partition. Strictly after T2 and after Stage 2 (the resgen/shim TU, if that route is taken,
|
||||
is one of the entries).
|
||||
|
||||
**Stage 4 — packaging.** Λ-08, plus whatever D6 decides. Depends on Stage 1 (there is no
|
||||
point documenting an install path for an unoptimized artifact) and on V4.
|
||||
|
||||
**Stage 5 — Fork B, only if D1 says so.** Internally ordered: B1 (entry point swap, S) →
|
||||
B3 (SWELL strategy per D2 — this gates everything downstream, because a module that cannot
|
||||
draw cannot be tested) → B2 (bundle layout + install, needs V10) → B4 (X11 embed + run loop,
|
||||
mostly T2's source work). B5 (the D5 reversal) precedes all of them as a documentation act.
|
||||
|
||||
---
|
||||
|
||||
## 7. Referred to T2
|
||||
|
||||
Source-level items I noticed while reading build files. **Recorded, not analyzed** — the
|
||||
parallel track owns each of these and I have deliberately not investigated further.
|
||||
|
||||
1. `src/shell/panel/panel_window.cpp:135` — `CreateDialogParam` + `MAKEINTRESOURCE(IDD_BANK_PANEL)`.
|
||||
Under SWELL this is `SWELL_CreateDialog(SWELL_curmodule_dialogresource_head, …)`. Relevant
|
||||
to Λ-01's resource-id-0 option, which requires the proc to be a `WNDPROC` returning
|
||||
`LRESULT`, cast to `DLGPROC` (`swell-functions.h:606–608`).
|
||||
2. `src/shell/actions/arrange_drop_win.cpp` and `src/shell/actions/instrument_drop_win.cpp` —
|
||||
zero `_WIN32` occurrences, no `<windows.h>`, yet `_win`-suffixed. Confirm they are
|
||||
genuinely portable, or add guards.
|
||||
3. `src/shell/actions/drag_out_win.cpp:249–282` — the `#else` "macOS / Linux (SWELL)" branch
|
||||
exists but has never been compiled. Whether the SWELL drag path is functionally equivalent
|
||||
to the Win32 `IDataObject`/`IDropSource` path is unaudited.
|
||||
4. `src/shell/persist/prune_fs.cpp:36` — `<windows.h>` behind two `_WIN32` guards. Confirm the
|
||||
non-Windows deletion path exists and is equivalent (this file is on the only
|
||||
file-deletion path in the product, per `src/core/reclaim/CLAUDE.md`).
|
||||
5. `src/shell/panel/draw_kit.cpp:11–15`, `draw_kit.h:27–29`, `src/shell/panel/panel_state.h:18–26` —
|
||||
the `windows.h`-vs-`swell/swell.h` include switches. These look correct; whether the
|
||||
SWELL subset actually covers every GDI call the kit makes is the question.
|
||||
6. `src/shell/panel/CLAUDE.md` records "Windows-only (D5) — font/GDI/HFONT choices assume
|
||||
Windows; no cross-platform font-fallback concern" as an invariant. Font selection on
|
||||
Linux SWELL (FreeType/Fontconfig-backed) is the concrete instance.
|
||||
7. `src/core/capture/capture_paths.cpp` — `normalizeSlashes` lowercases on `_WIN32` only, and
|
||||
`tests/test_capture_paths.cpp:19–46` already encodes the case-sensitive POSIX contract.
|
||||
The test is written; the behaviour under a case-sensitive filesystem is unverified.
|
||||
@@ -0,0 +1,792 @@
|
||||
# Λ-W1 Track 2 — source/runtime Linux-portability audit
|
||||
|
||||
Static analysis of `src/**/*.{cpp,h}` (293 files) plus the vendored SWELL / WDL / LICE /
|
||||
REAPER-SDK / VST3-SDK headers those files consume, 2026-08-02, branch
|
||||
`pl-w1-t2-source-runtime-audit` (cut from `dev` at 6e937b9). Answers one question: **what in
|
||||
the source blocks or degrades a Linux build/run**, for the extension (`reaper_reasampler`) and,
|
||||
separately, for the ReaSampler 9000 VST3 instrument (`reasampler_vst`).
|
||||
|
||||
Build-system surface (CMake, `cmake/`, resgen as a *build step*, vendor configuration,
|
||||
packaging, install) is a parallel track's; anything spotted here is listed under
|
||||
**§7 Referred to T1** rather than analyzed. macOS is out of scope except where a Linux fix
|
||||
shares its path — noted inline, one line, never a separate finding.
|
||||
|
||||
**Evidence discipline.** Every capability claim about SWELL / LICE / the REAPER SDK / the VST3
|
||||
SDK cites the vendor file it was read in. The author is on Windows and **cannot build or run on
|
||||
Linux**: nothing below was compiled or executed on Linux. Claims that need a Linux box carry
|
||||
`[verify — Linux]` with the exact check. Line numbers were re-read against the working tree
|
||||
immediately before writing.
|
||||
|
||||
---
|
||||
|
||||
## 1. Baseline — the portability posture today, by layer
|
||||
|
||||
### 1.1 `core/` — genuinely portable. Verified, not assumed.
|
||||
|
||||
Two independent checks, both exhaustive over the directory:
|
||||
|
||||
- **Include surface.** Every `#include` in `src/core/**` is either a `"core/…"` sibling, one of
|
||||
26 standard-library headers, or the CMake-generated `version_generated.h`
|
||||
(`src/core/version/app_version.cpp:12`, `src/core/wire/reasampler_uid.h:13`). **Zero** REAPER,
|
||||
SWELL, WDL, LICE, VST3-SDK, `windows.h`, or other vendor includes. The 26 headers are
|
||||
`<algorithm> <array> <atomic> <cassert> <cctype> <cerrno> <climits> <cmath> <cstddef>
|
||||
<cstdint> <cstdio> <cstdlib> <cstring> <filesystem> <fstream> <limits> <map> <optional>
|
||||
<set> <sstream> <string> <type_traits> <unordered_map> <unordered_set> <utility> <vector>` —
|
||||
all C++17, all present on libstdc++/libc++.
|
||||
- **Preprocessor surface.** The whole of `core/` contains exactly **nine** preprocessor
|
||||
conditional lines, in three places: the channel fork (`core/wire/reasampler_uid.h:29,34,39`),
|
||||
two `NDEBUG` assert guards (`core/view/view_mode_model.cpp:326,328,331,334`), and **one**
|
||||
platform conditional — `core/capture/capture_paths.cpp:18–20`, the Windows-only case-fold in
|
||||
`normalizeSlashes`. That is the entire platform-dependence of the pure core.
|
||||
|
||||
Byte-order is explicitly handled rather than assumed: `core/wire/bytes.h:26–31,62–74` builds and
|
||||
reads little-endian by shift, not by `memcpy` of a native integer, so it is byte-order-neutral
|
||||
by construction. `core/capture/wav_codec.h:52–53` states the one real assumption ("target is
|
||||
x86/ARM-LE only, no big-endian byte-swap") and `wav_codec.cpp:165–168` memcpys floats under it —
|
||||
correct on x86-64 and aarch64 Linux alike.
|
||||
|
||||
Sweeps that returned **nothing** across `src/core` **and** `src/app`: `sprintf_s`/`strcpy_s`/
|
||||
`_stricmp`/`_snprintf`/`_alloca`/`__forceinline`/`__declspec`/`#pragma comment`/`#pragma warning`/
|
||||
`#pragma pack`/`__int64`/`LPSTR`/`LPCSTR`/`LPWSTR`/`wchar_t`/`TCHAR`/`_T(`/`MAX_PATH`. There is
|
||||
**no** `#pragma` of any kind anywhere in `src/**` other than `#pragma once`, and no
|
||||
`__attribute__` / `__declspec` / anonymous-struct extension.
|
||||
|
||||
`long` appears in a handful of pure modules (`core/ui/card_meta.cpp:39–41,55`,
|
||||
`core/instrument/ui/envelope_overlay.cpp:45`). All are local computations from clamped doubles;
|
||||
LP64's wider `long` is strictly safer than LLP64's. One stale comment
|
||||
(`core/instrument/ui/envelope_overlay.cpp:19`, "a huge t would overflow a 32-bit long") is
|
||||
Windows-specific but the clamp it describes runs regardless. Not a defect.
|
||||
|
||||
**Verdict: `core/` builds and behaves identically on Linux.** The one conditional
|
||||
(`capture_paths.cpp:18`) is *correct* for Linux — case-sensitive paths must not be folded — and
|
||||
the pure tests already assert both branches (`tests/test_capture_paths.cpp:19–33,41–51`), which
|
||||
is direct evidence the pure layer was written with a non-Windows target in mind.
|
||||
|
||||
### 1.2 `tests/` — portable.
|
||||
|
||||
91 test TUs. Only `tests/test_capture_paths.cpp` forks on `_WIN32`, and it asserts the
|
||||
non-Windows behaviour explicitly (lines 28–33, 50–51). A `C:\`/`C:/` grep over `tests/` hits
|
||||
six files total, one of which is `test_capture_paths.cpp` itself; the five OTHER files — every
|
||||
hit opaque *string data* fed to a pure function
|
||||
(`test_bank_model.cpp:186–187,243–245`, `test_bake_wire.cpp:77`, `test_drag_out.cpp:291–293,388`,
|
||||
`test_origin_ledger.cpp:56`, `test_sample_usage.cpp:502`) — are not platform behaviour. They pass
|
||||
identically on Linux.
|
||||
|
||||
### 1.3 `app/` — portable.
|
||||
|
||||
`src/app/main.cpp` is one TU of pointers + entry + dispatch. `REAPER_PLUGIN_DLL_EXPORT` and
|
||||
`REAPER_PLUGIN_HINSTANCE` are already GCC/Clang-correct in the SDK
|
||||
(`vendor/reaper-sdk/sdk/reaper_plugin.h:52–53` → `__attribute__((visibility("default")))` and
|
||||
`void *`), and `REAPER_PLUGIN_ENTRYPOINT` is platform-neutral (`:56`). No Win32 call, no Win32
|
||||
type, no conditional. The one hazard is behavioural, not structural — see **L2-03**.
|
||||
|
||||
### 1.4 `shell/` — the whole cost sits here, and it is unevenly distributed.
|
||||
|
||||
65 platform-token lines across 30 of the 293 source files (grep:
|
||||
`_WIN32|WIN32|__APPLE__|_MSC_VER|__linux__|__GNUC__|_WIN64` over `src/**/*.{cpp,h}`). Their
|
||||
distribution is the story:
|
||||
|
||||
| Area | Lines | Shape |
|
||||
|---|---|---|
|
||||
| `shell/instrument/` (VST3 editor + embed) | 51 of 65 | Whole-TU `#ifdef _WIN32` … `#endif` — Windows-only by design |
|
||||
| `shell/panel/` | 8 | Include-selection + two small `#else` bodies |
|
||||
| `shell/persist/`, `shell/capture/`, `shell/actions/` | 4 | Trash fallback, `localtime_r`, the SWELL drag-out branch |
|
||||
| `core/` | 2 | The case-fold |
|
||||
|
||||
Two `shell/` directories are absent from this table because they were swept and came back
|
||||
completely clean, not because they went unchecked: `shell/view/` and `shell/bank_ops/` both
|
||||
return **zero** hits on the same grep (confirmed). Naming them here, since the table above is
|
||||
by hit-count and gives a reader no way to tell a zero-hit sweep from an unswept directory.
|
||||
|
||||
`src/resource.h` and `src/ext_keys.h` mention platform names only in comments — zero hits on
|
||||
this specific grep (re-run and confirmed), so they are not a row of this distribution; see
|
||||
`resource.h`/`resource.rc` under §7 for their actual (build-system) relevance.
|
||||
|
||||
The extension's guards are all *complete*: every `#ifdef _WIN32` that gates a **definition**
|
||||
has an `#else` (`draw_kit.cpp:12`, `capture.cpp:218`, `panel_audition.cpp:36,58`,
|
||||
`panel_state.h:20`, `prune_fs.cpp:169`, `drag_out_win.cpp:8`); the ones without an `#else`
|
||||
gate only an `#include` or a single statement (`panel_window.cpp:19,148`, `prune_fs.cpp:35`,
|
||||
`draw_kit.h:27`, `capture_paths.cpp:18`). **There is no undefined-symbol gap from this guard
|
||||
structure** — every `#ifdef` that needs an `#else` has one. This is narrower than "no
|
||||
undefined-symbol gap in the extension" full stop: L2-02's `GetCurrentProcessId`
|
||||
(`instrument_drop_win.cpp:59`) is an undefined symbol on Linux precisely because it carries
|
||||
**no** `#ifdef` at all, so it falls outside what this guard-completeness check measures.
|
||||
|
||||
The instrument's guards are complete in the opposite direction: `reasampler_editor.h:156–269`
|
||||
wraps the entire paint/input/timer/`wndProc`/`invalidate` family in `#ifdef _WIN32`, and
|
||||
`editor_platform.cpp:298–306` stubs only the three IPlugView overrides that must exist. Every
|
||||
`invalidate()` call site is inside a guard (verified exhaustively over `src/shell/instrument/`:
|
||||
53 call sites across eight files — `editor_input.cpp` (5), `editor_input_browse.cpp` (11),
|
||||
`editor_input_chrome.cpp` (10), `editor_input_curve.cpp` (4), `editor_input_deck.cpp` (9),
|
||||
`editor_input_waveform.cpp` (4), `editor_platform.cpp` (3, excluding the definition itself at
|
||||
`:65`), and `editor_session.cpp` (7, at 119, 126, 139, 141, 150, 156, 174, excluding a
|
||||
same-named mention inside a comment at `:106`) — each file whole-file- or whole-region-guarded
|
||||
by `#ifdef _WIN32` (e.g. `editor_input_browse.cpp:8` … `:177`); `reasampler_editor.h:266` is
|
||||
the declaration, not a call site. `editor_controls.cpp`, `editor_models.cpp`,
|
||||
`instrument_bake.cpp` have zero call sites, confirmed). So the editor *links* off Windows — it
|
||||
just does nothing.
|
||||
|
||||
### 1.5 What the platform layer actually provides (vendor-verified)
|
||||
|
||||
The extension's non-Windows path is SWELL-provided-by-host
|
||||
(`SWELL_PROVIDED_BY_APP` → function pointers resolved through `SWELL_dllMain`,
|
||||
`vendor/WDL/WDL/swell/swell-modstub-generic.cpp:135–145`). `[verify — Linux]` **What is stated
|
||||
here about REAPER's own `libSwell.so` is only evidence about WDL's own Makefile, not about what
|
||||
Cockos actually ships** — `vendor/WDL/WDL/swell/Makefile:100–107,141–151` shows
|
||||
`swell-gdi-lice.o`/`-DSWELL_LICE_GDI` built whenever GDK is enabled (`ifndef NOGDK`, `:111`),
|
||||
with `-DSWELL_FREETYPE` gated behind `ifndef NOFREETYPE` (`:144`) and `-lfontconfig` gated a
|
||||
level deeper behind `ifndef NOFONTCONFIG` (`:149`) — i.e. the Makefile makes the LICE-backed,
|
||||
freetype/fontconfig SWELL the *default* build, not a certainty about the binary REAPER
|
||||
distributes. **Every downstream claim built on "REAPER's Linux SWELL is the fontconfig-backed
|
||||
GDK build" inherits this same unstated assumption** — that includes L2-09's entire mechanism,
|
||||
the GDK-backend evidence cited for L2-07, and the cursor (`GDK_*`) / modifier-key
|
||||
(`GetAsyncKeyState`) claims below, none of which are re-flagged individually; this paragraph is
|
||||
their one shared source of doubt. Contrast with the null stub in `swell-gdi-generic.cpp`
|
||||
(`#ifndef SWELL_PROVIDED_BY_APP`, line 24), which is not in play for a REAPER-hosted extension
|
||||
either way.
|
||||
|
||||
Everything the panel layer calls was checked by name against
|
||||
`vendor/WDL/WDL/swell/swell-functions.h` and `swell-types.h`. Present and real:
|
||||
|
||||
- Window/dialog: `SWELL_CreateDialog` behind the `CreateDialogParam` macro
|
||||
(`swell-functions.h:616,619`), `DestroyWindow`, `SetFocus`, `GetFocus`, `GetParent`,
|
||||
`GetCapture`/`SetCapture`/`ReleaseCapture`, `IsWindowVisible`, `InvalidateRect`,
|
||||
`ScreenToClient`/`ClientToScreen`, `BeginPaint`/`EndPaint`, `SetTimer`/`KillTimer`,
|
||||
`GetWindowLong`/`SetWindowLong` (`:319–320`), `SetWindowPos` (`:286`).
|
||||
- Messages/macros: `GET_X_LPARAM`, `GET_Y_LPARAM`, `HIWORD`, `MAKEINTRESOURCE`
|
||||
(`swell-types.h:1220`), `PAINTSTRUCT`, `MSG` (`:262`), `SRCCOPY`, `WM_PAINT`, `WM_MOUSEMOVE`,
|
||||
`WM_MOUSEWHEEL`, `WM_CAPTURECHANGED`, `WM_DROPFILES`, `WM_KEYDOWN`, `WM_TIMER`,
|
||||
`WM_ERASEBKGND`, `TPM_RETURNCMD`, `MF_*`, `MB_*`, `ID*`, every `VK_*` used.
|
||||
- `WM_MOUSEWHEEL` really does carry **screen** coords in `lParam` on the GDK backend — the
|
||||
comment at `panel_window.cpp:100–102` is correct (`swell-generic-gdk.cpp:1442`).
|
||||
- `WM_CAPTURECHANGED` really is delivered on capture loss (`swell-generic-gdk.cpp:1795`,
|
||||
`swell-wnd-generic.cpp:7141,7150`), so the panel's rollback handler
|
||||
(`panel_window.cpp:90–98`) has a live trigger.
|
||||
- Cursors: `LoadCursor`→`SWELL_LoadCursor` (`swell-functions.h:728–730`), `SetCursor`
|
||||
(`:740–741`), and **all seven** IDCs the panel uses map to real GDK cursors —
|
||||
`IDC_ARROW`→`GDK_LEFT_PTR`, `IDC_HAND`→`GDK_HAND1`, `IDC_UPARROW`→`GDK_CENTER_PTR`,
|
||||
`IDC_SIZEWE`→`GDK_RIGHT_SIDE`, `IDC_SIZEALL`→`GDK_FLEUR`, `IDC_IBEAM`→`GDK_XTERM`,
|
||||
`IDC_NO`→`GDK_PIRATE` (`swell-generic-gdk.cpp:3736–3748` vs `panel_drag.cpp:141–147,159–161`).
|
||||
- `GetAsyncKeyState` returns the `0x8000` high bit for `VK_CONTROL`/`VK_SHIFT`/`VK_MENU`
|
||||
(`swell-generic-gdk.cpp:2439–2441`), so `panel_state.h:409–411` works verbatim.
|
||||
- `GetTickCount` is SWELL-provided (`swell-functions.h:81`), so `panel_drag.cpp:308,324` — the
|
||||
only unguarded Win32-looking calls in the panel — are fine.
|
||||
- `SWELL_InitiateDragDropOfFileList` **does exist on Linux**, and is a GDK implementation on
|
||||
the GDK backend: `swell-generic-gdk.cpp:3563–3592` sets up a hidden drop-source window and
|
||||
spins a nested `SWELL_RunMessageLoop` until capture drops, with a 500 ms no-motion timeout;
|
||||
the actual `gdk_drag_begin` call is inside the shared `dropSourceWndProc` helper it invokes
|
||||
(`:3446`), not textually inside the `3563–3592` range itself. The headless backend defines
|
||||
the **same-named function as an empty no-op stub** —
|
||||
`swell-generic-headless.cpp:246–248` is `void SWELL_InitiateDragDropOfFileList(...) { }` — so
|
||||
the "not a stub" framing only holds for the GDK backend specifically. The two backends are
|
||||
mutually exclusive (`swell-generic-headless.cpp:28` is `#ifndef SWELL_TARGET_GDK`), so nothing
|
||||
breaks in a GDK build, but a reader should not take "does exist on Linux" to mean "is
|
||||
implemented in every Linux SWELL backend." macOS (`swell-dlg.mm:3534`) also defines it; it is
|
||||
declared at `swell-functions.h:1011`.
|
||||
- LICE itself is portable: `lice.h:31` includes `swell-types.h` off Windows, `lice.cpp:22`
|
||||
includes `swell.h`, `LICE_SysBitmap::__resize` has a SWELL framebuffer path
|
||||
(`lice.cpp:179–182`), and `LICE_CachedFont::DrawTextImpl` carries real non-Windows branches
|
||||
(`lice_textnew.cpp:236–249,829,1027–1028,1040`).
|
||||
|
||||
Absent from SWELL, verified by grep over `vendor/WDL/WDL/swell/`: `FF_DONTCARE` (**zero hits
|
||||
anywhere in `vendor/WDL/`**), `GetCurrentProcessId` (only `GetCurrentThreadId`,
|
||||
`swell-functions.h:822`), `GetKeyState`, `WM_MOUSELEAVE`/`TrackMouseEvent`, `DragAcceptFiles`,
|
||||
`RegisterClass*`, `CreateWindowEx*`, `DefWindowProc*`, `MoveWindow`,
|
||||
`GetWindowLongPtr`/`SetWindowLongPtr`, `WHEEL_DELTA`, and any move-to-trash surface.
|
||||
|
||||
---
|
||||
|
||||
## 2. Findings
|
||||
|
||||
### L2-01 — `FF_DONTCARE` does not exist off Windows; `draw_kit.cpp` will not compile
|
||||
**Location:** `src/shell/panel/draw_kit.cpp:73` (`DEFAULT_PITCH | FF_DONTCARE`), reached on
|
||||
non-Windows through `draw_kit.cpp:12–16` (which includes `swell/swell.h`, **not** `windows.h`).
|
||||
|
||||
**Mechanism.** `swell-types.h` defines `DEFAULT_PITCH`, `DEFAULT_CHARSET`,
|
||||
`OUT_DEFAULT_PRECIS`, `CLIP_DEFAULT_PRECIS`, `DEFAULT_QUALITY`, `FW_BOLD`, `FW_NORMAL`,
|
||||
`FW_SEMIBOLD`, `TRANSPARENT` and every `DT_*` the kit uses — but **not** `FF_DONTCARE`. A grep
|
||||
for `FF_DONTCARE` over the whole of `vendor/WDL/` returns nothing; on Windows it comes from
|
||||
`<wingdi.h>` via `windows.h`. `draw_kit.cpp` is not platform-guarded (only its *include* is), so
|
||||
the `CreateFont` call is compiled on every platform.
|
||||
|
||||
**Severity: Blocker** — `error: 'FF_DONTCARE' was not declared in this scope` at
|
||||
`draw_kit.cpp:73`; `draw_kit` is linked into both loadable modules, so the extension does not
|
||||
build at all.
|
||||
**Effort: S** — the argument is `DEFAULT_PITCH | FF_DONTCARE`, and `FF_DONTCARE` is 0x00 in
|
||||
wingdi.h; the family bits are advisory to Windows' font mapper and meaningless to fontconfig.
|
||||
**Direction.** Drop the `| FF_DONTCARE` term, or define it locally in the non-Windows include
|
||||
branch. Do not add `windows.h`.
|
||||
|
||||
### L2-02 — `GetCurrentProcessId()` is called with no platform branch and SWELL does not export it
|
||||
**Location:** `src/shell/actions/instrument_drop_win.cpp:59` (temp `.vstpreset` filename). The TU
|
||||
has **no** `_WIN32` conditional anywhere (verified: its only `#include`s are `<atomic> <cstdint>
|
||||
<filesystem> <fstream> <string> <system_error> <vector>` plus project/SDK headers, lines 6–27).
|
||||
|
||||
**Mechanism.** On non-Windows the declaration would have to come from `reaper_plugin.h` →
|
||||
`swell.h`. `swell-functions.h` declares `GetCurrentThreadId` (`:822`) and no
|
||||
`GetCurrentProcessId`; the only two occurrences in the whole WDL tree are inside
|
||||
`WDL/shm_msgreply.cpp:26` and `WDL/win32_utf8.c:244`, neither of which is a SWELL export.
|
||||
|
||||
**Severity: Blocker** — `error: 'GetCurrentProcessId' was not declared in this scope`; the
|
||||
extension does not build.
|
||||
**Effort: S** — the PID exists only to keep two concurrent REAPER instances from colliding in
|
||||
the shared temp dir (comment at `:56–57`). `getpid()` behind a guard, or the already-imported
|
||||
`GetCurrentThreadId()` plus the existing atomic counter, satisfies the same requirement.
|
||||
**Direction.** Replace with a platform-neutral uniqueness source; the atomic counter at `:52`
|
||||
already carries the intra-process half.
|
||||
|
||||
### L2-03 — `REAPERAPI_LoadAPI` is all-or-nothing over ~869 API names and fails silently
|
||||
**Location:** `src/app/main.cpp:292–293` (`if (REAPERAPI_LoadAPI(rec->GetFunc) != 0) return 0;`).
|
||||
`main.cpp` does **not** define `REAPERAPI_MINIMAL`, so the full table is loaded
|
||||
(`vendor/reaper-sdk/sdk/reaper_plugin_functions.h:44–48`; 1738 `REAPERAPI_WANT_` guards ≈ 869
|
||||
entries).
|
||||
|
||||
**Mechanism.** The SDK's loader accumulates one `failcnt` across the entire table
|
||||
(`reaper_plugin_functions.h`, `REAPERAPI_LoadAPI` body: `failcnt += !(*table[i].dest =
|
||||
getAPI(table[i].name)); return failcnt;`). If a Linux REAPER build does not export *any single
|
||||
one* of those 869 names, `main.cpp` returns 0 and the extension never loads — with **no console
|
||||
message, no log line, nothing**. The SDK header carries no "Windows only" annotation on any
|
||||
entry (grep for `windows only|win32 only|not on mac|not on linux` returns nothing), so nothing
|
||||
here says a gap exists — but nothing rules it out either, and the failure mode is maximally
|
||||
opaque.
|
||||
|
||||
**Severity: Blocker (conditional)** `[verify — Linux]` — a silent load refusal with no
|
||||
diagnostic is the worst possible first-run experience for a port. **Scope note:** the
|
||||
all-or-nothing mechanism itself is identical on Windows too
|
||||
(`reaper_plugin_functions.h:45–46` — "an older version of REAPER may not succeed in loading",
|
||||
no platform fork in the loader) — nothing about the *mechanism* is Linux-specific, only whether
|
||||
*this particular* build happens to be missing an entry is unknown. Graded here as a Linux
|
||||
Blocker on failure-mode quality alone (per the Mechanism above), the same axis L2-04 is
|
||||
graded on below, despite L2-04's mechanism being certain rather than speculative — see L2-04's
|
||||
calculus note. Arguably this belongs as a cross-platform robustness note rather than a
|
||||
Linux-exclusive finding; left here because the fix is trivial and worth doing regardless of
|
||||
platform.
|
||||
**Effort: S** — confirmed against the two Direction options below: the `ShowConsoleMsg`
|
||||
diagnostic is one new line on the existing failure branch (`main.cpp:293`), and the
|
||||
`REAPERAPI_MINIMAL` swap follows a pattern already used elsewhere in this codebase
|
||||
(`panel_window.cpp:26–31`, `panel_audition.cpp:12–16`) rather than inventing a new one.
|
||||
**Direction.** Either switch `main.cpp` to `REAPERAPI_MINIMAL` + an explicit `WANT` list (the
|
||||
other TUs already do this — e.g. `panel_window.cpp:26–31`, `panel_audition.cpp:12–16`), or keep
|
||||
the full load but print the failure count via `rec->GetFunc("ShowConsoleMsg")` before returning
|
||||
0. The minimal list is also the honest inventory of what this extension actually needs.
|
||||
|
||||
### L2-04 — every persisted floating-point number is `LC_NUMERIC`-dependent, in both directions
|
||||
**Location (writers):** `src/core/json/json.cpp:39–43` (`%.17g` — the bank index, view model and
|
||||
tracking ledger all serialize through it), `src/core/model/provenance.cpp:40` (`%.17g` — the
|
||||
provenance blob inside the bank JSON). Two writers, not three: `tail_control.cpp:74` is a
|
||||
comment describing the format ("Byte-identical to the former snprintf writer:
|
||||
`{"mode":%d,"manualMs":%.17g}`"), not a write site — the actual per-project tail-setting write
|
||||
is `tail_control.cpp:78–79`, which already goes through `json::numToStr` (the same
|
||||
`json.cpp:39–43` codec cited above), so it is the same writer, not a third one.
|
||||
**Location (readers):** `src/core/json/json.cpp:190–196` (`std::strtod`),
|
||||
`src/core/wire/wire.cpp:121–130` (`Cursor::fieldDouble`, `std::strtod`),
|
||||
`src/core/capture/render_settings.cpp:178–186` (`std::stod` over REAPER's own `P_RAZOREDITS`).
|
||||
|
||||
**Mechanism.** `snprintf("%.17g")`, `strtod` and `stod` all honour `LC_NUMERIC`. Under a
|
||||
comma-decimal locale the writers emit `1,5`, which makes the bank JSON *structurally invalid*
|
||||
(an extra separator inside an object) — the whole index fails to parse on the next load. The
|
||||
readers are honestly fail-closed (all three require whole-token consumption:
|
||||
`json.cpp:194`, `wire.cpp:127`, `render_settings.cpp:184`), so they degrade to "malformed"
|
||||
rather than silently truncating — but that means a razor range or a bank field simply
|
||||
disappears. On Windows the CRT's start-up locale is `"C"` and nothing in this codebase calls
|
||||
`setlocale`, which is why this has never fired. On Linux the process locale is far more likely
|
||||
to be set by something else in the address space: SWELL's GDK backend calls `gtk_init_check`
|
||||
when built with `SWELL_SUPPORT_GTK` (`swell-generic-gdk.cpp:366`; the `#else` branch uses
|
||||
`gdk_init_check`, `:368`) and **never** calls `gtk_disable_setlocale` (grep over
|
||||
`vendor/WDL/WDL/swell/` returns no hit), and any GTK/Qt-based plugin loaded into the same
|
||||
process can do the same.
|
||||
|
||||
**Severity: Major** — builds and runs; under a non-C `LC_NUMERIC` the bank index is written
|
||||
unparseable and the project's whole bank is lost on reload. **Calculus note:** unlike L2-03
|
||||
(graded Blocker above on zero direct evidence, purely on failure-mode quality), this finding's
|
||||
mechanism is confirmed by reading the actual writer/reader call sites, and its failure mode —
|
||||
the entire bank index becomes unparseable — is at least as severe as L2-03's. It is graded only
|
||||
Major here because it additionally requires a non-C `LC_NUMERIC` in the hosting process, a
|
||||
likelihood factor L2-03 does not apply to its own claim. Reading both on the same axis (either
|
||||
both by failure-mode quality, or both by likelihood) would put them closer together than
|
||||
Blocker/Major suggests; resolving that is a scope call for whoever prioritizes the two, not a
|
||||
fact this audit can settle statically.
|
||||
**Effort: M** — two writers and three readers, all in `core/`, all unit-testable; the fix is
|
||||
a locale-independent path (`std::to_chars`/`std::from_chars`, C++17, or an explicit
|
||||
`std::locale::classic()`-bound stream), not a `setlocale` call in a plugin.
|
||||
**Direction.** Make the number codec locale-independent at its two writers and three readers;
|
||||
add a pure test that pins the emitted text for a fractional value. Do **not** "fix" this by
|
||||
calling `setlocale` — an extension must not mutate the host's locale.
|
||||
`[verify — Linux]` — read `LC_NUMERIC` inside a running REAPER-Linux process (e.g.
|
||||
`ShowConsoleMsg(setlocale(LC_NUMERIC, nullptr))`) before sizing the work.
|
||||
|
||||
### L2-05 — prune loses the Recycle Bin *and* the "file is locked" backstop at the same time
|
||||
**Location:** `src/shell/persist/prune_fs.cpp:167–209`; the non-Windows branch is `:200–208`.
|
||||
|
||||
**Mechanism.** Two Windows properties are load-bearing for the deletion authority and neither
|
||||
survives. (a) The Windows path routes through `SHFileOperationW` + `FOF_ALLOWUNDO` (`:186–188`)
|
||||
— deletions are recoverable from the Recycle Bin; the `#else` is a hard `fs::remove` (`:203`).
|
||||
The header already states this honestly (`:30–34`, `:162–166`). (b) Less obviously, the failure
|
||||
taxonomy at `:205` (`if (ec) return false; // real failure (locked/permission) -> skip`) encodes
|
||||
**Windows** file-sharing semantics: a bank file currently open by REAPER's audio engine (an
|
||||
active `PCM_source`, an item playing from it) cannot be deleted on Windows, so it is counted as
|
||||
"skipped". On Linux `unlink()` on an open file succeeds — the directory entry vanishes while the
|
||||
open fd keeps playing, and when the fd closes the bytes are gone with no trash to recover from.
|
||||
So the Linux prune can delete a file that is *audibly in use*, silently, and the user's only
|
||||
recovery floor (the superseded-file-survives-until-prune rule, root `CLAUDE.md` §resample bake)
|
||||
now has nothing under it.
|
||||
|
||||
**Severity: Major** — irreversible user-data loss where the Windows build is recoverable; the
|
||||
"locked" skip branch is dead code on Linux.
|
||||
**Effort: M** — trash is genuinely non-portable, but the XDG trash spec (`~/.local/share/Trash`
|
||||
with a `.trashinfo` sidecar) is a self-contained move-plus-metadata write, and the confirm gate
|
||||
already exists upstream.
|
||||
**Direction.** Either implement an XDG-trash move in the `#else` (preferred — the deletion
|
||||
authority is a single ~40-line function and this is exactly where the platform seam belongs), or
|
||||
make the prune confirmation text platform-aware so a Linux user is told the deletion is
|
||||
permanent. Do not leave the current silent asymmetry.
|
||||
|
||||
### L2-06 — the docked panel never opens on Linux, and fails silently when it doesn't
|
||||
**Location:** `src/shell/panel/panel_window.cpp:135–137`.
|
||||
|
||||
**Mechanism.** `CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), …)` maps on SWELL to
|
||||
`SWELL_CreateDialog(SWELL_curmodule_dialogresource_head, (resid), …)`
|
||||
(`swell-functions.h:616,619`) — it resolves the template out of a per-module registry populated
|
||||
by the **resgen-generated source**, not out of a linked `.rc`. That generated source is not
|
||||
currently part of the Linux target (the `target_sources` line is commented out;
|
||||
build-system detail → §7). The source-side consequence is what matters here: `SWELL_CreateDialog`
|
||||
returns `nullptr`, `panel_window.cpp:137` does `if (!g_panel.hwnd) return;`, and the toggle
|
||||
action is a **silent no-op** — no console line, no message box, and `bankPanelIsOpen()` keeps
|
||||
reporting false so the Actions-list checkmark never lights. The user's only symptom is "the
|
||||
button does nothing." Note also that `MAKEINTRESOURCE` becomes `((const char*)(UINT_PTR)(x))` on
|
||||
SWELL (`swell-types.h:1220`), so the id is a pointer-shaped integer — the resgen output must
|
||||
agree on `IDD_BANK_PANEL == 1000` (`src/resource.h:8`).
|
||||
|
||||
**Severity: Major** — the extension loads, every action works, and the primary UI surface is
|
||||
absent with no diagnostic.
|
||||
**Effort: S** on the source side (one failure branch), separate from T1's resgen wiring.
|
||||
**Direction.** Add a one-line `ShowConsoleMsg` on the `!g_panel.hwnd` path naming the missing
|
||||
dialog resource. That single line converts a mystery into a two-minute diagnosis and is worth
|
||||
having on Windows too.
|
||||
|
||||
### L2-07 — file-drop ingest onto the panel has no opt-in on SWELL
|
||||
**Location:** `src/shell/panel/panel_window.cpp:145–150` (the `DragAcceptFiles` call is
|
||||
`#ifdef _WIN32`), handler at `:47–61,65–67`.
|
||||
|
||||
**Mechanism.** The comment at `:145–147` is accurate — SWELL exposes no `DragAcceptFiles`
|
||||
(grep over `vendor/WDL/WDL/swell/` finds only `DragQueryFile` and `DragFinish`,
|
||||
`swell-functions.h:1006–1007`). What the comment does not say is *how* a drop would arrive
|
||||
instead. Reading the GDK backend: on a URI-list selection-notify, SWELL resolves the top-level
|
||||
window, walks down with `ChildWindowFromPoint` (which **is** descending — `for(;;)` loop,
|
||||
`swell-wnd-generic.cpp:6956–6981`), and `SendMessage(cw, WM_DROPFILES, (WPARAM)gobj, 0)`
|
||||
(`swell-generic-gdk.cpp:1622`). The default child proc forwards an *unhandled* `WM_DROPFILES`
|
||||
up to the parent only when the window *lacks* `WS_EX_ACCEPTFILES`
|
||||
(`swell-wnd-generic.cpp:7681`) — but that bit's only effect is to **suppress** the up-forward;
|
||||
it does not enable acceptance, and the message already reaches the window under the pointer via
|
||||
the `ChildWindowFromPoint` descent regardless of the bit's state. So the panel's own `dlgProc`
|
||||
plausibly receives the drop **without any opt-in at all**, but the earlier "ex-style bit, not
|
||||
an API call" framing was backwards about what the bit does. The real SWELL opt-in surface for
|
||||
`WS_EX_ACCEPTFILES` is the dialog *resource*, not a runtime call: `swell_resgen.pl:10` and
|
||||
`swell_resgen.php:204` translate a dialog's `WS_EX_ACCEPTFILES` style to
|
||||
`SWELL_DLG_WS_DROPTARGET`, and `swell-dlg-generic.cpp:320–321` sets
|
||||
`h->m_exstyle |= WS_EX_ACCEPTFILES` from that flag at dialog-creation time. `src/resource.rc:19`
|
||||
declares `STYLE WS_CHILD` only — no `WS_EX_ACCEPTFILES` — so the bit is off on Linux regardless
|
||||
of anything `panel_window.cpp` does at runtime. That makes L2-07's real seam `src/resource.rc` +
|
||||
resgen, the SAME work as L2-06 and §7 bullet 1, not an independent one-liner: a runtime
|
||||
`SetWindowLong(..., GWL_EXSTYLE, ... | WS_EX_ACCEPTFILES)` would only suppress the
|
||||
unhandled-drop forward-to-parent — it would not change whether the drop reaches `dlgProc` in
|
||||
the first place, which already happens via the `ChildWindowFromPoint` descent independent of
|
||||
the bit. Note also the doc's own conclusion is better-supported than it knew: `src/resource.rc:21–22`
|
||||
is `BEGIN`/`END` — zero child controls — so the GDK `ChildWindowFromPoint` descent has nowhere
|
||||
to land but the panel HWND itself. Two supporting details still check out: SWELL's
|
||||
`DragQueryFile` reads the same `DROPFILES` layout the handler assumes
|
||||
(`swell-wnd-generic.cpp:7730+`), and SWELL's `DragFinish` is a documented no-op ("caller will
|
||||
free hdrops", `:7725–7728`) while SWELL itself `GlobalFree`s the handle right after
|
||||
`SendMessage` (`swell-generic-gdk.cpp:1623–1624`) — so `panel_window.cpp:59` calling
|
||||
`DragFinish` is safe, not a double-free.
|
||||
|
||||
**Severity: Major `[verify — Linux]`** — if the routing does *not* reach a docked child dialog,
|
||||
one of the three ingest surfaces (file drop onto the bank panel) is silently dead with no error.
|
||||
The evidence above says it probably works; it is not proof, and it cannot be exercised until
|
||||
the panel itself renders (T1's resgen wiring — see L2-06).
|
||||
**Effort:** no independent source-side fix exists — this is the same resgen work already
|
||||
costed under L2-06 / §7 bullet 1. Do not add an `Effort: S` line here; adding the
|
||||
`SetWindowLong` call would compile, do nothing observable, and send an implementer looking for
|
||||
a bug that isn't where they'd look.
|
||||
**Direction.** No separate fix. Once the resgen work lands the panel dialog with
|
||||
`WS_EX_ACCEPTFILES` set from the resource, verify: `[verify — Linux]` drop a WAV onto the
|
||||
docked panel and confirm `WM_DROPFILES` reaches `dlgProc`.
|
||||
|
||||
### L2-08 — the VST3 instrument has no Linux editor, and no automatable parameters to fall back to
|
||||
**Location:** `src/shell/instrument/editor_platform.cpp:37–42` (`isPlatformTypeSupported`
|
||||
returns `kResultTrue` **only** for `kPlatformTypeHWND`, and only inside `#ifdef _WIN32`);
|
||||
`:298–306` (the non-Windows stubs); `reasampler_editor.h:156–269` (the entire paint/input family
|
||||
is `#ifdef _WIN32`); `reasampler_embed.cpp:135–141` (`REAPER_FXEMBED_WM_IS_SUPPORTED` returns 0
|
||||
off Windows). `reasampler_processor.cpp:360–365` still hands the host a `ReaSamplerEditor`.
|
||||
|
||||
**Mechanism.** The VST3 SDK *does* support Linux — `kPlatformTypeX11EmbedWindowID`
|
||||
(`vendor/vst3sdk/pluginterfaces/gui/iplugview.h:79`), `Linux::IRunLoop` with
|
||||
`registerEventHandler`/`registerTimer` (`:267–279`), `Linux::IEventHandler` (`:223`),
|
||||
`Linux::ITimerHandler` (`:239`), and a `linuxmain.cpp` module entry
|
||||
(`vendor/vst3sdk/public.sdk/source/main/linuxmain.cpp`). The instrument simply does not
|
||||
implement any of it. The consequence compounds: with `isPlatformTypeSupported` false for every
|
||||
type, the host falls back to a **generic parameter UI** — and a grep for
|
||||
`addParameter|parameters\.add|getParameterCount` across `src/shell/instrument/*.cpp` returns
|
||||
**nothing**. Zero VST3 parameters are registered as of this branch's base. A Linux user would
|
||||
get an instrument with no editor and no controls whatsoever. (Phase Γ-W4-T1
|
||||
`vst3-parameter-set` — `docs/product/parameter-automation.md` — is landing the parameter set
|
||||
concurrently, which materially improves this fallback; the *editor* gap is unaffected.)
|
||||
|
||||
The specific Win32 dependencies a Linux editor would have to replace, each verified absent from
|
||||
SWELL: `RegisterClassW`/`CreateWindowExW`/`DefWindowProcW` (`editor_platform.cpp:79–103,294`) —
|
||||
SWELL has no window-class model at all, only `SWELL_CreateDialog` and raw `HWND__` construction;
|
||||
`MoveWindow` (`:133`) — SWELL has `SetWindowPos` (`swell-functions.h:286`) instead;
|
||||
`GetWindowLongPtr`/`SetWindowLongPtr` (`:74,105,142`) — SWELL has the non-`Ptr` forms returning
|
||||
`LONG_PTR` (`:319–320`); `TrackMouseEvent`/`WM_MOUSELEAVE` (`:176–198`) — SWELL has neither, a
|
||||
gap the panel layer already documents at `panel_state.h:245–246`; `GetKeyState`
|
||||
(`editor_input_waveform.cpp:38`, `editor_input_curve.cpp:50`) — SWELL has only
|
||||
`GetAsyncKeyState` (`swell-functions.h:712`); `DragAcceptFiles`/`DragQueryFileW`
|
||||
(`:109,283`); `GetModuleHandle` (`:75`).
|
||||
|
||||
**Severity: Major (instrument only)** — the plugin would load and process audio; it would be
|
||||
unplayable and uneditable.
|
||||
**Effort: L** — an X11-embed `IPlugView` (`attachedToParent` receiving an X11 window id rather
|
||||
than an HWND), an `IRunLoop`-driven timer replacing `SetTimer`/`WM_TIMER`, an event-driven
|
||||
input path replacing the `wndProc` switch, and a LICE surface bound to that window. This is a
|
||||
new competence, not a port of the existing one.
|
||||
**Direction.** Treat as a separate, later decision from the extension (see §3). If it is ever
|
||||
taken, the natural shape is a small platform seam under `shell/instrument/` — `editor_platform`
|
||||
already *is* that seam; it needs a sibling, not a rewrite.
|
||||
|
||||
### L2-09 — the draw kit's font faces do not exist on Linux and substitute silently
|
||||
**Location:** `src/shell/panel/draw_kit.cpp:70–77` (`loadFont`), and the two literal faces it is
|
||||
called with — Segoe UI and Consolas, per `draw_kit.h:63–64`.
|
||||
|
||||
**Mechanism.** On Linux `CreateFont` goes through fontconfig when built with
|
||||
`-DSWELL_FONTCONFIG` (gated per the `[verify — Linux]` note in §1.5):
|
||||
`FcPatternAddString(pat, FC_FAMILY, lfFaceName)` → `FcConfigSubstitute`/`FcDefaultSubstitute` →
|
||||
`FcFontMatch` (`swell-gdi-lice.cpp:450–487`). Whether `FcFontMatch` itself can return failure is
|
||||
a claim about fontconfig's own internals — fontconfig is not vendored here, so it is dropped
|
||||
rather than asserted uncited (this doc's own evidence-discipline rule). What the vendored code
|
||||
does show: even a successful match doesn't guarantee a loadable face —
|
||||
`swell-gdi-lice.cpp:480–484` only sets `face` when `FcPatternGetString(hit, FC_FILE, …)`
|
||||
resolves to a nonempty string AND the following `FT_New_Face` succeeds; either step can fail,
|
||||
leaving `face` NULL, and in this build path there is no further fallback — the
|
||||
`MatchFont`/LiberationSans/DejaVuSans list at `:493–539` compiles only in the `#else`
|
||||
(`#ifndef SWELL_FONTCONFIG`) branch. However `swell-gdi-lice.cpp:400–401,561,564` show
|
||||
`CreateFont` always allocates and returns a non-null `HGDIOBJ__*` regardless of whether `face`
|
||||
resolved — the failure is recorded internally (`font->typedata = NULL`), not as a null return —
|
||||
so `draw_kit.cpp:74`'s own `if (!hf) return` guard does **not** catch this failure mode; `hf`
|
||||
comes back non-null either way. Whatever degraded outcome exists here comes from
|
||||
`LICE_CachedFont`'s own handling of a null-`typedata` font (§1.5 already cites real
|
||||
non-Windows branches in `lice_textnew.cpp:236–249,829,1027–1028,1040`), not from `loadFont`'s
|
||||
stated guard — `[verify — Linux]`. The two WCAG `static_assert`s (`draw_kit.cpp:53–54`) are on
|
||||
**pixel height and weight**, not on the face, so they still hold regardless.
|
||||
|
||||
**Severity: Minor** `[verify — Linux]` — cosmetic at best; whether it degrades further than
|
||||
wrong metrics (e.g. to no text at all) rests on the unconfirmed downstream null-face handling
|
||||
above, not on anything this audit can read statically.
|
||||
**Effort: S** — confirmed against the actual call sites: five total (`draw_kit.cpp:154–158`,
|
||||
four using "Segoe UI", one "Consolas"), each a single string-literal argument. A platform
|
||||
fallback is a `#ifdef`-guarded literal swap at those five sites, not a new mechanism —
|
||||
`draw_kit.cpp:69`'s comment ("the face is chosen here so a change is one line") describes one
|
||||
call's literal, not all five; the S estimate rests on there being only five call sites, not on
|
||||
that comment's wording.
|
||||
**Direction.** Add a platform fallback face list at the five `loadFont` call sites
|
||||
(`draw_kit.cpp:154–158`). DejaVu Sans / DejaVu Sans Mono are the safe Linux defaults; SWELL's
|
||||
own no-fontconfig fallback list names LiberationSans/DejaVuSans and
|
||||
LiberationMono/DejaVuSansMono (`swell-gdi-lice.cpp:505–507`), a reasonable precedent to copy.
|
||||
This same change fixes macOS (San Francisco / Menlo), one code path.
|
||||
|
||||
### L2-10 — OS drag-out on SWELL loses the copy-only mask, the readiness probe, and the outcome
|
||||
**Location:** `src/shell/actions/drag_out_win.cpp:249–282` (the `#else` branch);
|
||||
`drag_out_win.h:7–11,29–33` already states the first and third honestly.
|
||||
|
||||
**Mechanism.** Three separate degradations, all real but none fatal. (a) `DoDragDrop`'s
|
||||
`DROPEFFECT_COPY`-only mask (`:230`) is a **structural** guarantee that no target can MOVE a
|
||||
bank file out of the folder; `SWELL_InitiateDragDropOfFileList` takes no effect mask
|
||||
(`swell-functions.h:1011`), so the guarantee reduces to whatever the GDK drag advertises.
|
||||
(b) `canInitiateDragOut` degrades to `!paths.empty()` (`:276–278`), so the caller's careful
|
||||
coupling — "do not tear down the internal drag until the OS is known ready"
|
||||
(`panel_drag.cpp:227–244`) — has nothing to check against. (c) `initiateDragOut` returns `true`
|
||||
unconditionally (`:270`), so the advisory success return is meaningless. The caller ignores it,
|
||||
so (c) is inert today. Note the call path is already Linux-correct in one important respect:
|
||||
`handOffToOs` releases capture and resets drag state (`panel_drag.cpp:240–241`) *before*
|
||||
invoking the drag, which matters because SWELL's implementation takes capture on its own hidden
|
||||
window and spins a nested `SWELL_RunMessageLoop` until capture drops
|
||||
(`swell-generic-gdk.cpp:3575–3584`, inside `SWELL_InitiateDragDropOfFileList` — the function
|
||||
`drag_out_win.cpp:270` actually calls; the body is identical to the sibling
|
||||
`SWELL_InitiateDragDrop` at `:3531–3560`, which is where an earlier draft of this citation
|
||||
pointed) — with a 500 ms no-motion timeout that has no Windows analog.
|
||||
|
||||
**Severity: Minor** — the feature works; one safety property becomes conventional rather than
|
||||
structural.
|
||||
**Effort: S** — mostly documentation; there is no SWELL surface to restore the mask with.
|
||||
**Direction.** Leave the implementation; make sure the copy-only invariant's home
|
||||
(`drag_out_win.h:7–11`) is the doc a Linux reviewer is pointed at, and treat "MOVE is
|
||||
structurally impossible" as a Windows-only claim in any future spec text.
|
||||
|
||||
### L2-11 — `normalizeSlashes` case-folds on `_WIN32` only, which is right for Linux and wrong for macOS
|
||||
**Location:** `src/core/capture/capture_paths.cpp:18–20`.
|
||||
|
||||
**Mechanism.** The one platform conditional in `core/`. Linux filesystems are case-sensitive, so
|
||||
*not* folding is correct and the existing tests already assert it
|
||||
(`tests/test_capture_paths.cpp:50–51`). The shared-path note: macOS's default APFS/HFS+ is
|
||||
case-**insensitive**, so the same `#ifdef _WIN32` under-folds there — a pre-existing macOS
|
||||
defect this audit surfaces but does not own.
|
||||
|
||||
**Severity: Minor** (no Linux defect). **Effort: S** — confirmed: the one platform conditional
|
||||
in `core/` is a single `#ifdef _WIN32` block (`capture_paths.cpp:18–20`) with no other call
|
||||
site depending on the token; a predicate swap is a one-block change.
|
||||
**Direction.** No Linux action. If macOS is ever targeted, the predicate wants to be
|
||||
"case-insensitive filesystem", not "Windows".
|
||||
|
||||
### L2-12 — `fs::path::string()` narrowing is strictly better on Linux (informational)
|
||||
**Location:** `prune_fs.cpp:118`, `ingest.cpp:222,254`, `capture_paths.cpp:95`,
|
||||
`panel_bank_ops.cpp:39`, `scope_resolve.cpp:207`, `insert.cpp:71`,
|
||||
`capture_realtime_shell.cpp:306`, `capture.cpp:408`, `instrument_bake.cpp:166`.
|
||||
|
||||
**Mechanism.** On MSVC `std::filesystem::path::string()` narrows through the active code page;
|
||||
on Linux `path` is already `char`-based, so `.string()` is a byte passthrough and UTF-8 survives.
|
||||
The one site that deliberately works around the Windows behaviour —
|
||||
`instrument_drop_win.cpp:123–128`, using `u8string()` — is harmless on Linux (C++17, so
|
||||
`u8string()` returns `std::string`; `CMakeLists.txt:28` pins `CMAKE_CXX_STANDARD 17`, and under
|
||||
C++20 the `.c_str()` at `:128` would become a `const char8_t*` type error — worth knowing before
|
||||
anyone bumps the standard).
|
||||
|
||||
**Severity: Minor / informational.** **Effort: none.** **Direction:** no action; do not "fix" it.
|
||||
|
||||
---
|
||||
|
||||
## Surfaces checked and found clean
|
||||
|
||||
Recorded so the sweep's negative results are as auditable as its findings.
|
||||
|
||||
- **Missing standard includes** (the MSVC-transitively-provides class). Ran a use-vs-include diff
|
||||
across all 293 files for `<cstring> <cstdio> <memory> <atomic> <algorithm> <limits> <cmath>
|
||||
<functional> <ctime> <cstdlib>`. Five raw hits; all five verified false positives on read —
|
||||
four were the symbol name appearing in a *comment* (`render_settings.h:53`,
|
||||
`ext_state_read.h:20`, `action_registry.h:7`) or a correct `<cmath>` `std::abs`
|
||||
(`velocity_curve.cpp:229`, `<cmath>` at `:6`), and `processor_reload.cpp`'s `std::unique_ptr`
|
||||
comes from `reasampler_processor.h:12`. **No missing include found — but this only covers the
|
||||
ten headers above.** The two headers most likely to produce the classic
|
||||
MSVC-transitively-provides failure under libstdc++, `<vector>` and `<string>`, were excluded
|
||||
from that sweep; re-run including them: **45 files use `std::vector` without `#include
|
||||
<vector>`, and 52 use `std::string` without `#include <string>`** (raw grep-diff counts,
|
||||
unread). `<map>` and `<set>` add 2 and 3 more respectively. These 102 raw hits were **not**
|
||||
individually verified the way the five above were (that would mean reading 102 files); the
|
||||
claim this sweep actually supports is "up to 102 files may rely on transitive inclusion for
|
||||
`<vector>`/`<string>`/`<map>`/`<set>`, unconfirmed one by one" — not "no missing include
|
||||
found." The two header cases that matter most for the extension's own build
|
||||
(`shell/capture/capture_orchestrator.h`, `src/ext_keys.h`) are confirmed satisfied
|
||||
transitively.
|
||||
- **Templates / two-phase lookup.** Exactly 7 templates in the tree
|
||||
(`core/wire/bytes.h:25,61`, `core/wire/ext_state_read.h:37`,
|
||||
`core/instrument/engine/play_params.h:191,203,208,218`). None derives from a dependent base,
|
||||
none calls an unqualified dependent name, none needs `typename`/`template` disambiguation.
|
||||
GCC/Clang-safe as written.
|
||||
- **Compiler extensions.** Zero `#pragma` other than `#pragma once`; zero `__declspec`,
|
||||
`__attribute__`, `__forceinline`, anonymous struct/union, or MSVC-permissive construct in
|
||||
`src/**`.
|
||||
- **`HWND__` forward declaration.** `drag_out_win.h:19` declares `struct HWND__;`; SWELL declares
|
||||
`typedef struct HWND__ *HWND;` (`swell-types.h:211`) — same tag, no `-Wmismatched-tags`.
|
||||
- **`preview_register_t` platform fork.** `panel_audition.cpp:36–41,58–63` and
|
||||
`panel_state.h:20–24` match the SDK's own fork exactly (`CRITICAL_SECTION cs` on `_WIN32`,
|
||||
`pthread_mutex_t mutex` otherwise — `vendor/reaper-sdk/sdk/reaper_plugin.h:1308–1312`).
|
||||
`<pthread.h>` is included on the non-Windows branch. Correct.
|
||||
- **Prompts and message boxes.** Every user prompt goes through REAPER's own
|
||||
`GetUserInputs`/`ShowMessageBox`/`ShowConsoleMsg` (`panel_bank_ops.cpp:24–25,81,100,126,381`),
|
||||
never a Win32 `MessageBox`. Cross-platform by construction.
|
||||
- **Menus.** `CreatePopupMenu`/`InsertMenu`/`TrackPopupMenu(TPM_RETURNCMD)`/`DestroyMenu` are all
|
||||
SWELL-provided (`swell-functions.h:528–532`, and `TrackPopupMenu`/`DestroyMenu`/
|
||||
`CreatePopupMenu` entries); the "pos < 0 appends" assumption at `panel_bank_ops.cpp:226` matches
|
||||
SWELL's `SWELL_InsertMenu`. The comment at `:220–222` is accurate.
|
||||
- **Keyboard/accelerator path.** `accelerator_register_t` + `MSG`/`WM_KEYDOWN` + `GetFocus` +
|
||||
`GetParent` (`panel_input.cpp:503–521`) are all SWELL-provided; every `VK_*` used is in
|
||||
`swell-types.h`.
|
||||
- **Double-buffered paint.** `LICE_SysBitmap` + `getDC()` + `BitBlt(SRCCOPY)`
|
||||
(`panel_render.cpp:474,549`) — `LICE_SysBitmap` has real non-Windows paths
|
||||
(`lice.cpp:179`, `lice.h:362`), `BitBlt`/`SRCCOPY` are SWELL-provided.
|
||||
- **Byte order.** Handled explicitly everywhere it matters (see §1.1). No native-integer
|
||||
`memcpy` onto a wire buffer anywhere in `core/wire`.
|
||||
- **Hot-path guardrails.** Nothing in this audit's remediation directions touches
|
||||
`peaks::computeEnvelope` (still a free function), the audition call-through
|
||||
(`panel_audition.cpp` — direct calls, no interface), or the realtime tick's single-pointer-test
|
||||
idle path (`main.cpp:156`). L2-04's number-codec change is on the JSON/persist path, which root
|
||||
`CLAUDE.md` explicitly declares off all hot paths. **No recommendation here adds a hot-path
|
||||
indirection.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Extension vs. instrument — the cost split
|
||||
|
||||
The two artifacts are cleanly separable, and the evidence says the split is very lopsided.
|
||||
|
||||
### 3.1 Extension only (`reaper_reasampler`)
|
||||
|
||||
**What must change in source:** two compile Blockers (L2-01, L2-02 — both one-line), one load
|
||||
hazard (L2-03), one silent-failure diagnostic (L2-06), one correctness/safety fix (L2-05), one
|
||||
data-integrity fix (L2-04), and cosmetics (L2-09). Nothing here is architectural. The panel's
|
||||
whole SWELL/LICE surface — dialog lifecycle, docking, `WM_PAINT` double-buffered LICE draw,
|
||||
mouse/wheel/capture, cursors, menus, keyboard accelerator, modifier keys, tooltips, drag-out,
|
||||
drop-in — was checked call-by-call against the vendor headers and is **already SWELL-portable**;
|
||||
see §1.5 and "Surfaces checked". This is the single most load-bearing finding of the audit: the
|
||||
panel was written against SWELL's vocabulary throughout, and the Windows-only escapes are three
|
||||
small ones (`DragAcceptFiles`, `SHFileOperationW`, OLE `DoDragDrop`) that each already carry a
|
||||
non-Windows branch or a documented reason they do not.
|
||||
|
||||
**Estimate shape:** S+S+S+S+M+M+S. No L item.
|
||||
|
||||
### 3.2 Instrument additionally (`reasampler_vst`)
|
||||
|
||||
**What must be written from nothing:** an X11-embed `IPlugView` (window creation and parenting
|
||||
without `RegisterClass`/`CreateWindowEx`), an `IRunLoop`/`ITimerHandler`-driven replacement for
|
||||
the `SetTimer`/`WM_TIMER` sync poll, an event-driven input path replacing the entire `wndProc`
|
||||
switch (~150 lines of `editor_platform.cpp:139–296`), hover-leave detection without
|
||||
`TrackMouseEvent`, modifier reads via `GetAsyncKeyState` instead of `GetKeyState`, and drop-accept
|
||||
without `DragAcceptFiles`. The paint side is the cheapest part — every painter already draws into
|
||||
a `LICE_IBitmap` through the shared kit (`editor_paint.cpp:28,51`), so the drawing survives a
|
||||
window-system change intact; it is the *window and event plumbing* that is entirely absent. The
|
||||
TCP/MCP embed strip (`reasampler_embed.cpp:135–141`) is a smaller, separate 0→1
|
||||
(`REAPER_FXEMBED_WM_IS_SUPPORTED` currently returns 0 off Windows; the paint body at `:174+` is
|
||||
LICE and would port).
|
||||
|
||||
**Estimate shape:** one L, and it is a genuinely new competence rather than a port.
|
||||
|
||||
### 3.3 The consequence for sequencing
|
||||
|
||||
The extension is a Linux target that is largely *already met* and needs a handful of small,
|
||||
well-localized fixes. The instrument is a separate project. The two share exactly one file —
|
||||
`shell/panel/draw_kit` — and that file's only Linux blocker is L2-01. Daniel rules on whether the
|
||||
instrument is in scope at all; the evidence says the extension does not wait on it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Feature-degradation list — what builds but behaves differently, or not at all
|
||||
|
||||
Blunt column: **GONE** = the capability does not exist on Linux; **DIFFERENT** = it exists with
|
||||
changed behaviour; **AT RISK** = depends on an unverified assumption.
|
||||
|
||||
| Feature | Verdict | What actually happens | Cite |
|
||||
|---|---|---|---|
|
||||
| Prune → Recycle Bin | **GONE** | Hard `unlink`, unrecoverable | `prune_fs.cpp:200–208` |
|
||||
| Prune's "file is locked" skip | **GONE** | `unlink` on an in-use file succeeds; the skip branch is dead code | `prune_fs.cpp:205` |
|
||||
| Docked bank panel | **GONE (today)** | `SWELL_CreateDialog` returns null; toggle is a silent no-op until the resgen source is added (§7) | `panel_window.cpp:135–137` |
|
||||
| VST3 editor | **GONE** | `isPlatformTypeSupported` false for every type; host shows a generic UI | `editor_platform.cpp:37–42` |
|
||||
| VST3 generic-UI fallback | **GONE (today)** | Zero parameters registered as of this base; Phase Γ-W4-T1 changes this | grep `addParameter` over `shell/instrument/*.cpp` → no hits |
|
||||
| TCP/MCP embed strip | **GONE** | `REAPER_FXEMBED_WM_IS_SUPPORTED` returns 0 | `reasampler_embed.cpp:135–141` |
|
||||
| Kit fonts (Segoe UI / Consolas) | **DIFFERENT** | fontconfig substitutes silently; metrics and ellipsis points shift; ValueMono may lose tabular alignment | `swell-gdi-lice.cpp:450–487` |
|
||||
| OS drag-out copy-only guarantee | **DIFFERENT** | No effect mask; copy-only is conventional not structural | `drag_out_win.cpp:257–270` |
|
||||
| OS drag-out readiness/outcome | **DIFFERENT** | Probe degrades to "non-empty"; return is always `true`; 500 ms no-motion timeout has no Windows analog | `drag_out_win.cpp:276–278`, `swell-generic-gdk.cpp:3579–3580` |
|
||||
| Panel file-drop ingest | **AT RISK** | Depends on SWELL delivering `WM_DROPFILES` without the Win32 opt-in | `panel_window.cpp:145–150` |
|
||||
| Bank index float round-trip | **AT RISK** | Unparseable under a non-C `LC_NUMERIC` | `json.cpp:39–43,190–196` |
|
||||
| Extension load | **AT RISK** | Any one unresolved API name in ~869 = silent refusal | `main.cpp:292–293` |
|
||||
| Path case sensitivity | **DIFFERENT (correct)** | No case-fold; correct for Linux, and the tests already assert it | `capture_paths.cpp:18–20` |
|
||||
| Prune reclaim on a symlinked bank file | **AT RISK** | `fs::directory_iterator` + `is_regular_file()` follows symlinks (C++17); size is read from the target via `file_size()` but `fs::remove` deletes the link, not the target — prune reports N bytes reclaimed and reclaims zero. Symlinked media folders are far more idiomatic on Linux than Windows. | `prune_fs.cpp:113–124` |
|
||||
| Audition / preview | **INTACT** | `preview_register_t` fork matches the SDK; `PlayPreview`/`StopPreview` are REAPER API | `panel_audition.cpp:36–63` |
|
||||
| Docking, menus, cursors, keyboard, wheel, capture rollback, tooltips | **INTACT** | All SWELL-provided; verified call-by-call | §1.5 |
|
||||
| Capture pillar (offline + realtime), persist, tracking, prune *computation* | **INTACT** | Pure `core/` + REAPER API only | §1.1 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions
|
||||
|
||||
### `[verify — Linux]` — answerable only on a Linux box
|
||||
|
||||
1. **Process locale.** Read `setlocale(LC_NUMERIC, nullptr)` inside a running REAPER-Linux
|
||||
process (print via `ShowConsoleMsg`) on a machine whose user locale uses a decimal comma.
|
||||
Decides whether L2-04 is urgent or latent.
|
||||
2. **REAPER API completeness.** Instrument `main.cpp:292` to print
|
||||
`REAPERAPI_LoadAPI`'s return value instead of discarding it, load once, and record the count.
|
||||
Non-zero decides whether L2-03 is a real Blocker and names the gap.
|
||||
3. **Panel file-drop routing.** With the panel docked, drag a WAV from the file manager onto it
|
||||
and observe whether `dlgProc` sees `WM_DROPFILES` (L2-07). If not, add `WS_EX_ACCEPTFILES`.
|
||||
4. **Which SWELL GDI/locale build REAPER ships.** Whether REAPER's `libSwell.so` is built with
|
||||
`SWELL_SUPPORT_GTK` (→ `gtk_init_check`, `swell-generic-gdk.cpp:366`) or without
|
||||
(→ `gdk_init_check`, `:368`) changes the likelihood in (1). Observable indirectly via (1).
|
||||
5. **fontconfig substitution for "Consolas".** Whether it lands on a monospaced face on a stock
|
||||
distro decides whether L2-09 is cosmetic or a real readability regression on numeric readouts.
|
||||
6. **`SWELL_InitiateDragDropOfFileList` acceptance semantics.** Whether a GDK-initiated file drag
|
||||
is accepted as a copy by common targets (a file manager, another DAW), and whether the 500 ms
|
||||
no-motion timeout (`swell-generic-gdk.cpp:3579–3580`) cancels a slow user gesture.
|
||||
7. **Prune against an in-use file.** Play an item from a bank file, prune it, confirm the audio
|
||||
survives until the fd closes and the file is then unrecoverable — the concrete demonstration
|
||||
behind L2-05(b).
|
||||
|
||||
### `[Daniel]` — scope decisions, not knowledge gaps
|
||||
|
||||
1. **Is the VST3 instrument in scope for Linux at all?** §3 supplies the cost split; the
|
||||
extension does not depend on the answer. This is the phase's one real fork.
|
||||
2. **Is a hard `unlink` an acceptable prune on Linux**, with a platform-aware confirmation
|
||||
string — or must XDG trash be implemented before Linux ships? (L2-05.)
|
||||
3. **Is silent font substitution acceptable**, or does the kit get an explicit Linux face list?
|
||||
(L2-09.)
|
||||
4. **Does "copy-only is structural" survive as a shipped invariant** when one platform can only
|
||||
offer it conventionally? (L2-10.)
|
||||
|
||||
---
|
||||
|
||||
## 6. Ordering sketch
|
||||
|
||||
Dependency order only — this is not a plan, and it assigns no waves.
|
||||
|
||||
1. **Make it compile.** L2-01 (`FF_DONTCARE`), L2-02 (`GetCurrentProcessId`). Nothing else can
|
||||
be observed until these land; both are one-line and independent of each other.
|
||||
2. **Make it load, visibly.** L2-03 (`REAPERAPI_LoadAPI` diagnostic or `REAPERAPI_MINIMAL`) —
|
||||
must precede any runtime verification, because it is the failure mode that produces no
|
||||
evidence. Pairs naturally with `[verify — Linux]` (2).
|
||||
3. **Make the panel appear.** L2-06 (the missing-dialog diagnostic) alongside T1's resgen wiring.
|
||||
The diagnostic should land first so the resgen step can be confirmed rather than assumed.
|
||||
4. **Then, and only then, verify.** `[verify — Linux]` items 1, 3, 5, 6, 7 all need a running
|
||||
panel. Item 2 is already answered by step 2. **The critical path for this step runs through
|
||||
the parallel build-system track, not this one:** "a running panel" means T1's resgen wiring
|
||||
(§7 bullet 1) has landed — this doc doesn't own that work and doesn't know its schedule, so
|
||||
step 3's "alongside T1's resgen wiring" is doing real scheduling work, not just sequencing
|
||||
flavor. Anyone driving this ordering sketch needs to coordinate with T1 before step 4 can
|
||||
start, not just after step 3 finishes on this track's own items.
|
||||
5. **Data integrity.** L2-04 (locale-independent number codec) — pure `core/` work, unit-testable
|
||||
on Windows, and it should land before any Linux user saves a project. Sequenced after the
|
||||
verify only so its urgency is known; the *work* is not blocked.
|
||||
6. **Deletion safety.** L2-05 (XDG trash or a platform-aware confirm) — gated on `[Daniel]` (2).
|
||||
7. **Ingest opt-in, if needed.** L2-07 — no independent fix; it is the resgen work already
|
||||
sequenced in step 3, strictly gated on `[verify — Linux]` (3) to confirm it's needed at all.
|
||||
8. **Cosmetics.** L2-09 (fonts), L2-10 (drag-out doc). Independent of everything above.
|
||||
9. **Separately, if ruled in.** L2-08 — the instrument's X11 editor. No dependency in either
|
||||
direction on 1–8 except L2-01, which the shared `draw_kit` needs regardless.
|
||||
|
||||
---
|
||||
|
||||
## 7. Referred to T1 (build-system; spotted, not analyzed)
|
||||
|
||||
- **SWELL dialog resgen is not wired for Linux.** `src/app/CMakeLists.txt:97` — the
|
||||
`target_sources(... resource.rc_mac_dlg.h)` line is commented out in the Linux `else()` branch
|
||||
(and in the `APPLE` branch, `:86`). This is the mechanical cause of L2-06.
|
||||
- **VST3 target is `if(WIN32 …)`-gated.** `src/shell/instrument/CMakeLists.txt:9` — the target is
|
||||
not configured at all off Windows; `dllmain.cpp` is hard-coded at `:78` where Linux needs
|
||||
`linuxmain.cpp` (`vendor/vst3sdk/public.sdk/source/main/linuxmain.cpp` exists).
|
||||
- **`SHCreateStdEnumFmtEtc` / `SHFileOperationW` / `OleInitialize` need shell32/ole32.** Only on
|
||||
the Windows branch, but worth confirming the Linux branch links none of them.
|
||||
- **`reaper_plugin.h`'s relative SWELL include.** `vendor/reaper-sdk/sdk/reaper_plugin.h:49`
|
||||
does `#include "../WDL/swell/swell.h"`, which does not exist under `vendor/reaper-sdk/`. It
|
||||
resolves only because `${WDL_INC}` is `vendor/WDL/WDL` and `vendor/WDL/WDL/../WDL/swell/swell.h`
|
||||
is the same file. Works, but it is a coincidence worth knowing before anyone moves an include
|
||||
path.
|
||||
- **`<filesystem>` link requirement.** GCC < 9 needs `-lstdc++fs`; `core/` and `shell/` both use
|
||||
`std::filesystem` heavily.
|
||||
- **LICE TU set.** `CMakeLists.txt:79–82` adds `lice.cpp`, `lice_line.cpp`, `lice_arc.cpp`,
|
||||
`lice_textnew.cpp` unconditionally. All four have non-Windows paths, but the Linux link needs
|
||||
them compiled against `SWELL_PROVIDED_BY_APP` consistently with the module.
|
||||
|
||||
---
|
||||
|
||||
## Summary table
|
||||
|
||||
| ID | Finding | Severity | Effort | Artifact |
|
||||
|-------|-----------------------------------------------------------------|----------|--------|--------------|
|
||||
| L2-01 | `FF_DONTCARE` undefined off Windows (`draw_kit.cpp:73`) | Blocker | S | both |
|
||||
| L2-02 | `GetCurrentProcessId()` unguarded, absent from SWELL | Blocker | S | extension |
|
||||
| L2-03 | `REAPERAPI_LoadAPI` all-or-nothing over ~869 names, silent | Blocker `[verify]` | S | extension |
|
||||
| L2-04 | Persisted floats are `LC_NUMERIC`-dependent, both directions | Major | M | both |
|
||||
| L2-05 | Prune loses trash AND the locked-file backstop | Major | M | extension |
|
||||
| L2-06 | Panel dialog never created; silent no-op | Major | S | extension |
|
||||
| L2-07 | No drop-accept opt-in on SWELL; real seam is resgen, same as L2-06 | Major `[verify]` | — (see L2-06) | extension |
|
||||
| L2-08 | No Linux VST3 editor; no parameters to fall back to | Major | L | instrument |
|
||||
| L2-09 | Segoe UI / Consolas substitute silently via fontconfig | Minor | S | both |
|
||||
| L2-10 | SWELL drag-out: no copy mask, no probe, no outcome | Minor | S | extension |
|
||||
| L2-11 | Case-fold is `_WIN32`-only (right for Linux, wrong for macOS) | Minor | S | both |
|
||||
| L2-12 | `fs::path::string()` narrowing — Linux is strictly better | Informational | — | both |
|
||||
@@ -10,7 +10,8 @@ flatness, naming families) are **not restated**; where a finding below touches t
|
||||
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 PLAN.md §Q-W1..Q-W6.
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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** (PLAN.md §Q-W0 env-coupled
|
||||
bullet; `docs/product/code-organization.md` §2c.3; the load-bearing `sample_map` seconds
|
||||
invariant). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar
|
||||
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.
|
||||
@@ -63,7 +63,7 @@ fix-now findings in those files must be remediated in Q-W0 itself.
|
||||
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 (PLAN.md §S15). The *UI ceiling*, however, encodes a wall-clock intent ("2-second
|
||||
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
|
||||
@@ -165,7 +165,7 @@ fix-now findings in those files must be remediated in Q-W0 itself.
|
||||
`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 (PLAN.md §S15;
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
# Bank package — product notes
|
||||
|
||||
Framing, rationale, and design-direction calls behind **Phase Ε — bank export and
|
||||
import as a single-file package**. The tickable spec lives in `docs/PLAN.md`
|
||||
(§Phase Ε); the architecture detail will live in `src/core/package/CLAUDE.md` and
|
||||
`src/shell/package/CLAUDE.md` once those directories exist. This doc holds the
|
||||
*why* — the user problem, the container choice, the version-compatibility policy
|
||||
and the reasoning that produced it, the failure-mode table, and what a package
|
||||
deliberately does not carry.
|
||||
|
||||
Status: framed by product-designer (2026-08-02); **all three [Daniel]-class forks
|
||||
RULED the same day** — Ε-F1 **proprietary container** (`RSBK`), Ε-F2 **import always
|
||||
lands as a new bank**, with an automatic suffix on a name collision, Ε-F3 **refuse an
|
||||
import while the tracking ledger is degraded**. See §"Rulings" for the index and the
|
||||
recorded rationale; each is specified in place in the section that owns it. Nothing in
|
||||
this doc is open. Everything else below is a product-designer call with its reasoning
|
||||
stated; contradict it in review with an argument, not a preference.
|
||||
|
||||
---
|
||||
|
||||
## What it is (and what it is not)
|
||||
|
||||
**A bank package is one file that carries one bank — its audio and its index —
|
||||
out of a project and into another.** Today a bank is per-project by construction:
|
||||
the audio sits in `<projectDir>/reasampler_bank/` (`core/capture/capture_paths.h`'s
|
||||
`kBankSubfolder`) and the index that gives that audio meaning lives in the `.rpp`'s
|
||||
project ext state under the `"reasampler"` namespace (`src/ext_keys.h`'s
|
||||
`kProjExtBanksKey`). The two travel together with the project and nowhere else.
|
||||
Export writes both halves into a single `.rsbank` file; import lands them into
|
||||
another project's bank folder and index.
|
||||
|
||||
**It is not a project-transfer feature.** REAPER already moves projects — *Save
|
||||
project as… with copy of media*, track templates, subprojects. None of them can
|
||||
carry a ReaSampler bank, because none of them knows the ext-state index exists;
|
||||
copy the `reasampler_bank/` folder by hand into another project and you get a pile
|
||||
of `.wav` files with no names, no loop points, no root notes, no tempo stamps, no
|
||||
tiers, and no lineage. The package exists precisely because **the metadata is the
|
||||
part that cannot be moved by hand.**
|
||||
|
||||
**It is not a preset.** A package carries audio plus bank metadata. It does not
|
||||
carry ReaSampler 9000's dialed sound — filter, envelopes, splines, loop crossfade,
|
||||
rate, pitch. That is the instrument's `ComponentState`, and a user who wants the
|
||||
dialed sound in another project bakes it first (Phase Ξ's resample) and exports the
|
||||
resulting capture. The package is a *bank*, and the bank has always been the audio,
|
||||
not the instrument. See "What a package deliberately does not carry" below — this
|
||||
is the most likely user expectation mismatch in the whole feature, so it is headed
|
||||
off here rather than discovered in a support thread.
|
||||
|
||||
**It is not a re-encode.** Sample bytes leave the source project and arrive at the
|
||||
destination byte-identical. Frame count, sample rate, bit depth, channel count are
|
||||
untouched; no trim, no normalize, no mono collapse, no format conversion, no
|
||||
compression of the audio payload. The package payload is **opaque bytes** to
|
||||
everything in the export/import path except a hash function. This is the phase's
|
||||
trust anchor, and it is the direct analogue of the capture pillar's null test.
|
||||
|
||||
---
|
||||
|
||||
## Why a single file, not a folder copy
|
||||
|
||||
The obvious cheap alternative is "copy the bank folder, and write the index into a
|
||||
sidecar JSON beside it." Rejected, for four reasons, in descending order of weight:
|
||||
|
||||
1. **A folder has no place to put its own manifest that a user cannot lose.** The
|
||||
index is the part that makes the audio a bank. In a folder, the manifest is just
|
||||
one more file among two hundred `.wav`s — droppable, renamable, editable into
|
||||
inconsistency, and silently absent after a partial copy. In a single file it is
|
||||
the header, and the file either has one or is not a package.
|
||||
2. **Integrity and version tagging need one identity.** "Is this package complete,
|
||||
and can this build read it?" is answerable in one read of one file's first few
|
||||
kilobytes. A folder answers it only after enumerating and stat-ing every entry,
|
||||
and answers "was anything edited since export?" not at all.
|
||||
3. **The move gesture is one object.** Email it, drop it in shared storage, drop it
|
||||
on the docked panel. The panel already accepts `WM_DROPFILES` for ingest
|
||||
(`src/shell/panel/panel_window.cpp` header comment: "WM_DROPFILES -> ingest"), so
|
||||
a package can ride an affordance that exists.
|
||||
4. **Atomicity is buyable.** A single file can be written to a temp path and
|
||||
atomically renamed on success — the precedent the mono collapse already set
|
||||
(Ψ-W2-T2 landed the collapse "via temp file plus atomic rename"). A half-written
|
||||
folder looks exactly like a complete one.
|
||||
|
||||
The counter-argument for the folder is real and should be recorded: a folder is
|
||||
inspectable with no tooling. The container ruling below does **not** buy that back —
|
||||
`RSBK` is opaque without our tool — so the inspectability loss is an accepted cost,
|
||||
paid deliberately, not an oversight to be corrected later by reaching for ZIP.
|
||||
|
||||
---
|
||||
|
||||
## The container — a proprietary `RSBK` (Ε-F1, RULED)
|
||||
|
||||
**Ruled by Daniel, 2026-08-02: *"proprietary container."*** The package is a
|
||||
hand-rolled `RSBK` file. ZIP — whether via the vendored MiniZip64 in
|
||||
`vendor/WDL/WDL/zlib/` or as a hand-written stored-only ZIP *shape* — is rejected and
|
||||
is not to be revisited inside this phase.
|
||||
|
||||
**The shape.** Magic `RSBK`, a fixed little-endian header carrying the two version
|
||||
fields (§"Version tagging" below), a length-prefixed JSON manifest, then each entry's
|
||||
payload concatenated in manifest order. Framing overhead is tens of bytes, not
|
||||
kilobytes.
|
||||
|
||||
**What it reuses, rather than invents.** The little-endian byte codec
|
||||
(`core/wire/bytes.h` — `putLE` / `ByteReader`, called out in `src/core/wire/CLAUDE.md`
|
||||
as the earned template case) and the hand-rolled JSON layer (`core/json`). Both are
|
||||
already owned and already tested here.
|
||||
|
||||
**Why the ruling went this way.** The load-bearing reason is not effort — it is that
|
||||
the pure/shell split is this project's central discipline, and `RSBK` is the only
|
||||
candidate where the **whole codec lands pure** and the shell is a bytes-in/bytes-out
|
||||
skin. MiniZip's API is path-and-file-handle shaped (`ioapi.h`), so a ZIP codec drags
|
||||
the filesystem into the layer that discipline keeps free of hosts; a buffer-backed
|
||||
`zlib_filefunc_def` is possible but fiddly and defeats the "standard format" argument
|
||||
inside the code even while preserving it on disk. Compression buys almost nothing on
|
||||
float32 PCM. Writing a correct ZIP central directory by hand was the worst of both —
|
||||
more code than `RSBK`, the same hardening burden, plus Zip64 and name-encoding edge
|
||||
cases, and still no compression.
|
||||
|
||||
**The two costs, accepted with the ruling.** (1) The package is **opaque without our
|
||||
tool** — no unzip-and-look support path. (2) We own the hostile-input hardening of our
|
||||
own parser, to the discipline `bank_model::deserialize` and `parseLedger` already
|
||||
carry — *error signaled, never UB* (`bank_model.h`'s `BankModel::deserialize`). Both are priced in; a
|
||||
later "let's make it inspectable" impulse is a new phase's argument, not this one's.
|
||||
|
||||
**This was a one-way door and it is now shut** — packages are in users' hands the day
|
||||
it ships, and a later container change means either a second reader forever or
|
||||
stranded packages. The version ladder below, not a format swap, is how the format
|
||||
moves from here.
|
||||
|
||||
---
|
||||
|
||||
## Version tagging: two questions, and why one number cannot answer both
|
||||
|
||||
### The precedent this extends (read from source, 2026-08-02)
|
||||
|
||||
The repo already carries **two** versioning mechanisms, and they answer different
|
||||
questions:
|
||||
|
||||
1. **A blob-schema ladder.** `src/core/tracking/origin_ledger.cpp`'s version-ladder
|
||||
header comment states the
|
||||
ladder for the `owned_files` blob (v1 legacy path-only, v2
|
||||
current), pins `constexpr int kLedgerVersion = 2`, and — the load-bearing part —
|
||||
**reads and validates `"v"`, not merely writes it**: "A version above
|
||||
`kLedgerVersion` is therefore its own degraded status, never a Loaded ledger"
|
||||
(the same comment). The parse outcome is a three-way `Ok` / `Malformed` / `FutureVersion`
|
||||
(`origin_ledger.cpp`'s `ParseOutcome` enum and `parseStored`), deliberately distinguished so the operator gets the right
|
||||
recovery advice. A *field-vocabulary* gap behaves oppositely: an unrecognized
|
||||
`OriginKind` integer degrades to `Unknown` rather than failing the parse
|
||||
(`origin_ledger.cpp`'s `kindFromInt`), because "a vocabulary gap must not halt the prune"
|
||||
(`src/core/tracking/CLAUDE.md` §"Gotchas").
|
||||
2. **An app writing-version stamp.** `src/core/version/app_version.h`'s
|
||||
`WritingVersion` with `PreVersioning` / `Unknown` / `Stamped`, classified by
|
||||
`classifyWritingVersion`, stamped into project ext state by
|
||||
`ReaSamplerSession::saveToActiveProject` (`src/shell/persist/ext_state_io.cpp`) using `stampVersion()` (the numeric
|
||||
triple only, no channel suffix). It is informational: an absent stamp is "not an
|
||||
error and not a warning" (`app_version.h`'s `WritingVersion` comment, the `PreVersioning` case).
|
||||
|
||||
**An observation worth recording, not a defect to fix here:** `BankBook` writes
|
||||
`"version": 1` into the banks blob (`src/core/model/bank_book_json.cpp`'s `BankBook::serialize`) but its
|
||||
parser skips the key along with every other unknown one
|
||||
(`bank_book_json.cpp`'s `parseBook` — `if (!r.skipValue()) return false; // version, or unknown`).
|
||||
The book's version field is therefore **decorative today** — written, never read,
|
||||
never gating. The ledger's is the precedent to extend; the book's is the precedent
|
||||
not to repeat.
|
||||
|
||||
### The two questions a package must answer
|
||||
|
||||
- **"Can I parse this shape at all?"** — a hard gate. Monotonic integer. This is
|
||||
`origin_ledger`'s `"v"`.
|
||||
- **"Who wrote this, so I can tell the user what to open it with?"** — informational,
|
||||
never a gate. Semver string. This is `app_version`'s stamp.
|
||||
|
||||
A package carries **both**, and conflating them is the mistake to avoid. The stamp
|
||||
alone cannot gate (semver ordering does not track schema shape; a patch release can
|
||||
change a blob and a minor release can leave it alone). The ladder alone cannot
|
||||
advise (an integer tells a user nothing about which build to install).
|
||||
|
||||
### The refinement: `formatVersion` **and** `minReaderVersion`
|
||||
|
||||
A single ladder has one bad property: **every change strands every older reader,
|
||||
even a purely additive one.** That is not hypothetical here — look at what `Sample`
|
||||
has actually accumulated: `rootNote` and `loop` (`bank_model.h`'s `Sample::rootNote` / `Sample::loop`,
|
||||
"additive like `provenance`. Both default cleanly empty"), `captureTimeSigNum` /
|
||||
`captureTimeSigDenom` (`Sample::captureTimeSigNum` / `Sample::captureTimeSigDenom`, "0/0 means UNSTAMPED"), `channelCount`
|
||||
(`Sample::channelCount`, "0 = unknown — a pre-field entry"). Every one of those was additive with
|
||||
a defined absent-value. Under a single ladder, each would have blocked older readers
|
||||
for no reason.
|
||||
|
||||
So the package header carries two integers:
|
||||
|
||||
- **`formatVersion`** — what this writer emitted. Monotonic, bumped on any change.
|
||||
- **`minReaderVersion`** — the oldest reader that can read this package *safely*.
|
||||
Bumped only when a change is **structural** (a field's meaning changes, a section
|
||||
is removed, framing changes); left alone when a change is **additive** (a new
|
||||
optional manifest key, a new `Sample` field with a defined absent-value — exactly
|
||||
the four listed above).
|
||||
|
||||
The reader's rule is one line: **read it iff
|
||||
`minReaderVersion <= kPackageFormatVersion`.** `formatVersion` is then only for the
|
||||
message text and the log.
|
||||
|
||||
**One change class that looks additive and is not: a new enum value.**
|
||||
`BankModel::deserialize` *rejects* an out-of-range `SourceMode` or `Tier` rather than
|
||||
degrading it (`bank_model.cpp`'s `parseSample` — the `sourceMode` and `tier` branches), and every enum a package carries
|
||||
rides inside the nested `BankModel` blob. So growing either vocabulary is
|
||||
**structural** and bumps `minReaderVersion` too. This is wider than packages and
|
||||
predates them: `BankModel::deserialize` is also the live project ext-state parser
|
||||
(`bank_book_json.cpp`'s `parseBank`), so appending a `SourceMode` value already strands an older
|
||||
build opening a newer project's `.rpp`. Phase Ε inherits that property; it did not
|
||||
cause it, and changing it — degrade-to-`Unknown` at those two sites, the way
|
||||
`BakeStatus` already does — is a change to the model layer, not a package concern. It
|
||||
leaves the argument above untouched: the four fields that motivated the two-integer
|
||||
design are *fields*, and `parseSample`'s `skipValue()` fallback
|
||||
(`bank_model.cpp`), plus the manifest parsers' equivalent at each level, still
|
||||
carries them forward.
|
||||
|
||||
This is a borrowed pattern, not an invention: Matroska's `EBMLVersion` /
|
||||
`EBMLReadVersion` pair, PDF's catalog `/Version` over the header version, and OOXML's
|
||||
`mc:Ignorable` markup-compatibility mechanism all separate "what I am" from "what you
|
||||
must understand to read me." It costs one extra integer and one writer discipline —
|
||||
*decide honestly whether your change is additive* — and that discipline is exactly
|
||||
the one `origin_ledger` already enforces on `OriginKind`
|
||||
(`src/core/tracking/CLAUDE.md` §"Gotchas": "PERSISTED INTEGERS — never renumber, only
|
||||
append").
|
||||
|
||||
### Both directions, concretely
|
||||
|
||||
**Direction 1 — newer ReaSampler, older package. Always imports. Never refuses.**
|
||||
Every reader reads every `minReaderVersion <= kPackageFormatVersion`. Absent manifest
|
||||
keys take their defined defaults, exactly as `Sample`'s additive fields already do,
|
||||
and exactly as `origin_ledger` lifts a v1 path-only blob into v2 records with kind
|
||||
`Unknown` and empty ids (`origin_ledger.cpp`'s version-ladder header comment). Unrecognized manifest keys are
|
||||
skipped, which is already how every parser in this repo behaves
|
||||
(`bank_book_json.cpp`'s `parseBook`). Unrecognized enum integers (the manifest's own —
|
||||
`BankModel`'s nested ones reject) degrade to their defined `Unknown`-equivalent,
|
||||
never to the numeric default and never to a parse failure —
|
||||
`bake_wire`'s rule verbatim (`src/core/wire/CLAUDE.md` §"Modules", the `bake_wire` bullet: "an unrecognized value
|
||||
decodes as `Failed` rather than as the numeric default `Ok`").
|
||||
**The user sees:** a normal import summary. Optionally a single console line naming
|
||||
the older writer version. No dialog, no warning, no ceremony — a supported case is
|
||||
not an incident.
|
||||
|
||||
**Direction 2 — older ReaSampler, newer package. Refuses. Whole-package, nothing
|
||||
written.** `minReaderVersion > kPackageFormatVersion` is a hard stop, before a single
|
||||
byte is written to the bank folder and before the index is touched. This is exactly
|
||||
`LedgerStatus::FutureVersion`'s treatment, and for the same reason stated at
|
||||
`origin_ledger.cpp`'s version-ladder header comment: parsing an unknown shape by old rules "would yield a
|
||||
plausible-but-partial" result, and a partial bank is worse than no bank.
|
||||
**The user sees** a message box (`ShowMessageBox`, verified —
|
||||
`vendor/reaper-sdk/sdk/reaper_plugin_functions.h:6546`,
|
||||
`int (*ShowMessageBox)(const char* msg, const char* title, int type)`) naming three
|
||||
things, because any two of them leave the user stuck:
|
||||
|
||||
> **Cannot import this bank package.**
|
||||
> It was written by ReaSampler 1.7.0 and needs package format 3 or newer.
|
||||
> This build (1.5.2) reads package format 2.
|
||||
> Nothing was imported. Install ReaSampler 1.7.0 or newer and try again.
|
||||
|
||||
The writer's semver is what makes the message *actionable* — "format 3" alone tells a
|
||||
user nothing they can act on. That is the whole reason both fields exist.
|
||||
|
||||
**Refusing is the correct direction to refuse in**, and it is worth saying why
|
||||
rather than leaving it as taste: the destination project is the user's existing work.
|
||||
A refusal costs a transfer the user can retry after updating. A best-effort partial
|
||||
import costs silent data absence inside a project they will keep working in, and they
|
||||
will not find out which twelve of forty samples were dropped until they need one.
|
||||
|
||||
---
|
||||
|
||||
## What a package carries
|
||||
|
||||
- **The two version fields and the writer's semver**, in the fixed header.
|
||||
- **An export timestamp** and the **source bank's display name** — informational, and
|
||||
the default the import prompt pre-fills.
|
||||
- **One manifest entry per sample**, carrying that `Sample` record in
|
||||
**`bank_model`'s own serialization, nested verbatim**. This is the
|
||||
`bank_book_json` precedent applied outward: the book writer "emits the bank
|
||||
envelope … plus a raw `index` member whose value is the `BankModel` blob verbatim,
|
||||
so per-bank sample serialization stays owned by `bank_model` and is not duplicated
|
||||
here" (`bank_book_json.cpp`'s file-header comment). The package does the same, so a future `Sample`
|
||||
field reaches packages for free and the shape has exactly one owner.
|
||||
- **Per entry, additionally:** the payload's **bare file name** inside the package,
|
||||
its byte length, and a whole-file `hashBytes` digest
|
||||
(`core/capture/wav_codec.h`'s `hashBytes` — FNV-1a 64-bit over raw bytes, 16-char lowercase
|
||||
hex). Note carefully: `hashBytes`, **not** `hashWavContent`. The latter deliberately
|
||||
skips non-`fmt `/`data` chunks (`wav_codec.h`'s `hashWavContent`), which is right for dedup
|
||||
identity and wrong for "did these bytes survive the trip." Both hashes are already
|
||||
in the codebase; the package needs the raw one for integrity and carries the
|
||||
`Sample`'s existing `contentHash` for dedup, and they are different fields
|
||||
answering different questions.
|
||||
- **The bank's slot map** — display positions (`core/model/slot_map`), already JSON
|
||||
round-trippable. A bank's arrangement is part of what the user built.
|
||||
- **The payloads**, byte-exact, in manifest order.
|
||||
|
||||
`hashBytes` is FNV-1a — a corruption detector, not a cryptographic checksum. Say so
|
||||
plainly in the code and in any user-facing wording: it catches truncation, bit rot,
|
||||
and a mangled transfer. It does not certify provenance, and it is not a defense
|
||||
against a package deliberately crafted to collide. That is the right level of
|
||||
guarantee for this feature; overselling it would be the error.
|
||||
|
||||
## What a package deliberately does NOT carry
|
||||
|
||||
- **Any absolute path. Any path at all.** Entries are **bare file names** — no
|
||||
directory component, no `..`, no drive letter, no leading separator — validated on
|
||||
encode *and* on decode. The importer spells the destination path itself, through
|
||||
the same `capture_paths` arithmetic every capture already uses. This makes the
|
||||
relative-paths-only precision invariant **structural rather than remembered**:
|
||||
there is no field in the format capable of expressing an absolute path. It also
|
||||
closes the archive-traversal ("zip slip") bug class by construction, which is the
|
||||
one genuinely security-shaped surface this feature has.
|
||||
- **The origin ledger.** The ledger is *this project's* record of files *it*
|
||||
created, and it is the authority prune's protected set is computed from
|
||||
(`src/core/tracking/CLAUDE.md` §"Scope"). Importing foreign ownership records would
|
||||
assert this project's authority over another project's history. Instead the
|
||||
importer writes **its own** birth records for the files it lands, at the moment it
|
||||
lands them, through the one writer (`ReaSamplerSession::recordCreated`,
|
||||
`src/shell/persist/session.h` — it already takes an `OriginKind`). Without that,
|
||||
every imported file would be "foreign, therefore never reclaimed"
|
||||
(`core/tracking/CLAUDE.md` §"Invariants", the "No silent gaps" bullet) and a user's bank folder would grow forever.
|
||||
- **Live-instance usage records** (`rsusage_*`, `src/ext_keys.h`'s `kProjExtUsageKeyPrefix`). Per-instance
|
||||
runtime state of a specific project's specific FX instances. Meaningless elsewhere.
|
||||
- **Project state that is not bank state:** which bank was active, the Design View
|
||||
mode model (`view_state`), the tail setting, the project GUID, the bank-generation
|
||||
counter. A package is a bank, not a project.
|
||||
- **ReaSampler 9000's `ComponentState`.** Stated above; restated here because it is
|
||||
the expectation most likely to be wrong. The seam is left open, not closed: the
|
||||
manifest skips unknown keys, so a future `instrumentState` section is a purely
|
||||
additive change that does not bump `minReaderVersion`. Designing that seam now and
|
||||
spending it later is the point.
|
||||
|
||||
---
|
||||
|
||||
## Identity and collision on import
|
||||
|
||||
**The import target is settled first, because it frames all four collisions.
|
||||
Ε-F2, RULED by Daniel, 2026-08-02: *"always lands as a new bank, with an auto suffix
|
||||
if name collision."*** Every import creates a **new bank** in the destination book.
|
||||
It never merges into an existing bank, never lands into the pool, never offers a
|
||||
target picker, and never overwrites. **Merge-into-existing is out of scope for Phase
|
||||
Ε** — not deferred behind a flag, not a second action shipped later in this phase, not
|
||||
a checkbox. A user who wants imported samples in an existing bank imports and then
|
||||
uses the existing move/copy verbs, which already do exactly that and already carry
|
||||
their own undo.
|
||||
|
||||
Four distinct collisions hide under the word "collision," and they need four
|
||||
different answers.
|
||||
|
||||
1. **Sample id.** Ids are minted as `"cap-" + uniqueTag + "-" + fileName`
|
||||
(`src/shell/capture/capture.cpp`'s `OfflineRenderBackend::capture`) and `"imp-" + …`
|
||||
(`src/shell/actions/ingest.cpp`'s `importFileIntoActiveBank`) — unique within a project, **not** globally.
|
||||
Re-importing a package into the project it came from would collide.
|
||||
**Answer: remint every sample id on import**, under its own prefix, and remap
|
||||
`Provenance::parentSampleId` (`bank_model.h`'s `Provenance` struct) through the same map — to the
|
||||
reminted parent if that parent came in the same package, cleared otherwise. A
|
||||
foreign id never enters the destination index. This also makes "import the same
|
||||
package twice" a clean, duplicative, correct operation rather than an undefined
|
||||
one.
|
||||
2. **File name in the destination bank folder.** **Never overwrite.** Overwriting
|
||||
would destroy an existing capture, and only prune touches existing bank bytes.
|
||||
Mint a fresh unique name through the existing `deriveBankPaths` +
|
||||
unique-tag machinery (`core/capture/capture_paths.h`'s `deriveBankPaths`), automatically, no
|
||||
prompt, and report the count in the summary.
|
||||
3. **Content hash.** `BankModel::add` collapses an equal-`contentHash` add onto the
|
||||
existing entry (`bank_model.h`'s `AddResult::Collapsed`). Desirable — but
|
||||
if the file was already written to disk before the collapse, it becomes an
|
||||
instant orphan. **Answer: check the destination bank's `findByHash` BEFORE writing
|
||||
the payload**; on a hit, skip the write entirely and report "N already present."
|
||||
This is the one place the import must consult the model before touching the
|
||||
filesystem, and it is a concrete acceptance criterion rather than an optimization.
|
||||
4. **Bank display name.** `bank_book` enforces unique display names, trimmed and
|
||||
case-insensitive ASCII (`src/core/model/CLAUDE.md` §"Invariants", the "Bank identity, movement, dedup" bullet; `createBank`'s own
|
||||
contract at `bank_book.h` — *"Drums"/"drums"/" Drums " collide, including
|
||||
against the pool's "Pool"*), so `createBank("Drums")` into a project that already
|
||||
has "Drums" returns `false` with no mutation. **Answer: an automatic numeric
|
||||
suffix, specified below.** No prompt, no overwrite, no refusal.
|
||||
|
||||
### The auto-suffix rule (Ε-F2, implementation-binding)
|
||||
|
||||
The importer picks the destination bank's display name itself. The user is told what
|
||||
it picked; the user is never asked.
|
||||
|
||||
**The seed.** The seed is the package's recorded source bank display name, taken
|
||||
**verbatim**. If that name is absent, empty, or whitespace-only after the model's own
|
||||
trim, the seed is the literal `Imported bank`.
|
||||
|
||||
**The probe.** Let `seed` be that string and `fold(x)` be `BankBook`'s own uniqueness
|
||||
key — strip leading/trailing ASCII whitespace, lower-case ASCII letters
|
||||
(`bank_book.h`'s `BankBook::nameKey`). Take the **first** name in this sequence whose fold is not
|
||||
already carried by a bank in the destination book:
|
||||
|
||||
seed, seed + " 2", seed + " 3", seed + " 4", …
|
||||
|
||||
ascending from 2, unbounded. So `"Drums"` into a project already holding `"drums"`
|
||||
lands as `"Drums 2"`; a third copy lands as `"Drums 3"`.
|
||||
|
||||
**Four properties that make this unambiguous, each stated because omitting it lets two
|
||||
implementations diverge:**
|
||||
|
||||
1. **The seed is never re-parsed.** A package named `"Drums 2"` colliding in the
|
||||
destination lands as `"Drums 2 2"`, **not** `"Drums 3"`. This is deliberate and is
|
||||
not a defect to fix: a trailing integer cannot be distinguished from a user's own
|
||||
name (`"Kit 808"` would become `"Kit 2"` under a stripping rule, silently losing
|
||||
user-authored text). `resample_name::nextIterationName` may increment its tail only
|
||||
because ` r<N>` carries a marker; a bare integer carries none. Appending is the
|
||||
safe direction — it never mutates text the user wrote.
|
||||
2. **The probe fills gaps.** With `"Drums"` and `"Drums 3"` present and `"Drums 2"`
|
||||
free, the import lands as `"Drums 2"`. First-free-ascending, not
|
||||
highest-plus-one — the rule is a pure function of the destination's current name
|
||||
set, so the same package into the same project always produces the same name.
|
||||
3. **The suffix is derived from the destination, never from the package.** The package
|
||||
records only its source name. Nothing about a collision is stored in the package,
|
||||
and re-importing the same package into a different project can produce a different
|
||||
name. The probe terminates: with `B` banks in the destination, one of the first
|
||||
`B + 1` candidates is free by pigeonhole, so no cap is needed and none should be
|
||||
added.
|
||||
4. **The fold has exactly one home.** `import_plan` must **not** re-implement
|
||||
`nameKey` — `bank_book.h`'s `BankBook::nameKey` says in as many words that a drifted second copy
|
||||
would let the uniqueness invariant be violated. The probe therefore runs behind
|
||||
`BankBook`'s own folding, which means Ε-W2-T2 adds **one additive public `const`
|
||||
member** to `BankBook` (recommended: `std::string uniqueDisplayName(const
|
||||
std::string& seed) const`, returning the first free candidate) and calls it. That
|
||||
one member is the only edit any Ε track makes to `core/model/`.
|
||||
|
||||
**What the suffix does NOT touch.** It renames nothing but the new bank's display
|
||||
name. Sample ids are reminted by collision rule 1 regardless of whether a name
|
||||
collision occurred, and the two mechanisms are independent. **`Sample` display names
|
||||
are never suffixed** — two banks may legitimately hold a sample called `"Kick"`, and
|
||||
`resample_name`'s own contract already states that sample display names are not unique
|
||||
(`resample_name.h`'s `nextIterationName`). Bank-folder file names are handled by collision rule 2 and
|
||||
are unaffected by the bank's name. `slot_map` positions ride along unchanged.
|
||||
|
||||
**The pool case is guaranteed, not hypothetical.** Exporting the pool is in scope (the
|
||||
pool is structurally a bank), and the destination's pool always exists and always
|
||||
carries the protected name `"Pool"`. So a pool export imported anywhere lands as a
|
||||
**named** bank called `"Pool 2"`. That is correct under the Ε-F2 ruling — import never
|
||||
lands into the pool — and it should read as intended behaviour in the summary, not as
|
||||
a glitch.
|
||||
|
||||
**What the user sees, and their recovery.** The import summary names the bank it
|
||||
created, and says so plainly when the name was adjusted:
|
||||
|
||||
> Imported 42 samples into a new bank: **Drums 2** (a bank named "Drums" already
|
||||
> exists in this project).
|
||||
|
||||
The recovery path is the existing rename verb — one Ctrl-Z undoes the whole import
|
||||
including the bank creation, and a rename is one gesture if the user wants a different
|
||||
name. Neither needs a new affordance.
|
||||
|
||||
---
|
||||
|
||||
## Failure modes and what the user sees
|
||||
|
||||
Whole-package, all-or-nothing on both sides. The reasoning is the same one prune
|
||||
settled on: report before acting, and never leave a half-state that looks whole.
|
||||
|
||||
| Failure | Side | Behaviour | What the user sees |
|
||||
|---|---|---|---|
|
||||
| An indexed file is missing on disk | export | Refuse by default; offer "export the N present entries" only behind an explicit confirm that lists what is missing | Message box naming the missing entries; nothing written unless confirmed |
|
||||
| An indexed file is unreadable (locked/permission) | export | Same as missing | Same, distinguishing unreadable from absent |
|
||||
| Destination package file exists | export | Platform save dialog's own overwrite confirm | Native dialog |
|
||||
| Write fails partway | export | Temp file in the destination directory, atomic rename only on complete success | Console error; no `.rsbank` left behind. A truncated package must never exist |
|
||||
| `minReaderVersion` above this build | import | Refuse whole. Nothing written, index untouched | The three-part message box above (package needs / this build reads / what to install) |
|
||||
| Malformed or truncated container | import | Refuse whole. Reported **distinctly from** the version case | "This file is not a readable bank package (corrupt or truncated)." The distinction matters: the two have opposite recoveries — one is "install a newer build," the other is "get an intact copy." `origin_ledger.cpp`'s `parseStored` makes exactly this distinction for exactly this reason |
|
||||
| Entry name contains a path separator, `..`, or is absolute | import | Refuse whole, before any write | "This package is not well-formed." Hostile input, not user error — no need to elaborate |
|
||||
| Payload hash mismatch on any entry | import | Refuse whole, before landing anything | "This bank package is damaged (entry `<name>` failed its integrity check). Nothing was imported." |
|
||||
| A write fails mid-import (disk full, permission) | import | Roll back: delete the files **this import wrote** and abandon the index mutation | "Import failed and was rolled back. Nothing was added." |
|
||||
| Bank name collides in the destination | import | Auto-suffix, no prompt, no overwrite — first free of `seed`, `seed 2`, `seed 3`, … | Summary names the bank it created and says the name was adjusted |
|
||||
| File name collides in the bank folder | import | Auto-rename, no prompt | Counted in the summary line only |
|
||||
| Sample already present by content hash | import | Skip the write, collapse onto the existing entry | Counted in the summary line ("N already present") |
|
||||
| Tracking ledger degraded at import time | import | **Refuse whole**, before the picker's bytes are read and before any write — the guard runs first | The two-case message below, mirroring prune's abort |
|
||||
|
||||
**On the rollback, and why it is not an invariant breach.** Prune is the single
|
||||
exclusive file-deletion authority, with exactly one carve-out, stated in one place —
|
||||
`src/shell/persist/prune_fs.cpp`'s file-header comment: "a shell removing a file it wrote itself
|
||||
moments earlier and that no index ever referenced is self-cleanup, not authority
|
||||
over user data … the discriminator is 'did this call create it, and did anything ever
|
||||
reference it', not where it sits." An import rollback fits that discriminator
|
||||
exactly: the files were written by this call, and the index mutation is abandoned, so
|
||||
nothing ever referenced them. The spec must **cite** the carve-out rather than
|
||||
restate it, or a reviewer will correctly read the rollback as a breach.
|
||||
|
||||
**On undo.** The index side of an import is one Ctrl-Z, through the same
|
||||
`persistBankOp` undo batching every bank verb already uses
|
||||
(`src/shell/bank_ops/CLAUDE.md` §"Invariants", the "One bank operation is one Ctrl-Z" bullet; `Undo_BeginBlock2` / `Undo_EndBlock2` verified
|
||||
at `reaper_plugin_functions.h:7758` and `:7806`). **Undo does not un-write the
|
||||
files** — they remain on disk, referenced by no index, until a prune reclaims them.
|
||||
That is the same designed orphaned-until-prune window a non-empty bank delete already
|
||||
produces (`src/core/model/CLAUDE.md` §"Invariants", the "Bank identity, movement, dedup" bullet). Say it out loud in the spec; do not let
|
||||
a user infer that Ctrl-Z cleans the folder.
|
||||
|
||||
### Import under a degraded tracking ledger (Ε-F3, RULED: refuse)
|
||||
|
||||
**Ruled by Daniel, 2026-08-02: *"refuse mismatched import."*** An import that cannot be
|
||||
cleanly reconciled against the tracking ledger is **refused outright**. There is no
|
||||
confirm-and-proceed path, no "I understand the risk" checkbox, and no preference to
|
||||
turn the guard off. This ruling went against the framing recommendation, and the
|
||||
reasoning that carried it is recorded below rather than re-argued.
|
||||
|
||||
**The trigger, exactly.** The guard fires when `tracking::ledgerDegraded(status)` holds
|
||||
for the project's loaded ledger status — that is, `LedgerStatus::Unreadable` or
|
||||
`LedgerStatus::FutureVersion` (`src/core/tracking/origin_ledger.h`'s `LedgerStatus` and `ledgerDegraded`).
|
||||
`Fresh` (absent key — a legitimate new project) and `Loaded` both proceed normally.
|
||||
|
||||
**Two things the guard is deliberately NOT keyed on:**
|
||||
|
||||
- **Not `PruneReport::blockedByTracking`.** That flag also fires on unreadable
|
||||
`rsusage_*` keys, which are about live-instance protection during a *deletion*.
|
||||
Import deletes nothing and computes no protected set; it *writes* birth records. An
|
||||
undecodable usage key must not block an import, and reusing prune's composite flag
|
||||
would silently make it do so.
|
||||
- **Not the package.** Nothing in the `.rsbank` participates in this check. The package
|
||||
is untouched by a refusal and remains importable later, elsewhere, or after the
|
||||
project is repaired.
|
||||
|
||||
**When it runs.** First — **before the file picker opens**, before a byte of the
|
||||
package is read, before any allocation. Making the user find and pick a file we have
|
||||
already decided to refuse is the wrong order.
|
||||
|
||||
**What the user sees.** A console block through `ShowConsoleMsg`, mirroring prune's
|
||||
abort (`src/shell/actions/prune_action.cpp`'s `doBankPruneFolder` — the `blockedByTracking` console block) in structure and in tone, because a
|
||||
user who has hit prune's block should recognise this one. Every recovery line names
|
||||
**this build's** ext-state namespace via `version::extStateNamespace()` — the
|
||||
beta/stable trap prune already documents, where a beta user handed the stable spelling
|
||||
clears the wrong key and is still blocked. Two cases, exactly one of which fires:
|
||||
|
||||
*Malformed ledger:*
|
||||
|
||||
> ReaSampler import: ABORTED — the file-tracking ledger could not be read. Nothing was
|
||||
> imported.
|
||||
> The stored file-tracking ledger is malformed. It has been left intact rather than
|
||||
> overwritten, so it can be repaired or cleared:
|
||||
> `reaper.SetProjExtState(0, "reasampler", "owned_files", "")`
|
||||
> Clearing it makes every existing bank file un-reclaimable (they stop being
|
||||
> attributable to ReaSampler); no file is lost. Reopen the project afterwards — the
|
||||
> block is held for the rest of this session.
|
||||
> An import can land hundreds of files in one gesture. With no readable ledger, none of
|
||||
> them could be given a birth record, and every one would be permanently unreclaimable.
|
||||
|
||||
*Ledger from a newer build:*
|
||||
|
||||
> ReaSampler import: ABORTED — the file-tracking ledger could not be read. Nothing was
|
||||
> imported.
|
||||
> The stored file-tracking ledger was written by a NEWER version of ReaSampler than
|
||||
> this one, so its records cannot be read safely. It has been left intact and will NOT
|
||||
> be overwritten. Reopen the project with that newer version — do NOT clear this key
|
||||
> from here, that would discard tracking records this build cannot see. The block is
|
||||
> held for the rest of this session.
|
||||
> An import can land hundreds of files in one gesture. With no readable ledger, none of
|
||||
> them could be given a birth record, and every one would be permanently unreclaimable.
|
||||
|
||||
**The recovery path.** The status is written only by `loadFromProject`, so it is sticky
|
||||
for the session (`src/shell/persist/CLAUDE.md` §"Invariants", the "A ledger this build cannot read is degraded" bullet): repair or clear the key
|
||||
(malformed case only), or install the newer build (future-version case), **reopen the
|
||||
project**, then import again. The package needs no re-export, and nothing about the
|
||||
destination project was changed by the refusal.
|
||||
|
||||
**Export is NOT gated on the ledger, and that asymmetry is intentional.** Export writes
|
||||
no birth records, mutates nothing, and touches no ext state. A user whose ledger is
|
||||
degraded can still get their bank *out* — which is exactly the moment they are most
|
||||
likely to want to. Only the landing side refuses.
|
||||
|
||||
**Why the ruling went this way.** The rejected option — allow the import behind an
|
||||
up-front confirm — matched the accepted residual already stated at
|
||||
`core/tracking/CLAUDE.md` §"Invariants" (the "No silent gaps" bullet), where a capture made during a degraded session is
|
||||
recorded in memory but not persisted and degrades to foreign. The argument that carried
|
||||
is **scale**: that residual contemplates *one* untracked capture, and a bulk import can
|
||||
strand two hundred files in a single gesture. Same mechanism, different animal. A
|
||||
confirm would also push a data-lifecycle consequence onto the user at the one moment
|
||||
they are least equipped to evaluate it — mid-transfer, wanting the samples. The refusal
|
||||
costs a retry after a project reload; the confirm costs a bank folder that can never be
|
||||
reclaimed.
|
||||
|
||||
---
|
||||
|
||||
## Memory: the streaming seam that keeps the codec pure
|
||||
|
||||
A bank is not small. Float32 stereo at 48 kHz is ~23 MB per minute; a two-hundred-
|
||||
sample bank is plausibly gigabytes. **The naive shape — a pure
|
||||
`encodePackage(vector<uint8_t>) -> vector<uint8_t>` — holds the whole bank twice in
|
||||
RAM and is unshippable.** The temptation is then to move the codec into the shell so
|
||||
it can stream. That is the wrong correction, and the right one is a better seam:
|
||||
|
||||
- **Pure owns framing and arithmetic.** `encodeHeader(manifest) -> bytes` and
|
||||
`entryLayout(manifest) -> [{ name, offset, length }]` on the write side;
|
||||
`decodeHeader(prefix bytes) -> manifest + entry layout` on the read side. Offsets
|
||||
and lengths are arithmetic — perfectly pure, perfectly testable, and the exact
|
||||
place an off-by-one becomes a corrupt package.
|
||||
- **Shell owns the stream.** It writes the header, then appends payloads one at a
|
||||
time, reading each source file into a buffer, hashing it, writing it, and releasing
|
||||
it. On decode it reads the prefix, gets the layout, then seeks and streams each
|
||||
payload independently.
|
||||
|
||||
**Constraint, stated as an acceptance criterion:** the export and import paths hold
|
||||
**at most one entry's payload** in memory at a time. This is what keeps the codec
|
||||
pure without making the feature fail on real banks, and it is the kind of thing that
|
||||
is cheap to design in and expensive to retrofit.
|
||||
|
||||
**One honest cost.** Export and import are synchronous, on the UI thread, like every
|
||||
other action in the tool, and prune sets that precedent (a scan-then-confirm gesture
|
||||
that blocks). A multi-gigabyte bank will therefore freeze REAPER for seconds. The
|
||||
recommendation is to ship synchronous with a console progress/summary line and treat
|
||||
async as a later move if it bites — but this is a real `[propose]`-class call the
|
||||
implementation review should make deliberately rather than by default.
|
||||
|
||||
---
|
||||
|
||||
## Where it lives (pure / shell)
|
||||
|
||||
Two new directories, following the split the whole repo turns on.
|
||||
|
||||
**`src/core/package/` — pure, REAPER-free, unit-tested without a DAW.**
|
||||
|
||||
- `package_format` — the container framing and the version ladder in one place:
|
||||
the magic, the header layout, `kPackageFormatVersion`, `kPackageMinReaderVersion`,
|
||||
and `classifyPackageVersion(formatVersion, minReader) -> Readable | TooNew |
|
||||
Malformed`. The ladder lives with the framing because the ladder *is* the framing's
|
||||
contract, and it gets a header-comment ladder written the way
|
||||
`origin_ledger.cpp`'s version-ladder header comment writes one.
|
||||
- `package_manifest` — the manifest model and its JSON codec, nesting `BankModel`'s
|
||||
own blob verbatim.
|
||||
- `bank_package` — header encode / prefix decode / entry layout, composing the two
|
||||
above. Never holds a payload.
|
||||
- `export_plan` — the pure export decision: which entries, what names, what is
|
||||
missing, and therefore whether the export may proceed.
|
||||
- `import_plan` — the pure import decision: the id remap table, the parent remap, the
|
||||
per-entry write / skip-already-present / rename-to-avoid-collision disposition, and
|
||||
the destination bank name after uniqueness folding. **This module is why the whole
|
||||
feature is testable without a DAW** — every collision rule above is a pure function
|
||||
over strings and hashes.
|
||||
|
||||
`export_plan` and `import_plan` are separate TUs deliberately, not one `package_plan`:
|
||||
they share only the manifest type, and separating them is what lets the two Phase Ε
|
||||
build tracks run in parallel without fighting over a file. The seam is a
|
||||
responsibility seam, which is what the structural heuristic asks for.
|
||||
|
||||
**`src/shell/package/` — filesystem and REAPER-facing.**
|
||||
|
||||
- `package_io` — read a package file to bytes, write bytes through temp + atomic
|
||||
rename, read a bank file's bytes, write a landed file, enumerate existing bank-folder
|
||||
names, and execute the rollback delete (citing the `prune_fs` carve-out).
|
||||
- The file picker, which is REAPER's own on every platform — no `#ifdef _WIN32` /
|
||||
`#else swell/swell.h` split, no Win32 `GetSaveFileNameW`, no wide-char round trip.
|
||||
**Verified:** `GetUserFileName(int mode, const char* caption, const char*
|
||||
initial_file_or_path, const char* extension_list, char* fnOutNeedBig, int
|
||||
fnOutNeedBig_sz)` — `reaper_plugin_functions.h:3790`, documented at `:3788` — serves
|
||||
both verbs symmetrically: `mode=0` chooses a new file (export's destination),
|
||||
`mode=1` an existing one (import's source). `extension_list` takes the
|
||||
`'ReaSampler banks|*.rsbank|All files|*.*'` form. `GetUserFileNameForRead` is
|
||||
explicitly "Superseded, see GetUserFileName" (`:3796`) and is not used. No fallback
|
||||
is needed: `src/app/main.cpp`'s `#define REAPERAPI_IMPLEMENT` appears without
|
||||
`REAPERAPI_MINIMAL`, so the resolver walks the full table (`GetUserFileName` at
|
||||
`:9084`), and `REAPER_PLUGIN_ENTRYPOINT`'s `REAPERAPI_LoadAPI` check refuses to load the extension if any one function
|
||||
fails to resolve — so no REAPER build that loads us can lack it.
|
||||
- `export_bank` / `import_bank` — the promptless verbs, mirroring
|
||||
`src/shell/bank_ops/`'s pattern exactly: take a `ReaSamplerSession&`, do the work,
|
||||
return an outcome, **no prompts and no message boxes**. The bindable action and the
|
||||
panel menu item are then thin skins over one verb apiece, so the logic has one home
|
||||
(`src/shell/bank_ops/CLAUDE.md` §"Scope").
|
||||
|
||||
**The dependency-shape criterion, stated because the brief demands it.** The pure
|
||||
planners take **explicit value inputs** — the decoded manifest, the destination
|
||||
`BankBook`, the set of file names present in the bank folder — never a session handle,
|
||||
never a service container, never a "pass me the thing that has everything." The shell
|
||||
*gathers*; the core *decides*. That is the same shape `src/shell/persist/CLAUDE.md` §"Scope"
|
||||
already states ("it gathers rather than decides"). If a circular dependency shows up
|
||||
during the build, the fix is a service split or a thin interface at the seam — never
|
||||
threading an extra parameter through a chain of constructors, and never handing a
|
||||
container down. A base class that grows a dependency must not grow its subclasses'
|
||||
constructors.
|
||||
|
||||
---
|
||||
|
||||
## Invariant reconciliation
|
||||
|
||||
- **Relative paths only.** Strengthened, not merely preserved: the package format has
|
||||
no field capable of expressing a path, only a bare file name, validated at both
|
||||
ends. The destination path is spelled by `capture_paths` on the importing side.
|
||||
- **Capture and placement are separate acts.** Import writes files and index entries.
|
||||
It places **no** timeline item, ever — the same rule capture has always carried
|
||||
(root `CLAUDE.md`, "The load-bearing principle"). A user who wants the imported
|
||||
audio in the arrange uses the existing insert action.
|
||||
- **Prune is the single exclusive file-deletion authority.** Unchanged. The one
|
||||
rollback path is the documented self-cleanup carve-out, cited not restated.
|
||||
- **No lossy transforms.** The payload is opaque bytes on both sides. `wav_codec` is
|
||||
invoked on it only to hash and to read metadata already recorded — never to rebuild,
|
||||
trim, normalize, or collapse. The mono collapse in particular is a **capture-path**
|
||||
behaviour and must not reach the import path, for the same reason ingest is already
|
||||
excluded from it (root `CLAUDE.md`, exact-bounds invariant: "ingest is excluded,
|
||||
because an imported file is the user's bytes, not our capture"). A package's bytes
|
||||
are someone else's capture; the same exclusion applies with the same reasoning.
|
||||
- **Bit-identical round-trip.** Export → import → export yields byte-identical
|
||||
payloads. This is the phase's trust anchor and belongs in the acceptance criteria of
|
||||
the round-trip track, tested against frozen fixture bytes rather than against a
|
||||
freshly-generated pair.
|
||||
- **Bank generation.** Import mutates bank content that live ReaSampler 9000
|
||||
instances may play, so it must `bumpBankGeneration()`
|
||||
(`src/shell/persist/session.h`'s `ReaSamplerSession::bumpBankGeneration`, whose own comment says call sites "err toward
|
||||
bumping"). Export mutates nothing and must bump nothing, write no ext state, and
|
||||
open no undo point.
|
||||
- **Beta/stable channel isolation.** Packages are channel-**agnostic** and this is
|
||||
deliberate. Channel isolation exists so a beta cannot rewrite a stable project's
|
||||
ext state (`app_version.h`'s `extStateNamespace` — the ISOLATION comment); a package is a file the user moves by hand, not
|
||||
ambient project state, so there is no isolation property to preserve. A beta build
|
||||
and a stable build at the same package format read each other's packages, and that
|
||||
is the useful behaviour. The version ladder — not the channel — is what gates.
|
||||
|
||||
---
|
||||
|
||||
## Rulings — Daniel's, 2026-08-02
|
||||
|
||||
All three [Daniel]-class forks this doc opened were ruled the same day it was framed.
|
||||
Nothing here is open. This section is an index; each ruling is **specified** in the
|
||||
section that owns it, and that section is the implementation-binding text.
|
||||
|
||||
| Fork | Ruling | Specified in |
|
||||
|---|---|---|
|
||||
| **Ε-F1** | **Proprietary container.** Hand-rolled `RSBK`. ZIP via the vendored MiniZip64, and a hand-written stored-only ZIP shape, are both rejected | §"The container" |
|
||||
| **Ε-F2** | **Import always lands as a new bank**, with an automatic numeric suffix on a display-name collision. Merge-into-existing is out of scope for this phase | §"Identity and collision on import" — the frame, plus the auto-suffix rule |
|
||||
| **Ε-F3** | **Refuse** an import while the tracking ledger is degraded. No confirm-and-proceed path | §"Import under a degraded tracking ledger" |
|
||||
|
||||
**Two of the three went to a different answer than the framing recommended, and the
|
||||
reasons are worth keeping.** Ε-F2's recommendation was a *prompt* pre-filled with a
|
||||
uniqueness-folded suggestion; the ruling removed the prompt entirely, which is the
|
||||
better shape — the name is derived deterministically from the destination, the user is
|
||||
told rather than asked, and the existing rename verb is the recovery. Ε-F3's
|
||||
recommendation was allow-with-confirm; the ruling refused, and the counter-argument
|
||||
raised alongside that recommendation is what carried it (scale — the accepted residual
|
||||
contemplates one untracked capture, an import strands hundreds).
|
||||
|
||||
---
|
||||
|
||||
## Implementation decisions — Ε-W2-T1
|
||||
|
||||
Not [Daniel]-class forks — both were `[propose at review]` calls in `docs/PLAN.md`'s
|
||||
Ε-W2-T1 track, answered at implementation review rather than by Daniel, and recorded
|
||||
here per this phase's own convention for keeping such answers where the design lives
|
||||
rather than only in the track's own now-stale open-questions line.
|
||||
|
||||
- **Affordance: both the bindable action and the panel row.** The action targets the
|
||||
**active** bank and is the only spelling that can reach the **pool** (the panel's
|
||||
`showTabMenu` returns early on `isPool()` — a named-bank-tab context menu has no tab
|
||||
to right-click for the pool), while the exported unit's own definition above includes
|
||||
the pool. The panel row is the direct gesture on a specific named bank. Neither
|
||||
subsumes the other.
|
||||
- **Default file name: the bank's display name**, sanitized through
|
||||
`capture_paths::sanitizeStem`, seeded into `<projectDir>/<stem>.rsbank`. A
|
||||
project-derived name was the rejected alternative: three banks exported from one
|
||||
project must produce three distinguishable files, and a project-derived name
|
||||
collides on the second export. Known wart, worth recording rather than hiding:
|
||||
`sanitizeStem` collapses an all-non-ASCII display name to the literal `capture`, so
|
||||
two such banks still collide — the existing rename verb is the recovery, same as the
|
||||
import-side auto-suffix collisions above.
|
||||
|
||||
---
|
||||
|
||||
## Non-goals and guardrails
|
||||
|
||||
- **No auto-insertion of imported audio into the arrange.** Same rule as capture.
|
||||
- **No overwrite of an existing bank-folder file, ever.** Auto-rename instead.
|
||||
- **No partial import.** All-or-nothing, with rollback. A partially-imported bank is
|
||||
the failure mode this whole design is shaped to avoid.
|
||||
- **No re-encode, no trim, no normalize, no mono collapse on either side.**
|
||||
- **No compression of the audio payload.** `RSBK` concatenates payload bytes; there is
|
||||
no compressor in the path and none is to be added.
|
||||
- **No merge-into-existing import.** Every import creates a new bank (Ε-F2). There is
|
||||
no target picker, no "import into the active bank" variant, and no second action.
|
||||
Move/copy already move samples between banks after the fact.
|
||||
- **No instrument state in the package** — the seam is left additive, deliberately
|
||||
unspent.
|
||||
- **No whole-book export in this phase.** One package carries one bank, because that
|
||||
is the unit users think in. A future multi-bank package is an additive manifest
|
||||
change that does **not** bump `minReaderVersion`, so the option is preserved by
|
||||
construction rather than by promise. Do not build it now.
|
||||
- **Do not make the package a sync mechanism.** No "re-import to update," no
|
||||
reconciliation against a previously-imported package, no package identity tracked
|
||||
in project state. Import is a one-way copy-in. Anything else is a different product.
|
||||
@@ -1,19 +1,20 @@
|
||||
# Capture tail — spec
|
||||
|
||||
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
|
||||
`PLAN.md` (Milestone T); this doc holds the full technical detail **and** the
|
||||
product framing.
|
||||
tails that decay past the end of a capture range. The tickable milestone's landed
|
||||
history is in `docs/ARCHIVE.md` (Milestone T); this doc holds the full technical
|
||||
detail **and** the product framing.
|
||||
|
||||
> **Why this doc carries the technical spec (not `CONTEXT.md`).** Every 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/`
|
||||
> note. Capture-tail is a rider on the already-shipped offline-render path
|
||||
> (M3/M7), not a standalone pillar, and it is being specced without reopening
|
||||
> `CONTEXT.md`. So the authoritative detail lands **here**, house-styled to match
|
||||
> the CONTEXT specs; when the tail work lands, doc-keeper may fold the invariant
|
||||
> deltas into `CONTEXT.md §Precision invariants` as landed history. Same standing
|
||||
> discipline applies: **verify every REAPER API name/flag against
|
||||
> **Why this doc carries the technical spec (not the architecture docs).** Every
|
||||
> other pillar (capture M0–M11, Design View, Multi-bank) keeps its authoritative
|
||||
> technical spec as a per-directory `src/**/CLAUDE.md` section and its *why* in a
|
||||
> `docs/product/` note. Capture-tail is a rider on the already-shipped
|
||||
> offline-render path (M3/M7), not a standalone pillar, and it was specced without
|
||||
> reopening the architecture spec. So the authoritative detail lands **here**,
|
||||
> house-styled to match those specs; the landed invariant deltas are folded into
|
||||
> root `CLAUDE.md` §Precision invariants and `src/core/capture/CLAUDE.md` /
|
||||
> `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
|
||||
> below are transcribed from that header (line numbers cited) and are not guesses.
|
||||
|
||||
@@ -52,12 +53,20 @@ snapshot/restore, forces dither and all normalize-postprocessing off, and render
|
||||
32-bit float. The tail wires into that existing path — no new render trigger, no
|
||||
new backend.
|
||||
|
||||
### Bounds are always custom — so the tail bit is always `&1`
|
||||
### Bounds are always the time selection — so the tail bit is always `&4`
|
||||
|
||||
The backend renders with `RENDER_BOUNDSFLAG = 0` (custom time bounds) for **every**
|
||||
scope and every range type: it sets `RENDER_STARTPOS` / `RENDER_ENDPOS` explicitly
|
||||
from the request's exact seconds (`capture.cpp` ~L352–354). It does **not** use the
|
||||
time-selection / selected-items / regions bounds modes.
|
||||
The backend renders with `RENDER_BOUNDSFLAG = 2` (time selection) for **every**
|
||||
scope and every range type: it writes the request's exact seconds into the
|
||||
project's own time selection via `GetSet_LoopTimeRange` (`capture.cpp` ~L470–477;
|
||||
`RENDER_STARTPOS`/`RENDER_ENDPOS` are also written, as a defensive no-op for a
|
||||
mode-0-only field, but the window itself travels in the time selection). It does
|
||||
**not** use the custom-time-bounds mode (`RENDER_BOUNDSFLAG = 0`) — that mode was
|
||||
tried and retired: DAW observation showed REAPER resolving a custom-bounds window
|
||||
on a whole-millisecond grid AT RENDER TIME, flooring the end and rendering exactly
|
||||
the floored frame count, which silently broke the exact-bounds precision
|
||||
invariant. The time-selection mode does not floor the window. (The one narrative
|
||||
home for that finding is `render_settings.h`'s `kRenderBoundsTimeSelection`; this
|
||||
doc points there rather than retelling it.)
|
||||
|
||||
`RENDER_TAILFLAG` is a bitmask keyed to the **bounds mode**, not the capture range
|
||||
type (header line 3047):
|
||||
@@ -68,18 +77,20 @@ RENDER_TAILFLAG : &1=custom time bounds, &2=entire project, &4=time selection,
|
||||
&32=selected project markers/regions
|
||||
```
|
||||
|
||||
Because we always render in custom-time-bounds mode, **the only tail bit that ever
|
||||
applies is `&1`**. There is no per-range-type tail-flag decision to make — a razor
|
||||
capture, a time-selection capture, and an item capture are all custom-bounds
|
||||
renders under the hood, so all three take `RENDER_TAILFLAG = 1`.
|
||||
Because we always render in time-selection mode, **the only tail bit that ever
|
||||
applies is `&4`**. There is no per-range-type tail-flag decision to make — a razor
|
||||
capture, a time-selection capture, and an item capture are all time-selection-bounds
|
||||
renders under the hood, so all three take `RENDER_TAILFLAG = 4`.
|
||||
|
||||
> **Correction to the framing brief.** The brief asked us to pick a
|
||||
> `RENDER_TAILFLAG` bit *per capture range type* (time selection vs. razor vs. item)
|
||||
> and flagged `&32` as "markers/regions." The header (line 3047) says `&32` =
|
||||
> *selected project regions* and `&8` = *all markers/regions* — but neither matters:
|
||||
> our renders are all `RENDER_BOUNDSFLAG = 0`, so the tail bit is `&1` unconditionally.
|
||||
> The existing `kTailFlagCustomBounds = 1.0` constant in `capture.cpp` (~L80) is
|
||||
> already correct; the field wiring is what's missing.
|
||||
> our renders are all `RENDER_BOUNDSFLAG = 2`, so the tail bit is `&4` unconditionally.
|
||||
> The existing `kTailFlagTimeSelection = 4` constant in
|
||||
> `src/core/capture/render_settings.h` (the bounds mode's own bit, per bounds mode —
|
||||
> header line 3047) is already correct — it was right from the start; the wording
|
||||
> above it (which had assumed a custom-bounds render) was what was wrong.
|
||||
|
||||
### Mode 1 — Automatic (default): generous tail + auto-trim to -72 dB
|
||||
|
||||
@@ -87,7 +98,7 @@ Set, in addition to the exact `STARTPOS`/`ENDPOS` already driven:
|
||||
|
||||
| Setting | Value | Meaning / header ref |
|
||||
|---|---|---|
|
||||
| `RENDER_TAILFLAG` | `1` | apply tail for custom time bounds (line 3047, `&1`) |
|
||||
| `RENDER_TAILFLAG` | `4` | apply tail for time selection (line 3047, `&4`) |
|
||||
| `RENDER_TAILMS` | `8000` | the 8 s cap, in ms (line 3048) |
|
||||
| `RENDER_NORMALIZE` | `32768` | **only** the trim-ending-silence bit (line 3051, `&32768`) |
|
||||
| `RENDER_TRIMEND` | `≈ 0.000251` | -72 dB threshold (line 3062; scaling below) |
|
||||
@@ -155,7 +166,7 @@ The existing (currently unwired) `CaptureRequest.renderTail` / `tailMs` fields
|
||||
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
| `RENDER_TAILFLAG` | `1` |
|
||||
| `RENDER_TAILFLAG` | `4` |
|
||||
| `RENDER_TAILMS` | `request.tailMs` (clamped to the 8 s cap — see below) |
|
||||
| `RENDER_NORMALIZE` | `262144` (`kNormalizeDisableAll`, unchanged) |
|
||||
| `RENDER_TRIMEND` | not set / irrelevant (trim bit is clear) |
|
||||
@@ -176,9 +187,9 @@ adds a third state, so the wiring is a small enum, not a bool:
|
||||
- **None** (default for null-test / verify captures, and the current two-scope
|
||||
action defaults): `RENDER_TAILFLAG = 0`, `RENDER_TAILMS = 0`, normalize =
|
||||
disable-all. Exact bounds. Byte-identical to today.
|
||||
- **Auto** (the new user-facing default for tail-on captures): tailFlag `1`,
|
||||
- **Auto** (the new user-facing default for tail-on captures): tailFlag `4`,
|
||||
tailMs `8000`, normalize `32768` (surgical trim), trimEnd `0.00025119`.
|
||||
- **Manual(ms)**: tailFlag `1`, tailMs `clamp(ms, 8000)`, normalize `262144`
|
||||
- **Manual(ms)**: tailFlag `4`, tailMs `clamp(ms, 8000)`, normalize `262144`
|
||||
(disable-all), no trim.
|
||||
|
||||
Recommended shape: replace `bool renderTail` with a `TailMode { None, Auto,
|
||||
@@ -252,7 +263,7 @@ held to bit-identical repeats.
|
||||
- **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
|
||||
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
|
||||
decay is trimmed off.
|
||||
- **The null test uses NO tail.** The null-test / verify capture (M10) and any
|
||||
@@ -274,8 +285,8 @@ held to bit-identical repeats.
|
||||
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
|
||||
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
|
||||
the tail inherits unchanged, does not worsen). Correct and consistent.
|
||||
note the pre-existing send-isolation caveat, which the tail inherits
|
||||
unchanged, does not worsen). Correct and consistent.
|
||||
- **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
|
||||
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.**"
|
||||
|
||||
Its build roadmap lives in **PLAN.md §Phase Q** and its authoritative spec in
|
||||
**CONTEXT.md §Phase Q**. This doc holds the *why* — the quality bar, the evidence base
|
||||
Its build roadmap's landed history lives in **`docs/ARCHIVE.md`** ("Phase Q — Quality:
|
||||
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
|
||||
reference, and the numbered fork decisions.
|
||||
|
||||
@@ -224,25 +226,25 @@ them through the reorg, not to change them:
|
||||
These are the naming equivalent of the JSON-`Parser` DRY violation — concrete hazards, not taste:
|
||||
|
||||
1. **Four hand-rolled `Parser` classes, one name.** `class Parser` is defined **four times** —
|
||||
`bank_model.cpp:306`, `bank_book.cpp:663`, `owned_manifest.cpp:107`, `view_mode_model.cpp:654`.
|
||||
`bank_model.cpp`, `bank_book.cpp`, `owned_manifest.cpp`, `view_mode_model.cpp`.
|
||||
Q-W1 already deletes three of them by extracting `core/json`; the naming rule is that the
|
||||
survivor is **`json::Parser`** (or a more specific `json::Reader`/`json::Writer` pair — see
|
||||
Q-8), never a bare `Parser` in flat scope.
|
||||
2. **`FooterRect` and `ButtonRect` are shared across pure UI modules — and the codebase already
|
||||
*knows* it.** `struct FooterRect` and `struct ButtonRect` are defined in `prune_button.h`
|
||||
(lines 32, 46) and **reused** by `footer_bar.h`, which carries an explicit in-file "NAME NOTE"
|
||||
(`footer_bar.h:27–34`) documenting that `ButtonRect / FooterRect / SegmentRect / ActionBarRect /
|
||||
and **reused** by `footer_bar.h`, which carries an explicit in-file "NAME NOTE"
|
||||
(`footer_bar.h`) documenting that `ButtonRect / FooterRect / SegmentRect / ActionBarRect /
|
||||
KitBox / KitButtonBox` are "already owned in this namespace" and that new types must carry a
|
||||
`FooterBar*` prefix to avoid collision. That comment is a smell made visible: the flat
|
||||
`reasampler::` namespace forces every pure-UI author to hand-check for name collisions before
|
||||
minting a type. This is the single strongest in-codebase argument for the Q-4 sub-namespaces —
|
||||
under `reasampler::ui` these shared rect types get one clear owner and the hand-checking stops.
|
||||
3. **`Sample` (`bank_model.h:69`, the bank metadata struct) vs `AudioSample` (the `peaks` float
|
||||
3. **`Sample` (`bank_model.h`'s `Sample` struct, the bank metadata struct) vs `AudioSample` (the `peaks` float
|
||||
alias).** Already flagged in §2.4/Q-4; verified — `Sample` is the model record, `AudioSample`
|
||||
is a raw PCM float. Under `model::Sample` vs `audio::AudioSample` the collision risk is gone,
|
||||
but the *names* still read oddly side by side (a `Sample` that is metadata, an `AudioSample`
|
||||
that is one float). Noted; the namespace split is the required fix, a rename is optional (Q-8).
|
||||
4. **`Selection` (`bank_grid.h:112`) and `CellRect` (`bank_grid.h:23`) are generic names in a
|
||||
4. **`Selection` (`bank_grid.h`'s `Selection` struct) and `CellRect` (`bank_grid.h`'s `CellRect`) are generic names in a
|
||||
flat namespace.** `Selection` in particular is the kind of name a newcomer cannot place without
|
||||
opening the file. `ui::Selection` / `ui::CellRect` resolve it structurally; no rename needed
|
||||
beyond the namespace.
|
||||
@@ -253,23 +255,23 @@ Here the names are legal and non-colliding but do not read on one principle —
|
||||
at" gap:
|
||||
|
||||
1. **The model-family suffixes disagree: `_model` vs `_book` vs `Index`.** Verified: the pure model
|
||||
modules are `bank_model.{h,cpp}` (owning `class BankIndex`, `bank_model.h:132`), `bank_book.{h,cpp}`
|
||||
(owning `class BankBook`, `bank_book.h:208`), `view_mode_model.{h,cpp}` (owning `class ViewModeModel`,
|
||||
`view_mode_model.h:376`), `owned_manifest.{h,cpp}` (owning `class OwnedFileManifest`,
|
||||
`owned_manifest.h:52`). Four modules, four different file↔class naming relationships:
|
||||
modules are `bank_model.{h,cpp}` (owning `class BankIndex`, `bank_model.h`), `bank_book.{h,cpp}`
|
||||
(owning `class BankBook`, `bank_book.h`'s `BankBook`), `view_mode_model.{h,cpp}` (owning `class ViewModeModel`,
|
||||
`view_mode_model.h`'s `ViewModeModel`), `owned_manifest.{h,cpp}` (owning `class OwnedFileManifest`,
|
||||
`owned_manifest.h`). Four modules, four different file↔class naming relationships:
|
||||
`bank_model`→`BankIndex` (file says "model," class says "index"), `bank_book`→`BankBook`
|
||||
(file = class), `view_mode_model`→`ViewModeModel` (file = class), `owned_manifest`→`OwnedFileManifest`
|
||||
(file ≈ class, but the class adds "File"). The `bank_model`/`BankIndex` mismatch is the worst:
|
||||
the file name and its primary class name share no word. This is a genuine legibility wart — the
|
||||
fix is a *rename decision* (Q-8), not something the directory move alone resolves.
|
||||
2. **The `bank_book` "wraps `bank_model`" relationship is invisible in the names.** `BankBook`
|
||||
(`bank_book.h:208`) is a registry of `Bank` (`bank_book.h:147`), each wrapping a `BankIndex`
|
||||
(`bank_model.h:132`). The names `Book` → `Bank` → `Index` do not read as a containment hierarchy;
|
||||
(`bank_book.h`'s `BankBook`) is a registry of `Bank` (`bank_book.h`'s `Bank` struct), each wrapping a `BankIndex`
|
||||
(`bank_model.h`). The names `Book` → `Bank` → `Index` do not read as a containment hierarchy;
|
||||
a reader has to learn it. (Not necessarily worth a rename — "book of banks" is evocative — but
|
||||
it is the kind of call Q-8 should make deliberately, not by accident.)
|
||||
3. **`realtime_record.h` (pure) vs `capture_realtime.cpp` (shell) — the word order flips.** Verified:
|
||||
the pure realtime module is `realtime_record.{h}` (owning `RecordModePlan`/`RecordPhase`/
|
||||
`RecordTickInputs`, `realtime_record.h:57–173`) while its shell is `capture_realtime.cpp`. So the
|
||||
`RecordTickInputs`, `core/capture/capture_realtime.h`) while its shell is `capture_realtime.cpp`. So the
|
||||
pure core is `realtime_record` but the shell is `capture_realtime` — the two halves of one feature
|
||||
are named on inverted word order (`realtime_record` vs `capture_realtime`). Compare the *clean*
|
||||
shell-pair convention elsewhere: `drag_out` (pure) ↔ `drag_out_win` (shell) — same stem, suffix
|
||||
@@ -277,7 +279,7 @@ at" gap:
|
||||
naming-drift instance in the tree (Q-9).
|
||||
4. **`capture.{h,cpp}` is the *offline* backend shell, but the name claims all of capture.**
|
||||
Verified: `capture.h` declares `ICaptureBackend`, `OfflineRenderBackend`, **and**
|
||||
`RealtimeRecordBackend` (`capture.h:112,124,201`), while the realtime *implementation* lives in
|
||||
`RealtimeRecordBackend` (`capture.h`'s `OfflineRenderBackend`), while the realtime *implementation* lives in
|
||||
`capture_realtime.cpp` and its pure planner in `realtime_record.h`. So `capture` is really
|
||||
"capture interface + offline backend," a fat header (the §2.3 Interface-Segregation concern) whose
|
||||
name oversells its scope. Its Q-W3 hoist (`capture_orchestrator`/`scope_resolve`) is the moment
|
||||
@@ -288,11 +290,11 @@ at" gap:
|
||||
Swept for names a newcomer couldn't decode; the tree is mostly clean here (a credit to it). Two
|
||||
minor notes:
|
||||
|
||||
- **`guid_diff` / `GuidBaseline` (`guid_diff.h:40`)** — "GUID diff" is decodable in context (it
|
||||
- **`guid_diff` / `GuidBaseline` (`guid_diff.h`'s `GuidBaseline`)** — "GUID diff" is decodable in context (it
|
||||
diffs the live track/item GUID set between polls) but `GuidBaseline` reads more clearly as "the
|
||||
previous-poll snapshot" than the module name suggests. Low priority; leave unless its `core/view`
|
||||
relocation invites it.
|
||||
- **`MinMax` (`peaks.h:30`), `KitBox` (`component_geometry.h:28`)** — terse but correct and local;
|
||||
- **`MinMax` (`peaks.h`'s `MinMax`), `KitBox` (`component_geometry.h`'s `KitBox`)** — terse but correct and local;
|
||||
no change. Named here only to record they were swept and cleared.
|
||||
|
||||
### 2b.5 What the naming audit does NOT touch (hard boundary)
|
||||
@@ -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).
|
||||
|
||||
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
|
||||
its spec framing in **CONTEXT.md §"The pre-restructure audit wave (Q-W0)"**; this section is the
|
||||
evidence-doc framing (the *why* and the smell taxonomy), matching how §2/§2b frame the structural
|
||||
audits.
|
||||
itself is staff-engineer/DSP work. The wave's landed history lives in **`docs/ARCHIVE.md`**
|
||||
("Q-W0 fix-now remediations" and "Phase Q — Quality: the decision record"), and its findings live in
|
||||
**`docs/product/code-quality-audit.md`**; this section is the evidence-doc framing (the *why* and
|
||||
the smell taxonomy), matching how §2/§2b frame the structural audits.
|
||||
|
||||
### 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)
|
||||
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
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
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 in PLAN.md §Q-W0). It
|
||||
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:
|
||||
|
||||
+27
-17
@@ -1,8 +1,9 @@
|
||||
# Design View — product notes
|
||||
|
||||
Framing, rationale, and design-direction calls behind the **Design View** phase.
|
||||
The tickable spec lives in `PLAN.md` (Phase D) and the authoritative technical
|
||||
detail in `CONTEXT.md` (§Design View). This doc holds the *why* — the workflow
|
||||
The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase D) and the
|
||||
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
|
||||
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
|
||||
the user isn't surprised.
|
||||
|
||||
**Never touched:** `B_MUTE` and `I_SOLO`. The tool owns visibility, `B_MAINSEND`,
|
||||
`I_FXEN`, and FX-offline — nothing else — across every managed leaf, tagged or
|
||||
untagged. The user's mute/solo survives every toggle, untouched. This is the exact
|
||||
analog of the
|
||||
capture pillar's non-destructive invariant: **the tool never destroys the user's
|
||||
real state to do its job.**
|
||||
**Never touched:** `B_MUTE`. The tool owns visibility, `B_MAINSEND`, `I_FXEN`,
|
||||
FX-offline, and `I_SOLO` — nothing else — across every managed leaf, tagged or
|
||||
untagged.
|
||||
|
||||
**Solo is owned but never lost.** Solo is a per-mode surface: switching modes banks
|
||||
the outgoing mode's solo state, clears it, and replays the incoming mode's on
|
||||
return, verbatim. Two modes therefore never share a solo — you can solo the drum
|
||||
bus in Arrange and the sound-design chain in Design without either leaking into the
|
||||
other — and neither is destroyed. That is the same exact analog of the capture
|
||||
pillar's non-destructive invariant the flags above satisfy: **the tool never
|
||||
destroys the user's real state to do its job.** It is snapshot-and-restore, one
|
||||
level out from a single toggle to the pair of stances. Reapplying the current mode
|
||||
(tagging, project load) is not a switch and does not touch solo at all.
|
||||
|
||||
---
|
||||
|
||||
@@ -315,8 +323,10 @@ Mirrors the capture pillar's split exactly.
|
||||
- Snapshots prior flag values before parking (reads the same flags it will drive).
|
||||
- Resolves track GUIDs via `GetTrackGUID` / `guidToString` / `stringToGuid` for the
|
||||
index; never uses track index (unstable across reorders).
|
||||
- Never touches the master track's visibility flags; never touches `B_MUTE` /
|
||||
`I_SOLO` on anything.
|
||||
- On a real switch only, banks/clears/replays `I_SOLO` per the per-mode solo surface
|
||||
above.
|
||||
- Never touches the master track's visibility flags; never touches `B_MUTE` on
|
||||
anything.
|
||||
|
||||
**`persist` slice:**
|
||||
- Serialize/deserialize the view section (modes + membership + show-both + snapshots
|
||||
@@ -339,8 +349,7 @@ tooltip on the switch.
|
||||
`docs/design/`), say so and it moves. Flagging because establishing the
|
||||
directory is a project convention, not mine to assume silently.
|
||||
2. **Phase namespace.** Proposed **Phase D** (a lettered namespace) rather than
|
||||
M12 — see PLAN.md rationale. Confirm you're happy with letters for parallel,
|
||||
non-capture phases.
|
||||
M12. Confirm you're happy with letters for parallel, non-capture phases.
|
||||
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
|
||||
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
|
||||
manual lane. Its `C_LANEPLAYS` state is the user's, left exactly as they set it.
|
||||
|
||||
This is the fixed-lane analog of the two invariants already load-bearing in D1 —
|
||||
*never touch `B_MUTE`/`I_SOLO`* and *never touch the master* — extended to a third
|
||||
surface: **never drive a lane the tool did not mint.** It is the same non-destructive
|
||||
promise (the tool owns only what it created) reaching one level deeper, into the lane
|
||||
dimension.
|
||||
This is the fixed-lane analog of the invariants already load-bearing in D1 —
|
||||
*never touch `B_MUTE`*, *never touch the master*, and *never lose the user's solo*
|
||||
(see "Never touched" above) — extended to a further surface: **never drive a lane
|
||||
the tool did not mint.** It is the same non-destructive promise (the tool owns only
|
||||
what it created, and restores what it parks) reaching one level deeper, into the
|
||||
lane dimension.
|
||||
|
||||
### Lane-ownership index (the new data)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,11 @@
|
||||
|
||||
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
|
||||
the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN.md
|
||||
§Phase S** and its authoritative spec in **CONTEXT.md §Phase S** — this doc holds the
|
||||
*why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled
|
||||
decision record).
|
||||
the **product framing behind a scoped phase**. Its build roadmap lives in
|
||||
**`docs/ARCHIVE.md` §Phase S** and its authoritative spec in
|
||||
**`src/core/instrument/CLAUDE.md`** and **`src/shell/instrument/CLAUDE.md`** — this
|
||||
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
|
||||
**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
|
||||
overhaul** (Phase S points S10–S13) — see the r5 Addendum in §4. r4 (below) settled the four
|
||||
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"
|
||||
collides with the existing Design View phase). **PLAN.md §Phase S is now the
|
||||
authoritative roadmap; CONTEXT.md §Phase S is the authoritative spec.** This doc is the
|
||||
collides with the existing Design View phase). **`docs/ARCHIVE.md` §Phase S now records
|
||||
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
|
||||
*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
|
||||
@@ -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
|
||||
writes and the other re-parses.
|
||||
|
||||
**What the current index carries** (from `bank_model`'s `Sample`, per CONTEXT.md §Data
|
||||
model): id, display name, relative path, source range, channel count, sample rate,
|
||||
**What the current index carries** (from `bank_model`'s `Sample`): id, display name,
|
||||
relative path, source range, channel count, sample rate,
|
||||
length, capture tempo, an **optional key**, peak/RMS/LUFS, content hash, tier,
|
||||
provenance, timestamp. Notably it *already* has an optional key field and capture
|
||||
tempo — the seeds of pitch-mapping are there.
|
||||
@@ -426,8 +428,8 @@ tempo — the seeds of pitch-mapping are there.
|
||||
to velocity zones).
|
||||
- **Round-robin groups** (cycle through N samples on repeated same-note hits).
|
||||
- **Loop points** (sustain loop start/end for held notes; sample-accurate,
|
||||
zero-crossing-aware — CONTEXT already flags loop/zero-crossing handling as
|
||||
day-one-relevant for wavetable material).
|
||||
zero-crossing-aware — loop/zero-crossing handling is day-one-relevant for
|
||||
wavetable material).
|
||||
- **Amplitude envelope** (ADSR) and optionally filter/pitch envelopes.
|
||||
- **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
|
||||
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.
|
||||
§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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
engine Daniel smoke-tests.
|
||||
|
||||
**Ingest routes through the bank — "option 1"; the extension owns ingest (→ PLAN.md
|
||||
S8 + S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
|
||||
**Ingest routes through the bank — "option 1"; the extension owns ingest (→
|
||||
`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
|
||||
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,
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
@@ -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**
|
||||
(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
|
||||
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
|
||||
@@ -747,8 +749,9 @@ names the editor as the wound.
|
||||
|
||||
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
|
||||
directions, not open forks — recorded here per the doc's settled-decisions convention; PLAN.md
|
||||
§S10 and CONTEXT.md §Phase S (workflow hierarchy) carry the spec.
|
||||
directions, not open forks — recorded here per the doc's settled-decisions convention;
|
||||
`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
|
||||
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
|
||||
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
|
||||
modes carry the spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param
|
||||
here per the doc's settled-decisions convention; `docs/ARCHIVE.md` §S15 / §S16 records what
|
||||
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.
|
||||
|
||||
**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
|
||||
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",
|
||||
with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16 interaction note;
|
||||
CONTEXT.md §Pitch engine modes — Varispeed vs Preserve + the corrected WDL surface finding.
|
||||
**Where the spec lives:** `docs/ARCHIVE.md` §S16 (reshaped to "pitch engine modes + pitch
|
||||
envelope", with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16
|
||||
interaction note; `src/core/instrument/CLAUDE.md` §Sampling modes — Varispeed vs Preserve
|
||||
+ the WDL surface finding.
|
||||
|
||||
### 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
|
||||
rename/rebind test extends to the beta UID.
|
||||
|
||||
**Where the spec lives:** PLAN.md §S18; CONTEXT.md §VST3 channel identity — the UID pair + the
|
||||
pairing surface. The pairing surface's data half is already load-bearing V4 machinery; S18
|
||||
adds only the identity fork on top.
|
||||
**Where the spec lives:** `docs/ARCHIVE.md` §S18; `src/shell/instrument/CLAUDE.md` §VST3
|
||||
channel identity — the UID pair + the pairing surface. The pairing surface's data half is
|
||||
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
|
||||
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
|
||||
`r9` calls. Authoritative spec: **CONTEXT.md §Phase S — editor view-model redesign (S-VIEW)**;
|
||||
build roadmap: **PLAN.md §Phase S — editor view-model redesign**.
|
||||
`r9` calls. Current architecture: **`src/core/instrument/CLAUDE.md`** and
|
||||
**`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**
|
||||
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
|
||||
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
|
||||
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
|
||||
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**
|
||||
(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
|
||||
default. Both are framed in CONTEXT.md §S-VIEW with the SDK reality swept (drop-to-FX: the S17
|
||||
machinery is SDK-correct, so this is a *diagnosis* task, not a redesign; window size: the
|
||||
`getSize`/`checkSizeConstraint` mechanism is verified), and both are flagged for
|
||||
staff-engineer, not for a product fork.
|
||||
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) and §S-VIEW-SIZE-1
|
||||
(window size: the `getSize`/`checkSizeConstraint` mechanism is verified), and both were
|
||||
flagged for staff-engineer, not for a product fork.
|
||||
|
||||
**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 —
|
||||
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
|
||||
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.
|
||||
|
||||
**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
|
||||
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
|
||||
§Phase S — editor view-model redesign. This Addendum is the *why*; those are the *what/how*.
|
||||
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (envelope overlay, key-tracking,
|
||||
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*.
|
||||
|
||||
---
|
||||
|
||||
@@ -1110,7 +1117,7 @@ v6, both with back-compat defaults on read) are the same load-bearing core.
|
||||
|
||||
**Framing.** Folds one more control into the S-VIEW redesign: a **visual velocity → amp
|
||||
transfer-curve editor**. Today the engine maps velocity to gain *linearly* (`velocityGain_ =
|
||||
velocity / 127.0`, `sampler_core.cpp:261`), applied once at note-on in `Voice::start()`. Daniel
|
||||
velocity / 127.0`, `Voice::start()`), applied once at note-on in `Voice::start()`. Daniel
|
||||
wants that mapping to become an **editable transfer curve** — a bezier from a default flat line to
|
||||
an arbitrary multi-point curve — so velocity dynamics are fully shapeable per sound.
|
||||
|
||||
@@ -1174,7 +1181,7 @@ a LICE shell that draws handles and routes the mouse).
|
||||
evaluation is called at note-on, not per frame (see call 4).
|
||||
|
||||
4. **Voice-engine application point → `Voice::start()`, replacing the linear `velocity/127`.**
|
||||
Confirmed from source: `sampler_core.cpp:261` computes `velocityGain_ = velocity / 127.0` **once
|
||||
Confirmed from source: `Voice::start()` computes `velocityGain_ = velocity / 127.0` **once
|
||||
at note-on** inside `Voice::start()`; the per-frame render path (`advanceFrame`, line 408:
|
||||
`gain = amp * velocityGain_`) then just multiplies the cached scalar. So the transfer curve
|
||||
slots in at exactly one line: `velocityGain_ = curve.eval(velocity)` at note-on — **off the
|
||||
@@ -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
|
||||
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
|
||||
sub-section; PLAN.md §Phase S — editor view-model redesign (S-VIEW-9/S-VIEW-10 + fork R10-F1). This
|
||||
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `velocity_curve` module, its
|
||||
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*.
|
||||
|
||||
---
|
||||
@@ -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
|
||||
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
|
||||
recomposition of *existing* controls; no new params, no component-state bump, VST3 class UID
|
||||
unchanged. All drawing through the L1 kit by palette role; all layout/hit-test in new pure modules
|
||||
**What does NOT change (guardrails).** Zero engine-behavior change — this is a view
|
||||
recomposition of *existing* controls, with one additive exception: the new post-mixer master
|
||||
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
|
||||
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
|
||||
Sample-face recomposition (r11)"; PLAN.md §Phase S — editor Wave B (S-VIEW-11/12/13 + forks
|
||||
R11-F1/R11-F2). This Addendum is the *why*; those are the *what/how*.
|
||||
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `knob_deck`/`curve_popup`/
|
||||
`master_gain` modules) documents the current architecture; `docs/ARCHIVE.md` §FB1 and §FB2
|
||||
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
|
||||
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 +
|
||||
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
|
||||
Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26,
|
||||
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;
|
||||
the engine default is Daniel's fork.
|
||||
|
||||
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original
|
||||
dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
|
||||
UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the ReaSampler
|
||||
9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine features);
|
||||
**CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the channel-mode
|
||||
/ ingest / bank-generation / sampling-mode / pitch-engine contracts, the UX-overhaul spec,
|
||||
the product-name convention, the pure/shell split, the WDL finding, the must-verify
|
||||
SDK/bridge surfaces). This doc is the framing/decision record they point back to. The "no
|
||||
PLAN.md footprint" era is over.
|
||||
**Authoritative from here:** **`docs/ARCHIVE.md` §Phase S** is the landed roadmap (S1–S6
|
||||
the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier
|
||||
1 → embedded UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the
|
||||
ReaSampler 9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine
|
||||
features); **`src/core/instrument/CLAUDE.md`**, **`src/shell/instrument/CLAUDE.md`**, and
|
||||
**`src/core/wire/CLAUDE.md`** are the current spec (seam-field semantics, scope contracts,
|
||||
the channel-mode / ingest / bank-generation / sampling-mode / pitch-engine contracts, the
|
||||
UX-overhaul spec, the product-name convention, the pure/shell split, the WDL finding, the
|
||||
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
|
||||
|
||||
Framing, rationale, and design-direction calls behind the **Multi-bank** phase.
|
||||
The tickable spec lives in `PLAN.md` (Phase B) and the authoritative technical
|
||||
detail in `CONTEXT.md` (§Multi-bank). This doc holds the *why* — the workflow
|
||||
The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase B) and the
|
||||
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
|
||||
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
|
||||
|
||||
Framing, rationale, and the dual-canvas reconciliation behind the reshaped
|
||||
**Milestone 10 (provenance)**. The tickable spec lives in `PLAN.md` (M10); the
|
||||
authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus this
|
||||
note for the reconciliation calls. This doc holds the *why* and the open forks so
|
||||
they don't clutter the build docs.
|
||||
**Milestone 10 (provenance)**. The tickable spec's landed history is in
|
||||
`docs/ARCHIVE.md` (M10); the architecture detail is in `src/core/model/CLAUDE.md`
|
||||
and `src/shell/capture/CLAUDE.md` plus this note for the reconciliation calls.
|
||||
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.
|
||||
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:
|
||||
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
|
||||
path CONTEXT.md keeps forward-referencing but never scoped). The tickable spec
|
||||
lives in `PLAN.md` (Phase B point B5 for remove; **Phase R** for prune) and the
|
||||
authoritative technical detail in `CONTEXT.md` (§Sample removal, §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.
|
||||
path the spec kept forward-referencing but never scoped). The tickable spec's
|
||||
landed history is in `docs/ARCHIVE.md` (Phase B point B5 for remove; **Phase R**
|
||||
for prune) and the architecture detail lives in `src/core/model/CLAUDE.md` +
|
||||
`src/shell/bank_ops/CLAUDE.md` (§Sample removal) and `src/core/reclaim/CLAUDE.md`
|
||||
+ `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
|
||||
(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
|
||||
(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
|
||||
prose in CONTEXT.md and the tickable points in PLAN.md.
|
||||
decisions are folded into the fork sections below and into the B5 / Phase R
|
||||
history in `docs/ARCHIVE.md` and the architecture docs above.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
scopes. It is a real, promised capability with **no phase, no module, no point**
|
||||
— 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`
|
||||
has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set
|
||||
(the tracks that still exist), returning the residual membership entries to drop
|
||||
(CONTEXT.md §Design View: "prunes orphaned snapshots on every toggle/load;
|
||||
tolerates unknown/stale GUIDs (prune on reconcile)"). Prune is the **file-pool
|
||||
(`src/core/view/CLAUDE.md`: "tolerates unknown/stale GUIDs (pruned on reconcile
|
||||
via `ViewModeModel::reconcile(liveGuids)`)"). Prune is the **file-pool
|
||||
mirror of that pure pattern**:
|
||||
|
||||
> `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
|
||||
(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
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action
|
||||
|
||||
@@ -0,0 +1,829 @@
|
||||
# Render in place — product notes
|
||||
|
||||
Framing, rationale, and design-direction calls behind **Phase Ρ — render a track's
|
||||
output to a new sibling track, in the timeline, without touching the bank.** The
|
||||
tickable spec lives in `docs/PLAN.md` (§Phase Ρ); the architecture detail belongs in
|
||||
`src/shell/capture/CLAUDE.md` and `src/core/capture/CLAUDE.md` once the track lands.
|
||||
This doc holds the *why* — the third-verb argument that reconciles this feature with
|
||||
the capture/placement separation, the prior art it borrows from, the reuse inventory
|
||||
that makes it small, and the handful of decisions the shape actually turns on.
|
||||
|
||||
Status: framed by product-designer (2026-08-02) from Daniel's direct request the same
|
||||
day; **all three [Daniel]-class forks RULED the same day** — Ρ-F1 **refuse a
|
||||
multi-track selection**, Ρ-F2 **the result track always goes to Arrange**, Ρ-F3
|
||||
**follow the panel tail setting**. See §"Rulings" for the index and the recorded
|
||||
rationale; each is specified in place in the section that owns it. Nothing in this
|
||||
doc is open.
|
||||
|
||||
**Ρ-F2 overrode the framing, including Daniel's own original phrasing.** The request
|
||||
as first stated had the new track take whatever mode was active when the action fired;
|
||||
the ruling replaced that with an absolute rule. §"Mode transitions" is written against
|
||||
the ruling, and the A/B-on-the-bench behaviour that mode-following would have produced
|
||||
**no longer exists anywhere in this design** — do not reintroduce it as a benefit.
|
||||
|
||||
Everything else below is a product-designer call with its reasoning stated; contradict
|
||||
it in review with an argument, not a preference.
|
||||
|
||||
---
|
||||
|
||||
## What it is (and what it is not)
|
||||
|
||||
**Render in place takes one selected track, renders its output over the current
|
||||
range to a file, and drops that file as an item on a brand-new sibling track at the
|
||||
exact position it was rendered from — then moves the source track into Design mode.**
|
||||
The new track inherits the source's colour and its name with a `Capture ` prefix, and
|
||||
belongs to Arrange unconditionally (Ρ-F2). The bank is never opened, never read, never
|
||||
written.
|
||||
|
||||
The model Daniel named is REAPER's own *Render selected track time selection to new
|
||||
track (stereo) and mute original*. Phase Ρ differs in exactly one respect, and that
|
||||
respect is the whole feature: **instead of muting the original, it parks it.** The
|
||||
source track goes to Design mode — hidden from the arrange, out of the mix, FX
|
||||
offline, CPU reclaimed — and its rendered audio takes its place in the arrangement.
|
||||
That is a strictly better disposition than mute, because mute leaves the design
|
||||
scaffolding visible and its FX resident; Design mode removes both, reversibly, from
|
||||
a snapshot.
|
||||
|
||||
**It is not a capture.** No `Sample` is minted into any `BankModel`, no index entry is
|
||||
added, no file is recorded in the tracking ledger, the bank generation is not bumped,
|
||||
and no live ReaSampler 9000 instance reloads. The bank does not change in any way an
|
||||
observer could detect.
|
||||
|
||||
**It is not a freeze.** The source track's FX chain is untouched — not removed, not
|
||||
bypassed permanently, not flattened. Design View's park is snapshot-based and fully
|
||||
restored on toggle-back (`src/shell/view/CLAUDE.md` §Non-destructive restore), so
|
||||
switching to Design brings the source back exactly as it was, FX and routing intact.
|
||||
Ableton's *Freeze & Flatten* destroys the device chain; Phase Ρ never does.
|
||||
|
||||
**It is not a placement of a bank sample.** The insert action and the arrange drop
|
||||
both take something already in the bank and put it on the timeline. Phase Ρ's file
|
||||
was never in the bank and never will be. The two paths share `InsertMedia` and
|
||||
nothing else.
|
||||
|
||||
---
|
||||
|
||||
## The third verb — and why the load-bearing principle survives it
|
||||
|
||||
Root `CLAUDE.md` carries the tool's sharpest rule:
|
||||
|
||||
> **Capture and placement are separate acts.** Capturing audio writes a file to the
|
||||
> bank and adds an index entry. It **never** puts an item in the arrange view. […]
|
||||
> Any code path that auto-inserts a capture into the timeline violates the purpose of
|
||||
> the tool and **must be rejected in review**.
|
||||
|
||||
Phase Ρ renders audio, places an item in the arrange, and deliberately does not touch
|
||||
the bank. The question is not rhetorical and the answer is not "it's fine because
|
||||
Daniel asked for it."
|
||||
|
||||
**The answer is that the rule is about the bank, not about rendering.** Read the
|
||||
sentence again: the object of "capturing" is *the bank* — a file in the bank folder
|
||||
plus an index entry. The prohibition attaches to *that act* placing an item. What the
|
||||
rule protects is a two-way boundary:
|
||||
|
||||
- the arrangement must never gain an item as a side effect of a bank gesture, and
|
||||
- the bank must never gain a member as a side effect of an arrangement gesture.
|
||||
|
||||
Phase Ρ crosses neither direction, because **the bank is not a party to it.** The
|
||||
system has two verbs today and gains a third:
|
||||
|
||||
| Verb | Source | Sink | Touches the bank |
|
||||
|---|---|---|---|
|
||||
| **Capture** (`RunCapture`, batch, realtime, bake, ingest) | arrange / instrument | bank | writes it |
|
||||
| **Placement** (`RunInsertSelected`, `performArrangeDrop`) | bank | arrange | reads it |
|
||||
| **Render in place** (Phase Ρ) | arrange | arrange | never |
|
||||
|
||||
Three verbs, three distinct (source, sink) pairs. The bank appears in exactly two of
|
||||
them and never on both sides of one. The load-bearing rule is the statement that no
|
||||
single verb may have the bank on one side and the arrange on the other *in the wrong
|
||||
direction* — and Ρ has the bank on neither side.
|
||||
|
||||
What Ρ shares with capture is the **render**, not the capture: the same
|
||||
`renderOffline` seam, the same `FxBypassGuard`, the same exact-bounds custom time
|
||||
window, the same multi-track refusal, the same `RENDER_ADDTOPROJ = 0`. A render is a
|
||||
mechanism; a capture is a render *plus* a bank landing. Ρ takes the mechanism and
|
||||
declines the landing. That is reuse, not a breach.
|
||||
|
||||
### The boundary that keeps them from bleeding
|
||||
|
||||
Four things must stay true. Each is a review-rejectable condition, and three of the
|
||||
four are structural rather than remembered:
|
||||
|
||||
1. **Ρ's shell never names the bank.** `render_in_place.cpp` must not call
|
||||
`session.bank()`, `session.book()`, `session.recordCreated()`, or
|
||||
`session.bumpBankGeneration()`. The `Sample` that `OfflineRenderBackend::capture`
|
||||
returns is discarded, and on the project-media destination its `relativePath` is
|
||||
left **empty** — so a Ρ `Sample` is inert by construction and could not be usefully
|
||||
added to a bank even by accident.
|
||||
2. **Ρ cannot express "write into the bank folder."** The destination reaches the
|
||||
backend as a **two-valued enum** (`Bank` / `ProjectMedia`), never as a caller-supplied
|
||||
path. There is no string a Ρ caller could pass that lands a file in
|
||||
`reasampler_bank/`. This is the single most important structural choice in the
|
||||
phase: it makes the boundary a type, not a convention.
|
||||
3. **Ρ's file is never recorded as owned.** Prune deletes `(owned ∩ present) −
|
||||
referenced` (`src/core/reclaim/CLAUDE.md`), where `owned` comes from the tracking
|
||||
ledger. Ρ records nothing, so its file is not prune-eligible — and it lives outside
|
||||
the bank folder, so prune's enumeration never sees it either. Two independent
|
||||
layers. The symmetry is worth stating plainly: **the tool deletes only what it
|
||||
owns, and a render-in-place file belongs to the project, not to the tool.**
|
||||
4. **The traffic is one-way.** Ρ may borrow capture's render. **Capture may never
|
||||
borrow Ρ's placement.** No capture action grows a "…and place it" option, ever. If
|
||||
a future request wants capture-and-place, the answer is "fire the capture action,
|
||||
then fire the insert action" — two acts, which is the whole point.
|
||||
|
||||
**What would count as drift**, stated so a reviewer can name it: a `renderDir` string
|
||||
on `CaptureRequest` instead of the enum; a Ρ path that calls `session.bank().add()`;
|
||||
a Ρ file recorded via `recordCreated`; a `place` flag added to `CaptureActionDef`; or
|
||||
a "Ρ but also add it to the bank" convenience action. Any of those collapses the
|
||||
three verbs back into two and the rule stops meaning anything.
|
||||
|
||||
---
|
||||
|
||||
## Prior art, and what each one contributes
|
||||
|
||||
The shape is not novel; the *disposition of the source* is. Named precedents, because
|
||||
they anchor the argument better than reasoning does:
|
||||
|
||||
- **Logic Pro — Bounce in Place.** The idiom Ρ's name borrows. Renders a track's
|
||||
output to audio at the same timeline position, on a new track, with the source
|
||||
preserved. Confirms that "in place" in DAW usage means *at the same timeline
|
||||
position*, not *onto the same track* — which is why the name is right despite Ρ
|
||||
creating a new track.
|
||||
- **Pro Tools — Commit.** The closest prior art, and the one that validates the mode
|
||||
transition. Commit offers four dispositions for the source track: *Hide and Make
|
||||
Inactive* (the default), *Make Inactive*, *Delete*, and *Do Nothing*. The default
|
||||
is hide-and-deactivate — visually gone and processing gone. That is precisely what
|
||||
Design View's park already does (`B_SHOWINTCP=0`, `B_SHOWINMIXER=0`,
|
||||
`B_MAINSEND=0`, `I_FXEN=0`, per-FX offline), except that Ρ gets it *reversibly and
|
||||
as a membership fact* rather than as a per-track inactive flag. Ρ is Commit with
|
||||
a fifth disposition the DAWs do not have — *move to the design bench* — supplied by
|
||||
the tool's own mode system.
|
||||
([Sound on Sound](https://www.soundonsound.com/techniques/making-commitments),
|
||||
[Production Expert](https://www.production-expert.com/production-expert-1/pro-tools-track-commit-vs-track-freeze))
|
||||
- **REAPER — Render selected track time selection to new track and mute original.**
|
||||
The action Daniel named. Contributes the range semantics (time selection) and the
|
||||
new-track placement; Ρ replaces its source disposition and adds colour/name
|
||||
cloning.
|
||||
- **Ableton Live — Freeze & Flatten.** Contributes a negative: flatten destroys the
|
||||
device chain. Ρ explicitly does not, and the Design-mode park is what makes
|
||||
preserving it cost nothing at playback.
|
||||
|
||||
---
|
||||
|
||||
## What already exists — the reuse inventory
|
||||
|
||||
Daniel's framing was that the machinery is in place. It substantially is. This table
|
||||
is the proof, and it is also the spec's shape: each row names the module that answers
|
||||
the need, so the implementation is composition rather than construction.
|
||||
|
||||
| What Ρ needs | Already answered by |
|
||||
|---|---|
|
||||
| Resolve the source track + the range (razor-else-time) | `shell/capture/scope_resolve` — `ResolveScopeSource(CaptureScope::Track, …)` |
|
||||
| Refuse a multi-track render | `core/capture/render_settings` — `isMultiTrackStemRender` / `multiTrackRefusalMessage`, fired inside `renderOffline` |
|
||||
| Render exactly the requested window, wet, at track scope | `shell/capture/capture_orchestrator` — `renderOffline` + `FxBypassGuard` + `RenderTrackSelection` |
|
||||
| Never add the render to the project as an item | `shell/capture/capture.cpp` — `RENDER_ADDTOPROJ = 0`, unconditional |
|
||||
| Refuse a widened render | `core/capture/render_window::frameCountFor` + the bounds gate in `OfflineRenderBackend::capture` |
|
||||
| Snapshot and restore every `RENDER_*` project setting | `ScopedRenderSettings` (RAII) in `capture.cpp` |
|
||||
| Force the project to be saved first | the `EnumProjects` / `Main_SaveProject` gate in `OfflineRenderBackend::capture` |
|
||||
| Name the render after its source track + a discriminator | `core/capture/capture_name` — `composeCaptureName`, `shell/capture/capture.cpp` — `captureNameFor` |
|
||||
| Read the source track's display name (with the `Track N` fallback) | `shell/capture/scope_resolve::trackName` |
|
||||
| Collapse a bit-identical stereo render to mono | `core/capture/wav_codec::collapseToMono`, driven by `collapseCapturedFileToMono` |
|
||||
| Compute the `InsertMedia` bitmask with the stretch bit provably clear | `core/capture/insert_plan::computeInsertMode` |
|
||||
| Place a file at a known track + time, undo-wrapped, selection restored | the recipe in `shell/capture/insert.cpp` / `shell/actions/arrange_drop_win.cpp` |
|
||||
| Move a track into Design and reapply the active mode | `core/view` `MembershipIndex::tag` + `shell/view/view.h` `applyMode` / `mintManagedLanes` |
|
||||
| Persist the view model | `ReaSamplerSession::saveToActiveProject()` (the `persistViewState` pattern in `design_view_actions.cpp`) |
|
||||
| Register one more bindable action | `shell/actions/action_registry` — one `ActionTableRow` in `main.cpp`'s table |
|
||||
| Pure folder arithmetic over the flat `I_FOLDERDEPTH` delta list | `core/capture/track_topology` (extended — see §"The new track") |
|
||||
|
||||
**What genuinely does not exist**, and why nothing already there stretches to cover
|
||||
it — three small pure additions and one bounded seam:
|
||||
|
||||
1. **A render destination that is not the bank.** `OfflineRenderBackend::capture`
|
||||
derives its output path from `deriveBankPaths(projectDir, …)` unconditionally
|
||||
(`capture.cpp`'s `OfflineRenderBackend::capture`) and points `RENDER_FILE` at the bank folder. Nothing about
|
||||
that is parameterized. The alternative — render into the bank and then move the
|
||||
file out — was rejected: it puts a transient, unindexed, unowned file inside the
|
||||
folder prune enumerates, which is exactly the file class the ownership rule exists
|
||||
to reason about, and it would make the bank folder momentarily lie about its
|
||||
contents. **Seam:** a `CaptureDestination { Bank, ProjectMedia }` field on
|
||||
`CaptureRequest` (defaulting to `Bank`), resolved by the backend *after* its own
|
||||
save gate, plus a `RenderPaths deriveRenderPaths(absoluteDir, baseName, uniqueTag)`
|
||||
sibling in `capture_paths` that `deriveBankPaths` is then expressed in terms of, so
|
||||
the file-stem spelling keeps one owner.
|
||||
2. **The absolute path of the rendered file, returned.** `CaptureResult` carries only
|
||||
`sample.relativePath`, which Ρ deliberately leaves empty. One new field,
|
||||
`CaptureResult::absolutePath`, set on the Ok path.
|
||||
3. **Where a sibling track goes, in folder terms.** Genuinely new, genuinely
|
||||
necessary, and genuinely small — see §"The new track".
|
||||
4. **The idempotent `Capture ` prefix.** Six lines in `core/capture/capture_name`.
|
||||
|
||||
Everything else is composition. No new directory, no new backend, no new interface,
|
||||
no new persisted state.
|
||||
|
||||
---
|
||||
|
||||
## The render — scope, range, refusal
|
||||
|
||||
**Scope is Track**, always. `CaptureScope::Track` means the render hears the item/take
|
||||
FX plus the selected track's own track FX, with every ancestor and the master
|
||||
neutralized to unity — no FX, no fader, no pan/width/law colouring
|
||||
(`fxBypassPlanFor`, `FxBypassGuard`). That is exactly right for a drop-in
|
||||
replacement: what the render contains is *the track's own contribution to its
|
||||
parent*, which is what the new sibling track must reproduce when it feeds the same
|
||||
parent.
|
||||
|
||||
**The range is razor-else-time selection**, resolved by `ResolveScopeSource` — the
|
||||
same rule every other capture action already obeys. Razor wins when present; the
|
||||
razor union's bounds are the window. If neither a razor area nor a time selection is
|
||||
present, the action refuses with the reason `resolveRange` already produces. **Item
|
||||
extent is not a fallback**, and should not become one: item extent is item scope's
|
||||
concern, and a track render bounded by whichever items happen to be selected is a
|
||||
different and much less predictable verb.
|
||||
|
||||
**Multi-track selections are refused**, inherited rather than re-implemented.
|
||||
`renderOffline` fires `isMultiTrackStemRender` before touching anything, keyed on the
|
||||
render *source* (`SelectedTracks`, which track scope always uses), so any selection of
|
||||
more than one track refuses with `multiTrackRefusalMessage(CaptureScope::Track)`
|
||||
before a single project setting is written. Ρ inherits this for free and adds no
|
||||
check of its own.
|
||||
|
||||
**Ρ-F1, RULED by Daniel, 2026-08-02: *refuse*.** One selected track per fire is a
|
||||
**settled non-goal**, in the same sense as the other entries in §"What Phase Ρ
|
||||
explicitly is NOT" — not a deferral. There is no per-track loop planned, no second
|
||||
wave holding one, and no seam to be left half-open on the way past. If multi-track is
|
||||
ever wanted it is a new phase with its own framing, and the counter-argument that made
|
||||
this a fork (the stem-collapse hazard does not apply to a per-track loop, so the
|
||||
refusal is inherited rather than required) is recorded in §"Rulings" so it does not
|
||||
have to be rediscovered.
|
||||
|
||||
**Tail follows the panel setting — Ρ-F3, RULED by Daniel, 2026-08-02: *"follow panel
|
||||
tail settings."*** None / Auto / Manual, read from `bankPanelTailSetting()` like every
|
||||
other capture path. Forcing `None` would be Ρ inventing a policy the rest of the tool
|
||||
does not have, and a decaying design chain wants its tail when its source is about to
|
||||
be silenced.
|
||||
|
||||
**Two consequences of that ruling, both accepted, both stated here because an
|
||||
implementer meets them directly:**
|
||||
|
||||
1. **Under Auto or Manual the placed item is longer than the window it replaces.** The
|
||||
chain's decay rings past the range end and the render carries it, so the item on
|
||||
the result track extends past the source range. This is correct for reverb and
|
||||
wrong for a section meant to butt against the next one — and the ruling accepts
|
||||
that trade rather than splitting the behaviour. The user's lever is the panel's own
|
||||
tail setting: set it to None before firing if a hard edge is wanted.
|
||||
2. **The exact-bounds gate is inactive under Auto and Manual.** The gate in
|
||||
`OfflineRenderBackend::capture` runs **only** under `TailMode::None`, so Ρ renders
|
||||
in the other two modes are unguarded against a widened render. This is inherited
|
||||
from every other capture path, not introduced by Ρ, and it is not a caveat to bury:
|
||||
the phase's exactness guarantee under Auto/Manual is the *start* only.
|
||||
|
||||
The placed item's **start is exact in every tail mode**, because a tail is only ever
|
||||
added at the end. The null test therefore holds in all three modes — the shared region
|
||||
nulls; the tail simply has nothing to null against.
|
||||
|
||||
A third option was floated at framing and is **not ruled in**: keep the panel setting
|
||||
but run the bounds gate's start-alignment check regardless of tail mode, since a tail
|
||||
only ever extends the end. It is recorded as an unexercised alternative in §"Rulings"
|
||||
and is not to be built into this phase.
|
||||
|
||||
**Mono collapse applies**, unchanged. A render whose channels are bit-identical
|
||||
collapses losslessly to one channel and REAPER derives a mono item from the file
|
||||
(`insert.cpp` passes only a path). Daniel's Ψ.6 ask named "mono arrange items"
|
||||
explicitly, so this is the intended outcome, not a side effect. **But note what Ρ
|
||||
changes about the risk:** root `CLAUDE.md` already flags, as `[verify — DAW]`,
|
||||
whether REAPER sums a 1-channel item on a stereo track at the same unity gain as a
|
||||
dual-mono 2-channel item. Until Ρ, that property was unverified but not load-bearing
|
||||
— nothing in the tool placed a collapsed capture automatically. **Ρ is the first path
|
||||
where a collapsed render is placed into the mix by the tool itself,** which promotes
|
||||
that question from a footnote to a verification obligation on this phase.
|
||||
|
||||
---
|
||||
|
||||
## Where the file goes
|
||||
|
||||
**The project's recording path** — `GetProjectPathEx(proj, buf, sz)` (SDK header
|
||||
2550), which the header's own `RECORD_PATH` entry names as the way to get the
|
||||
*effective* path when `RECORD_PATH` is blank or relative (header 3102).
|
||||
|
||||
Why there rather than a dedicated `reasampler_renders/` folder: because the file is
|
||||
**the project's media, not the tool's.** REAPER's own render-to-new-track, apply-FX,
|
||||
and freeze glue actions all write into the recording path; *Clean current project
|
||||
directory* and *Save project as… with copy of media* both understand it. A file in
|
||||
the recording path is managed by REAPER's project-media machinery, which is exactly
|
||||
the machinery that should own it. A `reasampler_renders/` folder would be marginally
|
||||
more findable and would make ReaSampler the apparent owner of files it explicitly
|
||||
does not own — the wrong trade.
|
||||
|
||||
The relative-paths-only invariant is untouched: it binds the persisted `BankIndex`,
|
||||
and Ρ writes nothing to any index. REAPER stores the item's source path in the `.rpp`
|
||||
by its own rules.
|
||||
|
||||
---
|
||||
|
||||
## Placement — exactly
|
||||
|
||||
The item lands at **`src.startSeconds`**, the render window's start, unrounded and
|
||||
**unsnapped**.
|
||||
|
||||
Unsnapped is the load-bearing word. `performArrangeDrop` runs its drop time through
|
||||
`SnapToGrid` because a hand drop wants snapping; Ρ must not, because a snapped
|
||||
placement would move the audio off the sample-accurate position it was rendered from
|
||||
and break the property the whole tool is built on. **Ρ's placement is the null test
|
||||
performed automatically:** an offline render of a range, re-inserted at its source
|
||||
position, nulls to silence against the source — root `CLAUDE.md` calls that the
|
||||
tool's trust anchor. Ρ *is* that gesture, made a workflow. If Ρ's placement is not
|
||||
sample-exact, Ρ is broken, and the way you find out is by soloing the two tracks with
|
||||
one polarity-inverted.
|
||||
|
||||
The recipe is `insert.cpp`'s, verbatim, with the bank lookup removed:
|
||||
snapshot the cursor → `SetOnlyTrackSelected(newTrack)` → `SetEditCurPos(startSeconds,
|
||||
false, false)` → `InsertMedia(absolutePath, computeInsertMode(InsertOptions{}))` →
|
||||
restore the cursor. `InsertOptions{}` defaults give native length, no tempo conform,
|
||||
and `insert_plan` guarantees the &4 stretch-to-time-selection bit is never set — so
|
||||
"do not silently time-stretch on insert" holds by construction. Ρ must never offer a
|
||||
conform variant: a conform would defeat the exact placement it exists to produce.
|
||||
|
||||
**Selection afterwards is a deliberate divergence.** Every other placing path
|
||||
restores the caller's track selection. Ρ leaves **the new track selected, alone.**
|
||||
The reason is specific: in the headline case the source track is being parked out of
|
||||
sight in the same gesture, so restoring the selection would leave the user selecting
|
||||
an invisible track. The new track is the workflow's next subject; select it. The edit
|
||||
cursor *is* restored, since nothing about Ρ argues for moving it.
|
||||
|
||||
**The Ρ-F2 ruling inverts that reasoning in the Design-fired case, and the rule stays
|
||||
absolute anyway.** Fired from Design, the result track is the parked one and the source
|
||||
is the visible one, so leaving the result selected selects an invisible track — the
|
||||
exact thing the rule exists to avoid in the other direction. Making the selection
|
||||
conditional on the active mode would reintroduce mode-relative behaviour, which is
|
||||
precisely what the ruling removed from this action. One rule, both cases: the result
|
||||
track ends up selected, alone.
|
||||
|
||||
---
|
||||
|
||||
## The new track — index, folder, colour, name
|
||||
|
||||
### Index and folder — the one piece of genuinely new arithmetic
|
||||
|
||||
"Sibling" is easy to say and has three cases. Getting it wrong is audible, not
|
||||
cosmetic, which is why this is the one place Ρ adds a real (small) pure function
|
||||
rather than composing.
|
||||
|
||||
The naive answer — insert at `sourceIndex + 1` — is wrong twice:
|
||||
|
||||
- **Source is a folder parent** (`I_FOLDERDEPTH >= 1`). Inserting immediately after
|
||||
it makes the new track the folder's **first child**, so the rendered audio is
|
||||
summed back into the folder and runs through the parent's FX and fader a second
|
||||
time. Track scope already put the parent's own FX and fader *into* the render, so
|
||||
this double-processes audibly.
|
||||
- **Source is the last track in its folder** (`I_FOLDERDEPTH <= -1`). The source
|
||||
carries the folder's closing delta, so inserting after it lands the new track
|
||||
**outside** the folder — the audio then bypasses the folder bus entirely and the
|
||||
drop-in replacement is silently wrong in the other direction.
|
||||
|
||||
The correct rule is one computation in absolute nesting levels, over the same flat
|
||||
`I_FOLDERDEPTH` delta list `track_topology::directChildIndices` already prefix-sums.
|
||||
Given `depth[i]` for every track and `level[0] = 0`, `level[i+1] = level[i] +
|
||||
depth[i]` (and `level[count] = 0` for a well-formed project):
|
||||
|
||||
1. `L = level[srcIdx]` — the source's own nesting level.
|
||||
2. **Insert position** `p`: if `depth[srcIdx] >= 1` (folder parent), `p` = the first
|
||||
`j > srcIdx` with `level[j] == L` — i.e. immediately after the whole folder, at
|
||||
the source's own level; `count` if none. Otherwise `p = srcIdx + 1`.
|
||||
3. **Two folder-depth writes**, and only two. With `b = p - 1` (the track that will
|
||||
precede the new one) and `Lp = level[p]` (the level the track currently at `p`
|
||||
sits at, `0` at end-of-project): set `depth[b] = L - level[b]`, and set the new
|
||||
track's `depth = Lp - L`.
|
||||
|
||||
The total of all deltas is preserved, so nothing downstream of the insertion shifts.
|
||||
Checked against every case:
|
||||
|
||||
| Case | `depth[b]` after | new track `depth` | Result |
|
||||
|---|---|---|---|
|
||||
| Normal track, mid-folder or top level | unchanged (`0`) | `0` | inserted directly below, same level |
|
||||
| Last track in a folder (`-1`) | `0` | `-1` | new track becomes the folder's last member |
|
||||
| Last in two folders (`-2`) | `0` | `-2` | closing delta moves to the new track intact |
|
||||
| Folder parent | unchanged | `0` | new track lands after the whole folder, at the parent's level |
|
||||
| Last track in the project | unchanged | `0` or the source's close | consistent, sums to zero |
|
||||
|
||||
That is roughly thirty lines, fully unit-testable with no DAW, and it belongs beside
|
||||
`directChildIndices` in `core/capture/track_topology` — same input, same arithmetic,
|
||||
same file. A malformed project whose deltas do not sum to zero should clamp rather
|
||||
than assert; the failure mode is a track at the wrong nesting level, never a crash.
|
||||
|
||||
Creation is `InsertTrackInProject(proj, p, /*flags=*/0)` (SDK header 3954), then
|
||||
`GetTrack(proj, p)` (3501) to obtain the handle. **`flags = 0`, not `1`:** the header
|
||||
states `flags&1` adds default envelopes/FX, and a Ρ track must be bare — the FX are
|
||||
already baked into the audio, and a default chain would process the render a second
|
||||
time.
|
||||
|
||||
### Colour
|
||||
|
||||
`SetMediaTrackInfo_Value(newTrack, "I_CUSTOMCOLOR", (double)GetTrackColor(source))`.
|
||||
`GetTrackColor` (3517) returns the custom colour already OR'd with `0x1000000`, or
|
||||
`0` when the track has no colour set; `I_CUSTOMCOLOR` (2942) treats a value without
|
||||
that bit as "not used." So the same single line clones a colour *and* clones the
|
||||
absence of one, with no branch.
|
||||
|
||||
### Name
|
||||
|
||||
`"Capture " + sourceName`, where `sourceName` is `scope_resolve::trackName(source)` —
|
||||
`GetTrackName` (3629), which already answers REAPER's `Track N` convention for an
|
||||
unnamed track. An unnamed track 7 therefore yields `Capture Track 7`, which is a real,
|
||||
deterministic, identifiable name; this is Ψ-W2-T1's precedent applied unchanged.
|
||||
Written with `GetSetMediaTrackInfo_String(newTrack, "P_NAME", buf, true)` (2997).
|
||||
|
||||
**The prefix is idempotent — it never stacks.** If the source name already begins with
|
||||
`"Capture "`, the new name is the source name **verbatim**. So rendering `MONEY`
|
||||
gives `Capture MONEY`, and rendering `Capture MONEY` gives `Capture MONEY` again, not
|
||||
`Capture Capture MONEY`.
|
||||
|
||||
The alternative — a counter suffix, `Capture MONEY 2` — is rejected. REAPER does not
|
||||
uniquify track names either, duplicate track names are ordinary and harmless, and a
|
||||
counter is a treadmill that has to be maintained forever. What actually distinguishes
|
||||
two renders of the same source is their position in the track list and the item on
|
||||
each; the name's job is to say *what this is*, and it says that correctly the first
|
||||
time. Making the operation a fixed point is worth more than distinguishability here.
|
||||
|
||||
This is one pure function in `core/capture/capture_name` — `captureTrackName(sourceName)`
|
||||
— tested for the plain case, the already-prefixed case, the empty-source case, and
|
||||
the `Track N` case. The prefix string is a display convention, not a persisted key:
|
||||
unlike `kManagedLanePrefix` or an action-id suffix, changing it later strands nothing.
|
||||
|
||||
---
|
||||
|
||||
## Mode transitions — the source parks, the result goes to Arrange
|
||||
|
||||
Daniel's original phrasing was *"the source track stays/goes to design mode, and the
|
||||
resulting new sibling track […] stays in whatever mode was active when the action was
|
||||
run."* **The second half was overridden by his own later ruling — Ρ-F2, 2026-08-02:
|
||||
*"for this action which is not a capture, the result track should always go to
|
||||
arrange."*** The source half stands exactly as first stated; the result half is now
|
||||
absolute and mode-independent.
|
||||
|
||||
**Source track: unconditionally a Design member afterwards.**
|
||||
`membership().tag(sourceGuid, kDesignModeId)` covers both readings in one call —
|
||||
`tag` replaces any prior single-mode membership, so a source already in Design
|
||||
*stays* (no observable change) and a source in Arrange or untagged *goes*. This is
|
||||
exactly what the shipped `VIEW_TAG_DESIGN` action does to a selection; Ρ performs it
|
||||
on one track as part of a larger gesture.
|
||||
|
||||
Two inherited behaviours to state rather than fight:
|
||||
|
||||
- **Show-both on the source is not cleared.** Show-both is the user's explicit "pin
|
||||
this visible across modes" flag. Ρ tagging a source into Design must not silently
|
||||
unpin it; a show-both source stays visible in both stances, which is what the user
|
||||
asked for.
|
||||
- **A folder-parent source is not hidden by tagging it.** Parents are derived, never
|
||||
tagged: a parent is visible in every mode any descendant leaf is visible in
|
||||
(`core/view/CLAUDE.md`). Tagging a folder parent Design sets its *own* membership
|
||||
but leaves it visible in Arrange as long as any child is an Arrange member. This is
|
||||
a limitation of the shipped model that the existing tag action shares exactly; Ρ
|
||||
inherits it. Do **not** invent a cascade that tags the children — that changes the
|
||||
membership model to make one feature convenient.
|
||||
|
||||
**Result track: unconditionally an Arrange member**, whatever mode was active —
|
||||
`membership().tag(newTrackGuid, kArrangeModeId)`, synchronously and explicitly, **not**
|
||||
an `untag()` to the Arrange default. The distinction is load-bearing: an explicit
|
||||
membership record is what protects the ruling from the auto-tag detector (below). An
|
||||
untagged track is an Arrange member by observable behaviour but carries no record, and
|
||||
a record is what the detector must be made to respect.
|
||||
|
||||
**Note what is novel here, because it is worth a reviewer's attention.** Ρ is the
|
||||
**first** path in the tree to write an explicit `kArrangeModeId` record. The shipped
|
||||
*tag selected tracks → Arrange* action does not — it dispatches to `doUntag()`, i.e.
|
||||
Arrange-by-absence, which is why the constant appears in `view_mode_model` and
|
||||
`mode_enable` but never in a `tag()` call. The record is nonetheless well-formed and
|
||||
behaviourally identical to the untagged state everywhere that matters:
|
||||
`ViewModeModel::isMember` answers `true` for `arrange` and `false` for `design` in
|
||||
both cases, the derived-parent and lane rules read the same, and `untag()` still
|
||||
returns it to absence. The only differences are one more entry in the persisted
|
||||
membership index and — the point — its visibility to the detector's filter. **[verify
|
||||
— DAW]** that a project saved with an explicit Arrange record round-trips and shows no
|
||||
behavioural difference from an untagged track; the JSON round-trip is unit-testable and
|
||||
should be tested, but the live view behaviour is not.
|
||||
|
||||
**The placed item is tagged Arrange too**, explicitly, for the same reason. Item
|
||||
membership is a separate index from track membership and it is what drives lane
|
||||
minting; leaving the item untagged hands it to the detector, which tags a new item to
|
||||
the active mode whenever its track has no pre-existing single-mode content — and a
|
||||
brand-new track never does. After `InsertMedia`, enumerate the new track's items (it
|
||||
is brand new, so they are exactly the ones just placed) and tag each;
|
||||
`shell/capture/item_read::itemGuid` is the existing GUID seam.
|
||||
|
||||
### The one hazard the ruling creates — the auto-tag detector
|
||||
|
||||
The ruling **dissolves** the ordering hazard the mode-following rule carried and
|
||||
**creates a different one** in its place. Both are stated, because the first was
|
||||
written into the plan's acceptance criteria and is now wrong.
|
||||
|
||||
**Dissolved.** Under mode-following, the tag had to precede the `applyMode` reapply:
|
||||
an untagged track is an Arrange member by default, so a Design reapply running first
|
||||
would have parked a track destined for Design, and it would have stayed parked until
|
||||
the next switch. Under the ruling the result track *is* an Arrange member, so a Design
|
||||
reapply parking it is the correct outcome and the ordering no longer changes what the
|
||||
user sees. Tag first anyway, for state hygiene — it is simply no longer load-bearing.
|
||||
|
||||
**Created, and it is a defect rather than a nuance.** The panel's new-content detector
|
||||
(`panel_input::detectNewContent` over `guid_diff::GuidBaseline`, feeding
|
||||
`view_mode_model::autoTagNewContent`) tags **every new track to the active mode**,
|
||||
unconditionally: it diffs live GUIDs against the previous tick and never consults the
|
||||
membership index. Fire Ρ while Design is active and, on the next timer tick, the
|
||||
detector re-tags the brand-new result track — and its item — from Arrange to Design,
|
||||
silently reversing the ruling inside a second. Nothing in Ρ's own gesture can outrun
|
||||
it, because it runs after.
|
||||
|
||||
**The fix is one rule, and it is worth stating generally: an explicit tag wins over
|
||||
the detector.** `detectNewContent` drops any added GUID that already carries a
|
||||
membership record (`MembershipIndex::query(guid) != nullptr`) before building its
|
||||
auto-tag input. Two lines, at the point in that function where the model is already in
|
||||
hand. The rule is right beyond Ρ — the detector exists to classify content the *user*
|
||||
made, not content the tool made and has already classified — and it is a strict
|
||||
improvement on an existing rough edge: a track brought back by undo keeps its original
|
||||
mode instead of being re-tagged to whatever mode happens to be active at the time.
|
||||
|
||||
Two alternatives were considered and rejected. Re-arming the baseline after Ρ's
|
||||
gesture (the `bankPanelNotifyProjectLoaded` mechanism) works, but it absorbs an entire
|
||||
tick of genuinely new content silently — a blunt instrument aimed at two GUIDs.
|
||||
Changing `autoTagNewContent`'s own track rule changes Design View's behaviour for
|
||||
every caller in order to serve one.
|
||||
|
||||
The resulting behaviour, stated completely:
|
||||
|
||||
| Active mode when fired | Source afterwards | Result track afterwards | What the user sees |
|
||||
|---|---|---|---|
|
||||
| **Arrange** | Design — parked, hidden, FX offline | Arrange — visible, in the mix | The headline case. The design chain vanishes from the arrangement and its audio takes its place, at the same position, same colour, named after it. |
|
||||
| **Design** | Design — visible on the bench | Arrange — parked while Design is active | The bench keeps only the source. The render is not on screen; it is waiting in the arrangement, and switching to Arrange shows it in the source's place. |
|
||||
|
||||
**The Design-fired case produces no visible change, and that is the ruling's accepted
|
||||
cost.** Fire Ρ from the bench and the immediate feedback is that nothing appears. The
|
||||
price buys the action meaning exactly one thing wherever it is fired — *commit this
|
||||
design work into the arrangement* — which is the reasoning behind the ruling: Ρ is not
|
||||
a capture, and a capture's mode-relative habits do not apply to it.
|
||||
|
||||
**Feedback deserves reconsideration for this case, and only this case.** Ρ is
|
||||
otherwise silent on success because the new track is the feedback; fired from Design
|
||||
there is no visible new track, so a silent success is indistinguishable from a no-op.
|
||||
**[propose at review]** whether the Design-fired path should emit a one-line
|
||||
`ShowConsoleMsg` naming the track it created. Recommendation: yes — it costs one line
|
||||
and one string, and it is the only place in the phase where success is invisible.
|
||||
|
||||
**Lane minting runs**, via `mintManagedLanes(view, nullptr)` before the reapply, on
|
||||
the same path `doMoveItems` already uses — so a track that ends up carrying content
|
||||
for two modes splits into managed lanes exactly as it would from any other membership
|
||||
change. Ρ adds no lane rule of its own.
|
||||
|
||||
---
|
||||
|
||||
## Undo
|
||||
|
||||
**One undo block** (`Undo_BeginBlock2` / `Undo_EndBlock2` with `UNDO_STATE_ALL`, i.e.
|
||||
`-1`), opened before the track is created and closed after the mode reapply — the
|
||||
same shape `insert.cpp` and `performArrangeDrop` already use. The render itself sits
|
||||
*outside* the block: `renderOffline` mutates only `RENDER_*` project settings, which
|
||||
it snapshots and restores by RAII, and writes a file. Nothing there is undoable and
|
||||
nothing there should be in the undo history.
|
||||
|
||||
What one Ctrl-Z therefore restores: the new track is gone, its item with it, the
|
||||
source track's folder-depth write is reverted, and the track selection is back.
|
||||
|
||||
Three residuals, all inherited and all honest:
|
||||
|
||||
1. **The rendered file survives.** REAPER's undo does not delete files, prune is the
|
||||
exclusive deletion authority in this system, and Ρ's file is not even prune-
|
||||
eligible. An undone render leaves an orphan `.wav` in the project's recording
|
||||
path — precisely what REAPER's own render and record actions do. Not a defect.
|
||||
2. **The source stays tagged Design.** REAPER's undo restores live track state but
|
||||
does not roll back the view model's membership index or active mode — documented
|
||||
in `src/shell/view/CLAUDE.md` §Gotchas, where `snapshots_` already carries the same
|
||||
split. The way out is the existing *tag selected tracks → Arrange* action. Do not
|
||||
build a compensating mechanism for one feature; the model-vs-undo split is a
|
||||
phase-D-scale question, not Ρ's.
|
||||
3. **A membership entry for the deleted track's GUID lingers**, harmlessly:
|
||||
`ViewModeModel::reconcile(liveGuids)` prunes unknown GUIDs on its next pass.
|
||||
|
||||
**Persist runs after the block closes, not inside it** — the ordering
|
||||
`design_view_actions::doMoveItems` already documents, because `persistViewState` may
|
||||
raise a Save-As dialog and a modal dialog must not sit inside an open undo block.
|
||||
|
||||
---
|
||||
|
||||
## The action
|
||||
|
||||
**Command-id suffix: `RENDER_TRACK_IN_PLACE`.** FOREVER-STABLE per channel
|
||||
(`channelCommandId` composes `CEREBELLUM_REASAMPLER_` / `CEREBELLUM_REASAMPLER_BETA_`
|
||||
in front of it), so this string can never change once shipped — user keybindings key
|
||||
off the composed id.
|
||||
|
||||
Chosen deliberately as a **new verb family**, not a member of `CAPTURE_*`. The id is
|
||||
permanent and it is the most durable statement the codebase makes about which pillar
|
||||
a feature belongs to; filing this under `CAPTURE_` would encode the exact confusion
|
||||
the third-verb argument exists to prevent. `RENDER_*` also leaves room for a future
|
||||
`RENDER_ITEMS_IN_PLACE` without renaming anything.
|
||||
|
||||
**Actions-list phrase: `"render selected track to a new track (source moves to
|
||||
Design)"`**, which REAPER shows as *ReaSampler: render selected track to a new track
|
||||
(source moves to Design)*. Long, but the parenthetical is not decoration — a user
|
||||
binding this to a key must know the source is about to disappear from the arrangement
|
||||
before they press it, not after. The existing family already carries parentheticals of
|
||||
this weight (*insert selected sample at edit cursor (conform to tempo)*).
|
||||
|
||||
Registration is **one `ActionTableRow`** in `main.cpp`'s `buildMainActionTable()` — the
|
||||
Q-W6 data-driven table drives registration, `hookcommand` dispatch, and the unload
|
||||
mirror-unregister from that one row. Main section only; no `custom_action` /
|
||||
`hookcommand2` second registration is needed.
|
||||
|
||||
**Feedback:** silent on success (the new track is the feedback), `ShowConsoleMsg` on
|
||||
every refusal, carrying the reason `resolveRange` / `renderOffline` already produced.
|
||||
This matches `RunInsertSelected` exactly.
|
||||
|
||||
---
|
||||
|
||||
## What Phase Ρ explicitly is NOT
|
||||
|
||||
Stated as sharply as the goals, because a small phase stays small only if its edges
|
||||
are named:
|
||||
|
||||
- **Not a bank capture, in any form.** No index entry, no ledger record, no
|
||||
generation bump, no instance reload.
|
||||
- **Not multi-track.** Ρ-F1, RULED: one selected track per fire; more than one refuses
|
||||
with the message that already exists. Settled, not deferred — no per-track loop is
|
||||
planned, half-built toward, or left a seam for.
|
||||
- **Not mode-following.** Ρ-F2, RULED: the result track is an Arrange member
|
||||
unconditionally. There is no variant that follows the active mode, no per-fire
|
||||
choice, and no bench-visible result — firing from Design puts nothing on the bench.
|
||||
- **Not item-scoped.** No `RENDER_ITEMS_IN_PLACE`, no item-extent range fallback. The
|
||||
seam is left open by the id family; the feature is not built.
|
||||
- **Not a tempo-conforming insert.** No conform variant, ever — a conform would
|
||||
destroy the exact placement the feature exists to produce.
|
||||
- **Not a mute, not a delete, not a freeze.** The source keeps its items, its FX, its
|
||||
routing and its automation. It moves stance; it loses nothing.
|
||||
- **Not a source-track cascade.** Rendering a folder parent does not tag its children,
|
||||
does not restructure the folder, and does not touch anything but the two
|
||||
folder-depth values the insertion arithmetic requires.
|
||||
- **Not a new persisted state.** Membership writes go into the existing `"reasampler"`
|
||||
view section. Ρ adds no key, no version rung, no wire format.
|
||||
- **Not a new directory.** Three small pure additions to existing `core/capture`
|
||||
modules, one new shell TU in `shell/capture`, two lines in `panel_input.cpp`, one
|
||||
row in `main.cpp`.
|
||||
|
||||
---
|
||||
|
||||
## Invariant amendments this phase owns
|
||||
|
||||
Three statements in the tree become false the moment Ρ lands, and amending them is a
|
||||
**deliverable of the track**, not a follow-up — the precedent is Phase Ψ, where three
|
||||
such amendments were carried as acceptance criteria of the tracks that broke them. A
|
||||
track that lands Ρ without these reads as an invariant breach in review.
|
||||
|
||||
1. **`src/shell/capture/CLAUDE.md` §Invariants** — *"`RunInsertSelected` is the one
|
||||
deliberate exception to capture-never-places … every other capture entry point
|
||||
writes only a file + index entry."* Ρ adds a second placing path in this
|
||||
directory. The amended form must say that this directory now hosts two placing
|
||||
paths and state the discriminator: `RunInsertSelected` places a *bank sample*;
|
||||
`render_in_place` places a render that never entered the bank. Neither is a
|
||||
capture placing itself.
|
||||
2. **`src/shell/actions/CLAUDE.md` §Invariants** — *"`arrange_drop_win` is the only
|
||||
timeline-placing shell in this directory."* Strictly this stays true if Ρ's shell
|
||||
lives in `shell/capture/`, but the sentence reads as a claim about the system.
|
||||
Amend it to be explicit that it scopes to *this directory*, and cross-reference the
|
||||
third verb.
|
||||
3. **`src/core/view/CLAUDE.md` §Invariants** — *"New tracks are tagged to the active
|
||||
mode at creation."* Ρ-F2 makes this conditional for the first time: the detector's
|
||||
active-mode rule now applies only to a GUID that carries **no** membership record,
|
||||
because an explicit tag wins over it (§"Mode transitions"). Amend the sentence to
|
||||
say so, and state the reason in one clause — the detector classifies content the
|
||||
user made, not content the tool made and already classified. `src/shell/panel/`'s
|
||||
own CLAUDE.md describes `panel_input` as "the new-content auto-tag timer" without
|
||||
restating the rule, so it needs no amendment; if that changes, the rule has one
|
||||
home and this is it.
|
||||
|
||||
Root `CLAUDE.md` §"The load-bearing principle" should gain **one sentence**, not a
|
||||
rewrite: that a render which never enters the bank and never leaves it is a third
|
||||
verb outside the rule, with the two-way boundary spelled out. The rule's force must
|
||||
not be diluted — it is what keeps the tool honest — so the amendment names the
|
||||
exception precisely rather than softening the prohibition.
|
||||
|
||||
---
|
||||
|
||||
## Where it lives
|
||||
|
||||
**Pure** — three additions, all to existing modules with existing test targets, no new
|
||||
directory:
|
||||
|
||||
- `core/capture/track_topology` — the sibling-placement arithmetic
|
||||
(`siblingPlacement(depths, srcIdx) -> { insertIndex, precedingDepth, newDepth }`).
|
||||
Same input list, same prefix-sum, same file as `directChildIndices`.
|
||||
- `core/capture/capture_name` — `captureTrackName(sourceName)`, the idempotent prefix.
|
||||
- `core/capture/capture_paths` — `RenderPaths` + `deriveRenderPaths(absoluteDir,
|
||||
baseName, uniqueTag)`, with `deriveBankPaths` re-expressed over it so the stem
|
||||
spelling keeps one owner (`bankRelativeForName` already depends on that being true).
|
||||
|
||||
**Shell** — one new TU plus one bounded edit:
|
||||
|
||||
- `shell/capture/render_in_place.{h,cpp}` — the action body. It lives in
|
||||
`shell/capture/` rather than `shell/actions/` because it composes `renderOffline`
|
||||
and `ResolveScopeSource` and is genuinely a render path, not a skin over one; the
|
||||
directory's own CLAUDE.md says action *bodies* belong here and that `shell/actions`
|
||||
only skins mutation logic owned elsewhere.
|
||||
- `shell/capture/capture.cpp` — the destination branch (~6 lines at the path
|
||||
derivation) and `CaptureResult::absolutePath`. **No behavioural change on the bank
|
||||
path**: the enum defaults to `Bank`, and the bank branch must be byte-identical to
|
||||
today.
|
||||
- `shell/panel/panel_input.cpp` — two lines inside `detectNewContent`: drop added
|
||||
GUIDs that already carry a membership record, so an explicit tag wins over the
|
||||
auto-tag detector (§"Mode transitions"). This edit exists **only because of the Ρ-F2
|
||||
ruling**; without it the ruling reverses itself on the next timer tick. No other
|
||||
function in the file is touched.
|
||||
- `src/app/main.cpp` — one `ActionTableRow`.
|
||||
|
||||
**Performance posture:** every surface is cold — one gesture, once. None of the named
|
||||
hot paths (peaks envelope compute, audition, the realtime-capture tick's
|
||||
single-pointer-test idle fast path, the instrument's `process()`) is touched, and no
|
||||
guardrail applies beyond the general one.
|
||||
|
||||
---
|
||||
|
||||
## DAW-verification obligations
|
||||
|
||||
Following the plan's convention, stated up front so they are an obligation rather
|
||||
than a discovery. Nothing in Ρ is unit-testable past the pure functions.
|
||||
|
||||
- **The null test on Ρ's own output** — render a track over a range, then
|
||||
polarity-invert the source against the new track and confirm silence. This is the
|
||||
phase's trust anchor and the single most important check.
|
||||
- **The three folder cases** — a normal mid-folder track, a last-in-folder track, and
|
||||
a folder parent — each rendered, each confirming the new track's nesting level and
|
||||
that the render feeds (or bypasses) the folder bus correctly. `[verify — DAW]`
|
||||
whether `InsertTrackInProject` at index `p` combined with the two `I_FOLDERDEPTH`
|
||||
writes settles without an intermediate `TrackList_AdjustWindows(false)` (header
|
||||
7735; the note at 2721 says some attribute writes need a manual panel update, and
|
||||
the `isMinor` semantics are undocumented).
|
||||
- **The collapsed-mono placement** — render a dead-centre source, confirm the item is
|
||||
mono, and confirm it sums at the same level as the stereo source did. This is root
|
||||
`CLAUDE.md`'s existing `[verify — DAW]` on mono-item-on-stereo-track summing,
|
||||
promoted to load-bearing by Ρ.
|
||||
- **Both mode transitions** — fired from Arrange (source parks; result track visible
|
||||
and in the mix) and fired from Design (source stays on the bench; result track
|
||||
parked, then present in the source's place after switching to Arrange). **In each
|
||||
case wait out at least one panel timer tick and re-check the membership.** That is
|
||||
the auto-tag-detector regression, and it is the check that catches a missing
|
||||
explicit-tag-wins filter or an untagged item — either of which silently reverses the
|
||||
Ρ-F2 ruling. The old ordering check (that the new track is never momentarily parked)
|
||||
no longer applies: under the ruling the result track is an Arrange member and a
|
||||
Design reapply parking it is correct.
|
||||
- **Undo** — one Ctrl-Z removes the track and item and restores the folder depth; the
|
||||
file survives; the source stays tagged Design.
|
||||
- **The name and colour clone**, including a second run over an already-prefixed track
|
||||
(must not stack) and an unnamed source (must read `Capture Track N`).
|
||||
- **`GetProjectPathEx` on a project saved in a folder with a non-default recording
|
||||
path**, confirming the render lands where the project's media lives.
|
||||
|
||||
---
|
||||
|
||||
## Rulings — Daniel's, 2026-08-02
|
||||
|
||||
All three [Daniel]-class forks this doc opened were ruled the same day it was framed.
|
||||
Nothing here is open. This section is an index; each ruling is **specified** in the
|
||||
section that owns it, and that section is the implementation-binding text.
|
||||
|
||||
| Fork | Ruling | Specified in |
|
||||
|---|---|---|
|
||||
| **Ρ-F1** | **Refuse** a multi-track selection. One track per fire, inherited from `isMultiTrackStemRender`. A per-track loop is a settled non-goal for this phase, not a deferral | §"The render" — the multi-track paragraph; §"What Phase Ρ explicitly is NOT" |
|
||||
| **Ρ-F2** | **The result track always goes to Arrange**, whatever mode was active. *"For this action which is not a capture, the result track should always go to arrange."* | §"Mode transitions" — the absolute rule, the item tag, and the auto-tag-detector fix it requires |
|
||||
| **Ρ-F3** | **Follow the panel tail setting** (None / Auto / Manual), with both consequences accepted | §"The render" — the tail paragraph and its two numbered consequences |
|
||||
|
||||
**Ρ-F2 went against the framing, and against the request's own original wording.**
|
||||
The framing recommended mode-following on the strength of Daniel's first phrasing
|
||||
("stays in whatever mode was active") and of one use it enabled — an A/B of a chain
|
||||
against its own render, both on the bench, neither touching the arrangement. The
|
||||
ruling took the alternative the framing had itself named: an absolute rule, because Ρ
|
||||
is *not a capture* and the mode-relative habit belongs to the capture pillar, not
|
||||
here. **That A/B use no longer exists, and no text in this doc or in `PLAN.md` may
|
||||
still claim it as a benefit.** The cost the ruling accepts is that firing Ρ from
|
||||
Design produces no visible change; the compensating console message is a
|
||||
[propose at review] item in §"Mode transitions".
|
||||
|
||||
**The ruling also has a cost the framing did not anticipate**, recorded here so it is
|
||||
not read as scope creep at review: the panel's auto-tag detector tags every new track
|
||||
to the active mode, so without an explicit-tag-wins filter the Design-fired case
|
||||
reverses the ruling on the next timer tick. The two-line fix in
|
||||
`panel_input::detectNewContent` is a **deliverable of this phase**, caused by this
|
||||
ruling. Under mode-following it would not have been needed — the detector would have
|
||||
agreed with Ρ.
|
||||
|
||||
**Ρ-F1 and Ρ-F3 both matched the recommendation**, so nothing in the spec moved. The
|
||||
counter-arguments are kept because they are the reason each was a fork rather than a
|
||||
call, and they will resurface: for Ρ-F1, that the stem-collapse hazard does **not**
|
||||
apply to a per-track loop (each iteration renders one track), so the refusal is
|
||||
inherited rather than forced — what a loop would actually cost is bookkeeping (indices
|
||||
shift per insertion, so the folder arithmetic re-derives each pass; plus an undo label,
|
||||
a partial-failure story, and a selection-afterwards rule). For Ρ-F3, that a drop-in
|
||||
replacement arguably wants exact length, and that forcing `None` would have kept the
|
||||
exact-bounds gate live on every Ρ render.
|
||||
|
||||
**One unexercised alternative, recorded and not built.** For Ρ-F3, a third option was
|
||||
floated at framing: follow the panel setting *and* run the bounds gate's
|
||||
start-alignment check regardless of tail mode, since a tail only ever extends the end.
|
||||
It would recover the widening guard on the start edge without losing the tail, at the
|
||||
cost of editing a gate shared by every capture path for one caller's benefit. It is
|
||||
**not ruled in** and is not part of this phase. If the unguarded Auto/Manual render
|
||||
ever produces a real bug, this is the shape of the fix.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@ concrete and decidable:
|
||||
2. **A beta side-channel** — so development can continue and a beta build run
|
||||
*alongside* the stable one without the beta clobbering the release.
|
||||
|
||||
This doc holds the *why*, the forks, and a recommendation. When Daniel picks, the
|
||||
tickable points land in `PLAN.md` and the deploy/build wiring hands off to dev-ops.
|
||||
This doc holds the *why*, the forks, and a recommendation. The tickable points'
|
||||
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.
|
||||
|
||||
Status: framed by product-designer (2026-07-26); **all four forks settled by Daniel
|
||||
@@ -45,7 +45,7 @@ below:
|
||||
|
||||
Two sharp edges follow directly and recur throughout this note:
|
||||
|
||||
- **The `STABLE_FOREVER_STRING` command-id contract** (CLAUDE.md; `main.cpp:41`,
|
||||
- **The `STABLE_FOREVER_STRING` command-id contract** (CLAUDE.md; `app_version.h`'s `commandIdPrefix()`,
|
||||
prefix `CEREBELLUM_REASAMPLER_`). Command-id strings are minted once and **never
|
||||
changed after shipping** — user keybindings key off them. Two coexisting binaries
|
||||
that register the *same* id strings collide in REAPER's Actions list.
|
||||
@@ -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
|
||||
**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
|
||||
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
|
||||
real corruption path, not a theoretical one.
|
||||
|
||||
@@ -67,9 +67,9 @@ allowed to touch.
|
||||
|
||||
## What we have today
|
||||
|
||||
- No version anywhere. `CMakeLists.txt:2` is `project(reaper_reasampler LANGUAGES
|
||||
- No version anywhere. `CMakeLists.txt` is `project(reaper_reasampler LANGUAGES
|
||||
CXX)` — no `VERSION`. The binary announces itself only as `"ReaSampler loaded.\n"`
|
||||
to the console (`main.cpp:960`). There is no number a user, a bug report, or a
|
||||
to the console (`main.cpp`). There is no number a user, a bug report, or a
|
||||
future migration can key off.
|
||||
- The natural user-visible readout already exists: the docked LICE bank panel, and
|
||||
the console (`ShowConsoleMsg`). A version has cheap homes; none is wired.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
font bundling/redistribution; the kit keeps its current cached-font face.) The build points it feeds live in
|
||||
**PLAN.md §Phase L** (**L1** the shared LICE drawing kit, **L2** the dock-panel layout
|
||||
redesign, **L3** the VST editor + embed-strip restyle) and **CONTEXT.md §Phase L** (the
|
||||
design-system spec). Toolkit facts below are **verified against the vendored `vendor/WDL`
|
||||
tree**, not lore.
|
||||
font bundling/redistribution; the kit keeps its current cached-font face.) The build points it
|
||||
fed have landed; their history is in **`docs/ARCHIVE.md`** ("Phase L — Look-and-feel", **L1** the
|
||||
shared LICE drawing kit, **L2** the dock-panel layout redesign, **L3** the VST editor +
|
||||
embed-strip restyle) and the architecture now lives in **`src/core/ui/CLAUDE.md`** /
|
||||
**`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
|
||||
> 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 |
|
||||
|---|---|---|---|
|
||||
| `accent/primary` | pastel lime green | `~176,224,152` (`#B0E098`) | the live/active/selected signal — the eye-magnet |
|
||||
| `accent/secondary` | pastel teal | `~132,214,208` (`#84D6D0`) | categorical role A (non-active zones, a second cluster) |
|
||||
| `accent/secondary` | pastel teal | `~132,214,208` (`#84D6D0`) *(superseded, see below)* | categorical role A (non-active zones, a second cluster) |
|
||||
| `accent/tertiary` | pastel purple | `~194,170,232` (`#C2AAE8`) | categorical role B (tertiary zones, a distinct affordance class) |
|
||||
| `accent/hot` | brighter primary tint | `~200,236,178` (`#C8ECB2`) | hover / live / drag feedback (a lighter pastel-lime) |
|
||||
|
||||
**Locked values, where they diverge from the starting table (2026-07-31).** `accent/secondary`
|
||||
darkened to `#38A8A0` (same hue ~176°, same saturation, lightness 0.68 → 0.44). Its **binding
|
||||
limiter** — the pair that stops it going darker — is the velocity-curve trace on a
|
||||
*hover-lightened* `bg/cell` at **3.03:1** against a 3:1 floor, tighter than either the AA 4.5:1
|
||||
text-on-fill pair (4.91:1 on `bg/base`) or the indicator floor on rest `bg/cell` (3.94:1). ONE
|
||||
new Role was added past the original three, since "exactly three accents" is no longer a
|
||||
constraint: `overlay/trace` `#816AA6`, a muted violet for marks drawn *over* an accent fill (see
|
||||
§the two-neighbour problem below). The spectral ramp's mid stop also became its own value —
|
||||
the original pastel teal `#84D6D0` — but as a private constant, not a `Role`.
|
||||
|
||||
**The two-neighbour problem** (rule + numbers owned by `src/core/ui/CLAUDE.md`; the design
|
||||
consequence is recorded here). A mark drawn *over* an accent fill has to read against the fill
|
||||
*and* the surface behind it, which caps *any* single value at ≈3.07:1 against both. Two
|
||||
consequences shape the design rather than the palette: such a role is confined to the band it
|
||||
was picked for, and a **state** of that mark cannot be expressed as a hotter color — every
|
||||
value that clears the ceiling sits within 1.05:1 of every other, so the grabbed envelope handle
|
||||
signals by **size and a punched-out core** instead. That deliberately inverts the kit's
|
||||
"brighter = hotter" convention in this one band, because over a light fill a brighter tint is a
|
||||
*lower*-contrast tint (`accent/hot` measures 1.15:1 against the lime).
|
||||
|
||||
These are light-ish, low-saturation tints. On the **REAPER-grey surfaces** (`bg/cell` ≈
|
||||
`#3a3a3a`) they still clear **AA-large (3:1)** and the **state-indicator** floor — but the
|
||||
margin is **much smaller than it was on near-black** (roughly ~6:1–7:1 on grey vs. ~15:1
|
||||
on `#12121x`). This is a real DS-2-revision tension: **the greyer background pulls the
|
||||
margin is **much smaller than it was on near-black** (~7.6:1 primary / ~5.5:1 tertiary /
|
||||
~3.9:1 the darkened secondary on grey, vs. ~15:1 on `#12121x`). This is a real DS-2-revision tension: **the greyer background pulls the
|
||||
pastels toward the floor** at the same time the "keep it soft" rule pulls them away from
|
||||
saturation. Approach from the **soft side** (§2.1) — keep them as pastel as possible while
|
||||
still clearing the floor on **grey, not near-black** — but **if any pastel used as a state
|
||||
@@ -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
|
||||
accent constants (or a slightly wider pastel arc that passes *through* those three anchor
|
||||
points), keeping every stop in the pastel band. This ties the spectrum to the palette:
|
||||
the same three hues that mean "live / category A / category B" elsewhere are the endpoints
|
||||
and midpoint of the spectrum here, so the strip reads as an extension of the accent system,
|
||||
not a separate neon flourish. The **active** zone still lifts to `accent/primary` +
|
||||
the same hues that mean "live / category A / category B" elsewhere anchor the spectrum here,
|
||||
so the strip reads as an extension of the accent system, not a separate neon flourish.
|
||||
**The mid stop is its own constant, not an alias of `accent/secondary`** (decoupled
|
||||
2026-07-31): the ramp is a luminance progression while the accents are categorical roles, and
|
||||
darkening secondary for a categorical reason inverted lo→mid→hi. A monotonicity test now guards
|
||||
the ordering. The **active** zone still lifts to `accent/primary` +
|
||||
its bloom, so "which zone is live" stays unambiguous over the categorical spectral bands.
|
||||
|
||||
- **Feel:** premium, almost visualizer-grade — but soft and cohesive, a pastel spectrum
|
||||
@@ -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.
|
||||
|
||||
@@ -547,7 +571,7 @@ draw it.
|
||||
affordances, per-selection **move / copy / remove** sample menu.
|
||||
- **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 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,
|
||||
@@ -737,3 +761,100 @@ re-skin).**
|
||||
font obligation.
|
||||
- **Phase S is not gated on Phase L** — S7–S13 proceeded in parallel; they adopted the
|
||||
kit via L3 when it landed. Phase L is complete (L1–L7 all landed).
|
||||
|
||||
---
|
||||
|
||||
## 8. Antialiasing disposition — the drawn-surface audit
|
||||
|
||||
A standing inventory of every class of drawn surface and how it answers antialiasing, so the
|
||||
audit is re-runnable rather than a one-off sweep. **The rule the table applies:** an
|
||||
axis-aligned fill or hairline has no aliasing to remove — LICE's `aa` flag is inert on a pure
|
||||
horizontal or vertical run — so "already clean" there is a statement about geometry, not a
|
||||
concession. Everything with a slope or a curve must draw through a primitive that antialiases.
|
||||
|
||||
**Primitive gotchas this audit established (verified in `vendor/WDL/WDL/lice/`):**
|
||||
|
||||
- `LICE_Line` takes INTEGER endpoints. `aa=true` antialiases the span, but the endpoints are
|
||||
still quantized; `LICE_FLine` keeps float endpoints and `LICE_ThickFLine` is *always*
|
||||
antialiased and adds width.
|
||||
- `LICE_FillTriangle` takes **no** `aa` parameter at all — its sloped edges alias, and the
|
||||
only fix inside the kit is to re-stroke those edges with an AA line in the same ink.
|
||||
- **`LICE_Arc` does not rasterize an arc.** It rasterizes a whole circle clipped to a
|
||||
rectangular bounding box per 90° chunk (`lice_arc.cpp` `__DrawArc`), and its AA circle splits
|
||||
one unit of ink across two adjacent pixels by the **fractional part of the radius**
|
||||
(`w = yf - floor(yf)`, then `wa` and `ai - wa`). A half-integer radius therefore puts 50% on
|
||||
each of two pixels at the cardinal points, and stacked radii do not tile — vertical spacing
|
||||
between rings `r` and `r-1` dilates from 1.0 px at the top to 1.41 px at 45°. Measured on the
|
||||
shipped 3-ring knob arc: weakest cross-section peak **138/255** and perpendicular weight
|
||||
**1.62–3.24 px** against a nominal 3 (67% ripple).
|
||||
- **`LICE_ThickFLine` lays its width along the MINOR axis**, so perpendicular weight is
|
||||
`wid·cos θ`. Measured at width 2: **1.41–2.00 px** across a 0–90° sweep — it thins to
|
||||
`1/√2` of nominal at every diagonal.
|
||||
- Neither of those two is usable for a stroke that must hold a consistent weight. Arcs and
|
||||
spline contours draw through the analytic stroker instead (`core/ui/stroke_aa` +
|
||||
`shell/instrument/editor_stroke`): coverage is distance-to-polyline, MAX-accumulated into a
|
||||
scratch mask and blended **once**. The single blend is the structural part — compositing
|
||||
per segment re-lays ink over the previous segment's fringe.
|
||||
- A min/max waveform column plot cannot be antialiased by the column fill itself (the columns
|
||||
are vertical). The outline is what reads as jagged, so it is stroked separately.
|
||||
|
||||
> **Methodological lesson — why this table got two rows wrong.** The original audit verified
|
||||
> *which primitive each surface called* and treated an `aa=true` argument as the answer. It
|
||||
> never verified *what the primitive rasterized*. Both misses hid behind a true-looking
|
||||
> statement: `LICE_Arc` really does antialias, and `LICE_ThickFLine` really is always
|
||||
> antialiased — neither fact says anything about opacity or perpendicular weight, which is
|
||||
> what was actually broken. **A disposition row is only earned by a measurement of the
|
||||
> rendered output** (peak alpha, weight across angle), not by reading the call site.
|
||||
|
||||
| Surface | Where | Disposition |
|
||||
|---|---|---|
|
||||
| Radial knob track arc | `editor_internal.h` `drawKnobFace` | **Fixed (2026-08-01), widened (2026-08-01)** — the stacked-radius `LICE_Arc` ring never reached an opaque core. Now ONE analytic stroke (`strokeArcAA`); `kKnobTrackArcPx` was initially left at 1 px, below the ≥2 px opaque-core threshold (`core/ui/CLAUDE.md`), and surfaced to Daniel as a by-eye call — he ruled to enlarge all sub-2 px stroker widths, so it is now 2 px and reaches a guaranteed opaque core. |
|
||||
| Radial knob value arc | `editor_internal.h` `drawKnobFace` | **Fixed (2026-08-01)** — same stroke, `kKnobValueArcPx` = 3 px, clear of the opaque-core threshold. Measured: peak **255/255** at every cross-section, weight **2.95–3.11 px** (5% ripple). |
|
||||
| Knob needle | `drawKnobFace` | **Fixed (2026-08-01)** — `LICE_ThickFLine`'s minor-axis width thinned it to `cos θ` as the knob swept. Now `strokeLineAA`, 2 px. |
|
||||
| Inner curve dial arc | `drawInnerDial` | **Fixed (2026-08-01)** — the arc shared the knob track/value arc's stacked-radius opacity defect. Same one analytic fix, at `kInnerDialArcPx` = 2 px (at the opaque-core threshold). |
|
||||
| Inner curve dial needle | `drawInnerDial` | **Converted (2026-08-01), widened (2026-08-01)** — this needle was already `LICE_FLine` (float endpoints, always AA), not `LICE_ThickFLine`; a 1 px AA line has no width to lay along a minor axis, so it never had the knob needle's `cos θ` defect. Moved to `strokeLineAA` at 1 px for one-seam consistency, not because it was broken — but 1 px is below the analytic stroker's opaque-core threshold, so it fell under Daniel's later blanket ruling and is now `kInnerDialNeedlePx` = 2 px. |
|
||||
| Staged envelope segment slopes | `editor_paint_waveform.cpp` | **Fixed (2026-08-01)** — one `strokePolylineAA` over the whole polyline, so the stage joints blend once. Vertices stay INTEGER by design: they are the positions the draggable handles are drawn at. |
|
||||
| Spline (drawn EG) contour | `editor_paint_waveform.cpp` `paintSplineOverlay` | **Fixed (2026-08-01)** — the trace was never gapped; it was fully aliased (every pixel full or empty) because the loop passed INTEGER `cy`, quantizing the slope into alternating 1/2 px steps. Now sub-pixel y (`subpixelFromPoint`) through `strokePolylineAA`. Measured: peak **255/255**, weight **1.95–2.01 px** (3% ripple). |
|
||||
| Velocity-curve popup trace | `editor_paint_curve.cpp` | **Fixed (2026-08-01)** — same cause, same treatment. |
|
||||
| Velocity-curve mini thumbnail | `editor_paint_curve.cpp` | **Fixed (2026-08-01), widened (2026-08-01)** — strokes analytically at sub-pixel y instead of integer-endpoint `LICE_Line`. Initially kept as a 1 px hairline (a 2 px trace was thought to blot at thumbnail scale), but 1 px is below the opaque-core threshold; Daniel's ruling raised `kMiniTracePx` to 2 px, same as the popup trace. |
|
||||
| Waveform min/max columns | `draw_kit.cpp` `drawWaveform` | **Fixed** — column fill unchanged (it cannot alias), plus an AA `LICE_FLine` stroke joining each column's extremes to its neighbour's, in the same ink. Shared with the docked bank panel and the browser cards. **Measured cost** (Release, MSVC 14.44, real LICE, 24 stereo cards × 136 columns = 6528 columns): fill alone 0.070 ms per full-grid repaint, fill+stroke 0.48 ms — the stroke is ~0.41 ms, about 2.5% of a 60 Hz frame, and the grid repaints on hover/scroll/drag, not continuously. One-off scratchpad measurement, 2026-08-01, harness not committed — not a standing regression guard; re-measure before relying on it again. |
|
||||
| Preview play triangle | `editor_paint_chrome.cpp` | **Fixed** — `LICE_FillTriangle` has no `aa`; its two sloped edges are re-stroked with AA `LICE_FLine`. |
|
||||
| Envelope/spline node handles (squares) | `editor_paint_waveform.cpp` | Already clean — axis-aligned `LICE_FillRect`. |
|
||||
| Envelope curve knots (circles) | `editor_paint_waveform.cpp` | Already clean — `LICE_FillCircle` with `aa=true`. |
|
||||
| Knob body disc | `drawKnobFace` / `drawInnerDial` | Already clean — `LICE_FillCircle` with `aa=true`. |
|
||||
| Buttons | `draw_kit.cpp` `drawButton` | Already clean — `LICE_RoundRect` with `aa=true`. |
|
||||
| Piano key faces + edges | `editor_paint_chrome.cpp` `drawKeyboard` | Already clean — axis-aligned fills and a vertical hairline. **See §8.1.** |
|
||||
| Loop span, crossfade region, marker bars, grab tab | `editor_paint_waveform.cpp` | Already clean — axis-aligned fills. |
|
||||
| Group fences, card/tab/tooltip borders, focus rings | deck, browse, panel painters | Already clean — `LICE_DrawRect`, axis-aligned. |
|
||||
| Surface fills + inner edge highlights | `draw_kit.cpp` `fillSurface` | Already clean — `LICE_GradRect` + axis-aligned hairlines. |
|
||||
| Embed strip (TCP/MCP) | `reasampler_embed.cpp` | Already clean — axis-aligned fills only. |
|
||||
| Docked bank panel chrome | `panel_render.cpp` | Already clean — axis-aligned fills, rects and hairlines. Its only exposure to this pass is the shared `drawWaveform`. |
|
||||
| Text | `draw_kit.cpp` `text` | Already clean — `LICE_CachedFont` AA glyph cache (§1.1). |
|
||||
|
||||
**Analytic stroker cost** (Release, MSVC, real LICE, one-off scratchpad harness 2026-08-01,
|
||||
not committed — re-measure before relying on it): 30 knob arcs **0.113 ms → 0.169 ms**; a
|
||||
500 px spline contour **0.013 ms → 0.047 ms**. About +0.09 ms per full editor repaint, on a
|
||||
surface that repaints on interaction rather than continuously. Micro-optimisation, each lever
|
||||
measured in isolation: writing the blend straight to the bitmap's bits rather than through
|
||||
`LICE_PutPixel` is the big one (arcs 0.169 vs 0.253 ms); reusing the scratch mask across
|
||||
calls matters on the contour's large bounding box (0.047 vs 0.073 ms); `float` over `double`
|
||||
is small but real (contour coverage 0.045 vs 0.051 ms). The per-row valid-extent bookkeeping
|
||||
in the mask is a **wash** against the simpler clear-the-whole-box design (0.218 vs 0.218 ms
|
||||
for a full repaint) — it wins on the contour and loses on the small arc boxes; it is kept
|
||||
because the contour is the drag-interactive surface.
|
||||
|
||||
### 8.1 Was the piano-key width defect an aliasing artifact?
|
||||
|
||||
**No.** Every piano key is an axis-aligned `LICE_FillRect` with an integer width, so there is
|
||||
no sloped or curved edge for aliasing to act on — the defect could not have had that cause.
|
||||
It was integer-division residue: `keyboard_strip` tiles same-class keys at one integer width
|
||||
and the indivisible remainder of the band width has to go *somewhere*. The fix put it in
|
||||
symmetric end margins instead of in a key, which is arithmetic, not rasterization.
|
||||
|
||||
**Does the fix survive DPI scaling?** At the client-pixel level, yes — key widths are uniform
|
||||
by construction at every client width the strip's test sweep covers. Above that level it is
|
||||
**unverified**, and for a structural reason worth keeping visible: nothing in the instrument
|
||||
implements `IPlugViewContentScaleSupport`, so a host that scales the plugin window resamples
|
||||
the already-rasterized uniform widths at the physical-pixel level, where the guarantee no
|
||||
longer applies. That is a host-scaling question, not an antialiasing one, and it is recorded
|
||||
as a gotcha in `src/core/instrument/CLAUDE.md`.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# DAW verification — bank-package transfer across machines
|
||||
|
||||
What a DAW pass must establish for `.rsbank` export and import, and the exact strings or
|
||||
counts to read off. The unit corpus (`tests/fixtures/package_compat/`) already proves the
|
||||
version ladder, the truncation verdicts and the hostile-name refusals against frozen
|
||||
bytes. **Nothing below is covered by it**: every cell here depends on a real REAPER
|
||||
session, a real file dialog, or a genuine second machine.
|
||||
|
||||
**Build to use.** Release, installed into `UserPlugins/`, REAPER restarted — extensions
|
||||
load at startup only. Note the version the *About*/version action reports; §5 needs it.
|
||||
|
||||
**Machines to use.** Two: **A** (the source) and **B** (the destination). B must be a
|
||||
different machine, or at minimum a different user account with its own REAPER resource
|
||||
path and its own projects folder — the point is that no absolute path from A can resolve
|
||||
on B. A USB stick, a network share, or a cloud folder are all acceptable transports.
|
||||
|
||||
**Projects to use.** On A: one **saved** project with a bank holding at least **three**
|
||||
samples, at least one of them audibly distinct from the others, and at least one whose
|
||||
display name carries a non-ASCII character (e.g. `Café hit`). On B: one **saved**,
|
||||
otherwise empty project.
|
||||
|
||||
---
|
||||
|
||||
## 1. Export writes one file and touches nothing else
|
||||
|
||||
On A, right-click the bank's header in the docked panel → **Export as package...** (or
|
||||
run *ReaSampler: export active bank as package*). Accept the suggested file name.
|
||||
|
||||
Read off:
|
||||
|
||||
- The console shows `ReaSampler export: wrote 3 entry/entries (N bytes) to <path>`, with
|
||||
the entry count matching the bank.
|
||||
- A single `.rsbank` file exists at that path. **No `.rsbanktmp` sibling remains** — a
|
||||
leftover temp file means the atomic rename did not complete.
|
||||
- The bank's card count, the bank folder's file count, and the project's dirty flag are
|
||||
all **unchanged**. An export writes no ext state and opens no undo point, so REAPER
|
||||
must not consider the project modified by it alone.
|
||||
- Nothing was added to the arrange view.
|
||||
|
||||
## 2. The transfer itself — the claim no unit test can make
|
||||
|
||||
Copy the `.rsbank` to B by whatever transport you chose. Do **not** copy the project, the
|
||||
bank folder, or anything else.
|
||||
|
||||
On B, open the empty saved project. Panel bank menu → **Import bank package...** (or run
|
||||
*ReaSampler: import bank package (.rsbank)*), and choose the transferred file.
|
||||
|
||||
Read off:
|
||||
|
||||
- A message box: `Imported 3 sample(s) into a new bank: "<bank name>".`
|
||||
- The console block repeats that line and ends with `One undo removes the imported bank
|
||||
and its entries. It does NOT delete the imported files ...`.
|
||||
- The panel shows a **new** bank with the same display name and the same number of cards,
|
||||
**in the same order** as on A.
|
||||
- B's bank folder holds three new files. The non-ASCII display name from A renders
|
||||
correctly on the card — a mangled name here means the UTF-8 path/name conversion broke
|
||||
in transit.
|
||||
- **Audition each card.** They must sound like their counterparts on A. This is the whole
|
||||
claim: the audio survived a machine boundary with no shared path.
|
||||
- Press **Ctrl-Z once**. The imported bank and its entries disappear in one step. The
|
||||
three files remain in B's bank folder (that is stated in the console block above, and is
|
||||
the designed behaviour — a prune reclaims them). Redo to continue.
|
||||
|
||||
## 3. Re-importing the same package never overwrites
|
||||
|
||||
Still on B, import the **same** file a second time.
|
||||
|
||||
Read off:
|
||||
|
||||
- A second new bank appears, named with a suffix (`<bank name> 2`), and the box's
|
||||
`(a bank named "<bank name>" already exists in this project)` clause appears in the
|
||||
console block.
|
||||
- B's bank folder now holds **six** files, not three. The console reports
|
||||
`3 file(s) landed under a freshly minted name (the package's own name was already taken
|
||||
in the bank folder). An existing bank file is never overwritten.`
|
||||
- The first imported bank's cards still audition correctly — nothing was replaced under it.
|
||||
|
||||
## 4. Round trip back to the source
|
||||
|
||||
On B, export the imported bank (§1) to a second `.rsbank`. Carry it back to A and import
|
||||
it into A's original project.
|
||||
|
||||
Read off:
|
||||
|
||||
- The import succeeds and lands as a new bank beside the original.
|
||||
- The original bank on A is untouched: same card count, same names, same audio.
|
||||
- Compare the two `.rsbank` files' **sizes**. They will usually differ — entry names,
|
||||
sample ids and the export timestamp are all legitimately re-minted across a trip. The
|
||||
payload bytes are what must survive, and that half is closed by
|
||||
`tests/test_package_round_trip.cpp` against frozen bytes; do **not** treat a size
|
||||
difference here as a defect.
|
||||
|
||||
## 5. The too-new refusal, with the message read verbatim
|
||||
|
||||
This is the direction a user hits when a collaborator is ahead of them, and the message is
|
||||
the only actionable output. Produce it by hand:
|
||||
|
||||
1. Copy the `.rsbank` from §1 to a scratch name.
|
||||
2. Open the copy in a hex editor. Bytes 0–3 are `RSBK`; bytes 4–7 are `formatVersion`
|
||||
little-endian; bytes **8–11** are `minReaderVersion` little-endian.
|
||||
3. Change byte **8** from `01` to `02`, and byte **4** from `01` to `02` (a writer cannot
|
||||
require a reader newer than the format it wrote — leaving `formatVersion` at 1 makes
|
||||
the file incoherent and it will be refused as malformed instead, which is a different
|
||||
cell). Save.
|
||||
4. Import the edited copy.
|
||||
|
||||
Read off — the message box, all four lines:
|
||||
|
||||
```
|
||||
Cannot import this bank package.
|
||||
It was written by ReaSampler <the version noted at the top> and needs package format 2 or newer.
|
||||
This build (<the same version>) reads package format 1.
|
||||
Nothing was imported. Install ReaSampler <the same version> or newer and try again.
|
||||
```
|
||||
|
||||
- The writer version named is the one **this** build stamped in §1 (the hex edit does not
|
||||
touch the semver), so the second and fourth lines will name your own version. That is
|
||||
expected — what is being verified is that all three facts are present and the box
|
||||
appears at all.
|
||||
- **No** new bank, **no** new files in the bank folder, **no** undo point.
|
||||
|
||||
## 6. The truncated-download refusal is a different message
|
||||
|
||||
Copy the §1 package again and delete the last few hundred bytes (any hex editor, or
|
||||
`head -c` / `fsutil` — the exact count does not matter as long as the file is shorter).
|
||||
Import it.
|
||||
|
||||
Read off:
|
||||
|
||||
- The message box reads exactly: `This file is not a readable bank package (corrupt or
|
||||
truncated). Nothing was imported.`
|
||||
- It is **not** the §5 message. Crossing these two is the failure this cell exists to
|
||||
catch — "install a newer build" does not fix a partial download.
|
||||
- No new bank, no new files.
|
||||
|
||||
## 7. Corruption in the middle is caught before anything lands
|
||||
|
||||
Copy the §1 package again and flip a single byte **well past the halfway point** (inside a
|
||||
payload, not the header). Import it.
|
||||
|
||||
Read off:
|
||||
|
||||
- The message box names the offending entry:
|
||||
`This bank package is damaged (entry "<name>" failed its integrity check). Nothing was
|
||||
imported.`
|
||||
- The bank folder gained **no** files at all — not even the entries before the damaged
|
||||
one. Verification runs to completion before the first write, so a damaged package costs
|
||||
no rollback.
|
||||
|
||||
## 8. The unsaved-project refusals
|
||||
|
||||
- On B, File → New Project (do not save). Try to import. Read off:
|
||||
`Save the project before importing a bank package -- an unsaved project has no bank
|
||||
folder to import into.` The file picker must **not** have opened first.
|
||||
- On A, in an unsaved project with no bank, try to export. Read off the console:
|
||||
`ReaSampler export: save the project first -- an unsaved project has no bank folder to
|
||||
read from.`
|
||||
|
||||
## 9. Drag-and-drop reaches the same verb
|
||||
|
||||
On B, drag a `.rsbank` from the file manager onto the docked ReaSampler panel.
|
||||
|
||||
Read off: the same import box as §2, and the same new bank. A `.rsbank` is a whole bank,
|
||||
not audio — it must never land as an item in the arrange view.
|
||||
|
||||
---
|
||||
|
||||
## Recording the result
|
||||
|
||||
For each section, record **pass**, **fail with the string actually seen**, or **not
|
||||
exercised**. §2 and §4 are the load-bearing ones: they are the only cells in this document
|
||||
that involve a real machine boundary, and no unit test can stand in for them.
|
||||
@@ -0,0 +1,154 @@
|
||||
# DAW verification — track-scope capture over a multi-track selection
|
||||
|
||||
What a DAW pass must establish for the multi-track track capture, and the exact numbers
|
||||
or strings to read off. Nothing below can be closed by a unit test: every item depends on
|
||||
what REAPER actually does with a render request.
|
||||
|
||||
**Build to use.** Release, installed into `UserPlugins/`, REAPER restarted — extensions
|
||||
load at startup only. Set the docked panel's tail toggle to **None** before every cell;
|
||||
Auto adds an 8 s window and Manual a fixed one, and both would invalidate the frame-count
|
||||
readings.
|
||||
|
||||
**Project to use.** One saved project, project sample rate pinned to 48000. Two audio
|
||||
tracks, `A` and `B`, each holding one item at least 30 s long, with *audibly different*
|
||||
content (a tone on `A`, a drum loop on `B`). One folder track `F` with `A` and `B` as its
|
||||
children, used only in §5.
|
||||
|
||||
---
|
||||
|
||||
## 1. The regression floor — single-track track capture is unchanged
|
||||
|
||||
Select **track `A` only**. Make a time selection from **10.000 s to 12.000 s**. Run
|
||||
*ReaSampler: capture selected track(s)*.
|
||||
|
||||
Read off:
|
||||
|
||||
- A file appears in the project's bank folder, and one new card appears on the panel.
|
||||
- The card's length reads **2.000 s**; its frame count is **96000** (`round(12.0 × 48000)
|
||||
− round(10.0 × 48000)`). The backend refuses the capture with `BoundsMismatch` if the
|
||||
render is more than one frame off that, so a landed capture already proves the number
|
||||
to ±1 — what you are confirming here is that it landed at all.
|
||||
- The REAPER console shows **no** `ReaSampler capture failed:` line.
|
||||
- Track `A` is still the only selected track afterwards.
|
||||
- **Content, not just length.** Listen to the landed file. `A` and `B` carry *audibly
|
||||
different* content by the project setup above (tone vs. drum loop), so this is a by-ear
|
||||
check, not a null test: the capture must be the tone alone, with **no** drum-loop bleed.
|
||||
Expected: pure tone, matching `A` soloed. Failing: any trace of `B`'s drum loop audible
|
||||
in the file. This is not a tautology check — the SDK header's own `RENDER_SETTINGS` line
|
||||
admits a second reading, `(&(1|2)==0)=master mix`, under which a single-track track
|
||||
capture could render the **whole master mix** (both `A` and `B`) rather than `A` alone;
|
||||
drum-loop bleed here is exactly what that misreading would produce, and this is the
|
||||
cheapest place in the whole doc to catch it.
|
||||
|
||||
**This is the byte-identical floor.** If either cell now refuses, the change is wrong —
|
||||
the refusal must fire only above one track.
|
||||
|
||||
## 2. The defect cell — two selected tracks now refuse
|
||||
|
||||
Select **`A` and `B` together**. Time selection 10.000–12.000 s. Run *capture selected
|
||||
track(s)*.
|
||||
|
||||
Read off:
|
||||
|
||||
- The console prints exactly:
|
||||
`ReaSampler capture failed: A track capture renders the selected tracks through the
|
||||
master, and more than one track cannot land as a single file. Capture one track at a
|
||||
time, or route them into a folder/bus track and capture that (a folder's own output is
|
||||
its children summed).`
|
||||
- **No** new card on the panel, and **no** new `.wav` in the bank folder (check the folder
|
||||
directly — a stray file with nothing indexing it would mean the refusal fired too late).
|
||||
- `A` and `B` are both still selected, both still unmuted, and neither track's fader, pan,
|
||||
or FX-bypass state changed. The refusal returns before any guard is constructed, so
|
||||
there should be nothing to restore — this reading is what confirms that.
|
||||
|
||||
Repeat with a **razor area spanning both tracks** and no time selection: identical
|
||||
readings. Note that the track *selection* is what the refusal counts — a razor over two
|
||||
tracks with only `A` selected is a one-track capture and must still succeed (§1).
|
||||
|
||||
## 3. The decisive observation — what `&128` actually writes
|
||||
|
||||
**This is the one that retires an inference, and it is the reason `docs/TODO.md` still
|
||||
carries an entry.** The refusal in §2 rests on reading the SDK header's single-file bit
|
||||
`&(4<<16)` as applying to item/razor sources only, never to `&128` — so N selected tracks
|
||||
are believed to produce N files. That has never been observed.
|
||||
|
||||
Drive REAPER's own Render dialog by hand, with the extension out of the loop:
|
||||
|
||||
1. Select `A` and `B`.
|
||||
2. File → Render. **Source:** *Selected tracks via master* — the dialog wording for `&128`
|
||||
(SDK header ~3041). Do **not** pick *Stems (selected tracks)* — that is `&2`, a
|
||||
different source bit that unambiguously writes one file per track and would confirm
|
||||
nothing about `&128`.
|
||||
**Bounds:** *Custom time range*, 10.000 to 12.000 s.
|
||||
3. **File name:** a literal stem with **no wildcards at all** — e.g. `stemprobe`. Clear
|
||||
`$track` / `$item` / anything else from the pattern; the extension writes exactly one
|
||||
literal stem, so the probe must too.
|
||||
4. Render to an empty scratch folder.
|
||||
|
||||
Read off — **the file count in that folder**:
|
||||
|
||||
- **Two files** (however REAPER disambiguated them, or one file that visibly got
|
||||
overwritten): the inference holds, the §2 refusal is correct, and the `docs/TODO.md`
|
||||
entry can be closed by writing this observation into `src/shell/capture/CLAUDE.md` as
|
||||
fact.
|
||||
- **One file containing `A` and `B` summed** (confirm by ear, or by nulling it against a
|
||||
master render of the same range with only `A` and `B` unmuted): the inference is wrong,
|
||||
the §2 refusal costs a working capture, and the track-scope half should be narrowed back
|
||||
per the `docs/TODO.md` entry. The item-scope half stays either way.
|
||||
|
||||
Also record **what REAPER named the files** — that decides whether a future correct
|
||||
multi-track capture could ever be built on this source at all.
|
||||
|
||||
## 4. Recapture replays the same answer
|
||||
|
||||
Take a **single-track** track capture that carries provenance (capture a range on `A`
|
||||
whose source item is itself a bank sample, so `detectParent` fires), select its card, and
|
||||
run *re-capture from source*. It must regenerate — same audio, same 96000 frames.
|
||||
|
||||
Then construct the multi-track case: a recorded recipe whose `trackGuids` names two
|
||||
tracks. The reachable way to get one is to have captured it before this change; if no such
|
||||
entry exists in any project, record that this cell was **not exercised** rather than
|
||||
inventing one. When it is exercised, read off:
|
||||
|
||||
- `ReaSampler re-capture failed:` followed by the **same** message text as §2.
|
||||
- The bank entry is untouched — same file, same hash, same card.
|
||||
|
||||
## 5. The way out actually works
|
||||
|
||||
Route `A` and `B` into folder `F`. Select **`F` only**, time selection 10.000–12.000 s,
|
||||
capture track scope.
|
||||
|
||||
Read off: one card, 2.000 s, and the audio contains **both** `A` and `B`. This is what the
|
||||
refusal message tells the user to do, so it has to be true.
|
||||
|
||||
## 6. Realtime still accepts a multi-track selection
|
||||
|
||||
Select `A` and `B`. Run *ReaSampler: capture selected track(s) in realtime* over the same
|
||||
range. Read off: **one** card, and its audio contains both tracks. Realtime taps each
|
||||
source track with a send into one temp track, so it sums where the offline render cannot —
|
||||
the divergence from §2 is deliberate and this cell is what confirms it is real.
|
||||
|
||||
## 7. Mono collapse — what is and is not reachable
|
||||
|
||||
Capture a range on a track whose content is dead-center (a mono source panned center, or
|
||||
a duplicated-channel file), using time selection **10.000 s to 12.000 s** (2.000 s, 96000
|
||||
frames at 48000 Hz — the §1 convention, so the resulting file size is exact). The panel has
|
||||
no channel-count readout anywhere (`Sample::channelCount` is not drawn by
|
||||
`src/shell/panel/panel_render.cpp`), so read the proxy instead:
|
||||
|
||||
- Check the landed `.wav`'s size on disk (Explorer → Properties, or a directory listing). A
|
||||
successful collapse is the extension's own rebuild — canonical 44-byte header + 96000 ×
|
||||
4 bytes = **384,044 bytes**. A file near double that (~768,044 bytes, plus whatever
|
||||
REAPER's own render adds for `bext`/metadata chunks) means the collapse did not fire —
|
||||
recheck the source is genuinely dead-center before treating this as a defect.
|
||||
- The console shows **no** `the lossless mono collapse ... already reached the bank; only
|
||||
the size win from the collapse was lost.` line.
|
||||
|
||||
**Not DAW-reachable:** the collapse's *failure* branch. It fires only if the captured file
|
||||
cannot be read, or its temporary rewrite cannot be written or renamed, inside the same
|
||||
call that just rendered the file — there is no manual way to inject that fault between the
|
||||
render and the rename. The branch is covered only at its reporting seam
|
||||
(`tests/test_wav_codec.cpp`, `testCollapseOutcomeSuffixesAreDistinctStrings`), and its console line
|
||||
has never been seen in a running REAPER. If you ever do see it, the render already reached
|
||||
the bank — the report only tells you the collapse's size win was lost, not that the bytes
|
||||
were verified (see `docs/TODO.md`'s 0-byte-render entry).
|
||||
@@ -0,0 +1,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,122 @@
|
||||
# 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/bake_landing.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/render_bounds_gate.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/render_in_place.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/footer_input.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_fx_park.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/package_export_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/actions/insert_fx_action.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 insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers)
|
||||
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. 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})
|
||||
|
||||
# The park surface's decisions are pure (header-inline, no REAPER types), so they are
|
||||
# CTest-covered like a core/ module: the snapshot trust test, the refusal message and
|
||||
# its once-per-changed-set gate, and the apply's undo-point fold. Declared here rather
|
||||
# than in a src/shell/view/CMakeLists.txt because that directory deliberately has none
|
||||
# — its TUs are compiled into this target directly. view_mode_model is linked for
|
||||
# makeParkPlan, the flag set the trust test folds over; pinning that against hand-built
|
||||
# ops would not catch a change to the planner.
|
||||
reasampler_test(view_fx_park LINK fx_offline view_mode_model)
|
||||
|
||||
# Bank-package import: the promptless verb plus its action skin. Kept as its own
|
||||
# appended block rather than merged into the lists above, so the two package
|
||||
# directions stay textually independent.
|
||||
target_sources(reaper_reasampler PRIVATE
|
||||
${REASAMPLER_SRC_DIR}/shell/package/import_bank.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/package_import_action.cpp)
|
||||
target_link_libraries(reaper_reasampler PRIVATE import_landing package_pickers)
|
||||
|
||||
# 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()
|
||||
+149
-184
@@ -1,25 +1,16 @@
|
||||
// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
|
||||
//
|
||||
// This file is the entire contract between REAPER and the extension:
|
||||
// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and
|
||||
// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry
|
||||
// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro).
|
||||
// * REAPER calls it, handing over `rec` — a small dispatch struct.
|
||||
// - rec->GetFunc(name) resolves any REAPER API function to a pointer
|
||||
// - rec->Register(what,ptr) plugs OUR callbacks into REAPER
|
||||
// * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and
|
||||
// fills in every global function pointer (ShowConsoleMsg, InsertMedia...).
|
||||
// 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.
|
||||
//
|
||||
// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates
|
||||
// storage for those global pointers. Every other .cpp includes
|
||||
// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations.
|
||||
//
|
||||
// Since Q-W3 this TU is ONLY pointers + entry + dispatch; since Q-W6 its own
|
||||
// action family registers through the DATA-DRIVEN TABLE below (kMainActionRows +
|
||||
// action_registry's registerActionTable/actionTableHandleCommand/
|
||||
// unregisterActionTable) — adding a bindable action here means adding ONE row and
|
||||
// its handler function, nothing else (OCP). The design_view / bank / ingest
|
||||
// families keep their own register/handle/unregister triples, called from entry.
|
||||
// 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"
|
||||
@@ -31,13 +22,19 @@
|
||||
|
||||
#include "core/capture/render_settings.h" // captureActionTable
|
||||
#include "core/version/app_version.h" // appVersion
|
||||
#include "ingest.h"
|
||||
#include "shell/actions/action_registry.h" // the Q-W6 registration table
|
||||
#include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home)
|
||||
#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home)
|
||||
#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 "shell/actions/insert_fx_action.h" // insert-as-FX action body
|
||||
#include "shell/actions/package_export_action.h" // bank-package export action body
|
||||
#include "shell/actions/package_import_action.h" // bank-package import action body
|
||||
#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/capture/render_in_place.h" // render-in-place action body
|
||||
#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
|
||||
@@ -46,20 +43,13 @@
|
||||
namespace capture = reasampler::capture;
|
||||
|
||||
// Globals other files reference via `extern`.
|
||||
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle
|
||||
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
|
||||
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr;
|
||||
reaper_plugin_info_t* g_rec = nullptr;
|
||||
|
||||
// Retired command-id SUFFIXES. Kept ONLY to mirror-unregister them on unload so a
|
||||
// user's stale keybindings are cleaned up. Never re-register these. Composed through
|
||||
// the channel prefix at unload (channelIdFor) so a beta unload clears beta-qualified
|
||||
// retired ids and a stable unload clears stable's — each channel cleans up only its
|
||||
// own family.
|
||||
// * The M7 four-mode ids (tracks/items/razor WET).
|
||||
// * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the
|
||||
// master realtime action are REMOVED (capture is now item + track only; realtime
|
||||
// taps the selected track). Their shipped ids are retired so old keybindings clear.
|
||||
// * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants
|
||||
// are REMOVED; tail is now a panel-setting toggle, not a paired action.
|
||||
// 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",
|
||||
@@ -70,62 +60,55 @@ static const char* const kRetiredCaptureCmdSuffixes[] = {
|
||||
"CAPTURE_TRACK_TAIL",
|
||||
};
|
||||
|
||||
// The persistence session (M4): 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() — which (B2) resolves to
|
||||
// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize
|
||||
// the book back into the active project's ext state (the `banks` key) so it travels
|
||||
// with the .rpp. Replaces the M3 session-only g_bank.
|
||||
// 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;
|
||||
|
||||
// --- Action handlers (the table's function pointers) --------------------------
|
||||
//
|
||||
// Each is a thin stateless routing shim: (session, per-row arg) -> the action body
|
||||
// hoisted in Q-W3/Q-W4 (shell/capture/, shell/panel/). The bodies own all behavior;
|
||||
// these exist only so the table rows can be plain data with flat function pointers.
|
||||
// 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.
|
||||
|
||||
// Capture scope family: `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 (never a hand-kept parallel list).
|
||||
// `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); }
|
||||
// Insert: `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (CONTEXT.md
|
||||
// §insert: conform is opt-in, never silent); 0 inserts at native length.
|
||||
// `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);
|
||||
}
|
||||
// Placement of the PLAYER, not of an item: adds a ReaSampler 9000 to the selected track's
|
||||
// FX chain. Sessionless — it reads the panel's selection, exactly like the drag it mirrors.
|
||||
static void RunInsertAsFx(int) { reasampler::doInsertAsFx(); }
|
||||
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 RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); }
|
||||
static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
|
||||
static void RunExportBankPackage(int) { reasampler::doBankPackageExport(g_session, g_session.book().activeBankId()); }
|
||||
static void RunImportBankPackage(int) { reasampler::doImportBankPackage(g_session); }
|
||||
static void RunShowVersion(int) {
|
||||
// On-demand version readout — the ONLY version output on any path (Phase V: no
|
||||
// unconditional startup print; routine console chatter pops the console window).
|
||||
// On-demand only — no unconditional startup print (routine console chatter pops
|
||||
// the console window).
|
||||
ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str());
|
||||
}
|
||||
|
||||
// --- The registration table (Q-W6) --------------------------------------------
|
||||
//
|
||||
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix (channel prefix
|
||||
// composed at register — stable rebuilds the exact shipped id, e.g.
|
||||
// "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"; beta its isolated forever-family), the
|
||||
// Actions-list phrase (after the "ReaSampler[ beta]: " lead), the handler, and its
|
||||
// per-row arg. Registration, hookcommand dispatch, and the unload mirror-unregister
|
||||
// all iterate this data — adding an action = adding a row + a handler above.
|
||||
//
|
||||
// The capture scope rows (CAPTURE_ITEM / CAPTURE_TRACK) come first, sourced from the
|
||||
// pure captureActionTable() taxonomy (render_settings) — suffix/phrase live in that
|
||||
// one testable list, and `arg` carries the row index back to RunCapture. The
|
||||
// remaining rows are this TU's singles, in the pre-table registration order.
|
||||
// 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;
|
||||
@@ -135,40 +118,53 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
||||
rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase,
|
||||
&RunCaptureScopeRow, static_cast<int>(i)});
|
||||
|
||||
// M5: show/hide the docked bank panel (display-only; never captures/inserts).
|
||||
// Show/hide the docked bank panel (display-only; never captures/inserts).
|
||||
rows.push_back({"TOGGLE_BANK_PANEL", "toggle bank panel", &RunToggleBankPanel});
|
||||
// S8: Item-scope capture + assignment-request write (capture family because it
|
||||
// leans on the capture render machinery; the other ingest surfaces live in the
|
||||
// ingest family and the panel drop callback).
|
||||
rows.push_back({"CAPTURE_ITEM_ASSIGN",
|
||||
"capture selected item into bank + assign to active instance",
|
||||
&RunCaptureItemAssign});
|
||||
// M6: place the panel's selected sample at the edit cursor. Two variants that
|
||||
// differ ONLY in InsertOptions — native length vs the explicit conform opt-in.
|
||||
// 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});
|
||||
// M11: one action fires N captures (per selected item / per razor area); the
|
||||
// original selection is restored on every exit path. Bank-only, never places.
|
||||
// A LOAD_*, not an INSERT_*: INSERT_SELECTED/_CONFORM place a timeline item;
|
||||
// this loads an instrument onto a track's FX chain. The id is permanent, so
|
||||
// keeping the two apart here is the most durable statement of which verb this is.
|
||||
rows.push_back({"LOAD_INSTRUMENT_ON_TRACK",
|
||||
"insert selected sample as ReaSampler 9000 on the selected track",
|
||||
&RunInsertAsFx});
|
||||
// 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});
|
||||
// M8: realtime sibling of the offline CAPTURE_TRACK scope — records the selected
|
||||
// track's own output into a hidden temp track, dialog-free — plus its
|
||||
// cancel-in-flight companion (stop + restore, non-destructive).
|
||||
// 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});
|
||||
// M10: regenerate the selected PROVENANCED sample from its recorded source's
|
||||
// current state, in place. Bank-only, never places on the timeline.
|
||||
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
|
||||
&RunRecaptureFromSource});
|
||||
// Phase V: on-demand version readout for bug reports.
|
||||
// A RENDER_*, not a CAPTURE_*: the id is permanent and is the most durable
|
||||
// statement the codebase makes about which pillar a feature belongs to.
|
||||
rows.push_back({"RENDER_TRACK_IN_PLACE",
|
||||
"render selected track to a new track (source moves to Design)",
|
||||
&RunRenderTrackInPlace});
|
||||
// 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({"EXPORT_BANK_PACKAGE", "export active bank as package",
|
||||
&RunExportBankPackage});
|
||||
rows.push_back({"IMPORT_BANK_PACKAGE", "import bank package (.rsbank)",
|
||||
&RunImportBankPackage});
|
||||
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
|
||||
|
||||
return rows;
|
||||
@@ -180,74 +176,62 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
||||
static void OnTimer()
|
||||
{
|
||||
// Advance any in-flight realtime capture FIRST, so a project switch is caught and
|
||||
// the capture torn down/restored before session.poll() reacts to that switch.
|
||||
// LOAD-BEARING (CONTEXT.md §Phase Q): the idle fast-path is a SINGLE POINTER
|
||||
// TEST — the cross-TU drive call is made only when a capture is in flight.
|
||||
// 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();
|
||||
// poll()'s undo/redo-reload and project-switch Load branches call
|
||||
// loadFromProject, replacing g_session.view() wholesale — deferred here while
|
||||
// an applyMode is on the stack (reachable if a pumped message loop re-enters
|
||||
// this timer mid-apply), same retry-next-tick shape as the consume guard below.
|
||||
// Deferred WHOLE, not just the reload branches: poll()'s identity tracking is
|
||||
// cheap to skip for one tick and resumes correctly, unlatched, on the next.
|
||||
if (!reasampler::modeApplyInProgress()) g_session.poll();
|
||||
|
||||
// D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view
|
||||
// model but deliberately does NOT apply visibility — that would couple persist
|
||||
// to the view shell). Instead poll() raises a one-shot load signal; here — the
|
||||
// integration layer that already drives both persist and the view shell — we
|
||||
// drain it and reapply the SAVED active mode's visibility/processing so opening a
|
||||
// project saved in Design mode parks the Arrange tracks automatically, no manual
|
||||
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
|
||||
// skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
|
||||
//
|
||||
// The SAME signal re-arms the bank panel's new-content detector: a load must
|
||||
// re-baseline the detector against the just-loaded project's content so its
|
||||
// pre-existing tracks are never mis-detected as "new" and mass-tagged into the
|
||||
// active mode (the reload-mis-tag bug). Notify BEFORE the reapply so the detector's
|
||||
// re-arm and the model restore ride the one authoritative load event.
|
||||
if (g_session.consumeLoadSignal()) {
|
||||
// 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).
|
||||
// modeApplyInProgress is tested BEFORE the consume, not after the applyMode call:
|
||||
// the load signal is one-shot, a re-entered applyMode fails closed, and a reapply
|
||||
// dropped here is the saved mode never applied at all. Holding the whole block
|
||||
// back one tick also keeps the panel re-arm ahead of the reapply.
|
||||
if (!reasampler::modeApplyInProgress() && g_session.consumeLoadSignal()) {
|
||||
reasampler::bankPanelNotifyProjectLoaded();
|
||||
// Reconcile the restored lane-ownership index against the live project's lanes
|
||||
// FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth),
|
||||
// so a saved lane-split project's managed/manual classification is correct
|
||||
// before the active mode's lane visibility is reapplied. Never re-mints, never
|
||||
// mass-tags — it only records managed ownership recovered from lane names.
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Reflect a live bank change (capture / project load) in the docked grid.
|
||||
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
|
||||
// an actual change. No-op when the panel is closed.
|
||||
reasampler::bankPanelRefresh();
|
||||
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
|
||||
}
|
||||
|
||||
// --- projectconfig hook: reload the session on undo/redo (R-B) ---------------
|
||||
// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on
|
||||
// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's
|
||||
// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view
|
||||
// would stay stale until close+reopen. REAPER's projectconfig extension fires
|
||||
// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore
|
||||
// (isUndo == true for both). We hook it to drive a session reload.
|
||||
// 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 (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as
|
||||
// firing BEFORE any state restore. Reading GetProjExtState synchronously here would
|
||||
// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request
|
||||
// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which
|
||||
// point REAPER has finished restoring the <EXTSTATE> block and GetProjExtState returns
|
||||
// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling.
|
||||
//
|
||||
// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false);
|
||||
// we ignore that here so a normal open flows solely through the timer's identity-transition
|
||||
// Load path (no double load). Only undo/redo (isUndo=true) requests the reload.
|
||||
// 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();
|
||||
}
|
||||
|
||||
// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores
|
||||
// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"),
|
||||
// which REAPER persists in its own <EXTSTATE> RPP block — NOT via this extension's own
|
||||
// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo
|
||||
// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER
|
||||
// keeps dispatching (we claim none). SaveExtensionConfig writes nothing.
|
||||
// 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*/)
|
||||
{
|
||||
@@ -257,7 +241,6 @@ static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*
|
||||
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
|
||||
project_config_extension_t* /*reg*/)
|
||||
{
|
||||
// Nothing to write: our data rides in ext state, not project lines.
|
||||
}
|
||||
|
||||
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
|
||||
@@ -268,23 +251,29 @@ static project_config_extension_t g_projectConfig{
|
||||
nullptr, // userData
|
||||
};
|
||||
|
||||
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
|
||||
// return false otherwise so REAPER keeps looking. This TU's own family dispatches
|
||||
// through the registration table; the Q-W4 families claim their own ids after it.
|
||||
// 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;
|
||||
// Design View action family (D4). Claims only its own ids; returns false for the
|
||||
// rest so this hook keeps looking (per the contract).
|
||||
if (reasampler::designViewHandleCommand(command)) return true;
|
||||
// Multi-bank action family (B3). Same contract: claims only its own ids.
|
||||
if (reasampler::bankHandleCommand(command)) return true;
|
||||
// S8 ingest action family (Media-Explorer import). Same contract.
|
||||
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)
|
||||
@@ -299,39 +288,31 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
{
|
||||
if (!rec)
|
||||
{
|
||||
// rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every
|
||||
// callback with the same strings prefixed '-' (per the contract).
|
||||
// rec == nullptr => REAPER is UNLOADING us.
|
||||
if (g_rec)
|
||||
{
|
||||
// Abort any in-flight realtime capture FIRST, while the API pointers are
|
||||
// still live — finalize-or-abort + restore so we never leave a temp track,
|
||||
// an armed track, or an altered transport/cursor in the user's project on
|
||||
// unload. Commit whatever was captured (best effort) before tearing down.
|
||||
// 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);
|
||||
// Tear down the Design View action family (D4) — mirror-unregisters each
|
||||
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
|
||||
g_rec->Register("-hookcommand2", (void*)&OnHookCommand2);
|
||||
reasampler::designViewUnregisterActions(g_rec);
|
||||
// Tear down the multi-bank action family (B3) — same mirror-unregister.
|
||||
reasampler::bankUnregisterActions(g_rec);
|
||||
// Tear down the S8 ingest action family — same mirror-unregister.
|
||||
reasampler::ingestUnregisterActions(g_rec);
|
||||
// Tear down this TU's own family from the registration table (reverse
|
||||
// table order; each '-command_id' re-presents the SAME interned,
|
||||
// channel-qualified pointer used at register).
|
||||
// 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). Clears stale user keybindings on unload. Composed
|
||||
// per channel so a beta clears beta-qualified retired ids, stable its own.
|
||||
// for them this session).
|
||||
for (const char* suffix : kRetiredCaptureCmdSuffixes)
|
||||
g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix));
|
||||
}
|
||||
// Destroy the docked window and release cached thumbnails before we drop
|
||||
// the API pointers (DockWindowRemove/DestroyWindow need them live).
|
||||
// Before dropping the API pointers: DockWindowRemove/DestroyWindow need them live.
|
||||
reasampler::bankPanelShutdown();
|
||||
g_rec = nullptr;
|
||||
return 0;
|
||||
@@ -349,13 +330,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
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 (M5). Does not open the
|
||||
// window — only stores the session pointer.
|
||||
// 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);
|
||||
|
||||
// Register this TU's whole action family from the table: command_id -> gaccel
|
||||
// per row, all channel-qualified, all FOREVER-STABLE per channel.
|
||||
{
|
||||
const std::vector<reasampler::ActionTableRow> rows = buildMainActionTable();
|
||||
reasampler::registerActionTable(rec, rows.data(), rows.size());
|
||||
@@ -367,37 +345,24 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
if (g_cmdToggleBankPanel)
|
||||
rec->Register("toggleaction", (void*)&OnToggleAction);
|
||||
|
||||
// Register the Design View action family (D4): toggle/activate mode, tag/untag/
|
||||
// show-both selected tracks. Each mints its own command_id + gaccel; the single
|
||||
// hookcommand below routes them via designViewHandleCommand. Registered before
|
||||
// the hook so every id is minted first.
|
||||
// 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);
|
||||
|
||||
// Register the multi-bank action family (B3): create/rename/delete/evacuate bank,
|
||||
// activate (cycle + pool), move/copy selected samples to a bank, and the two
|
||||
// full-height layout toggles. Shares g_session with the Design View family; routed
|
||||
// by the same hookcommand via bankHandleCommand. Registered before the hook.
|
||||
reasampler::bankRegisterActions(rec, &g_session);
|
||||
|
||||
// Register the S8 ingest action family: the Media-Explorer import-into-bank+assign
|
||||
// action. Shares g_session with the other families; routed by the same hookcommand via
|
||||
// ingestHandleCommand. (The arrange capture+assign action is a table row above; the
|
||||
// drop path is a bank_panel callback, not a bindable action.)
|
||||
reasampler::ingestRegisterActions(rec, &g_session);
|
||||
|
||||
// One hookcommand routes every ReaSampler action (table + the three families).
|
||||
// Registered once, after all command ids are minted.
|
||||
rec->Register("hookcommand", (void*)&OnHookCommand);
|
||||
rec->Register("hookcommand2", (void*)&OnHookCommand2);
|
||||
|
||||
// Drive project-load / Save-As detection (M4 persist). 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.
|
||||
// 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);
|
||||
|
||||
// Register the projectconfig hook so an UNDO/REDO state restore reloads the
|
||||
// session's book + view from the restored ext state (R-B). The timer's identity
|
||||
// poll cannot see an undo (same project identity), so this hook owns undo/redo; it
|
||||
// requests a deferred reload that the next timer tick drains (see the hook comment).
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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(package)
|
||||
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)
|
||||
+13
-28
@@ -5,14 +5,11 @@
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
// peaks implementation.
|
||||
// peaks — pure implementation. See peaks.h.
|
||||
//
|
||||
// One linear pass per channel. The frame->bin partition is computed with integer
|
||||
// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the
|
||||
// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That
|
||||
// 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.
|
||||
// One linear pass per channel. Frame->bin partition uses integer arithmetic so it's exact for
|
||||
// any frameCount/binCount pairing: bin b owns [b*frameCount/binCount, (b+1)*frameCount/binCount)
|
||||
// — earlier bins absorb the remainder, no rounding drift, no dropped tail.
|
||||
|
||||
namespace reasampler::audio {
|
||||
|
||||
@@ -22,11 +19,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
||||
std::size_t binCount) {
|
||||
Envelope envelope(channelCount);
|
||||
if (channelCount == 0) {
|
||||
return envelope; // no channels -> no envelopes
|
||||
return envelope;
|
||||
}
|
||||
|
||||
// Never read past what the buffer actually holds, even if the caller's
|
||||
// frameCount overstates the buffer (defensive: no OOB on a short buffer).
|
||||
// Never read past what the buffer actually holds, even if frameCount overstates it.
|
||||
const std::size_t availableFrames = interleaved.size() / channelCount;
|
||||
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}
|
||||
|
||||
for (std::size_t b = 0; b < binCount; ++b) {
|
||||
// Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount).
|
||||
// Guard against size_t overflow in b*frames and (b+1)*frames: binCount is
|
||||
// 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.
|
||||
// Guard b*frames / (b+1)*frames overflow: binCount is caller-controlled and
|
||||
// unbounded. Unreachable in practice (would OOM first) but guarded to avoid UB.
|
||||
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 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());
|
||||
if (columnCount <= 0 || nbins == 0) return MinMax{};
|
||||
|
||||
// Clamp col to [0, columnCount-1].
|
||||
if (col < 0) col = 0;
|
||||
if (col >= columnCount) col = columnCount - 1;
|
||||
|
||||
// Half-open bin range for this column, mirroring computeEnvelope's exact partition.
|
||||
// 64-bit products: col*nbins can exceed int range for a large oversampled envelope
|
||||
// (same overflow discipline as computeEnvelope's frame-span arithmetic above).
|
||||
// Half-open bin range for this column, mirroring computeEnvelope's partition. 64-bit
|
||||
// products: col*nbins can exceed int range for a large oversampled envelope.
|
||||
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
|
||||
const std::int64_t end64 =
|
||||
(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);
|
||||
|
||||
// When the column spans no full bin (more columns than bins), use the enclosing bin
|
||||
// so no column is left empty.
|
||||
// When the column spans no full bin (more columns than bins), use the enclosing bin.
|
||||
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
|
||||
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
|
||||
|
||||
@@ -102,14 +90,11 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
||||
AudioSample linearThreshold) {
|
||||
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 frames = std::min(frameCount, availableFrames);
|
||||
if (frames == 0) return kNoFrameAboveThreshold;
|
||||
|
||||
// Scan backward: the first frame (from the end) whose loudest channel exceeds the
|
||||
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
|
||||
// Scan backward; `f` runs frames..1 so `f-1` never wraps.
|
||||
for (std::size_t f = frames; f > 0; --f) {
|
||||
const std::size_t frame = f - 1;
|
||||
const std::size_t base = frame * channelCount;
|
||||
|
||||
+42
-76
@@ -1,32 +1,20 @@
|
||||
#pragma once
|
||||
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved
|
||||
// PCM. We compute our own thumbnails from the captured file rather than depending
|
||||
// on REAPER's peak API: we own the file format, so this is simpler, testable, and
|
||||
// dependency-free. A future bank panel (M5) calls this at whatever bin resolution
|
||||
// the panel width dictates and draws one min/max envelope per channel.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
|
||||
// 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 {
|
||||
|
||||
// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer
|
||||
// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so
|
||||
// peaks consumes that directly with no lossy conversion. If a capture ever lands
|
||||
// as a different depth, the caller converts to float at the boundary — the
|
||||
// thumbnail core stays single-typed.
|
||||
//
|
||||
// NAMED AudioSample, not `Sample`: `reasampler::Sample` is already bank_model's
|
||||
// metadata struct. A `using Sample = float` here would collide at namespace scope
|
||||
// wherever both headers are visible (the bank_panel module includes both). The
|
||||
// audio-domain name also reads more precisely — this is one PCM sample value.
|
||||
// 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 of a channel's envelope: the extremes of every sample that fell in it.
|
||||
// min <= max always. For an empty bin (more bins than frames), both are 0.
|
||||
// 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;
|
||||
@@ -37,83 +25,61 @@ struct MinMax {
|
||||
// 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.
|
||||
// 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 (precision
|
||||
// invariant: channel count preserved).
|
||||
// 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 are split into `binCount` contiguous spans as
|
||||
// evenly as possible; when frameCount does not divide evenly, the remainder is
|
||||
// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the
|
||||
// tail is never dropped and no bin reads out of bounds. When binCount > frameCount
|
||||
// the trailing empty bins are {0, 0}.
|
||||
// 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.
|
||||
//
|
||||
// Defined behavior for degenerate input (no UB, no throw):
|
||||
// binCount == 0 -> per channel: an empty bin vector.
|
||||
// channelCount == 0 -> an empty envelope (no channels).
|
||||
// frameCount == 0 -> per channel: binCount bins, all {0, 0}.
|
||||
// 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);
|
||||
|
||||
// The merged min/max for display column `col` (0-based, of `columnCount` total columns)
|
||||
// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects
|
||||
// to that column. This is the display-side collapse of an envelope computed at HIGHER
|
||||
// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a
|
||||
// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then
|
||||
// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots.
|
||||
// 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
|
||||
// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is
|
||||
// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to
|
||||
// [0, columnCount-1]. Pure.
|
||||
// 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 returned by lastFrameAboveThreshold when NO frame in the scanned range
|
||||
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous:
|
||||
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
|
||||
// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly.
|
||||
// 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 (the max
|
||||
// absolute value across all channels of that frame — NO stereo fold, just the
|
||||
// loudest channel that frame) exceeds `linearThreshold`, returning that frame index.
|
||||
// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
|
||||
// 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
|
||||
// (docs/product/capture-tail.md §The realtime path): the recorded tail window is
|
||||
// scanned back from the end for the last frame still above -72 dB, and the file is
|
||||
// truncated one frame past it. Deliberately a separate primitive from
|
||||
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail),
|
||||
// this answers "the last frame above a level" (a boundary). Bending the bin-oriented
|
||||
// envelope to a frame-exact boundary question is a worse fit (spec §option a).
|
||||
// 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.
|
||||
//
|
||||
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
|
||||
// Must hold >= frameCount * channelCount; extra is ignored, and a
|
||||
// short buffer is clamped to what it actually holds (no OOB read).
|
||||
// channelCount channels per frame (the stride). The per-frame test is the max
|
||||
// |sample| over these channels — the frame is "above" if its
|
||||
// loudest channel is above the threshold.
|
||||
// frameCount frames to consider (the scan starts at the last of these).
|
||||
// linearThreshold the comparison level as 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 > this.
|
||||
//
|
||||
// Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above,
|
||||
// and degenerate inputs) so the trim boundary math is locked outside the DAW.
|
||||
// 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,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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 one bounds mode a capture hands its window over on (`kRenderBoundsTimeSelection`) and the tail bit paired with it (`kTailFlagTimeSelection`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal).
|
||||
- `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 render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. It also owns the one short-render diagnostic: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched on the retired custom-bounds mode, quoted by a refusal as a count coincidence and nothing more) and `isOnMillisecondGrid`, the whole-millisecond tolerance that count depends on.
|
||||
- `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`.
|
||||
- **The custom-time-bounds field floors the render window to the millisecond; the
|
||||
time selection does not.** Both observations and why only one bounds mode is
|
||||
reachable: `render_settings.h`'s `kRenderBoundsTimeSelection` — the one narrative
|
||||
home; this bullet is a pointer, not a retelling. Do not reintroduce
|
||||
`RENDER_BOUNDSFLAG=0`.
|
||||
- `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,5 +1,5 @@
|
||||
// batch_capture.cpp — pure logic for M11 batch capture. See header.
|
||||
// NO REAPER types; unit-tested by tests/test_batch_capture.cpp.
|
||||
// batch_capture.cpp — pure logic for batch capture. See header.
|
||||
// Unit-tested by tests/test_batch_capture.cpp.
|
||||
|
||||
#include "core/capture/batch_capture.h"
|
||||
|
||||
@@ -12,9 +12,7 @@ std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges)
|
||||
units.reserve(ranges.size());
|
||||
int ordinal = 0;
|
||||
for (const BatchRange& r : ranges) {
|
||||
// Drop empty/inverted ranges — the offline backend refuses end<=start too, so
|
||||
// planning one would only manufacture a guaranteed per-unit failure. Ordinals
|
||||
// count kept units so the reported numbering is contiguous.
|
||||
// Drop empty/inverted ranges — the offline backend refuses end<=start too.
|
||||
if (!(r.endSeconds > r.startSeconds)) continue;
|
||||
++ordinal;
|
||||
units.push_back({ordinal, r.startSeconds, r.endSeconds});
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
#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).
|
||||
// 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 (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:
|
||||
// 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) 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.
|
||||
// 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 (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.
|
||||
// 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>
|
||||
@@ -31,10 +26,10 @@
|
||||
|
||||
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.
|
||||
// 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
|
||||
@@ -42,20 +37,18 @@ struct CaptureUnit {
|
||||
};
|
||||
|
||||
// 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.
|
||||
// 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 (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).
|
||||
// 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.
|
||||
@@ -65,10 +58,9 @@ struct BatchUnitResult {
|
||||
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).
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
std::string captureTrackName(const std::string& sourceName) {
|
||||
const std::string prefix(kCaptureTrackPrefix);
|
||||
// A source with no readable name yields the bare word rather than a trailing
|
||||
// space; both spellings are fixed points, which is what makes the whole function
|
||||
// one (a track named exactly "Capture" must not become "Capture Capture"). Read
|
||||
// from kCaptureTrackPrefixBare rather than chopped off prefix, so the two names
|
||||
// can't drift out of sync with each other (both expand from the same header token).
|
||||
const std::string bare = kCaptureTrackPrefixBare;
|
||||
|
||||
if (sourceName.empty()) return bare;
|
||||
if (sourceName == bare) return sourceName;
|
||||
if (sourceName.rfind(prefix, 0) == 0) return sourceName;
|
||||
return prefix + sourceName;
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,84 @@
|
||||
#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);
|
||||
|
||||
// The single source of truth for the word itself — kCaptureTrackPrefixBare and
|
||||
// kCaptureTrackPrefix below both expand from this one token, so editing it can never
|
||||
// desync captureTrackName's "no readable source name" bare-word fallback from the
|
||||
// separator-terminated prefix it is derived from.
|
||||
#define REASAMPLER_CAPTURE_TRACK_WORD "Capture"
|
||||
|
||||
// The bare word behind kCaptureTrackPrefix, needed by captureTrackName's
|
||||
// no-readable-source-name fallback.
|
||||
inline constexpr const char* kCaptureTrackPrefixBare = REASAMPLER_CAPTURE_TRACK_WORD;
|
||||
|
||||
// Prefixed onto a source track's name to name the track a render-in-place created.
|
||||
// A display convention, not a persisted key — unlike a lane prefix or an action-id
|
||||
// suffix, changing it later strands nothing.
|
||||
inline constexpr const char* kCaptureTrackPrefix = REASAMPLER_CAPTURE_TRACK_WORD " ";
|
||||
|
||||
// The new track's name for a render of `sourceName`. IDEMPOTENT — a fixed point on
|
||||
// its own output, so a second render over a result track yields "Capture MONEY"
|
||||
// again rather than "Capture Capture MONEY". A counter suffix is deliberately not
|
||||
// offered: REAPER does not uniquify track names either, and what distinguishes two
|
||||
// renders of one source is their position, not their name.
|
||||
std::string captureTrackName(const std::string& sourceName);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -6,25 +6,16 @@
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The content-identity hashes (hashBytes / hashWavContent) moved to wav_codec
|
||||
// (Q-W3, audit §4e) — one pure owner of the RIFF chunk walk, shared with the
|
||||
// layout parse so hashing and decoding cannot desynchronize.
|
||||
|
||||
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.
|
||||
// Strip a trailing slash but preserve a lone "/" (root).
|
||||
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;
|
||||
@@ -39,8 +30,7 @@ std::string sanitizeStem(const std::string& baseName) {
|
||||
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.
|
||||
// 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') ||
|
||||
@@ -55,48 +45,50 @@ std::string sanitizeStem(const std::string& baseName) {
|
||||
return out;
|
||||
}
|
||||
|
||||
RenderPaths deriveRenderPaths(const std::string& absoluteDir,
|
||||
const std::string& baseName,
|
||||
const std::string& uniqueTag) {
|
||||
std::string stem = sanitizeStem(baseName);
|
||||
if (!uniqueTag.empty()) {
|
||||
stem += "_" + sanitizeStem(uniqueTag);
|
||||
}
|
||||
|
||||
RenderPaths r;
|
||||
r.fileStem = stem; // stem only — REAPER appends the extension
|
||||
r.fileName = stem + ".wav";
|
||||
r.absoluteDir = normalizeSlashes(absoluteDir);
|
||||
return r;
|
||||
}
|
||||
|
||||
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.
|
||||
// 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");
|
||||
|
||||
const RenderPaths r = deriveRenderPaths(
|
||||
dir.empty() ? std::string{} : dir + "/" + kBankSubfolder, baseName, uniqueTag);
|
||||
|
||||
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;
|
||||
p.fileStem = r.fileStem;
|
||||
p.fileName = r.fileName;
|
||||
p.relativePath = bankRelativeForName(r.fileName);
|
||||
p.absoluteDir = r.absoluteDir;
|
||||
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 {};
|
||||
}
|
||||
@@ -109,10 +101,7 @@ std::string resolveBankFile(const std::string& projectDir,
|
||||
}
|
||||
|
||||
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 the former persist shell's
|
||||
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
|
||||
if (rppPath.empty()) return {};
|
||||
if (rppPath.empty()) return {}; // unsaved project: keep empty, no fallback
|
||||
std::string dir = std::filesystem::path(rppPath).parent_path().string();
|
||||
return normalizeSlashes(dir);
|
||||
}
|
||||
@@ -128,9 +117,7 @@ BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
||||
|
||||
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);
|
||||
r.needed = (oldDir != newDir); // Save-in-place leaves the dir unchanged
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -139,42 +126,16 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject,
|
||||
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.
|
||||
// See capture_paths.h for the GUID-primary rationale and rule order.
|
||||
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;
|
||||
return ProjectTransition::Load; // forked sibling: same GUID, different object
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
#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).
|
||||
// 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>
|
||||
@@ -20,7 +12,7 @@
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The project-relative bank subfolder. All captured wavs live here so the bank
|
||||
// travels with the .rpp (CONTEXT.md §Settled decisions: per-project 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
|
||||
@@ -34,150 +26,107 @@ struct BankPaths {
|
||||
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
|
||||
};
|
||||
|
||||
// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent)
|
||||
// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte
|
||||
// format — so this module holds path arithmetic only, with no RIFF chunk knowledge.
|
||||
|
||||
// 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).
|
||||
// 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 (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).
|
||||
// [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.
|
||||
// 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 BankModel::add's relative-only invariant.
|
||||
// Where one render writes, with no index spelling at all: the directory REAPER is
|
||||
// told to render into plus the stem/file name it produces there. `absoluteDir` is
|
||||
// taken as given (normalized only) rather than derived, because a render that never
|
||||
// enters the bank has no bank subfolder to append — the render-in-place verb points
|
||||
// this at the project's own recording path.
|
||||
struct RenderPaths {
|
||||
std::string absoluteDir; // RENDER_FILE (forward slash, no trailing slash)
|
||||
std::string fileName; // <stem>.wav
|
||||
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
|
||||
};
|
||||
|
||||
// The file-stem spelling for one render: baseName is the sanitized file-stem source,
|
||||
// uniqueTag an optional sanitized disambiguator (timestamp/counter) so repeated
|
||||
// renders don't collide. Produces "<stem>[_<tag>].wav". THE one owner of that
|
||||
// spelling — deriveBankPaths is expressed over it, and bankRelativeForName depends
|
||||
// on the bank's spelling never drifting from it.
|
||||
RenderPaths deriveRenderPaths(const std::string& absoluteDir,
|
||||
const std::string& baseName,
|
||||
const std::string& uniqueTag);
|
||||
|
||||
// Derives the bank paths for one capture: the same stem spelling as
|
||||
// deriveRenderPaths, in the bank subfolder, plus the project-relative path the
|
||||
// index stores.
|
||||
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.
|
||||
// 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 (M4) --------------------------------------
|
||||
// --- Persist-side path arithmetic -------------------------------------------
|
||||
//
|
||||
// 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.
|
||||
// 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: 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.
|
||||
// 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 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.
|
||||
// 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 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.
|
||||
// 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 (W12 combined identity fix) -----------------
|
||||
// --- Project-identity transition ---------------------------------------------
|
||||
//
|
||||
// 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.
|
||||
// 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 : 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.
|
||||
// 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,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See
|
||||
// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp.
|
||||
// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.)
|
||||
// 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"
|
||||
|
||||
@@ -9,11 +8,8 @@ namespace reasampler::capture {
|
||||
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
|
||||
RecordModePlan p;
|
||||
|
||||
// Stereo vs mono output recording, latency-compensated either way so the
|
||||
// recorded file lines up with the source. A request asking for <= 1 channel
|
||||
// records mono-out; anything else records stereo-out. (Higher channel counts
|
||||
// still record stereo-out here — REAPER's output-record modes are mono/stereo
|
||||
// only; a >2-channel realtime capture is out of scope for this increment.)
|
||||
// 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;
|
||||
|
||||
@@ -26,42 +22,32 @@ RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
|
||||
}
|
||||
|
||||
OutputTap outputTapForWetDry(double wetDry) {
|
||||
// Fully wet (1.0) taps post-fader; any dry-ward value taps pre-FX — the true
|
||||
// pre-FX dry that offline render cannot produce (the realtime backend's whole
|
||||
// reason to exist for the M10 null test). PostFxPreFader is an explicit future
|
||||
// option, not reachable from the wet/dry axis, so it is not returned here.
|
||||
return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx;
|
||||
}
|
||||
|
||||
Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
|
||||
Sample s;
|
||||
// Same id shape as the offline path: "cap-<tag>-<fileName>" would need the file
|
||||
// name; here the recorded file name is the tail of relativePath. Keep the id
|
||||
// stable + unique via the tag, and include the relative path tail so two
|
||||
// captures with the same tag (impossible in practice) still differ.
|
||||
// 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; // project-relative (invariant)
|
||||
s.relativePath = cap.relativePath;
|
||||
s.sourceMode = cap.sourceMode;
|
||||
s.sourceRange.startSeconds = cap.startSeconds;
|
||||
s.sourceRange.endSeconds = cap.endSeconds;
|
||||
// PPQ/beats deferred (musical-placement concern) — identical to the offline path.
|
||||
// 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; // L7 F1 meter stamp (0/0 = unstamped)
|
||||
s.captureTimeSigNum = cap.captureTimeSigNum; // 0/0 = unstamped
|
||||
s.captureTimeSigDenom = cap.captureTimeSigDenom;
|
||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
||||
// contentHash set by the caller (capture_realtime.cpp) after the file is
|
||||
// finalized and on disk — the hash is over the finished file bytes. Left empty
|
||||
// here because sampleFromRecordedCapture runs before the file exists (the
|
||||
// mapping is pure / DAW-free); the shell patches it in after the move+trim.
|
||||
// Phase S seam fields (rootNote / loop) left empty (D-B) — same reasoning as the
|
||||
// offline path: a realtime record of wet output is not a single played note, so
|
||||
// no root note is derivable; loop points are set by a later explicit action.
|
||||
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;
|
||||
}
|
||||
@@ -72,20 +58,15 @@ RecordPhase advanceRecordPhase(RecordPhase current,
|
||||
double rangeEndSeconds) {
|
||||
switch (current) {
|
||||
case RecordPhase::Recording: {
|
||||
// Transport stopped while we still expected to be recording -> the user
|
||||
// (or REAPER) stopped early. Move to the flush wait and finalize whatever
|
||||
// was captured up to the stop.
|
||||
// Stopped early (user or REAPER) -> finalize what was captured so far.
|
||||
if (!inputs.transport.recording) return RecordPhase::Finalizing;
|
||||
|
||||
// Reached the range end (latency-compensated play position). >= (not >)
|
||||
// so a cursor landing exactly on the end completes.
|
||||
// >= (not >): a cursor landing exactly on the end completes.
|
||||
if (inputs.transport.playPosition >= rangeEndSeconds)
|
||||
return RecordPhase::Finalizing;
|
||||
|
||||
// Self-defense (review §3): the transport is running but the play cursor
|
||||
// is not advancing to the end (stuck / looping). Without this the machine
|
||||
// stays in Recording forever, leaking the temp track + armed sink. Force
|
||||
// the flush wait once wall-clock exceeds the nominal duration + margin.
|
||||
// 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;
|
||||
@@ -94,22 +75,16 @@ RecordPhase advanceRecordPhase(RecordPhase current,
|
||||
}
|
||||
|
||||
case RecordPhase::Finalizing: {
|
||||
// The transport is stopped; wait for REAPER to flush/close the recorded
|
||||
// take on the audio thread. Finalize (move + Sample) only once the file
|
||||
// exists AND is stable (review §2) — moving it early races the flush and
|
||||
// yields a truncated / missing capture.
|
||||
// Moving the file before it's stable would race REAPER's flush and
|
||||
// yield a truncated/missing capture.
|
||||
if (inputs.fileReady) return RecordPhase::Done;
|
||||
|
||||
// Bound the wait: a file that never stabilizes fails cleanly rather than
|
||||
// hanging the in-flight state for the session.
|
||||
if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds)
|
||||
return RecordPhase::Failed;
|
||||
|
||||
return RecordPhase::Finalizing;
|
||||
}
|
||||
|
||||
// Terminal phases are sticky: once the verdict is in, a later tick (a stray
|
||||
// extra call before the shell has finished tearing down) must not flip it.
|
||||
case RecordPhase::Done:
|
||||
case RecordPhase::Failed:
|
||||
default:
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
#pragma once
|
||||
// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8).
|
||||
// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module
|
||||
// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp /
|
||||
// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.)
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The realtime shell drives the
|
||||
// transport, the temp track, the send routing, and the file move —
|
||||
// all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong
|
||||
// pieces are split out here and unit-tested outside the DAW:
|
||||
//
|
||||
// 1. the record-mode/recipe bookkeeping: given a capture scope + a desired
|
||||
// FX-tap point (post-fader / pre-FX / post-FX-pre-fader), the I_RECMODE and
|
||||
// I_RECMODE_FLAGS integer values the temp track must carry.
|
||||
// 2. the recorded-file -> Sample mapping: given a finished capture (the
|
||||
// recorded file's project-relative path + the request's own bounds/format),
|
||||
// the populated Sample handed to bank_model. Mirrors the inline Sample
|
||||
// population OfflineRenderBackend does — factored out so it is tested once,
|
||||
// without a DAW, and shared shape with the offline path is guaranteed.
|
||||
//
|
||||
// The I_RECMODE / I_RECMODE_FLAGS bit MEANINGS are transcribed verbatim from
|
||||
// reaper_plugin_functions.h line ~2197-2198 (see kRecMode* constants); the CHOICE
|
||||
// of which values each scope needs is this module's logic and is tested.
|
||||
// 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>
|
||||
@@ -35,26 +19,17 @@ using model::Sample;
|
||||
using model::Tier;
|
||||
using model::SourceMode;
|
||||
|
||||
// --- I_RECMODE values (verbatim from SDK header ~2197) -----------------------
|
||||
//
|
||||
// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none,
|
||||
// 3=stereo out w/latency compensation, 4=midi output, 5=mono out,
|
||||
// 6=mono out w/ latency compensation, 7=midi overdub, 8=midi replace.
|
||||
//
|
||||
// We record a track's OUTPUT (the scoped signal routed into the temp track),
|
||||
// latency-compensated, so the recorded file lines up sample-accurately with the
|
||||
// source. Stereo vs mono is chosen by the request's channel count.
|
||||
// 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 values (verbatim from SDK header ~2198) ------------------
|
||||
//
|
||||
// I_RECMODE_FLAGS : int * : record mode flags, &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 whole SDK — offline render has no
|
||||
// pre-FX bit (see render_settings.h note + the M10 null-test note in PLAN.md).
|
||||
// The realtime backend is therefore the true pre-FX "dry" path.
|
||||
// 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
|
||||
@@ -68,41 +43,34 @@ enum class OutputTap {
|
||||
};
|
||||
|
||||
// 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 (higher bits are left at their default 0
|
||||
// here — we only own the tap-point bits).
|
||||
// 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; otherwise stereo-out latency-comp.
|
||||
// tap -> the &3 output-recording bits.
|
||||
// Pure so the "which I_RECMODE for N channels + this tap" rule is unit-tested
|
||||
// without a DAW; the shell reads the request and applies these via
|
||||
// SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
|
||||
// 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; any
|
||||
// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing
|
||||
// capability). Kept pure + separate from recordModePlanFor so the wet/dry ->
|
||||
// tap decision is tested on its own; PostFxPreFader is not selected by wetDry
|
||||
// (it is an explicit future option, not on the wet/dry axis).
|
||||
// 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 the Sample population is a single tested transform (mirror of
|
||||
// the inline population in OfflineRenderBackend::capture).
|
||||
// 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 (relative-paths-only invariant;
|
||||
// the shell resolves REAPER's recorded absolute path back to project-relative).
|
||||
// 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, so id and
|
||||
// file name stay consistent — same discipline as the offline path).
|
||||
// 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).
|
||||
@@ -115,60 +83,41 @@ struct RecordedCapture {
|
||||
|
||||
int channelCount = 0;
|
||||
|
||||
// TEST-ONLY / dead in production (Q-W3 review follow-up): the shell no longer
|
||||
// populates these five fields before calling sampleFromRecordedCapture — the
|
||||
// finalize path (capture_realtime_finalize.cpp) leaves them at their defaults
|
||||
// and instead calls the shared stampCaptureSample(result.sample, ...) right
|
||||
// after, which writes Sample::sampleRate/captureTempo/captureTimeSigNum/
|
||||
// captureTimeSigDenom/createdTimestamp directly, overwriting whatever
|
||||
// sampleFromRecordedCapture set from these. Kept (not deleted) because the pure
|
||||
// unit tests still construct/assert them directly; removing the fields is a
|
||||
// struct-shape decision out of scope here.
|
||||
// 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 (shell reads Master_GetTempo)
|
||||
// Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime).
|
||||
// 0/0 = unstamped (matches the Sample default; formatter renders a blank read-out).
|
||||
int captureTimeSigNum = 0;
|
||||
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 (shell reads the clock)
|
||||
std::int64_t createdTimestamp = 0; // unix epoch seconds
|
||||
};
|
||||
|
||||
// Builds the Sample for a finished realtime capture. Deliberately identical in
|
||||
// shape to OfflineRenderBackend's population: exact request bounds (no rounding),
|
||||
// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start.
|
||||
// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline).
|
||||
// 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 (M8 rework) ----------------------------
|
||||
// --- 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 — "given where the transport is now, should
|
||||
// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and
|
||||
// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is
|
||||
// factored out here; the REAPER shell only reads the transport/clock/file and applies
|
||||
// the verdict (stop, wait for the file to flush, then finalize/abort + restore).
|
||||
// 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.
|
||||
//
|
||||
// The lifecycle has TWO waits, not one:
|
||||
// 1. the RECORD wait (Recording): the transport is running; we 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, §3 of review).
|
||||
// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes
|
||||
// the recorded take on the AUDIO thread — the file may not be fully written/closed
|
||||
// for a tick or two. We defer the file move until the file exists AND is stable
|
||||
// (§2 of review), bounded by a flush ceiling so a file that never appears fails
|
||||
// cleanly rather than hanging.
|
||||
// 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, shell keeps ticking.
|
||||
// Finalizing — live-but-stopped: transport halted, shell stops the transport once
|
||||
// then ticks waiting for the recorded file to flush/stabilize.
|
||||
// Done — terminal: the file is flushed + stable, finalize (move + Sample) now.
|
||||
// Failed — terminal: the flush ceiling tripped without a stable file — give up
|
||||
// (RenderFailed) + restore. (A record that produced NO file at all also
|
||||
// lands here via the shell's finalize returning RenderFailed.)
|
||||
// Only Recording and Finalizing are live phases the shell advances per tick; Done and
|
||||
// Failed are the shell's verdict to act on (finalize-or-fail, then restore).
|
||||
// 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,
|
||||
@@ -176,63 +125,34 @@ enum class RecordPhase {
|
||||
Failed
|
||||
};
|
||||
|
||||
// A distilled transport reading for the pure transition, so the state machine never
|
||||
// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition`
|
||||
// is GetPlayPositionEx (latency-compensated what-you-hear position).
|
||||
// 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 so the machine stays REAPER-free AND owns every timing/ceiling
|
||||
// decision (the shell only reads and reports; it never decides a transition itself).
|
||||
// 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;
|
||||
|
||||
// Wall-clock seconds since begin() (the shell reads a steady clock). Drives the
|
||||
// record safety ceiling: a transport that starts but never advances to the range
|
||||
// end (stuck / looping) would otherwise keep the machine in Recording forever.
|
||||
double elapsedSeconds = 0.0;
|
||||
|
||||
// Wall-clock seconds spent in the Finalizing phase (since the transport stop).
|
||||
// Drives the flush ceiling: bound the deferred-finalize wait so a file that never
|
||||
// stabilizes fails cleanly instead of hanging.
|
||||
double finalizingSeconds = 0.0;
|
||||
|
||||
// Whether the recorded take's file exists AND is stable/closed this tick (the
|
||||
// shell resolves the take source path and checks size-stable-across-a-tick).
|
||||
// Only consulted in Finalizing.
|
||||
bool fileReady = false;
|
||||
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)
|
||||
};
|
||||
|
||||
// --- Safety ceilings (named constants, review §2/§3) -------------------------
|
||||
//
|
||||
// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form
|
||||
// the record wall-clock ceiling. Generous so a normal record (with pre-roll, count-in,
|
||||
// or transport latency) never trips it; tight enough that a stuck transport is force-
|
||||
// terminated within a few seconds of overrun.
|
||||
// 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;
|
||||
|
||||
// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the
|
||||
// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the
|
||||
// take on the audio thread within a tick or two in practice; this is a generous bound.
|
||||
// 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: given the current phase, this tick's inputs, and the record
|
||||
// range end, return the next phase. Total + deterministic.
|
||||
//
|
||||
// From Recording:
|
||||
// * recording AND cursor < end AND under the record ceiling -> Recording (wait)
|
||||
// * recording AND cursor >= end -> Finalizing (reached end)
|
||||
// * NOT recording -> Finalizing (stopped early)
|
||||
// * recording BUT over the record ceiling (end-start+margin)-> Finalizing (stuck: forced)
|
||||
// From Finalizing:
|
||||
// * fileReady -> Done (flushed + stable)
|
||||
// * over the flush ceiling without a stable file -> Failed (give up)
|
||||
// * otherwise -> Finalizing (keep flushing)
|
||||
// Done and Failed are sticky: feeding a terminal phase back returns it unchanged, so a
|
||||
// late tick before teardown finishes cannot flip the verdict (the idempotence the
|
||||
// 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,
|
||||
|
||||
@@ -7,14 +7,14 @@ namespace reasampler::capture {
|
||||
namespace {
|
||||
|
||||
// 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 kBaseNewTrack = 1; // add new track
|
||||
constexpr int kBaseCurrentTrack = 0;
|
||||
constexpr int kBaseNewTrack = 1;
|
||||
|
||||
// Tempo-conform bits, verbatim from the header doc-comment.
|
||||
constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x
|
||||
constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x
|
||||
constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x
|
||||
constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo
|
||||
constexpr int kMatchTempo1x = 8;
|
||||
constexpr int kMatchTempoHalf = 16;
|
||||
constexpr int kMatchTempoDbl = 32;
|
||||
constexpr int kDontPreservePitch = 64;
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -24,8 +24,7 @@ int computeInsertMode(const InsertOptions& opts) {
|
||||
|
||||
switch (opts.conform) {
|
||||
case TempoConform::None:
|
||||
// No tempo bits: native length, no stretch. (Also never &4.)
|
||||
return mode;
|
||||
return mode; // native length, no stretch; never &4
|
||||
case TempoConform::Ratio1x:
|
||||
mode |= kMatchTempo1x;
|
||||
break;
|
||||
@@ -37,9 +36,7 @@ int computeInsertMode(const InsertOptions& opts) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Tempo bits are set (conform != None). Add the pitch-shift bit only when the
|
||||
// caller asked NOT to preserve pitch. When conform == None we already returned
|
||||
// above, so this can never fire without a tempo bit present.
|
||||
// Reached only when a tempo bit is set (None already returned above).
|
||||
if (!opts.preservePitch)
|
||||
mode |= kDontPreservePitch;
|
||||
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
#pragma once
|
||||
// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing
|
||||
// the InsertMedia `mode` bitmask from a small options struct.
|
||||
// insert_plan — the REAPER-free logic behind the `insert` shell: computing the
|
||||
// InsertMedia `mode` bitmask from a small options struct.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW
|
||||
// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to
|
||||
// get wrong and its bits are load-bearing for the "no silent time-stretch"
|
||||
// invariant, so it is factored here and unit-tested. The REAPER-bound placement
|
||||
// (InsertMedia call, edit-cursor movement, undo block) lives in insert.cpp and is
|
||||
// DAW-verified.
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||
// only. The InsertMedia bitfield is easy to get wrong and its bits are
|
||||
// load-bearing for the "no silent time-stretch" invariant, so it's factored here
|
||||
// and unit-tested. The REAPER-bound placement (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):
|
||||
// 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,
|
||||
// &16=try to match tempo 0.5x, &32=try to match tempo 2x,
|
||||
// &64=don't preserve pitch when matching tempo, ...
|
||||
// We intentionally use only the base target (0/1) and the tempo-conform bits
|
||||
// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is
|
||||
// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals).
|
||||
// We use only the base target (0/1) and the tempo-conform bits (&8/&16/&32/&64).
|
||||
// We NEVER set &4 (stretch/loop to fit time selection) — the silent-time-stretch
|
||||
// path the tool forbids.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// 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
|
||||
// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's
|
||||
// convention for base modes 0/1 (the header names no explicit edit-cursor bit;
|
||||
// see the flagged runtime assumption in insert.cpp).
|
||||
// We expose only the two placement targets needed here; "add as takes" (3) is
|
||||
// out of scope. Both insert at the edit cursor — REAPER's convention for base
|
||||
// modes 0/1 (the header names no explicit edit-cursor bit; see the flagged
|
||||
// runtime assumption in insert.cpp).
|
||||
enum class InsertTarget {
|
||||
NewTrack, // mode base 1: add a new track for the item
|
||||
CurrentTrack, // mode base 0: add to the current/selected track
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
namespace reasampler::capture {
|
||||
|
||||
double autoTrimEndRatio() {
|
||||
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is
|
||||
// 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.
|
||||
// Amplitude ratio = 10^(dB/20) (header ~3062). For -72 dB this is ~0.00025119.
|
||||
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
|
||||
}
|
||||
|
||||
@@ -20,8 +18,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
||||
TailRenderSettings t;
|
||||
switch (mode) {
|
||||
case TailMode::None:
|
||||
// Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off,
|
||||
// disable-all normalize (the current default), no trim.
|
||||
// Exact bounds — byte-identical to the pre-tail capture.
|
||||
t.tailFlag = kTailFlagNone;
|
||||
t.tailMs = 0.0;
|
||||
t.normalize = kNormalizeDisableAll;
|
||||
@@ -29,24 +26,19 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
||||
return t;
|
||||
|
||||
case TailMode::Auto:
|
||||
// Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence
|
||||
// bit (32768) — every other postprocessing bit clear. A fixed-threshold
|
||||
// trailing-silence trim is a pure boundary decision (it scales/limits/fades
|
||||
// nothing), so it re-introduces none of the coloring the disable-all bit
|
||||
// guarded against, and two identical requests trim at the identical sample
|
||||
// -> bit-identical repeats hold (spec §surgical normalize).
|
||||
t.tailFlag = kTailFlagCustomBounds;
|
||||
// Surgical normalize: only the trim-ending-silence bit set, every other
|
||||
// postprocessing bit clear. A fixed-threshold trim scales/limits/fades
|
||||
// nothing, so identical requests trim at the identical sample -> holds
|
||||
// the bit-identical-repeats invariant.
|
||||
t.tailFlag = kTailFlagTimeSelection;
|
||||
t.tailMs = kMaxTailMs;
|
||||
t.normalize = kNormalizeTrimEnd;
|
||||
t.trimEnd = autoTrimEndRatio();
|
||||
return t;
|
||||
|
||||
case TailMode::Manual:
|
||||
// Fixed tail, no trim -> keep the disable-all normalize exactly as the
|
||||
// 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;
|
||||
// Clamped to the cap regardless of source; negative floors to 0.
|
||||
t.tailFlag = kTailFlagTimeSelection;
|
||||
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
|
||||
t.normalize = kNormalizeDisableAll;
|
||||
t.trimEnd = 0.0;
|
||||
@@ -60,14 +52,10 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
||||
double manualTailMs) {
|
||||
switch (mode) {
|
||||
case TailMode::None:
|
||||
// Exact — no extra recording (byte-identical to today's realtime capture).
|
||||
return rangeEndSeconds;
|
||||
return rangeEndSeconds; // exact, no extra recording
|
||||
case TailMode::Auto:
|
||||
// The 8 s runaway cap past the range end; the decay-trim shortens it later.
|
||||
return rangeEndSeconds + kMaxTailSeconds;
|
||||
return rangeEndSeconds + kMaxTailSeconds; // runaway cap; decay-trim shortens later
|
||||
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;
|
||||
}
|
||||
// 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*/) {
|
||||
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
|
||||
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
|
||||
// handled by fxBypassPlanFor, not by these render bits.
|
||||
// wetDry doesn't affect this mapping (seam for future dry work); FX scoping
|
||||
// is handled by fxBypassPlanFor, not by these render bits.
|
||||
RenderSettingsChoice c;
|
||||
|
||||
switch (mode) {
|
||||
case SourceMode::MasterMix:
|
||||
case SourceMode::TimeSelection:
|
||||
// Master IS the mix — wet-only; &(1|2)==0, no source bits.
|
||||
c.settings = kRenderMasterMix;
|
||||
c.settings = kRenderMasterMix; // wet-only, no source bits
|
||||
c.supported = true;
|
||||
return c;
|
||||
|
||||
case SourceMode::SelectedTracks:
|
||||
// Selected tracks via master (&128) — wet (post-FX). Header ~3041.
|
||||
c.settings = kRenderSelTracksViaMaster;
|
||||
c.supported = true;
|
||||
return c;
|
||||
|
||||
case SourceMode::SelectedItems:
|
||||
// Selected media items, rendered to ONE file (single-file bit) so a
|
||||
// multi-item selection yields a single bank entry, not N wavs.
|
||||
// Single-file bit so a multi-item selection yields one bank entry.
|
||||
c.settings = kRenderSelItems | kRenderSingleFile;
|
||||
c.supported = true;
|
||||
return c;
|
||||
|
||||
case SourceMode::RazorArea:
|
||||
// Render razor edits to ONE file (same single-file rationale as items).
|
||||
c.settings = kRenderRazorEdits | kRenderSingleFile;
|
||||
c.supported = true;
|
||||
return c;
|
||||
|
||||
case SourceMode::Realtime:
|
||||
// Not an offline-render source — the realtime backend (M8) owns it.
|
||||
c.settings = kRenderMasterMix;
|
||||
c.supported = false;
|
||||
c.supported = false; // not an offline-render source
|
||||
return c;
|
||||
}
|
||||
// Unreachable for a valid enum; fail closed (unsupported) rather than render.
|
||||
@@ -118,12 +100,62 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
||||
return c;
|
||||
}
|
||||
|
||||
SourceMode sourceModeForScope(CaptureScope scope) {
|
||||
void applySecondPassRenderSettings(const bool secondPass, RenderSettingsChoice& choice) {
|
||||
if (secondPass) { choice.settings |= kSecondPassRender; }
|
||||
}
|
||||
|
||||
const char* renderSourceLabel(SourceMode mode) {
|
||||
switch (mode) {
|
||||
// MasterMix and TimeSelection share this label because they ARE the same
|
||||
// render — see the header.
|
||||
case SourceMode::MasterMix:
|
||||
case SourceMode::TimeSelection: return "master mix";
|
||||
case SourceMode::SelectedTracks: return "selected tracks via master";
|
||||
case SourceMode::SelectedItems: return "selected media items";
|
||||
case SourceMode::RazorArea: return "razor edits";
|
||||
case SourceMode::Realtime: return "realtime record";
|
||||
}
|
||||
return "unknown"; // unreachable for a valid enum; never claim a source
|
||||
}
|
||||
|
||||
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) {
|
||||
switch (scope) {
|
||||
case CaptureScope::Item: return SourceMode::SelectedItems;
|
||||
case CaptureScope::Item:
|
||||
return itemExtentIsWindow ? SourceMode::SelectedItems
|
||||
: SourceMode::SelectedTracks;
|
||||
case CaptureScope::Track: return SourceMode::SelectedTracks;
|
||||
}
|
||||
return SourceMode::SelectedItems; // unreachable for a valid enum; fail closed
|
||||
// Unreachable for a valid enum; fail closed to the time-bounded render, which
|
||||
// honors the requested bounds whatever the selection is.
|
||||
return SourceMode::SelectedTracks;
|
||||
}
|
||||
|
||||
bool isMultiTrackStemRender(SourceMode mode, int sourceTrackCount) {
|
||||
return mode == SourceMode::SelectedTracks && sourceTrackCount > 1;
|
||||
}
|
||||
|
||||
std::string multiTrackRefusalMessage(CaptureScope scope) {
|
||||
// Deliberately does not name realtime capture as a way out, though it is the one
|
||||
// action that sums correctly here: realtime is non-deterministic (hardware/performed
|
||||
// FX, no bit-identical-repeats guarantee), so pointing an offline refusal at it would
|
||||
// trade one invariant for another rather than just naming a substitute. A stated
|
||||
// choice, not an oversight.
|
||||
switch (scope) {
|
||||
case CaptureScope::Item:
|
||||
return "This range is narrower than the selected items, so it renders "
|
||||
"through their tracks -- and those items span more than one track, "
|
||||
"which this shape cannot land as a single file. Capture one track's "
|
||||
"items at a time, or make the range match the items' extent.";
|
||||
case CaptureScope::Track:
|
||||
return "A track capture renders the selected tracks through the master, "
|
||||
"and more than one track cannot land as a single file. Capture one "
|
||||
"track at a time, or route them into a folder/bus track and capture "
|
||||
"that (a folder's own output is its children summed).";
|
||||
}
|
||||
// Unreachable for a valid enum; a refusal with no way out is still better than a
|
||||
// silent one, so fail closed to the scope-agnostic half of the message.
|
||||
return "This selection spans more than one track, which cannot land as a single "
|
||||
"file. Capture one track at a time.";
|
||||
}
|
||||
|
||||
RangeSource inferRangeSource(bool hasRazorArea) {
|
||||
@@ -135,17 +167,13 @@ FxBypassPlan fxBypassPlanFor(CaptureScope scope) {
|
||||
FxBypassPlan p;
|
||||
switch (scope) {
|
||||
case CaptureScope::Item:
|
||||
// Item = take/item FX ONLY. Bypass the item's own track FX, every
|
||||
// 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.)
|
||||
// Take FX live in the item and are always rendered — bypass everything else.
|
||||
p.bypassSelfFx = true;
|
||||
p.bypassAncestorFx = true;
|
||||
p.bypassMaster = true;
|
||||
return p;
|
||||
case CaptureScope::Track:
|
||||
// Track = item FX + the selected track's OWN FX. Keep self FX; bypass
|
||||
// every ancestor (parent/folder) and the master. Parent/master GAIN
|
||||
// still applies (I_FXEN is FX-only) — documented boundary.
|
||||
// Keep self FX; bypass every ancestor (parent/folder) and the master.
|
||||
p.bypassSelfFx = false;
|
||||
p.bypassAncestorFx = true;
|
||||
p.bypassMaster = true;
|
||||
@@ -158,24 +186,19 @@ std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
|
||||
std::vector<RazorRange> ranges;
|
||||
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;
|
||||
while (in >> startTok >> endTok >> guidTok) {
|
||||
// Envelope-lane areas carry a real GUID; skip them (razor captures track audio only).
|
||||
// A track-audio area's GUID token is the empty quoted string `""`.
|
||||
// Skip envelope-lane areas (real GUID); keep only track-audio (`""`).
|
||||
if (guidTok != "\"\"") continue;
|
||||
|
||||
// Parse the two time tokens. std::stod throws on garbage — guard so one
|
||||
// malformed triple does not abort the whole parse.
|
||||
// std::stod throws on garbage — guard so one malformed triple doesn't
|
||||
// abort the whole parse.
|
||||
double start = 0.0, end = 0.0;
|
||||
try {
|
||||
std::size_t sp = 0, ep = 0;
|
||||
start = std::stod(startTok, &sp);
|
||||
end = std::stod(endTok, &ep);
|
||||
// Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse
|
||||
// is a malformed area, not a valid range.
|
||||
// Reject trailing garbage (e.g. "1.0x") — a partial parse is malformed.
|
||||
if (sp != startTok.size() || ep != endTok.size()) continue;
|
||||
} catch (...) {
|
||||
continue;
|
||||
@@ -197,23 +220,13 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
|
||||
}
|
||||
|
||||
const std::vector<CaptureActionDef>& captureActionTable() {
|
||||
// Built once (function-local static): two SCOPE actions, item + track. Both
|
||||
// exact bounds by default; the tail mode a capture applies is read from the
|
||||
// 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.
|
||||
// FOREVER-STABLE ids — never edit a shipped string. No master capture
|
||||
// action (its id was retired; do not reintroduce it).
|
||||
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 selected item(s)", "item",
|
||||
CaptureScope::Item},
|
||||
|
||||
// Track scope — item FX + the track's own FX.
|
||||
{"CAPTURE_TRACK",
|
||||
"capture selected track(s)", "track",
|
||||
CaptureScope::Track},
|
||||
|
||||
+139
-154
@@ -1,26 +1,10 @@
|
||||
#pragma once
|
||||
// render_settings — the REAPER-free logic behind the capture action family.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The capture shell (capture.cpp) and
|
||||
// action layer (main.cpp) read the actual DAW state (time selection, selected
|
||||
// tracks/items, razor strings, the ancestor-track chain) and hand the raw values
|
||||
// here so the genuinely-pure, easy-to-get-wrong pieces are unit-tested outside
|
||||
// the DAW:
|
||||
//
|
||||
// 1. sourceMode -> the RENDER_SETTINGS integer bit value (wet only).
|
||||
// 2. a P_RAZOREDITS string -> the list of (start,end) ranges + their union bound.
|
||||
// 3. range inference: razor-present -> razor union, else time selection. Range
|
||||
// is a SOURCE choice orthogonal to the capture scope.
|
||||
// 4. the FX-scope bypass plan: given a scope + an ancestor-chain length, which
|
||||
// tracks' FX to bypass so each scope hears only the FX it should (the M7
|
||||
// "items captured through parent FX" defect is corrected here).
|
||||
// 5. the capture-action table (id string, description, scope) — the taxonomy,
|
||||
// in one place so main.cpp iterates it instead of hand-listing.
|
||||
//
|
||||
// The RENDER_SETTINGS bit MEANINGS are transcribed verbatim from
|
||||
// reaper_plugin_functions.h line ~3041 (see kRender* constants); the CHOICE of
|
||||
// which bits each source mode sets is this module's logic and is tested.
|
||||
// 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>
|
||||
@@ -32,67 +16,62 @@ namespace reasampler::capture {
|
||||
using model::SourceMode;
|
||||
|
||||
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) --
|
||||
//
|
||||
// Only the bits this module actually uses are named. Values are the documented bit
|
||||
// weights; the DOC of each is the SDK header's, not a guess.
|
||||
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 kSecondPassRender = 2048; // &2048 2nd Pass Render
|
||||
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
|
||||
// NOTE: kRenderPreFaderStems (&8192) is 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) — bypassing the FX-enable of the tracks that
|
||||
// fall outside a scope — NOT by any render bit. All capture actions render wet
|
||||
// (post the FX that remain enabled); the scope decides which FX remain enabled.
|
||||
// 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
|
||||
|
||||
// --- Render bounds mode -------------------------------------------------------
|
||||
//
|
||||
// A capture hands its window over on RENDER_BOUNDSFLAG=2 — the project's own TIME
|
||||
// SELECTION (value verbatim, header ~3042), written through GetSet_LoopTimeRange.
|
||||
//
|
||||
// Custom time bounds (RENDER_BOUNDSFLAG=0, RENDER_STARTPOS/RENDER_ENDPOS, header
|
||||
// ~3045-3046) must NOT be reintroduced: REAPER resolved a custom-bounds window on a
|
||||
// whole-millisecond grid AT RENDER TIME, floored the end, wrote the floored value back
|
||||
// over RENDER_ENDPOS, and rendered exactly the floored frame count — twice, to the
|
||||
// frame. Re-rendering on this mode came back exact on both edges, including a start
|
||||
// carrying a sub-millisecond remainder, which is what locates the floor in the
|
||||
// custom-bounds field rather than downstream in the render engine. This is the one
|
||||
// narrative home for that; other sites point here.
|
||||
inline constexpr int kRenderBoundsTimeSelection = 2;
|
||||
|
||||
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
|
||||
//
|
||||
// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release
|
||||
// decay past the range end. Every offline capture renders custom-time-bounds, so
|
||||
// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047).
|
||||
// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside
|
||||
// the DAW exactly like renderSettingsFor; the backend just applies them.
|
||||
//
|
||||
// RENDER_NORMALIZE bit meanings (verbatim from SDK header ~3051):
|
||||
// &32768 = trim ending silence (the surgical Auto path)
|
||||
// &(4<<16) = disable all render postprocessing (the None/Manual path)
|
||||
// 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
|
||||
|
||||
// RENDER_TAILFLAG &1 = apply tail for custom time bounds (header ~3047). We render
|
||||
// custom bounds unconditionally, so this is the only tail bit that ever applies.
|
||||
inline constexpr int kTailFlagNone = 0;
|
||||
inline constexpr int kTailFlagCustomBounds = 1; // &1
|
||||
inline constexpr int kTailFlagNone = 0;
|
||||
|
||||
// Auto-trim trailing-silence threshold. -72 dB is quiet enough that the trimmed
|
||||
// region is inaudible decay, loud enough to not chase a reverb's infinite noise
|
||||
// floor. Daniel-set. Single source of truth: the RENDER_TRIMEND ratio derives from
|
||||
// this dB, never the reverse.
|
||||
// RENDER_TAILFLAG's bits are keyed PER BOUNDS MODE (header ~3047): &4 is the
|
||||
// time-selection mode's bit, the pair of kRenderBoundsTimeSelection above. A tail set
|
||||
// under a different mode's bit renders no tail at all, so these two move together.
|
||||
inline constexpr int kTailFlagTimeSelection = 4;
|
||||
|
||||
// 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;
|
||||
|
||||
// Max tail rendered past the range end. The runaway guard: a non-decaying or
|
||||
// looping signal never crosses the trim threshold, so this caps the render.
|
||||
// Daniel-set. Shared by the offline (T1) and future realtime (T2) tail paths.
|
||||
// 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. The header (~3062) documents
|
||||
// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20).
|
||||
// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and
|
||||
// a future config change to the dB does not require hand-recomputing the ratio.
|
||||
//
|
||||
// std::pow is not constexpr before C++26, so this is a function, not a constant.
|
||||
// For -72 dB: 10^(-72/20) = 10^(-3.6) ~= 0.00025119 (the value the DAW confirm targets).
|
||||
// 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 (docs/product/capture-tail.md §The three tail states):
|
||||
// None — exact bounds, no tail. Byte-identical to the pre-tail capture. The
|
||||
// default and the ONLY mode for null-test / verify captures.
|
||||
// Auto — generous 8 s tail then trim trailing silence to -72 dB (surgical
|
||||
// normalize). The user-facing tail-on option (panel toggle).
|
||||
// Manual — a fixed tail length (clamped to the 8 s cap), no trim.
|
||||
// The three tail states — see src/core/capture/CLAUDE.md.
|
||||
enum class TailMode {
|
||||
None,
|
||||
Auto,
|
||||
@@ -100,103 +79,126 @@ enum class TailMode {
|
||||
};
|
||||
|
||||
// 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); it is 0 otherwise. This is the pure mapping — the backend reads
|
||||
// these four fields straight onto GetSetProjectInfo.
|
||||
// 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)
|
||||
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or the bounds mode's bit)
|
||||
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 (+ the requested manual tail ms) to its RENDER_* values.
|
||||
// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is
|
||||
// clamped to kMaxTailMs — the runaway guard applies whether the length came from
|
||||
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
|
||||
// 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 (in project seconds) a tail mode records to, given
|
||||
// the request's exact range end (docs/product/capture-tail.md §The realtime path).
|
||||
// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so
|
||||
// the window end is where the transport actually stops:
|
||||
// None -> rangeEndSeconds (exact — no extra recording).
|
||||
// Auto -> rangeEndSeconds + kMaxTailSeconds (the 8 s runaway cap; trimmed later).
|
||||
// Manual -> rangeEndSeconds + clamp(manualTailMs, kMaxTailMs)/1000 (fixed, no trim).
|
||||
// `manualTailMs` is used ONLY for Manual. Pure so the mode->window arithmetic (and
|
||||
// the Manual clamp) is unit-tested outside the DAW; the backend applies the returned
|
||||
// end to the record time selection. Shared -72 dB / 8 s constants are the same ones
|
||||
// the offline tail uses (single source of truth).
|
||||
// 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 is the M8 backend, not offline render).
|
||||
// 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, not by these bits.
|
||||
// `wetDry` is accepted but ignored for the mapping — retained in CaptureRequest
|
||||
// as the seam for future dry work (M10 null test).
|
||||
//
|
||||
// CONFIRMED (SDK header ~3041):
|
||||
// MasterMix / TimeSelection -> master mix (0).
|
||||
// SelectedTracks -> &128 selected tracks via master.
|
||||
// SelectedItems -> &32 | single-file (one wav, not one-per-item).
|
||||
// RazorArea -> &4096| single-file.
|
||||
// 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 (Daniel, critical) ----------------
|
||||
// Apply the 2nd Pass Render setting to the RENDER_SETTINGS block
|
||||
void applySecondPassRenderSettings(const bool secondPass, RenderSettingsChoice& choice);
|
||||
|
||||
// The render source a mode drives, in words. Exists for the offline backend's
|
||||
// bounds refusal: the two ways a render can miss its window — a source that
|
||||
// derives its own bounds (selected items, razor edits) versus a time-bounded
|
||||
// render that came up short — are indistinguishable from a frame count alone,
|
||||
// and naming the source is what tells them apart in a bug report.
|
||||
//
|
||||
// Two FX scopes. The render RANGE (razor-else-time) is orthogonal to the scope.
|
||||
// Item -> item/take FX ONLY (no track, no parent/folder, no master FX).
|
||||
// Track -> item FX + the selected track's OWN track FX (no parent/folder/master).
|
||||
// There is NO master scope: to capture the master you render a track instead. The
|
||||
// master track's FX/gain/pan are still NEUTRALIZED as part of the out-of-scope
|
||||
// chain for both item and track captures (bypassMaster below) — master is a
|
||||
// bypass target, not a capture scope.
|
||||
// MasterMix and TimeSelection deliberately answer the SAME words: they map to the
|
||||
// same RENDER_SETTINGS value and render identically, so naming them apart would
|
||||
// assert a render distinction that does not exist.
|
||||
const char* renderSourceLabel(SourceMode mode);
|
||||
|
||||
// --- 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. Item captures selected items, Track
|
||||
// captures selected tracks (via master).
|
||||
SourceMode sourceModeForScope(CaptureScope scope);
|
||||
// The render source mode each scope drives. Track scope always captures its
|
||||
// selected tracks (via master), time-bounded by RENDER_STARTPOS/ENDPOS.
|
||||
//
|
||||
// Item scope captures the selected items ONLY when `itemExtentIsWindow` — i.e.
|
||||
// when those items' own extent already prints the requested window (see
|
||||
// render_window::itemExtentPrintsWindow). REAPER's selected-items render source is
|
||||
// INFERRED to derive its bounds from the item extents, so a window strictly inside
|
||||
// (or wider than) a selected item cannot be expressed through it; that case renders
|
||||
// time-bounded through the items' own tracks. The inference is unverified — see
|
||||
// src/core/capture/CLAUDE.md §Gotchas for what it rests on.
|
||||
//
|
||||
// The FX SCOPE is unaffected by the swap (fxBypassPlanFor is keyed on CaptureScope,
|
||||
// not on the source mode, so an item capture still hears take/item FX only), but the
|
||||
// CONTENT reaching the render is not: the selected-tracks source prints everything
|
||||
// upstream of the track — its folder children and its receives — which the shell
|
||||
// transiently silences (shell/capture/render_isolation). An overlapping item on the
|
||||
// track ITSELF is deliberately not isolated; see src/shell/capture/CLAUDE.md.
|
||||
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow);
|
||||
|
||||
// True for the one render shape that cannot land as a single capture: a selected-tracks
|
||||
// render covering more than one track — a ranged item capture whose items span several
|
||||
// tracks, or any multi-track track capture. That source is read as rendering one file
|
||||
// per selected track — the single-file bit is documented for item/razor sources only
|
||||
// (SDK header ~3041), which is the whole basis for the reading and is DAW-unverified.
|
||||
// If it holds, N tracks collapse N stems onto one literal render pattern and whichever
|
||||
// file survived would land as a successful capture carrying one track's audio. The
|
||||
// caller refuses instead.
|
||||
//
|
||||
// Scope is deliberately NOT a parameter: the exposure comes from the render SOURCE,
|
||||
// which both scopes reach.
|
||||
bool isMultiTrackStemRender(SourceMode mode, int sourceTrackCount);
|
||||
|
||||
// The refusal text for the shape above. Keyed on scope because only the way OUT differs:
|
||||
// an item capture can also widen its range to the items' own extent, which a track
|
||||
// capture has no analog for. Kept beside the predicate so the two read as siblings.
|
||||
std::string multiTrackRefusalMessage(CaptureScope scope);
|
||||
|
||||
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
|
||||
//
|
||||
// Every scope action infers its render range the same way: if a razor area is
|
||||
// present, use the razor union; otherwise use the time selection. Razor is a
|
||||
// range SOURCE, not a capture mode (the M7 four-mode model conflated them).
|
||||
// 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 the "razor wins when present" rule is tested
|
||||
// without a DAW; the shell supplies whether any razor area was found.
|
||||
// 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: whether to bypass (a) the
|
||||
// captured track's OWN FX, (b) each of its ancestor (parent/folder) tracks' FX,
|
||||
// and (c) the master FX. The caller (FxBypassGuard) resolves these flags to
|
||||
// concrete MediaTrack* by walking the ancestor chain via GetParentTrack and
|
||||
// clears I_FXEN on each flagged track, snapshotting first (RAII restore).
|
||||
//
|
||||
// SCOPE BOUNDARY: I_FXEN bypasses a track's FX plugins but NOT its volume/pan.
|
||||
// The guard (FxBypassGuard, main.cpp) therefore ALSO neutralizes the fader GAIN
|
||||
// (D_VOL -> unity) of every track in this same bypass set, so a Track/Item
|
||||
// capture rendered via master does NOT bake in the parent/folder/master fader
|
||||
// level (Daniel: the capture is likely re-routed through that chain later). PAN
|
||||
// is deliberately left untouched (D_PAN is coupled to D_WIDTH/D_PANLAW — a clean
|
||||
// neutralize is non-trivial; flagged as a follow-up, not half-done). This plan
|
||||
// selects the SET; the guard applies both the FX bypass and the gain neutralize.
|
||||
// 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
|
||||
@@ -213,53 +215,36 @@ struct RazorRange {
|
||||
};
|
||||
|
||||
// Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
|
||||
// TRIPLES of <start> <end> <envGuidString>. The envelope GUID is "" (an empty
|
||||
// quoted string, i.e. the literal two chars `""`) for a track-audio area and a
|
||||
// GUID like {…} for an envelope-lane area.
|
||||
//
|
||||
// Returns only the track-audio ranges (envelope-lane triples are skipped — razor
|
||||
// captures target track audio, not envelope lanes). Malformed/short trailing tokens are ignored, not fatal.
|
||||
// A range with end <= start is dropped (no negative/empty areas leak through).
|
||||
// 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 so every area is inside the rendered file.
|
||||
// Returns {0,0} for an empty input (caller treats that as "no razor area").
|
||||
// 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 and track. The range each captures
|
||||
// (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action
|
||||
// variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads
|
||||
// at fire time (see tail_control + bank_panel), so a single pair of actions covers
|
||||
// every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos).
|
||||
// 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.
|
||||
//
|
||||
// Phase V (V4): the row stores the channel-AGNOSTIC pieces — a command-id SUFFIX (the
|
||||
// tail after the family prefix) and a description PHRASE (the label after the "ReaSampler:
|
||||
// " lead). The registering shell composes the full, channel-qualified id/name via
|
||||
// app_version's channelCommandId / channelActionName (commandIdPrefix + suffix /
|
||||
// actionDisplayPrefix + phrase). This keeps the pure table free of any channel branch:
|
||||
// stable rebuilds the exact shipped id "CEREBELLUM_REASAMPLER_CAPTURE_TRACK" from
|
||||
// prefix + "CAPTURE_TRACK"; beta yields "CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK".
|
||||
//
|
||||
// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id) — never
|
||||
// change a shipped value. baseName feeds the file stem (sanitized by capture_paths).
|
||||
// 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 base for this capture
|
||||
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. Kept here (pure) so the taxonomy is
|
||||
// one testable list, not scattered registration code.
|
||||
// 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. Razor is an inferred range, not a mode, and each
|
||||
// scope enforces its FX-scope invariant via fxBypassPlanFor. The tail mode each
|
||||
// capture applies is read from the docked-panel setting, not baked into the row.
|
||||
// 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,61 @@
|
||||
// render_window.cpp — see the header.
|
||||
|
||||
#include "core/capture/render_window.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// Round-to-nearest: the convention THIS module measures a window by, so a mid-frame
|
||||
// position maps to the closer frame boundary rather than always down. Not a claim
|
||||
// about how any renderer resolves that position -- see the header's caveat.
|
||||
long long frameIndexAt(double seconds, int sampleRate) {
|
||||
return std::llround(seconds * static_cast<double>(sampleRate));
|
||||
}
|
||||
|
||||
// See the header for why whole milliseconds get a tolerance and why it is this small.
|
||||
double floorToMilliseconds(double seconds) {
|
||||
const double ms = seconds * 1000.0;
|
||||
if (isOnMillisecondGrid(seconds)) return std::nearbyint(ms) / 1000.0;
|
||||
return std::floor(ms) / 1000.0;
|
||||
}
|
||||
|
||||
} // 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 renderHonoredBounds(long long expectedFrames, long long actualFrames) {
|
||||
const long long delta = actualFrames > expectedFrames
|
||||
? actualFrames - expectedFrames
|
||||
: expectedFrames - actualFrames;
|
||||
return delta <= 1;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
bool isOnMillisecondGrid(double seconds) {
|
||||
const double ms = seconds * 1000.0;
|
||||
return std::fabs(ms - std::nearbyint(ms)) < 1e-6;
|
||||
}
|
||||
|
||||
long long msFlooredEndFrameCount(double startSeconds, double endSeconds,
|
||||
int sampleRate) {
|
||||
return frameCountFor(startSeconds, floorToMilliseconds(endSeconds), sampleRate);
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
// render_window — pure frame arithmetic for a capture's requested window: the frame
|
||||
// count a project-time range occupies, whether a render whose bounds come from the
|
||||
// selected items' own extent already prints that window, and the one diagnostic a
|
||||
// refused render quotes — whether its shortfall matches a millisecond-floor coincidence.
|
||||
// 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.
|
||||
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate);
|
||||
|
||||
// True when a landed render's frame count is consistent with `frameCountFor`'s
|
||||
// answer for the same window. Tolerates a one-frame difference, and exactly one.
|
||||
//
|
||||
// That bound is EMPIRICAL. It is provable only for renderer models that derive the
|
||||
// count from the window's LENGTH (floor/ceil/round of (end-start)*rate) or resolve
|
||||
// both edges by the SAME convention; a renderer that resolves the start edge and the
|
||||
// end edge by DIFFERENT conventions can legitimately sit TWO frames from this answer
|
||||
// (tests/test_render_window.cpp pins both facts). Which model REAPER uses is
|
||||
// unverified, so a refusal one or two frames wide may be this gate's fault rather than
|
||||
// the render's. Widening past one frame retires the exact-bounds invariant rather than
|
||||
// relaxing it, and is not a fix to reach for before that question is answered.
|
||||
bool renderHonoredBounds(long long expectedFrames, long long actualFrames);
|
||||
|
||||
// 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);
|
||||
|
||||
// --- Diagnostics: where a short render lost its frames ------------------------
|
||||
|
||||
// The frames this window would hold if its END were resolved on a whole-millisecond
|
||||
// grid, floored, instead of exactly. That is what REAPER's offline render did on the
|
||||
// retired custom-time-bounds mode (render_settings.h's kRenderBoundsTimeSelection states
|
||||
// the whole observation): two live short renders (48 kHz, TailMode::None) printed this
|
||||
// count to the frame. Kept as the refusal's shape check — a refused render matching it
|
||||
// says the floor is back, on a mode that was measured escaping it.
|
||||
//
|
||||
// Still a DESCRIPTION, never a request: nothing renders from this number and no capture
|
||||
// path asks for it — a refusal quotes it to say the shortfall has the known shape, which
|
||||
// is not the same as proving that this particular render took it. Whole-millisecond values
|
||||
// are recognized within a nanosecond, because a decimal millisecond is not always one
|
||||
// in binary (1.007 * 1000 lands just below 1007) and a bare floor would drop a
|
||||
// millisecond from a window already on the grid. A nanosecond is far under one frame
|
||||
// at any rate we render, so a real sub-millisecond remainder still floors.
|
||||
//
|
||||
// The tolerance is ours, not REAPER's: on a `1.007`-class grid point, a REAPER floor
|
||||
// that does NOT carry the same epsilon would miss this shape entirely, and a real
|
||||
// floored render would then read as an unmatched short render rather than the known one
|
||||
// — silence here is not proof the floor didn't happen (docs/TODO.md records why this
|
||||
// premise needs a DAW measurement before anything is built on it).
|
||||
long long msFlooredEndFrameCount(double startSeconds, double endSeconds,
|
||||
int sampleRate);
|
||||
|
||||
// True when `seconds` sits on a whole-millisecond boundary, under the nanosecond
|
||||
// tolerance msFlooredEndFrameCount depends on and for the reason stated there. Public so
|
||||
// that premise is testable directly rather than only through the count it feeds.
|
||||
bool isOnMillisecondGrid(double seconds);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -1,4 +1,4 @@
|
||||
// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor.
|
||||
// tail_control — pure implementation. See tail_control.h.
|
||||
|
||||
#include "core/capture/tail_control.h"
|
||||
|
||||
@@ -18,15 +18,15 @@ TailMode cycleTailMode(TailMode current) {
|
||||
return TailMode::None; // unreachable for a valid enum; fail to the safe default
|
||||
}
|
||||
|
||||
bool toggleSecondPass(bool current) {
|
||||
return !current; // wow
|
||||
}
|
||||
|
||||
double clampManualMs(double manualMs) {
|
||||
// Same runaway guard the pure tailRenderSettingsFor applies to Manual: floor a
|
||||
// negative request to 0, cap at the 8 s ceiling.
|
||||
return std::clamp(manualMs, 0.0, kMaxTailMs);
|
||||
}
|
||||
|
||||
double adjustManualMs(double current, int notches, double stepMs) {
|
||||
// Clamp the stepped value so both scroll directions saturate at the bounds rather
|
||||
// than running away (the same [0, kMaxTailMs] guard clampManualMs enforces).
|
||||
return clampManualMs(current + notches * stepMs);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ std::string tailToggleLabel(const TailSetting& setting) {
|
||||
case TailMode::None: return "Tail: Off";
|
||||
case TailMode::Auto: return "Tail: Auto";
|
||||
case TailMode::Manual: {
|
||||
// Append the CLAMPED length in seconds to one decimal so the readout can
|
||||
// never show an over-cap value even if manualMs was stored past the cap.
|
||||
// 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);
|
||||
@@ -46,17 +46,9 @@ std::string tailToggleLabel(const TailSetting& setting) {
|
||||
return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The setting is a flat object of one enum + one double, riding the shared
|
||||
// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader —
|
||||
// the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17
|
||||
// significant digits (%.17g) — the shortest form that round-trips every IEEE-754
|
||||
// double exactly — so deserialize(serialize(x)) == x holds bit-for-bit.
|
||||
// deserialize stays forgiving in outcome: any parse failure returns nullopt so
|
||||
// the caller falls back to a default, exactly as an absent ext-state key does.
|
||||
// --- 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 {
|
||||
|
||||
@@ -89,6 +81,7 @@ std::string serializeTailSetting(const TailSetting& setting) {
|
||||
json::Writer w(out);
|
||||
w.keyRaw("mode", json::numToStr(modeToInt(setting.mode)));
|
||||
w.keyRaw("manualMs", json::numToStr(setting.manualMs));
|
||||
w.keyRaw("secondPass", json::boolToStr(setting.secondPass));
|
||||
} // Writer closes the object here (see bank_model's NRVO note)
|
||||
return out;
|
||||
}
|
||||
@@ -99,7 +92,8 @@ std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
|
||||
|
||||
int modeInt = 0;
|
||||
double ms = 0.0;
|
||||
bool haveMode = false, haveMs = false;
|
||||
bool secondPass = false;
|
||||
bool haveMode = false, haveMs = false, haveSecondPass = false;
|
||||
r.skipWs();
|
||||
if (!r.consume('}')) {
|
||||
do {
|
||||
@@ -111,13 +105,16 @@ std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
|
||||
} else if (key == "manualMs") {
|
||||
if (!r.parseDouble(ms)) return std::nullopt;
|
||||
haveMs = true;
|
||||
} else if (key == "secondPass") {
|
||||
if (!r.parseBool(secondPass)) return std::nullopt;
|
||||
haveSecondPass = 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
|
||||
if (!haveMode || !haveMs || !haveSecondPass) return std::nullopt; // absent key -> malformed -> default
|
||||
|
||||
const std::optional<TailMode> mode = modeFromInt(modeInt);
|
||||
if (!mode) return std::nullopt;
|
||||
@@ -125,6 +122,7 @@ std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
|
||||
TailSetting out;
|
||||
out.mode = *mode;
|
||||
out.manualMs = ms;
|
||||
out.secondPass = secondPass;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
#pragma once
|
||||
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode
|
||||
// toggle. The panel shell (shell/panel/) owns the SWELL window, LICE drawing, and
|
||||
// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length
|
||||
// clamp, and the toggle's label text — lives here so it is unit-tested outside the
|
||||
// DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch.
|
||||
//
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||
// only (plus render_settings for the pure TailMode enum). Builds and unit-tests
|
||||
// without REAPER.
|
||||
// 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>
|
||||
@@ -16,54 +10,45 @@
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of
|
||||
// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a
|
||||
// project with no stored tail setting (older / never-adjusted) falls back to on load.
|
||||
// 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;
|
||||
|
||||
// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough
|
||||
// that a few notches cover the useful range, fine enough to dial a length precisely.
|
||||
// Daniel-set. The panel maps one wheel notch to +/- this many ms via adjustManualMs.
|
||||
// Fine-adjust step per scroll-wheel notch in Manual mode. Daniel-set.
|
||||
inline constexpr double kManualStepMs = 250.0;
|
||||
|
||||
// The panel's current tail setting: the mode plus the length used ONLY when the
|
||||
// mode is Manual. Held as in-memory panel/session state (shell/panel), default
|
||||
// None so a capture with no explicit choice stays exact-bounds / byte-identical to
|
||||
// today. `manualMs` is a stored default a future fine-adjust UI can tune; it is
|
||||
// clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest.
|
||||
// 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.
|
||||
// 2nd-Pass Render optional, off by default
|
||||
struct TailSetting {
|
||||
TailMode mode = TailMode::None;
|
||||
double manualMs = kDefaultManualTailMs;
|
||||
TailMode mode = TailMode::None;
|
||||
double manualMs = kDefaultManualTailMs;
|
||||
bool secondPass = false;
|
||||
};
|
||||
|
||||
// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is
|
||||
// pinned by a test and the panel's click handler owns no enum arithmetic of its own.
|
||||
// An out-of-range value (unreachable for a valid enum) cycles back to None.
|
||||
// Cycles the tail mode: None -> Auto -> Manual -> None.
|
||||
TailMode cycleTailMode(TailMode current);
|
||||
|
||||
// The effective manual length a Manual capture uses: `manualMs` clamped to
|
||||
// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies).
|
||||
// Exposed so the panel can show the clamped value and main.cpp hands a pre-clamped
|
||||
// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual.
|
||||
// Toggles the 2nd-Pass Render
|
||||
bool toggleSecondPass(bool 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]. Positive notches lengthen, negative shorten. Pure so the fine-adjust
|
||||
// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler
|
||||
// owns no arithmetic of its own. Meaningful only for TailMode::Manual.
|
||||
// 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 for a setting, e.g. "Tail: Off", "Tail: Auto". In Manual mode the
|
||||
// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s" —
|
||||
// Off/Auto carry no length. Pure so the exact strings (and the Manual format) are
|
||||
// test-pinned, including the boundary lengths (0.0s, 8.0s).
|
||||
// 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 (mode + manualMs), for persist to store the tail
|
||||
// setting per-project alongside the bank and view model. Kept pure/testable here —
|
||||
// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a
|
||||
// compact object; deserialize returns std::nullopt on malformed input so the caller
|
||||
// (persist) falls back to a default setting, exactly as an absent key does.
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
SiblingPlacement siblingPlacement(const std::vector<int>& folderDepths, int srcIndex) {
|
||||
const int count = static_cast<int>(folderDepths.size());
|
||||
if (count == 0) return SiblingPlacement{};
|
||||
|
||||
const int src = srcIndex < 0 ? 0 : (srcIndex >= count ? count - 1 : srcIndex);
|
||||
|
||||
// levels[i] is track i's absolute nesting depth; levels[count] is the depth the
|
||||
// list closes at (0 in a well-formed project). Negative is unrepresentable, so a
|
||||
// malformed over-closing delta clamps here rather than propagating.
|
||||
std::vector<int> levels(static_cast<std::size_t>(count) + 1, 0);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const int next = levels[static_cast<std::size_t>(i)] +
|
||||
folderDepths[static_cast<std::size_t>(i)];
|
||||
levels[static_cast<std::size_t>(i) + 1] = next < 0 ? 0 : next;
|
||||
}
|
||||
|
||||
const int L = levels[static_cast<std::size_t>(src)];
|
||||
|
||||
int p = src + 1;
|
||||
if (folderDepths[static_cast<std::size_t>(src)] >= 1) {
|
||||
p = count; // an unterminated folder swallows the rest of the list
|
||||
for (int j = src + 1; j <= count; ++j) {
|
||||
if (levels[static_cast<std::size_t>(j)] == L) { p = j; break; }
|
||||
}
|
||||
}
|
||||
|
||||
SiblingPlacement out;
|
||||
out.insertIndex = p;
|
||||
out.precedingIndex = p - 1;
|
||||
out.precedingDepth = L - levels[static_cast<std::size_t>(p - 1)];
|
||||
out.newDepth = levels[static_cast<std::size_t>(p)] - L;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
// track_topology — pure folder arithmetic over a project's track list: which tracks
|
||||
// are the DIRECT children of a folder parent, and where a new SIBLING of a given
|
||||
// track goes, both 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);
|
||||
|
||||
// Where a new track goes so it is a SIBLING of `srcIndex` — same nesting level, same
|
||||
// folder — and the two I_FOLDERDEPTH writes that put it there.
|
||||
struct SiblingPlacement {
|
||||
int insertIndex = 0; // the index the new track occupies after insertion
|
||||
|
||||
// The track that will PRECEDE the new one (insertIndex - 1), and its rewritten
|
||||
// delta. -1 only for a degenerate empty list, where there is nothing to write.
|
||||
int precedingIndex = -1;
|
||||
int precedingDepth = 0;
|
||||
|
||||
int newDepth = 0; // the new track's own I_FOLDERDEPTH
|
||||
};
|
||||
|
||||
// Both naive answers are audibly wrong, which is why this is arithmetic and not
|
||||
// `srcIndex + 1`: inserting straight after a folder PARENT makes the new track that
|
||||
// folder's first child (its audio re-enters the parent's FX and fader), and inserting
|
||||
// straight after the folder's LAST track steals that track's closing delta and drops
|
||||
// the new one outside the folder entirely (its audio bypasses the folder bus).
|
||||
//
|
||||
// Levels are absolute nesting depths recovered from the deltas (level[0] = 0,
|
||||
// level[i+1] = level[i] + depth[i]). A folder parent's insert point is the first
|
||||
// following track back at the source's own level — i.e. after the whole folder;
|
||||
// everything else inserts directly below the source. On a well-formed delta list
|
||||
// (one whose deltas sum to zero) the two writes preserve the total delta sum, so no
|
||||
// track after the insertion changes level — the malformed case below does not carry
|
||||
// that guarantee; the clamp keeps the result legal, not level-preserving.
|
||||
//
|
||||
// A malformed list (deltas not summing to zero, an out-of-range srcIndex) CLAMPS to
|
||||
// the nearest legal placement rather than asserting: the failure mode of a corrupt
|
||||
// project must be a track at the wrong nesting level, never a crash.
|
||||
SiblingPlacement siblingPlacement(const std::vector<int>& folderDepths, int srcIndex);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -1,19 +1,18 @@
|
||||
// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor.
|
||||
//
|
||||
// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the
|
||||
// content hash both walk with it, so their view of the container cannot drift.
|
||||
// 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
|
||||
#include <utility> // std::move
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// Little-endian readers. Bounds are checked by the caller before each read; these
|
||||
// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB.
|
||||
// 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));
|
||||
}
|
||||
@@ -28,7 +27,7 @@ bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char*
|
||||
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 FORMAT ASSUMPTION).
|
||||
// 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;
|
||||
|
||||
@@ -37,19 +36,16 @@ constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL;
|
||||
constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
|
||||
|
||||
std::string fnvHex(std::uint64_t h) {
|
||||
// 16-digit lowercase hex (zero-padded) for a fixed-length string.
|
||||
char buf[17];
|
||||
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 --------------------------------------------
|
||||
// --- The one RIFF chunk traversal --------------------------------------------
|
||||
//
|
||||
// One sub-chunk of a RIFF/WAVE container as the walk sees it: header at
|
||||
// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`.
|
||||
// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk
|
||||
// whose declared size lies past the end is still REPORTED (callers decide how to
|
||||
// treat it) but its body must not be read.
|
||||
// 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;
|
||||
@@ -60,9 +56,8 @@ struct WavChunkView {
|
||||
// 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 — exactly the process-then-
|
||||
// break shape the pre-consolidation walkers shared.
|
||||
// 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;
|
||||
@@ -100,8 +95,8 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
||||
std::uint32_t sampleRate = 0;
|
||||
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
|
||||
|
||||
// Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A
|
||||
// malformed/truncated file is "invalid", never an OOB read.
|
||||
// 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)) {
|
||||
@@ -112,11 +107,9 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
||||
channels = readU16LE(bytes, c.bodyOffset + 2);
|
||||
sampleRate = readU32LE(bytes, c.bodyOffset + 4);
|
||||
bitsPerSample = readU16LE(bytes, c.bodyOffset + 14);
|
||||
// For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading
|
||||
// 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM
|
||||
// integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to
|
||||
// reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in
|
||||
// the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected).
|
||||
// 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);
|
||||
@@ -124,16 +117,13 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
||||
}
|
||||
haveFmt = true;
|
||||
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
||||
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
|
||||
// Reject if it runs past the buffer (truncated / lying header).
|
||||
// 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; // data before fmt — not a WAV we parse
|
||||
if (!haveFmt) return out;
|
||||
|
||||
// Plain IEEE-float tag (0x0003): accept as-is.
|
||||
// Extensible tag (0xFFFE): accept only when the SubFormat tag read from
|
||||
// the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag
|
||||
// 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT
|
||||
// float and must be rejected to prevent mis-decoding as float.
|
||||
// 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);
|
||||
@@ -268,6 +258,76 @@ std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
return out;
|
||||
}
|
||||
|
||||
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes) {
|
||||
MonoCollapse out;
|
||||
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid || layout.channelCount < 2) return out;
|
||||
|
||||
const std::size_t frames = layout.frameCount();
|
||||
if (frames == 0) return out;
|
||||
|
||||
const std::size_t stride = layout.channelCount;
|
||||
const std::vector<AudioSample> pcm = extractFloatFrames(bytes, layout, 0, frames);
|
||||
if (pcm.size() != frames * stride) return out; // short read -> decline, never guess
|
||||
|
||||
// Bit patterns, not values: see the header. memcpy is the only defined float->bits
|
||||
// read, and it compiles to a register move.
|
||||
auto bitsOf = [](AudioSample s) {
|
||||
std::uint32_t bits = 0;
|
||||
std::memcpy(&bits, &s, 4u);
|
||||
return bits;
|
||||
};
|
||||
for (std::size_t f = 0; f < frames; ++f) {
|
||||
const std::uint32_t first = bitsOf(pcm[f * stride]);
|
||||
for (std::size_t c = 1; c < stride; ++c) {
|
||||
if (bitsOf(pcm[f * stride + c]) != first) return out;
|
||||
}
|
||||
}
|
||||
|
||||
// float -> double -> float round-trips exactly for every finite value and for
|
||||
// +-0/+-infinity (double represents every float bit pattern in those classes), so
|
||||
// channel 0 reaches the rebuilt file unaltered. The one hole: a signaling NaN is
|
||||
// quieted by the float->double promotion, so an identical-bit sNaN pair could
|
||||
// collapse to a different bit pattern than it started with. Not reachable from
|
||||
// REAPER-rendered audio, but the bit-identical predicate above admits NaN inputs,
|
||||
// so this rebuild is not exempt from the claim it makes.
|
||||
std::vector<double> mono(frames);
|
||||
for (std::size_t f = 0; f < frames; ++f)
|
||||
mono[f] = static_cast<double>(pcm[f * stride]);
|
||||
|
||||
out.collapsed = true;
|
||||
out.bytes = buildFloat32Wav(1, layout.sampleRate, frames, mono);
|
||||
return out;
|
||||
}
|
||||
|
||||
CollapsedWav applyMonoCollapse(std::vector<std::uint8_t> bytes) {
|
||||
CollapsedWav out;
|
||||
MonoCollapse collapse = collapseToMono(bytes);
|
||||
if (collapse.collapsed) {
|
||||
const WavLayout rebuilt = parseWavLayout(collapse.bytes);
|
||||
if (rebuilt.valid) {
|
||||
out.bytes = std::move(collapse.bytes);
|
||||
out.layout = rebuilt;
|
||||
out.collapsed = true;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
out.bytes = std::move(bytes);
|
||||
out.layout = parseWavLayout(out.bytes);
|
||||
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;
|
||||
@@ -279,12 +339,6 @@ std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
||||
}
|
||||
|
||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
||||
// Walk the RIFF/WAVE container (the shared traversal) 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.
|
||||
if (isRiffWave(bytes)) {
|
||||
std::uint64_t h = kFnvOffsetBasis;
|
||||
auto feedByte = [&](std::uint8_t b) {
|
||||
@@ -295,23 +349,18 @@ std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
||||
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'));
|
||||
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 ")) {
|
||||
// Feed the entire fmt body (all fields, including format tag, channels,
|
||||
// sample rate, bits-per-sample — everything that defines the audio format).
|
||||
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")) {
|
||||
// Feed the entire PCM payload.
|
||||
if (c.bodyInBounds) {
|
||||
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
||||
feedByte(bytes[c.bodyOffset + i]);
|
||||
|
||||
+100
-103
@@ -1,39 +1,9 @@
|
||||
#pragma once
|
||||
// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e:
|
||||
// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32
|
||||
// build + size-field patch + the WAV-aware content hash, in one tested module.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
|
||||
//
|
||||
// Before this module, RIFF container knowledge (chunk-header arithmetic, even-byte
|
||||
// padding, size fields) was minted at four sites: wav_trim's layout parse,
|
||||
// capture_paths' content-hash chunk walk, ingest's hand-built float32 writer, and
|
||||
// capture_realtime's in-place size patch. 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. Now every walker/builder/patcher is here,
|
||||
// on ONE chunk-traversal implementation.
|
||||
//
|
||||
// WHY TRIM EXISTS (docs/product/capture-tail.md §The realtime path). The realtime
|
||||
// backend records a generous tail window, then trims the trailing decay by
|
||||
// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is
|
||||
// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk
|
||||
// size and the `data` sub-chunk size) must be patched to the kept byte count, or
|
||||
// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking,
|
||||
// format verification, and the size-field patch offsets — is exactly the fiddly,
|
||||
// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER
|
||||
// shell does only the file I/O: read the bytes, call the pure parse, run the decay
|
||||
// scan, call the pure plan, patch + write the truncated bytes.
|
||||
//
|
||||
// FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV
|
||||
// (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project
|
||||
// record format, which the manual procedure sets to WAV/32-bit-float). The parser
|
||||
// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt `
|
||||
// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE
|
||||
// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything
|
||||
// else (a different depth, a non-WAV, a compressed source) is reported invalid and
|
||||
// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a
|
||||
// file it does not understand. This is deliberately conservative.
|
||||
// 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>
|
||||
@@ -49,22 +19,20 @@ 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 (see FORMAT ASSUMPTION); every other field
|
||||
// is meaningful only when valid.
|
||||
// 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 ` (the interleave stride)
|
||||
std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed)
|
||||
std::uint16_t channelCount = 0; // from `fmt ` (interleave stride)
|
||||
std::uint32_t sampleRate = 0;
|
||||
|
||||
// The `data` chunk: byte offset of its first PCM byte within the file, and its
|
||||
// declared PCM byte length. frameCount = dataByteLength / (channelCount * 4).
|
||||
// The `data` chunk: PCM byte offset + declared length.
|
||||
// frameCount = dataByteLength / (channelCount * 4).
|
||||
std::size_t dataByteOffset = 0;
|
||||
std::size_t dataByteLength = 0;
|
||||
|
||||
// Byte offset of the two little-endian uint32 size fields the truncate patch
|
||||
// rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk
|
||||
// size (the 4 bytes immediately before dataByteOffset).
|
||||
// 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;
|
||||
|
||||
@@ -74,19 +42,15 @@ struct WavLayout {
|
||||
}
|
||||
};
|
||||
|
||||
// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything
|
||||
// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk,
|
||||
// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only
|
||||
// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB).
|
||||
// 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 (the shape peaks consumes).
|
||||
// Clamps to the frames the buffer actually holds — never reads past `data`. Returns
|
||||
// empty for an invalid layout or an out-of-range start. The floats are read
|
||||
// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would
|
||||
// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux
|
||||
// on x86/ARM-LE) is little-endian and REAPER writes LE WAV.
|
||||
// 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,
|
||||
@@ -94,10 +58,8 @@ std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& byt
|
||||
|
||||
// --- Truncate plan + size-field patch ---------------------------------------
|
||||
|
||||
// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte
|
||||
// length and the two size-field values to patch. `valid` is false if the layout is
|
||||
// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller
|
||||
// clamps beforehand; this guards it too).
|
||||
// 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;
|
||||
|
||||
@@ -109,64 +71,99 @@ struct WavTruncatePlan {
|
||||
// the 8-byte "RIFF"+size prefix)
|
||||
};
|
||||
|
||||
// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV.
|
||||
// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure +
|
||||
// total. The shell applies it: patch the two size fields in the byte buffer
|
||||
// (patchU32LE), then truncate the file to newFileByteLength.
|
||||
// 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` — the RIFF/data size
|
||||
// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size()
|
||||
// (the plan's offsets came from a valid parse of the same buffer).
|
||||
// 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 32-bit-float RIFF/WAVE byte buffer from interleaved
|
||||
// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT,
|
||||
// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels,
|
||||
// `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is
|
||||
// narrowed to float by cast — the bank contract is 32-bit float (see FORMAT
|
||||
// ASSUMPTION above); the reduction is intentional. The output round-trips through
|
||||
// parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical
|
||||
// source through REAPER's PCM_source, then writes the bank copy with this.
|
||||
// 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);
|
||||
|
||||
// A buffer after the collapse has had its say, PAIRED with the parse of the bytes
|
||||
// actually returned — so a caller that hashes `bytes`, reads a channel count off
|
||||
// `layout` and then writes `bytes` cannot describe one buffer while writing another.
|
||||
struct CollapsedWav {
|
||||
std::vector<std::uint8_t> bytes; // the rebuilt 1-channel WAV, or the input verbatim
|
||||
WavLayout layout; // the parse OF `bytes`
|
||||
bool collapsed = false;
|
||||
};
|
||||
|
||||
// `collapseToMono` over a whole buffer, for a caller that goes on to hash and measure
|
||||
// the result rather than rewrite a file (`shell/capture`'s collapseCapturedFileToMono is
|
||||
// the file-side path over the same predicate). Takes the buffer by value: a decline hands
|
||||
// those same bytes straight back. A rebuild that does not parse back is discarded rather
|
||||
// than returned, so an invalid `layout` can only ever mean the INPUT was not a usable WAV.
|
||||
CollapsedWav applyMonoCollapse(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) -----------------------------------------
|
||||
|
||||
// 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).
|
||||
// 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 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) and the ingest import
|
||||
// in place of the raw hashBytes call. Walks the container with the SAME chunk
|
||||
// traversal parseWavLayout uses, so hashing and decoding can never desynchronize.
|
||||
// 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,404 @@
|
||||
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / param / ui)
|
||||
|
||||
## Scope
|
||||
|
||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in six
|
||||
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`.
|
||||
- **`param/`** — what the instrument tells a VST3 host about its automatable parameters,
|
||||
with no VST3 type in it: the FOREVER-FROZEN id table, the exposed set derived from
|
||||
`deckParamCommit`, the plain-value layer, and the one formatter per unit category. Sits
|
||||
ABOVE `ui/` — the list is a function of the commit predicate, never the reverse. See
|
||||
`param/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.
|
||||
|
||||
- **How a control reaches the audio is ONE decision, recorded in ONE place** — `deckParamCommit`
|
||||
and `liveCommitFor` (`ui/deck_groups`), a THREE-state classification (`Live` /
|
||||
`NoteOnLatched` / `Reload`) whose header is THE home for where each control sits and why —
|
||||
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: **clicking anywhere in an envelope deck (amp, pitch, filter) — panel,
|
||||
knob or button — focuses that deck's overlay**, and the focused deck takes an `AccentPrimary`
|
||||
border. At most one is overlay-active, and **none is a valid resting state — the editor opens
|
||||
there**, reached by clicking a control surface outside the three decks. Focus SETS rather than
|
||||
toggles, so editing the deck you selected cannot deselect it; the overlay itself neither steals
|
||||
nor clears focus for the same reason. Never persisted; it selects what is drawn, not what is
|
||||
played. *(The per-deck corner radio this replaced is gone; MASTER keeps the corner slot for its
|
||||
passive gain-reduction lamp.)*
|
||||
|
||||
**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), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), 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 AND time-stretcher 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()`.
|
||||
- **The WRITE rate (duration) and the TAP rate (pitch) are independent, and that is the whole time-stretcher** — `writeFrame` for a surplus source frame, `processNoInput` for a starved output frame, plain `process` for the 1:1 case, `setShiftRatio` for pitch, and `setFeedRate` so the splice crossfade is sized against the real drain rate. The header owns the argument, including why this is not the resampled-read-with-a-cancelling-shift the `WDL_Resampler` invariant above forbids.
|
||||
- **Splices are PITCH-SYNCHRONOUS when the source's period is known** (`setSourcePeriod`, fed from `period_detect` via the loader): the nominal jump becomes the multiple of that period nearest the window that still fits the ring's jump bound (~1.25 windows), so an aligned landing point sits at the CENTRE of the correlation search instead of possibly not existing inside it at all. The search is unchanged and still earns its keep — it absorbs the jump's rounding to whole frames and tracks a source whose period drifts. **An unknown period restores the fixed-window geometry exactly** (`periodAlignedJump`, `pitch_shift.h`); do not "simplify" that fallback into an approximation of it.
|
||||
- `period_detect` — the source's own fundamental period, estimated ONCE per load (two-pass YIN:
|
||||
a decimated cumulative-mean-normalized difference picks the period, the full-rate difference
|
||||
function refines it to a fraction of a frame), so `pitch_shift`'s splice jump can be a whole
|
||||
number of it. **Runs off the audio thread by link graph** (`period_detect.h` is the one home
|
||||
for that invariant) — the same shape as the extension's link graph not gaining the voice
|
||||
engine. Its one caller is the loader (`map/sample_map`'s
|
||||
`buildSampleData`), which hands the answer down on `SampleData::sourcePeriodFrames`. A period
|
||||
is DERIVED from the audio, so it is cache and not state: nothing persists it, and it takes no
|
||||
rung of the payload ladder. **Answering "none" is a first-class result** — noise, polyphony,
|
||||
percussion and a source whose period changes mid-sample all return it, and the shifter's
|
||||
fixed-window geometry is the documented fallback. **Detection analyses the SUSTAIN LOOP when
|
||||
the capture carries one long enough to host the full search band** (`periodAnalysisSpan`),
|
||||
otherwise the whole source: the loop is what a Gate voice asymptotically plays, and a phrase
|
||||
whose head is pitched differently from its sustain would otherwise disagree its way to none.
|
||||
A shorter loop analyses the whole source rather than a narrowed band — a narrower span may
|
||||
never buy itself a higher lowest-findable fundamental.
|
||||
- `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header.
|
||||
- `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, the processor multiply and the host's `toPlain` so the needle, persisted value, audio multiply and reported dB cannot drift. Math only — the dB label is `param/param_format`'s, so the editor and the host cannot print it two ways.
|
||||
- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle.
|
||||
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`.
|
||||
|
||||
### `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…v16), 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, v15 the master-bus limiter enable, v16 the playback rate + the baseline pitch offset. 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), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups_measured.cpp`, the one place that already includes both headers. 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 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, loop enable, 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: `resolveLaneSplit` is THE lane-split decision (two lanes 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), free of any pixel geometry so the meter's bar count can ask the same question without a band rect; `waveformSurface` folds it and then measures it against the band, which is why its `laneCount` can still report 1 for a Stereo split on a band too thin to divide. It also yields **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect, plus generic named draggable markers with a drag-delta resolver, clamp, and the zero-crossing snap. The snap is RADIUS-BOUNDED (`snapToZeroCrossing`), the radius denominated in PIXELS and converted through `xToFrame`; `nearestZeroCrossing` beside it is the unbounded primitive and is NOT what a drag applies. Ctrl defeats the snap outright at the shell (`editor_input_waveform.cpp`), the same "get out of the way, I want exactness" role it plays on the knobs' fine drag. `markerHandleRect` is 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 band's DRAWN COLUMN SPAN at the band's full height, in both modes: same inset and same width as `component_geometry`'s `waveformColumnCount`, so overlay pixel and waveform column are one pixel and every overlay lands on the column that draws the frame it names. Deriving that width instead of restating the inset is what keeps the two chains from drifting apart again. 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.
|
||||
- **The four marks.** One grammar — line + shaped cap + label — over START / LOOP / END / XFADE. Which of them are PRESENT is the mode's answer, and drawn iff grabbable: Trigger carries START alone (the loop pair and the fade are absent, not dim), Gate always carries the pair so the drag-to-set-loop gesture survives the enable being off, and the fade needs an active loop to have a seam. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes.
|
||||
- `loop_marks` — the loop enable's state machine, split from the geometry above on the axis the surface already has: that says where a mark is, this says what the loop IS. `SampleLoop::hasLoop` is the single authority and `resolveLoopMarks`/`applyLoopMarks` are its only two folds — the resolve re-parks on `defaultLoopBounds` only when the span is one `resolveLoop` would refuse (so a user's off keeps its positions and `parked` separates the two OFF states), and the write folds collapse-to-off in and ties the crossfade to the SPAN rather than to the enable. Links `loop_span` so the span the user is offered and the span the engine accepts stay one definition.
|
||||
- `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_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required.
|
||||
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know.
|
||||
- `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, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n−1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. 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 deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. 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, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, and the cells present are CENTRED in it at their natural `kDeckCellW`**: a `-1` id reserves one cell's width without a cell, and the reserve is paid for in symmetric end margins, never in wider cells. 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 stretching the knobs off their pitch; only the reserve COUNT matters, so where a `-1` sits in `cellIds` is free. Two rejected laws, recorded so they are not re-proposed: dropping the reserves (reflows every neighbour on a mode flip, which is what the reserves exist to prevent), and dividing the run between the cells present (the retired law — it handed a reduced face 90–100 px cells). A SPANNING group is the one place a reserve's position still matters, because there it advances a fixed slot.
|
||||
- `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 binding and its clamps, over
|
||||
`param_taper`'s maps), `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 the
|
||||
value is COPIED rather than round-tripped: that taper bypass is mandatory and must never be
|
||||
"simplified" back into a norm round trip), `deckParamUnit`/`snapDeckParamNorm` (THE snap-unit
|
||||
table, and where each control's full scale enters — a whole DISPLAYED percent is a different
|
||||
norm step at 0..100 %, 0..200 % and ±100 %). Display FORMATTING is not here — `param/`'s
|
||||
`param_format` owns the one formatter per unit category, because the host and the editor must
|
||||
be its two callers and neither may hold a second implementation. 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.
|
||||
- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. `kMeterColumnW` is the SUM of those three, exported so `deck_groups`' MASTER descriptor reserves exactly what the interior consumes — the column is banked to grow, and a reserve that did not track it would underfill or overrun silently. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveform_view`'s `resolveLaneSplit` answers** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Also owns `meterTickNumeralled` (the spec-pinned 0/−12/−24/−36/−48/−60 numeral set, beside the tick step it derives from), `meterNumeralRect` (bottom-clamped, so the floor tick's numeral cannot hang out of the gutter), and `meterSingleLaneState` — the one bar folds both channels PER FIELD, never picking a whole channel by level. Composes `engine/meter_ballistics` per channel and gives the gain-reduction lamp the peak tick's own hold-then-release, without which a catch smaller than 20 dB × the UI period is dark again before it has been drawn twice; the audio thread's clip flag is ORed in because it is the only latch that sees every block. `meterDrawEqual` is what lets the UI tick repaint on change alone. Also owns the editor's two paint-dispatch predicates, so neither lives as a bare comment in the shell: `meterFastPathEligible` (is a WM_PAINT dirty rect wholly inside the field?) and `meterBarsWithinField` (the self-containment invariant that fast path rests on), both asserted in `test_master_meter.cpp`.
|
||||
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `overlayEnvForGroup`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-FOCUS state machine (which deck names which envelope, 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; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the −92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164.
|
||||
- `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. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
|
||||
- `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.
|
||||
- `curve_tessellate` — the staged envelope's TRACE, split from `envelope_overlay` on the axis those two already have: that module decides where a node LANDS, this strokes the span BETWEEN two of them. Joins the non-knot vertices with the curve each stage's exponent defines, sampled one point per pixel column, at `start + (end - start) * curveMap(phi)` — the composition `envelopes.h`'s four evaluators use, so a drawn stage and the sound it makes cannot diverge. Node vertices keep their exact integer coordinates (the handles are drawn on them); only the interior samples are sub-pixel. A neutral exponent or a zero level span emits the two endpoints and nothing between, which is the straight stroke drawn before curves existed, vertex for vertex.
|
||||
- `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, and it is not linear in seconds either** — it does NOT line up with the waveform under it, and each of its four equal stage slots is filled by `param_taper`'s own norm, so a node's position within its slot IS its knob's needle position. Two stages therefore cannot be compared by eye at a 10:1 ratio; the ms labels carry the number. Only a sustain-less AHD's x-axis is wall-clock/PCM-aligned and linear. Content-fit auto-scale and a minimum drawn stage width were both considered and REJECTED — the first moves the axis under the hand, the second decouples the drawn position from the value and breaks the drag inverse.
|
||||
- **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,13 @@
|
||||
add_subdirectory(engine)
|
||||
add_subdirectory(map)
|
||||
add_subdirectory(note)
|
||||
add_subdirectory(ui)
|
||||
# After ui: the VST3 parameter identity reads the deck's commit predicate and its value binding.
|
||||
add_subdirectory(param)
|
||||
# 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,107 @@
|
||||
# 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 chain is printed — voice, master gain, then the limiter, in the processor's
|
||||
own order.** `bake_render.cpp`'s master stage carries the argument. The limiter is printed
|
||||
only when it is ENGAGED; bypassed, `renderBake` never constructs one and the result is the
|
||||
pre-limiter render frame for frame. The lookahead is compensated inside the render — the
|
||||
buffers carry an extra flush window and the capture is read past it — so an engaged bake
|
||||
under the ceiling is bit-identical to a bypassed one, not the same audio 2 ms late.
|
||||
- **A printed capture replayed through an engaged limiter is limited TWICE — a NAMED
|
||||
boundary, not a bug**, and the same shape as the automation-lane limitation below. The
|
||||
reset is what normally prevents it (`limiterEnabled` is not on the survive list, so a bake
|
||||
hands the enable back off), and at unity the second pass has nothing to take: every sample
|
||||
of the printed file is already at or under the ceiling, and the limiter reduces only where
|
||||
its detector reads ABOVE it — which after a bake means its inter-sample estimate alone. Dial
|
||||
the enable back on over raised gain, though, and the capture is limited on top of limiting
|
||||
that is already in its samples. Not detectable from inside the instrument and not corrected
|
||||
there; the user's remedy is to leave the enable where the bake put it.
|
||||
- **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.
|
||||
- **`kStageTimeMaxSeconds` (the stage-time ceiling `param_taper` owns) is not a reset-list
|
||||
candidate at all** — it bounds a knob's taper, is never itself a dialed value, and so has
|
||||
no disposition to classify against the ratified reset rule.
|
||||
- **A host automation lane outranks the reset, and the bake cannot clear it — a NAMED
|
||||
limitation, not a bug.** Every reset-class value that is also an exposed VST3 parameter is
|
||||
now notified to the host (the reset writes through `setInstrumentParams`, which is the one
|
||||
notification funnel), so the host's DISPLAY follows the reset. A lane, however, lives in the
|
||||
host's project data: if a reset-class parameter carries one, the host replays its curve onto
|
||||
audio that already has that processing baked in — double processing, and the "sounds as the
|
||||
dialled instrument sounded just before the click" claim does not hold in that case. There is
|
||||
no detection available: `IAutomationState` reports the host's automation mode for the whole
|
||||
plug-in, not per parameter, so both "refuse the bake" and "reset only the un-automated ones"
|
||||
are unbuildable rather than merely unattractive. The user's remedy is to remove the lane.
|
||||
|
||||
## 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 and then the master stage, 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 read-rate bound is an upper bound, not a model.** Anything that
|
||||
slows the read makes the head take longer to cross its span, so the window is scaled by the
|
||||
slowest read the voice can reach — a shallower excursion leaves trailing silence in the file.
|
||||
Rate is a term of it under BOTH engines and the deepest downward pitch offset under Varispeed
|
||||
alone (`playbackStretch` argues each); both the Trigger span and the Gate exhaustion length
|
||||
take the product, and the Gate-with-loop branch takes neither.
|
||||
- **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,24 @@
|
||||
# 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)
|
||||
|
||||
# limiter beside sampler_core, not through it: the render prints the whole master stage, and
|
||||
# the limiter runs on the summed output rather than inside a voice.
|
||||
reasampler_pure_library(bake_render
|
||||
SOURCES bake_render.cpp
|
||||
LINK PUBLIC bake_plan sampler_core limiter)
|
||||
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)
|
||||
# loop_marks is a TEST-only edge: it defines what a neutral loop looks like on the band, so
|
||||
# the reset's loop assertions read it rather than restating it.
|
||||
reasampler_test(bake_reset LINK bake_reset loop_marks)
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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/time_stretch.h" // clampStretchRate (THE rate bound)
|
||||
#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;
|
||||
}
|
||||
|
||||
// OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the
|
||||
// factor a source span is scaled by to bound how long it takes to play out. Two terms:
|
||||
//
|
||||
// Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve
|
||||
// feeds the stretcher at it, so either way the source is consumed at that many frames per
|
||||
// output frame. Taken through the engine's clamp, because that is the value Voice::start
|
||||
// actually plays.
|
||||
//
|
||||
// The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head
|
||||
// advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read
|
||||
// rate alone, which is the only sense in which the two are decoupled there.
|
||||
//
|
||||
// A Gate release is ticked per output frame, so neither term touches it.
|
||||
double playbackStretch(const PlayParams& play, int velocity) {
|
||||
double down = 0.0;
|
||||
if (play.pitchEngine == PitchEngine::Varispeed) {
|
||||
down = (std::min)(0.0, kVelocityPitchRangeSemitones *
|
||||
play.pitchVelocityCurve.eval(velocity));
|
||||
// Taken as a bound rather than exactly, like the velocity term beside it: an upward
|
||||
// offset only makes the read faster, and every term in this sum is a floor.
|
||||
down += (std::min)(0.0, play.pitchOffsetSemitones);
|
||||
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 std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate);
|
||||
}
|
||||
|
||||
// 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 = playbackStretch(dialed.play, p.velocity.value());
|
||||
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,94 @@
|
||||
// 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
|
||||
// slowest read the dialed voice can reach: Rate under BOTH engines, plus the
|
||||
// deepest downward pitch offset under Varispeed.
|
||||
// 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 half of that 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,118 @@
|
||||
// See bake_render.h.
|
||||
|
||||
#include "core/instrument/bake/bake_render.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "core/instrument/engine/limiter.h"
|
||||
#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,
|
||||
bool limiterEnabled) {
|
||||
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();
|
||||
// The limiter delays its output by its lookahead, so the buffers carry that many extra
|
||||
// frames and the window is read that far in — the file is the same frames it would be
|
||||
// with the limiter bypassed, not the capture shifted late by 2 ms. The extra input is
|
||||
// SILENCE rather than more rendered audio: the file ends at the window, so a peak past
|
||||
// it is not in the capture and must not duck the frames that are.
|
||||
const auto flushFrames = static_cast<std::size_t>(
|
||||
limiterEnabled ? engine::limiterLookaheadSamples(plan.sampleRate) : 0);
|
||||
const auto rendered = static_cast<std::size_t>(plan.renderFrames());
|
||||
std::vector<AudioSample> left(rendered + flushFrames, 0.f);
|
||||
std::vector<AudioSample> right(channels == 2 ? rendered + flushFrames : 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;
|
||||
}
|
||||
|
||||
// The whole master stage is printed here rather than left for the processor, in the
|
||||
// processor's own order — gain, then the limiter — because resetAfterBake hands both
|
||||
// controls back neutral: a render that only summed voices would return every iteration
|
||||
// shifted by 1/gain and unlimited, and a gain dialed to silence would come back at full
|
||||
// level. A flat gain 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 (AudioSample& s : left) s *= gain;
|
||||
for (AudioSample& s : right) s *= gain;
|
||||
|
||||
if (limiterEnabled) {
|
||||
engine::Limiter limiter;
|
||||
// Enabled BEFORE prepare, whose reset snaps to the enable target: that starts the
|
||||
// render already engaged. Enabling afterwards takes process()'s live-engage path,
|
||||
// which mutes for the delay-line prime and then fades in — silencing the head of the
|
||||
// capture. prepare()'s allocation and transcendentals are legal here: the bake runs
|
||||
// on the UI thread, never in process().
|
||||
limiter.setEnabled(true);
|
||||
limiter.prepare(plan.sampleRate);
|
||||
// One call: kMaxBakeFrames bounds the whole buffer well inside int, and a block
|
||||
// split would change nothing (the limiter carries its state across calls).
|
||||
limiter.process(left.data(), channels == 2 ? right.data() : nullptr,
|
||||
static_cast<int>(left.size()));
|
||||
}
|
||||
|
||||
out.channelCount = channels;
|
||||
out.sampleRate = plan.sampleRate;
|
||||
const auto lead = static_cast<std::size_t>(plan.leadInFrames) + flushFrames;
|
||||
const auto total = static_cast<std::size_t>(plan.totalFrames);
|
||||
out.interleaved.resize(total * static_cast<std::size_t>(channels));
|
||||
for (std::size_t f = 0; f < total; ++f) {
|
||||
out.interleaved[f * channels] = left[lead + f];
|
||||
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
@@ -0,0 +1,40 @@
|
||||
// bake_render — the offline pass: one programmed note through a voice engine built for
|
||||
// this render alone, summed into an interleaved buffer.
|
||||
//
|
||||
// Never touches a live engine and never runs on the audio thread. Takes its SampleData BY
|
||||
// VALUE for the reason this directory's CLAUDE.md records.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/bake/bake_plan.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
struct BakeAudio {
|
||||
std::vector<AudioSample> interleaved; // [f0c0, f0c1, f1c0, …]
|
||||
int channelCount = 0; // 0 = nothing rendered
|
||||
int sampleRate = 0;
|
||||
|
||||
std::int64_t frameCount() const {
|
||||
return channelCount > 0
|
||||
? static_cast<std::int64_t>(interleaved.size()) / channelCount
|
||||
: 0;
|
||||
}
|
||||
bool empty() const { return frameCount() == 0; }
|
||||
};
|
||||
|
||||
// Renders `plan` through `sample`'s own voice path and then the master stage the processor
|
||||
// runs after the engine: `masterGainLinear`, then the limiter when `limiterEnabled` — see
|
||||
// bake_render.cpp for why both print here rather than in the processor. `limiterEnabled`
|
||||
// false yields the pre-limiter render. The result is the plan's captured window: the
|
||||
// lead-in frames are rendered and dropped. An unplayable sample yields an empty result.
|
||||
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear,
|
||||
bool limiterEnabled);
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
@@ -0,0 +1,29 @@
|
||||
// See bake_reset.h.
|
||||
|
||||
#include "core/instrument/bake/bake_reset.h"
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
BakeReset resetAfterBake(const map::InstrumentParams& dialed) {
|
||||
BakeReset out;
|
||||
// The root is what the note was rendered at, so it is exactly what the new capture
|
||||
// plays back at unity — resetting it would detune every following iteration.
|
||||
out.params.rootOverride = dialed.rootOverride;
|
||||
// How far pitch tracks the keyboard is a fact about the mapping; a single rendered
|
||||
// note carries no trace of it.
|
||||
out.params.keyTrack = dialed.keyTrack;
|
||||
// There is no key-range parameter to carry (core/instrument/CLAUDE.md: no key-range
|
||||
// concept) — if one is ever added it belongs on this list, not in the defaults.
|
||||
|
||||
// Play mode is on neither ratified list, so it is classified here, and the acceptance
|
||||
// criteria decide it: the bake's product is a finished one-shot carrying its own
|
||||
// attack, span and release. Trigger plays that back verbatim — note-off ignored, the
|
||||
// default AHD flat at unity over the whole span. Gate would re-gate it: the default
|
||||
// release would cut the printed tail at note-off, and every further iteration would cut
|
||||
// the previous one's again. "Neutral" for this control means "adds no processing",
|
||||
// which is Trigger, not the value struct's own Gate default.
|
||||
out.params.play.playMode = PlayMode::Trigger;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
@@ -0,0 +1,26 @@
|
||||
// bake_reset — hand the instrument back neutral after a bake: the dialed processing now
|
||||
// lives in the recaptured audio, so the controls that produced it return to their defaults.
|
||||
//
|
||||
// The rule, ratified by Daniel: a control resets iff its effect is in the printed audio; a
|
||||
// MAPPING fact survives, because it describes how the file is played, not how it was made.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/map/sample_map.h" // InstrumentParams
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
// The two surfaces a bake resets. Master gain lives on the processor rather than in the
|
||||
// parameter set; it is answered here because renderBake prints it into the file (see
|
||||
// bake_render.cpp's master stage) rather than left to the shell. The limiter needs no field
|
||||
// of its own: its enable rides the parameter set, and the render prints it too.
|
||||
struct BakeReset {
|
||||
map::InstrumentParams params;
|
||||
double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain
|
||||
};
|
||||
|
||||
// Everything defaults; the survivors are copied back explicitly (this directory's CLAUDE.md
|
||||
// owns why that direction, and which classifications are ratified).
|
||||
BakeReset resetAfterBake(const map::InstrumentParams& dialed);
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
@@ -0,0 +1,95 @@
|
||||
# The shifter is hand-rolled rather than WDL_SimplePitchShifter because that header drags
|
||||
# <windows.h> in via wdltypes.h, which cannot enter the pure engine.
|
||||
reasampler_pure_library(pitch_shift SOURCES pitch_shift.cpp LINK PUBLIC peaks)
|
||||
# Links only pitch_shift: linking more would break the plain-data-boundary proof — and
|
||||
# specifically the compile-time proof it does not drag in the WDL <windows.h> chain.
|
||||
reasampler_test(pitch_shift LINK pitch_shift)
|
||||
|
||||
# Deliberately NOT linked by sampler_core, enforcing period_detect.h's off-audio-thread
|
||||
# invariant at build time: sampler_core_tests links sampler_core and nothing else, so no TU
|
||||
# on the render path can name detectPeriod without failing to link.
|
||||
reasampler_pure_library(period_detect SOURCES period_detect.cpp LINK PUBLIC peaks)
|
||||
reasampler_test(period_detect LINK period_detect)
|
||||
|
||||
reasampler_pure_library(velocity_curve SOURCES velocity_curve.cpp)
|
||||
# Links only velocity_curve, deliberately not editor_geometry: the proof the engine can
|
||||
# depend on the curve without inheriting the editor's layout types.
|
||||
reasampler_test(velocity_curve LINK velocity_curve)
|
||||
|
||||
reasampler_pure_library(master_gain SOURCES master_gain.cpp)
|
||||
reasampler_test(master_gain LINK master_gain)
|
||||
|
||||
# Declared before sampler_core because the voice now runs one per sounding note.
|
||||
add_subdirectory(filter)
|
||||
|
||||
# The sustain loop's validity + crossfade geometry, shared by the voice and the editor's
|
||||
# marker layer. After filter: it links play_params' dependency set, which includes it.
|
||||
add_subdirectory(loop)
|
||||
|
||||
# The live-parameter block: the value layer plus its publication, deliberately linking no
|
||||
# engine — the block is a plain value the voice observes, not a thing the engine owns.
|
||||
reasampler_pure_library(live_params
|
||||
SOURCES live_params.cpp
|
||||
LINK PUBLIC peaks velocity_curve filter curve_law)
|
||||
reasampler_test(live_params LINK live_params)
|
||||
|
||||
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
|
||||
# block render). The per-sample render half stays inline in voice.h precisely so this TU
|
||||
# boundary costs the hot path nothing.
|
||||
reasampler_pure_library(sampler_core
|
||||
SOURCES voice.cpp voice_engine.cpp
|
||||
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span
|
||||
time_stretch)
|
||||
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
|
||||
# or REAPER type reaching the core would fail to compile or link here.
|
||||
reasampler_test(sampler_core LINK sampler_core)
|
||||
|
||||
# The filter's own seams are covered by the four targets in filter/; this one covers the
|
||||
# integration: pipeline order, per-voice independence, and the off-by-default bit-identity.
|
||||
reasampler_test(sampler_filter LINK sampler_core)
|
||||
|
||||
# Live delivery is the third integration seam over the same engine: what a published block
|
||||
# does to a voice that is already sounding, and what it must leave alone.
|
||||
reasampler_test(live_delivery LINK sampler_core)
|
||||
|
||||
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
|
||||
# both mode shapes share, and the Trigger tail's terminal behaviour.
|
||||
reasampler_test(staged_envelopes LINK sampler_core)
|
||||
|
||||
# Measurement harness for Preserve on low-frequency material: how the splice search's
|
||||
# reachable relocation interval interacts with a long source period. Written longhand and
|
||||
# deliberately NOT add_test()'d — it sweeps frequencies, windows and spectra and takes ~2m40s
|
||||
# in Debug, which does not belong in a gate whose other targets run in seconds. It still
|
||||
# builds with everything else, so it cannot rot into non-compilation. Run it by hand, in
|
||||
# Release, when the question is what Preserve does to a given frequency.
|
||||
add_executable(preserve_low_frequency_tests
|
||||
${REASAMPLER_TESTS_DIR}/test_preserve_low_frequency.cpp)
|
||||
# period_detect beside sampler_core, not through it: the harness plays the role the loader
|
||||
# does, which is exactly the seam under measurement.
|
||||
target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core period_detect)
|
||||
|
||||
# Bridges the two structural proofs above (sample_map never links the voice engine;
|
||||
# sampler_core never links period_detect) for the one case that needs both: a REAL detected
|
||||
# period reaching a real Preserve render. Its own target rather than extending either.
|
||||
reasampler_test(period_render_integration LINK sample_map sampler_core)
|
||||
|
||||
# The Preserve read's source-feed schedule — the TIME half beside pitch_shift's PITCH half.
|
||||
# Header-only (it sits on the per-sample feed), hence INTERFACE.
|
||||
add_library(time_stretch INTERFACE)
|
||||
target_include_directories(time_stretch INTERFACE ${REASAMPLER_SRC_DIR})
|
||||
target_link_libraries(time_stretch INTERFACE loop_span)
|
||||
reasampler_test(time_stretch LINK time_stretch)
|
||||
|
||||
# The master bus's two pure halves. Neither links the engine: the limiter runs on the summed
|
||||
# output, and the ballistics run on what the audio thread published about it.
|
||||
reasampler_pure_library(limiter SOURCES limiter.cpp)
|
||||
reasampler_test(limiter LINK limiter)
|
||||
|
||||
reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp)
|
||||
reasampler_test(meter_ballistics LINK meter_ballistics)
|
||||
|
||||
# The meter's ACCUMULATE half, beside the ballistics that consume it. Header-only (the folds
|
||||
# sit on the audio thread's per-block path), hence INTERFACE.
|
||||
add_library(meter_accumulate INTERFACE)
|
||||
target_include_directories(meter_accumulate INTERFACE ${REASAMPLER_SRC_DIR})
|
||||
reasampler_test(meter_accumulate LINK meter_accumulate)
|
||||
@@ -0,0 +1,492 @@
|
||||
#pragma once
|
||||
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, sustain-less AHD,
|
||||
// AHD pitch offset). Concrete classes, every body defined in-class: these are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, so they must inline into the render loop.
|
||||
// NEVER give them a common base or a virtual tick() — that vtable lands on the hottest
|
||||
// inner loop in the program (root CLAUDE.md, structural heuristic 3).
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/util/curve_law.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using util::curveMap;
|
||||
|
||||
// The A/H/D split of a bounded span, in frames.
|
||||
struct AhdSpan {
|
||||
std::int64_t attack = 0;
|
||||
std::int64_t hold = 0;
|
||||
std::int64_t decay = 0;
|
||||
std::int64_t total = 0; // attack + hold + decay; <= span by construction
|
||||
};
|
||||
|
||||
// THE span split, shared by every sustain-less envelope so they cannot disagree about where a
|
||||
// stage boundary is. Attack takes at most the whole span and Decay at most what Attack left,
|
||||
// so `remaining` is non-negative without a clamp; Hold then takes its FRACTION of that
|
||||
// remainder, which is why total <= span holds for every (attack, decay, fraction) triple and
|
||||
// there is no sum to clamp. The two per-stage mins reproduce the retired Trigger fade clamp
|
||||
// exactly (head first, tail into what is left), so a migrated instance keeps its stage lengths.
|
||||
inline AhdSpan fitAhd(std::int64_t spanFrames, const AhdParams& p) {
|
||||
AhdSpan out;
|
||||
const std::int64_t span = spanFrames > 0 ? spanFrames : 0;
|
||||
std::int64_t a = p.attackFrames > 0 ? p.attackFrames : 0;
|
||||
if (a > span) a = span;
|
||||
std::int64_t d = p.decayFrames > 0 ? p.decayFrames : 0;
|
||||
if (d > span - a) d = span - a;
|
||||
const std::int64_t remaining = span - a - d;
|
||||
double frac = p.holdFraction;
|
||||
if (!(frac > 0.0)) frac = 0.0; // also catches NaN
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
out.attack = a;
|
||||
out.decay = d;
|
||||
out.hold = static_cast<std::int64_t>(static_cast<double>(remaining) * frac + 0.5);
|
||||
out.total = out.attack + out.hold + out.decay;
|
||||
return out;
|
||||
}
|
||||
|
||||
// The AHD's normalized level at `offset` frames into the span: 0 -> 1 over attack, flat 1
|
||||
// across hold, 1 -> 0 over decay, 0 outside. Pure over the offset so both the ticking pitch
|
||||
// envelope and the positional amplitude one read one shape.
|
||||
inline double ahdLevelAt(double offset, const AhdSpan& s, double attackCurve,
|
||||
double decayCurve) {
|
||||
if (offset < 0.0 || offset >= static_cast<double>(s.total)) return 0.0;
|
||||
if (s.attack > 0 && offset < static_cast<double>(s.attack)) {
|
||||
return curveMap(offset / static_cast<double>(s.attack), attackCurve);
|
||||
}
|
||||
const double decayStart = static_cast<double>(s.total - s.decay);
|
||||
if (s.decay > 0 && offset >= decayStart) {
|
||||
double t = (offset - decayStart) / static_cast<double>(s.decay);
|
||||
if (t > 1.0) t = 1.0;
|
||||
return 1.0 - curveMap(t, decayCurve);
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Absorbs a step a live parameter move would otherwise put straight into an evaluator's
|
||||
// output, as an offset that decays to EXACTLY zero — so the at-rest path carries no residue
|
||||
// and the smoother's own branch stays predictably false. Per-frame decay rather than a
|
||||
// wall-clock one, matching the voice's takeover declick; the floor is far below both domains
|
||||
// this is used in (amplitude, and semitones of pitch offset).
|
||||
class StepSmoother {
|
||||
public:
|
||||
// `step` is (level before the change - level after it): adding it back reproduces the
|
||||
// pre-change output exactly on the first frame.
|
||||
void absorb(double step) {
|
||||
offset_ += step;
|
||||
active_ = (offset_ > kFloor || offset_ < -kFloor);
|
||||
if (!active_) offset_ = 0.0;
|
||||
}
|
||||
void clear() { offset_ = 0.0; active_ = false; }
|
||||
bool active() const { return active_; }
|
||||
|
||||
// This frame's offset; decays afterwards, latching inactive at the floor.
|
||||
double advance() {
|
||||
const double out = offset_;
|
||||
offset_ *= kDecay;
|
||||
if (offset_ < kFloor && offset_ > -kFloor) {
|
||||
offset_ = 0.0;
|
||||
active_ = false;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr double kDecay = 0.95;
|
||||
static constexpr double kFloor = 1e-5;
|
||||
double offset_ = 0.0;
|
||||
bool active_ = false;
|
||||
};
|
||||
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
// Segment math:
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
|
||||
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
|
||||
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
|
||||
//
|
||||
// stagePos_ is the elapsed position within the current stage. It is a double rather than a
|
||||
// frame count only so applyLive can hold a fractional normalized position; every value it
|
||||
// takes on the un-edited path is integral, so the segment math is bit-identical to the
|
||||
// integer-counter engine.
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
stagePos_ = 0.0;
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Gate off: enter Release from the CURRENT level — release-before-sustain releases from
|
||||
// the partial attack/decay level, not from sustainLevel. A running smoother deliberately
|
||||
// survives: it is mid-glide, and cutting it here would reintroduce the step it absorbed.
|
||||
void noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
stagePos_ = 0.0;
|
||||
}
|
||||
|
||||
// Live parameter delivery to a fresh voice — one that has NOT yet rendered a frame, whose
|
||||
// latched copy may predate the newest edit. It takes the params outright: there is no
|
||||
// phase to hold and nothing to be continuous with. applyLive cannot serve here in either
|
||||
// direction — with a stale duration of 0 its phi rule reads stagePos_ == 0 as a COMPLETED
|
||||
// stage and discards the newly-dialled time, and with a stale duration > 0 against a new 0
|
||||
// it absorbs a full-scale step into a voice that has emitted nothing, fading the onset in.
|
||||
void snapLive(const AdsrParams& params) {
|
||||
params_ = params;
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Live parameter delivery to a SOUNDING voice. The mid-stage rule is HOLD NORMALIZED
|
||||
// STAGE POSITION: phi = elapsed/duration is kept fixed across the change, so this frame's
|
||||
// level is unchanged by construction and the remainder of the stage takes its share of the
|
||||
// newly-dialled duration. The rule is expressed over normalized position, never over
|
||||
// output level, so a per-segment curve exponent composes with it as a pure map of phi.
|
||||
//
|
||||
// Two cases phi cannot cover, both absorbed by the smoother rather than allowed to step:
|
||||
// a sustain level moved while the voice holds it (sustain is a level, not a timed stage),
|
||||
// and a stage duration dialled to exactly zero mid-stage (the stage ceases to exist and
|
||||
// completes at its terminal level).
|
||||
void applyLive(const AdsrParams& params) {
|
||||
const double before = stageLevel(params_);
|
||||
const double oldDuration = stageDuration(params_);
|
||||
const double newDuration = stageDuration(params);
|
||||
if (newDuration > 0.0) {
|
||||
stagePos_ = (oldDuration > 0.0) ? stagePos_ * (newDuration / oldDuration)
|
||||
: newDuration; // a collapsed stage was complete
|
||||
}
|
||||
params_ = params;
|
||||
const double after = stageLevel(params_);
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
// Once Release completes the envelope latches Finished and returns 0.0 forever (until
|
||||
// the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
|
||||
// output frame.
|
||||
//
|
||||
// While the smoother runs the return may sit OUTSIDE [0,1] by the offset it is decaying
|
||||
// (bounded by the step it absorbed). finished() ignores that residue, so a Release that
|
||||
// completes with an offset still decaying is hard-cut when the voice frees — the audible
|
||||
// remainder of a step the smoother had already taken most of.
|
||||
double tick() {
|
||||
const double out = tickStage();
|
||||
return smooth_.active() ? out + smooth_.advance() : out;
|
||||
}
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
// The level tick() would emit right now under `params` without advancing anything. THE one
|
||||
// home for every segment's shape: tickStage owns only the advance and the stage
|
||||
// transitions and reads its output from here, so a per-segment curve added later lands in
|
||||
// one place and the smoother can never size a step against a different curve than the
|
||||
// output takes.
|
||||
double stageLevel(const AdsrParams& params) const {
|
||||
switch (stage_) {
|
||||
case Stage::Attack: {
|
||||
if (params.attackFrames <= 0) return 1.0;
|
||||
double l = stagePos_ / static_cast<double>(params.attackFrames);
|
||||
if (l > 1.0) l = 1.0;
|
||||
return curveMap(l, params.attackCurve);
|
||||
}
|
||||
case Stage::Hold:
|
||||
// A zero-length hold falls straight through to Decay on the next tick, whose
|
||||
// level at position 0 is 1.0 — unless decay is zero too, which lands on sustain.
|
||||
if (params.holdFrames > 0) return 1.0;
|
||||
return (params.decayFrames <= 0) ? params.sustainLevel : 1.0;
|
||||
case Stage::Decay: {
|
||||
if (params.decayFrames <= 0) return params.sustainLevel;
|
||||
double t = stagePos_ / static_cast<double>(params.decayFrames);
|
||||
if (t > 1.0) t = 1.0; // never bites on the un-edited path (transitions at >=)
|
||||
return 1.0 + (params.sustainLevel - 1.0) * curveMap(t, params.decayCurve);
|
||||
}
|
||||
case Stage::Sustain:
|
||||
return params.sustainLevel;
|
||||
case Stage::Release: {
|
||||
if (params.releaseFrames <= 0) return 0.0;
|
||||
double t = stagePos_ / static_cast<double>(params.releaseFrames);
|
||||
if (t > 1.0) t = 1.0;
|
||||
return releaseFrom_ * (1.0 - curveMap(t, params.releaseCurve));
|
||||
}
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// The current stage's dialled duration under `params`; 0 for the untimed stages.
|
||||
double stageDuration(const AdsrParams& params) const {
|
||||
switch (stage_) {
|
||||
case Stage::Attack: return static_cast<double>(params.attackFrames);
|
||||
case Stage::Hold: return static_cast<double>(params.holdFrames);
|
||||
case Stage::Decay: return static_cast<double>(params.decayFrames);
|
||||
case Stage::Release: return static_cast<double>(params.releaseFrames);
|
||||
default: return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
double tickStage() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
level_ = 0.0;
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.attackFrames)) {
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at
|
||||
// 1.0 beyond what Attack already emitted) so a zero-length hold emits no
|
||||
// extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general
|
||||
// recursion). Re-enters the STAGE evaluator, never tick(), so a running
|
||||
// smoother is applied exactly once per frame.
|
||||
return tickStage();
|
||||
}
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.holdFrames)) {
|
||||
stage_ = Stage::Decay;
|
||||
stagePos_ = 0.0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.decayFrames)) {
|
||||
stage_ = Stage::Sustain;
|
||||
stagePos_ = 0.0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = stageLevel(params_);
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
level_ = stageLevel(params_);
|
||||
const double out = level_;
|
||||
stagePos_ += 1.0;
|
||||
if (stagePos_ >= static_cast<double>(params_.releaseFrames)) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
double stagePos_ = 0.0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
|
||||
// The sustain-less AHD amplitude shape, evaluated at a source-frame offset into the span
|
||||
// rather than by ticking output frames: under Varispeed a transposed voice consumes source
|
||||
// faster than output, so driving the shape off the read position keeps every stage boundary on
|
||||
// the same source frames regardless of engine. Note-off-immune and time-boxed by the span.
|
||||
//
|
||||
// Positional means there is no phase counter to hold across a live edit, so the phi rule
|
||||
// AdsrEnvelope applies has nothing to act on here; a live reshape is a level step, absorbed by
|
||||
// the same bounded smoother.
|
||||
class AhdEnvelope {
|
||||
public:
|
||||
// `spanFrames` is the bound the stages are fitted into — (playEnd - startFrame) for the
|
||||
// Trigger amp and filter envelopes. A zero/negative span finishes immediately.
|
||||
void configure(std::int64_t spanFrames, const AhdParams& params) {
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
fit(params, /*latchFinished=*/false); // a fresh note starts from a clean read
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Peer of AdsrEnvelope::snapLive: a voice that has rendered nothing takes the new shape
|
||||
// outright, with no step to absorb.
|
||||
void snapLive(const AhdParams& params) {
|
||||
fit(params, /*latchFinished=*/false);
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Live delivery to a sounding voice at its current `sourceOffset`. See the class note for
|
||||
// why this smooths rather than holding a normalized position.
|
||||
void applyLive(double sourceOffset, const AhdParams& params) {
|
||||
const double before = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
// LATCHED: a voice already read past its fitted total must never resurge because a
|
||||
// later live move reopened the total. Reachable on any active() voice, including one
|
||||
// ringing out past its own end (voice.h) where tickAmplitude() still runs.
|
||||
fit(params, /*latchFinished=*/true);
|
||||
const double after = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
|
||||
// Amplitude at `sourceOffset` = (readPos - startFrame). Latches finished() at or past the
|
||||
// fitted total, which is what frees the voice.
|
||||
double amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset >= static_cast<double>(fit_.total)) {
|
||||
if (sourceOffset >= static_cast<double>(fit_.total)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
const double out = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
return smooth_.active() ? out + smooth_.advance() : out;
|
||||
}
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
const AhdSpan& stages() const { return fit_; }
|
||||
|
||||
private:
|
||||
// `latchFinished`: once true, a re-fit can only ever KEEP finished_ true, never clear it —
|
||||
// see applyLive above for why. configure()/snapLive() pass false: those are a fresh read
|
||||
// (new note or a not-yet-rendered voice), which must compute finished_ from scratch.
|
||||
void fit(const AhdParams& p, bool latchFinished) {
|
||||
fit_ = fitAhd(span_, p);
|
||||
attackCurve_ = p.attackCurve;
|
||||
decayCurve_ = p.decayCurve;
|
||||
const bool empty = (fit_.total <= 0);
|
||||
finished_ = latchFinished ? (finished_ || empty) : empty;
|
||||
}
|
||||
|
||||
std::int64_t span_ = 0;
|
||||
AhdSpan fit_;
|
||||
double attackCurve_ = util::kCurveNeutral;
|
||||
double decayCurve_ = util::kCurveNeutral;
|
||||
bool finished_ = true;
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past the AHD),
|
||||
// advancing one frame. The voice converts it to a ratio multiply (Varispeed) or a shift-amount
|
||||
// add (Preserve). Unlike the amplitude AHD this owns its own position counter — pitch-envelope
|
||||
// time is wall-clock output frames — so the mid-stage rule applies in full.
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
// `spanFrames` is the playable span the Hold fraction is taken against.
|
||||
void configure(std::int64_t spanFrames, const PitchEnvParams& params) {
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
params_ = params;
|
||||
fit_ = fitAhd(span_, params.shape);
|
||||
pos_ = 0.0;
|
||||
}
|
||||
void noteOn() { pos_ = 0.0; smooth_.clear(); }
|
||||
|
||||
// Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice
|
||||
// that has rendered nothing takes the new shape and depth outright. `enabled` is a discrete
|
||||
// toggle travelling by reload, so the caller's copy of it is deliberately ignored.
|
||||
//
|
||||
// Both live entry points re-take `spanFrames` rather than keeping configure()'s: the span is
|
||||
// an OUTPUT-frame duration the caller converts from the read rate, and that rate carries a
|
||||
// live control (voice.h's pitchEnvSpanFrames). Passing the span back unchanged is exact.
|
||||
void snapLive(std::int64_t spanFrames, const PitchEnvParams& params) {
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
params_.peakSemitones = params.peakSemitones;
|
||||
params_.shape = params.shape;
|
||||
fit_ = fitAhd(span_, params_.shape);
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized
|
||||
// position within whichever leg the envelope is in, and absorb the depth step (peak is a
|
||||
// level, not a duration). A moved span re-fits under the same rule, so a live Pitch move
|
||||
// reshapes this envelope continuously instead of leaving it on the note-on read rate.
|
||||
void applyLive(std::int64_t spanFrames, const PitchEnvParams& params) {
|
||||
const double before = offsetAt();
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
const AhdSpan next = fitAhd(span_, params.shape);
|
||||
pos_ = holdPhase(fit_, next);
|
||||
params_.peakSemitones = params.peakSemitones;
|
||||
params_.shape = params.shape;
|
||||
fit_ = next;
|
||||
const double after = offsetAt();
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
|
||||
double tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
const double offset = offsetAt();
|
||||
pos_ += 1.0;
|
||||
return smooth_.active() ? offset + smooth_.advance() : offset;
|
||||
}
|
||||
|
||||
private:
|
||||
// The semitone offset at the current position — the shared evaluator for both tick() and
|
||||
// applyLive's before/after comparison.
|
||||
double offsetAt() const {
|
||||
if (!params_.enabled) return 0.0;
|
||||
return params_.peakSemitones *
|
||||
ahdLevelAt(pos_, fit_, params_.shape.attackCurve, params_.shape.decayCurve);
|
||||
}
|
||||
|
||||
// The position under `next` holding the normalized position within whichever leg pos_ is
|
||||
// in. A leg dialled to zero completes: the position lands on that leg's new end.
|
||||
double holdPhase(const AhdSpan& old, const AhdSpan& next) const {
|
||||
const double oa = static_cast<double>(old.attack);
|
||||
const double oh = static_cast<double>(old.hold);
|
||||
const double od = static_cast<double>(old.decay);
|
||||
const double na = static_cast<double>(next.attack);
|
||||
const double nh = static_cast<double>(next.hold);
|
||||
const double nd = static_cast<double>(next.decay);
|
||||
if (pos_ < oa) return (na > 0.0) ? pos_ * (na / oa) : na;
|
||||
if (pos_ < oa + oh) return (nh > 0.0) ? na + (pos_ - oa) * (nh / oh) : na + nh;
|
||||
if (pos_ < oa + oh + od) {
|
||||
return (nd > 0.0) ? na + nh + (pos_ - oa - oh) * (nd / od) : na + nh + nd;
|
||||
}
|
||||
return na + nh + nd; // already past the envelope: stay past it under the new shape
|
||||
}
|
||||
|
||||
PitchEnvParams params_;
|
||||
std::int64_t span_ = 0;
|
||||
AhdSpan fit_;
|
||||
double pos_ = 0.0;
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,263 @@
|
||||
# src/core/instrument/engine/filter — the per-voice resonant filter
|
||||
|
||||
## Scope
|
||||
|
||||
The pure per-voice filter a sounding voice runs: a Zavalishin TPT/SVF with a continuous
|
||||
morph under one of two laws — HP→BP→LP or HP→notch→LP — and a drive stage. No REAPER, no
|
||||
VST3, no allocation, no I/O. Everything
|
||||
here lives in `reasampler::instrument::engine::filter`, nested per the
|
||||
directory-mirrors-namespace convention, which keeps `FilterSettings` and friends out of
|
||||
`reasampler::instrument::engine` proper where `play_params.h` lives — and `play_params.h`
|
||||
now stores a `FilterSettings` by value, so that separation is load-bearing rather than
|
||||
merely tidy. Five files, one responsibility each:
|
||||
|
||||
- `filter_params` — the control domain: normalized [0,1] knob position → cutoff Hz, Q, and
|
||||
drive depth, plus the exact inverses for cutoff and Q.
|
||||
- `filter_coeffs` — the DSP domain: `SvfCoeffs` and the TPT coefficient solve from
|
||||
(cutoff Hz, Q, sample rate).
|
||||
- `filter_morph` — the morph domain: `MorphLaw`, normalized position → per-tap weights under
|
||||
the selected law, and the fold of those weights into the three multipliers the kernel
|
||||
applies.
|
||||
- `filter_saturate` — `softLimit`, the drive stage's shaper. Header-only inline; it sits
|
||||
inside the per-sample recursion.
|
||||
- `voice_filter` — `FilterSettings` and `VoiceFilter`, the concrete per-voice type.
|
||||
`process()` is defined in the header.
|
||||
|
||||
### The cutoff is the only control that re-solves per frame, and it re-solves alone
|
||||
|
||||
`prepare()` is the full solve; `setCutoffNorm()` is the per-frame one. The split exists because
|
||||
**a modulated corner must move continuously** — Daniel's ruling, replacing a retired 2048-step
|
||||
quantizer that staircased the sweep in ~5.8-cent jumps — and a full `prepare()` per frame is the
|
||||
wasteful way to buy that. Only `g = tan(pi*fc/sr)` depends on cutoff: Q's parabola, the morph's
|
||||
`cos`/`sin`, and the folded mix (a function of the weights and `k` alone) do not, so
|
||||
`setCutoffNorm` re-derives none of them and reuses the `q_` cached at `prepare()`.
|
||||
|
||||
Measured at 48 kHz, MSVC `/O2`, net of the sweep generator and the kernel:
|
||||
|
||||
| per frame | ns |
|
||||
|---|---|
|
||||
| kernel alone, no re-solve | 2.8 |
|
||||
| full `prepare()` | 56.9 |
|
||||
| `setCutoffNorm`, constant logs recomputed | 22.8 |
|
||||
| `setCutoffNorm`, constant logs hoisted (shipped) | 15.5 |
|
||||
|
||||
The last row is 16 voices of continuously-swept filter at ~1.2% of one core — affordable, which
|
||||
is why nothing approximates `tan` here. The hoist is in `filter_params.cpp`: the sweep endpoints
|
||||
and the Q parabola are functions of compile-time constants, and recomputing those five
|
||||
logarithms per frame cost more than the solve they fed. **Do not put a quantizer back on the
|
||||
control value to save the solve** — make the solve cheaper instead.
|
||||
|
||||
## Invariants
|
||||
|
||||
### No vtable on the per-sample path
|
||||
|
||||
The Cortex-M4 source this began as was a virtual hierarchy (`FilterBase` → `Filter` →
|
||||
`Biquad` → `{BiquadHP, BiquadLP}`) whose base class routed the channel loop through
|
||||
pure-virtual `process_channel_frame` / `filter` / `update_feedback` so a `FilterDecorator`
|
||||
chain could wrap it. **None of that came across, and none of it may come back.**
|
||||
`VoiceFilter` is concrete, `process()` is inlined, and there is no `IFilter`, no decorator
|
||||
seam, no virtual `tick()`, and no allocation in `process()` — root `CLAUDE.md`'s structural
|
||||
heuristic 3 names this class of dispatch blowout directly.
|
||||
|
||||
### The rate enters ONLY through `g = tan(pi*fc/sr)`
|
||||
|
||||
There is no reference sample rate, calibration rate, or fallback rate anywhere in this
|
||||
module, and introducing one is the specific regression to guard against. An earlier design
|
||||
carried a `kFilterFeedbackDelaySeconds = 1/48000` tuning constant for a feedback tap; that
|
||||
tap, its ring buffer, and the constant are all deleted. A non-positive rate yields `g == 0`
|
||||
and a bypass mix (signal passes through) — never an invented rate.
|
||||
|
||||
### Why the high-pass feedback tap was right on Q15 hardware and wrong here
|
||||
|
||||
The ported firmware fed a saturated share of an earlier output back into the high-pass
|
||||
input. Its stated rationale — that the HP numerator collapses toward zero at low cutoff,
|
||||
taking the resonance with it — is **inverted**, and the comment asserting it has been
|
||||
removed rather than carried forward. Measurement: the HP `b0` approaches **1** as cutoff
|
||||
falls (0.99987 at 20 Hz); it is the **low-pass** `b0` that collapses (1.7e−06 at 20 Hz).
|
||||
|
||||
The tap was a Q15 fixed-point workaround. At 16-bit fixed point the low-cutoff biquad loses
|
||||
a ~17-bit cancellation and the resonance really does die; the feedback injected it back by
|
||||
another route. float32 survives that cancellation with 7 bits to spare, so on this target
|
||||
the tap did not restore character — it *reduced* it (HP landed 0.4% off the analytic RBJ
|
||||
target with the tap disabled, and 25% off with it enabled), and it introduced both level
|
||||
dependence and rate dependence.
|
||||
|
||||
Daniel's ruling on the level-dependent resonance bloom it produced: *"was a feature on the
|
||||
hardware (one knob colorful HP for master FX), wrong choice for this approach."* Drive is
|
||||
now an explicit user-controlled stage instead of an emergent side effect.
|
||||
|
||||
### The morph is a blend of taps, never a coefficient switch
|
||||
|
||||
An SVF produces high, band, and low from the same state, which is the reason this topology
|
||||
was chosen. `FilterMode` as a discrete enum is retired. HP at 0.0, LP at 1.0, continuous
|
||||
throughout, and both endpoints are exact under either law — only the centre differs.
|
||||
|
||||
The crossfade is **equal-power** in both laws, and that is forced by the topology rather
|
||||
than picked by ear. At the corner the taps are `HP = jQ`, `BP = Q`, `LP = -jQ` — adjacent
|
||||
taps in exact quadrature and HP/LP in exact antiphase, relationships the bilinear transform
|
||||
preserves exactly at the prewarped corner. A `cos`/`sin` pair therefore holds the crossfaded
|
||||
power at unity across the whole sweep; a linear crossfade of a quadrature pair would sag to
|
||||
`1/sqrt(2)` mid-leg, a 3 dB hole that reads as a defect rather than as character.
|
||||
|
||||
### The two morph laws, and why only one of them has a flat corner
|
||||
|
||||
`MorphLaw` is a two-value selector on `FilterSettings`, **defaulting to `HighBandLow`** —
|
||||
that is the reviewed-and-measured law, and it is enumerator 0 so a zero-initialized or absent
|
||||
persisted field lands on it rather than on the SEM leg.
|
||||
|
||||
- **`HighBandLow` (HP→BP→LP, the default).** Two equal-power legs crossfading **adjacent taps
|
||||
only**, BP at the centre. Because adjacent taps are in quadrature, the corner magnitude is
|
||||
algebraically `Q*sqrt(cos² + sin²) = Q` at every position — measured flat to 4e-6 across 65
|
||||
positions. **That flatness guarantee is specific to this law.** Do not weaken the assertion
|
||||
that pins it in order to accommodate the other law.
|
||||
- **`HighNotchLow` (HP→notch→LP, the Oberheim SEM).** One equal-power crossfade weighting HP
|
||||
and LP **together** across the whole sweep, `bp == 0` throughout. The notch is not tuned in:
|
||||
HP and LP sit at exactly +90° and −90° at the corner, so equal weights cancel there by
|
||||
construction. Here the corner magnitude deliberately goes to **zero** at the centre —
|
||||
measured worst case −88 dB on the shipped `{250, 1000, 4000}` Hz cutoff grid, typically −110 to
|
||||
−145 dB. Over the full control range (20 Hz – 20 kHz, Q 0.1 – 10) the worst residual is
|
||||
shallower — −69.8 dB at 192 kHz / 30 Hz / Q=10 — from float conditioning in the folded
|
||||
`x − k·v1` term as `fc/sr → 1e-4` at high Q; it is Q-dependent (Q=0.1 holds −110 dB everywhere)
|
||||
and still an excellent notch, not a broadband defect. `test_filter.cpp`'s null test covers this
|
||||
full range with a Q-scaled threshold rather than the flat −74 dB the shipped grid alone would
|
||||
justify. The fold makes the centre's cancellation structural rather than a runtime near-miss:
|
||||
`m2 = lp - hp` is **exactly** `0.0f` at the centre, because `cos` and `sin` of π/4 differ by
|
||||
about an ulp of *double*, nine orders below float's spacing there, so they narrow to one float.
|
||||
|
||||
SEM's zero is at the **notch frequency**, not a broadband level sag — off the corner the pair
|
||||
is still equal-power, so neither law's legs dip. Measuring that requires dividing by each
|
||||
tap's own analytic response first: at `Q = 0.1` a 2-pole approaches its passband so slowly
|
||||
that the pure low tap still reads 0.896 at 50 Hz, and a raw reading would report a 20% "sag"
|
||||
that is the Q, not the morph.
|
||||
|
||||
**The toggle is free on the hot path, and must stay that way.** `morphWeights` runs at
|
||||
`prepare()` cadence; the law is consumed there and nowhere else. The kernel, `svfCoeffs`, and
|
||||
`morphMix`'s fold are identical between the laws — all a law selects is three floats the
|
||||
kernel was already multiplying by. Verified at the machine-code level, not by inspection: the
|
||||
same TU compiled `/O2` against the pre-toggle and post-toggle headers emits byte-identical
|
||||
assembly for `process()` and `processFrame()`. `VoiceFilter` gained no member and `process()`
|
||||
gained no branch. A design that puts the law selector inside the per-sample path is wrong —
|
||||
rework it rather than paying for it.
|
||||
|
||||
### Drive is a contraction inside the loop, which is what makes it unconditionally stable
|
||||
|
||||
`softLimit(u, depth) = u / sqrt(1 + (depth*u)²)` shapes the **band-pass integrator state**.
|
||||
Three properties carry the design:
|
||||
|
||||
- `depth == 0` makes it algebraically the identity (`x / sqrt(1) == x`, exact in IEEE), so
|
||||
drive 0 is **bit-exact** linear whether or not `softLimit` is actually called. The test
|
||||
asserts bit-identity against the same kernel with the limiter deleted.
|
||||
- `process()` gates the call on `driven_` (`driveDepth_ != 0`, cached at `prepare()`) rather
|
||||
than calling `softLimit` unconditionally. `sqrt`/div sit on the per-sample recursive
|
||||
dependency chain, so out-of-order execution can't hide their latency, and at drive 0 that
|
||||
cost buys nothing. Measured: 11.2 ns/sample unconditional vs 4.1 ns gated — the gated form
|
||||
lands at the limiter-removed floor. `driven_` only changes at `prepare()`, so the branch
|
||||
predicts perfectly. The gate is a perf optimization on top of the bit-identity above, not a
|
||||
substitute for it — deleting the gate would still be correct, just 2.7x slower at rest.
|
||||
- `|softLimit(u, d)| <= |u|` for every depth, so the state update can only shrink the state.
|
||||
The filter cannot gain energy from the drive stage: stability at any Q and any cutoff is
|
||||
structural, and self-oscillation is impossible. This is why the shaper must keep unit slope
|
||||
at the origin — a shaper with gain above 1 there turns the resonator into an oscillator.
|
||||
- It shapes the **state**, not the zero-delay loop. A nonlinearity inside the loop would
|
||||
break the closed-form `a1`/`a2`/`a3` solve and need per-sample Newton iteration.
|
||||
|
||||
Placement is the resonance path because that is where the firmware's character came from,
|
||||
and because the band-pass state sits at zero in the passband and at DC — so drive colours
|
||||
the resonance and leaves the passband transparent (measured 0.98 at max drive). It is not a
|
||||
distortion box in series with the signal; a caller wanting that has every other plugin.
|
||||
|
||||
**Drive × resonance interact by design.** What reaches the shaper is the resonance state,
|
||||
already multiplied by roughly `2*Q`, so the same drive setting bites harder the more
|
||||
resonance is dialled in — and harder on a hotter input. That level dependence is the
|
||||
*point* of an explicit drive control; what Daniel rejected was level dependence nobody
|
||||
asked for. At drive 0 there is none, to 0.0004% over a 1000:1 level range.
|
||||
|
||||
`kFilterDriveDepthMax` (4.0) was set against measurement, not feel: at max drive, full-scale
|
||||
input and max resonance the resonant peak lands ~10 dB under the passband — plainly
|
||||
crushed, which is the asked-for "extreme". Raising it further inverts the filter's shape
|
||||
(21 dB under passband at depth 64), turning the peak the user dialled in into a notch.
|
||||
There is deliberately **no makeup gain** — any law for it would be invented rather than
|
||||
derived, and drive is due an ear pass against the radial dial.
|
||||
|
||||
### The cutoff control is sample-rate-free; the clamp is not
|
||||
|
||||
`filterCutoffHzFromNorm` sweeps a fixed 20 Hz – 20 kHz (three exact decades, so norm 1/3 is
|
||||
200 Hz and 2/3 is 2 kHz) and takes no sample rate. The persisted value is the normalized
|
||||
knob position, so a rate-derived endpoint would make one preset sound different at 44.1k and
|
||||
96k. The Nyquist clamp (`kFilterNyquistFraction`, 0.48) is a property of the bilinear
|
||||
transform — `tan(pi*fc/sr)` diverges at Nyquist — so it lives in `svfCoeffs` where the rate
|
||||
is already a parameter. 20 kHz is under 0.48·sr at 44.1k and above, so the clamp never eats
|
||||
live knob travel there; the source's hardcoded 23 kHz endpoint did exactly that at 44.1k.
|
||||
|
||||
### Q spans 0.1 → 10 with √2 at the center
|
||||
|
||||
Settled by Daniel. The source's `Q = M_SQRT1_2 + resonance` mapping (floored at 0.707, no
|
||||
center anchor) was **rewritten, not ported**. The curve is quadratic in log Q through the
|
||||
three anchors rather than two spliced log segments — same anchors either way, but no slope
|
||||
kink at the center detent. The quadratic term is nonzero only because √2 is not the
|
||||
geometric mean of 0.1 and 10; `filterNormFromQ` divides by it. The SVF consumes it as
|
||||
`k = 1/Q`.
|
||||
|
||||
### Denormal flushing: why conjunctive, honestly
|
||||
|
||||
`process()` flushes **both** integrators to exact zero once both are below
|
||||
`kFilterDenormalFloor` (1e-30). The honest reason is narrower than it sounds: `isSilent()`
|
||||
means "both integrators are exactly zero," so both have to reach zero for that check to mean
|
||||
anything, and the conjunctive test is the cheapest way to guarantee it.
|
||||
|
||||
The stronger claim — that a per-variable flush limit-cycles at the floor — does **not**
|
||||
reproduce on this topology. Measured (Q=10, fc=1kHz, 48k): shipped conjunctive goes silent at
|
||||
sample 10783 with 0 subnormals; a per-variable independent flush goes silent ~180 samples
|
||||
earlier and an either-below-zero-both flush ~970 samples earlier, both also 0 subnormals, no
|
||||
limit cycle, and the same excited RMS. That claim WAS real on the retired Direct Form I state,
|
||||
where the flushed variables (`y1`/`y2`) were the actual filter OUTPUT, so zeroing one injected
|
||||
a discontinuity the resonance then amplified. Here `ic1`/`ic2` are integrator STATE, not
|
||||
output: zeroing one only removes energy, a contraction rather than an injection, so the hazard
|
||||
is structurally absent. The only demonstrable hazard is no flush at all, which never reaches
|
||||
exact zero and grinds through subnormals for thousands of samples on a released voice.
|
||||
|
||||
Keep the conjunctive test regardless — it costs nothing extra and is the right guarantee for
|
||||
`isSilent()` — but don't cite the limit-cycle rationale for TPT; it belongs to the retired
|
||||
topology.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **TPT is what fixed the low-cutoff conditioning defect** — this is a topology change, not
|
||||
a relocation. Direct Form I encoded pole proximity in `a1 → -2`, `a2 → +1` and cancelled
|
||||
them against each other every sample; at `fc/sr ≈ 1e-4` that ~17-bit cancellation moved the
|
||||
measured 20 Hz / 192 kHz LP peak by **-27% on a true-peak scan, -57% measured at the
|
||||
analytic peak frequency** (the degraded pole itself moves, so the two methods diverge), and
|
||||
the error is non-monotone with rate rather than a fixed percentage (+5% high at 96 kHz).
|
||||
TPT encodes the same proximity in `a1`'s small deviation from 1, which float32 resolves:
|
||||
checked against an exact-double evaluation of the same difference equation (which matches
|
||||
the analytic target to within measurement noise), TPT's float32-narrowed coefficients are
|
||||
genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz — real coefficient
|
||||
narrowing, not measurement-window noise, and comfortably inside the test's 0.4% tolerance
|
||||
either way. Do not reintroduce a direct-form kernel.
|
||||
- **A coefficient jump here produces no isolated output spike, measured.** Preserving state
|
||||
across `prepare()` is strong enough that even an instantaneous cutoff/Q/morph jump leaves the
|
||||
boundary frame inside the signal's own frame-to-frame range — a single-frame-spike metric
|
||||
cannot detect one. What the caller's per-frame glide prevents is therefore the *parameter*
|
||||
arriving as a step (and the zipper of repeated steps at control rate), not a click at the
|
||||
jump itself. A test claiming to prove the glide must measure how fast the output diverges,
|
||||
not how far one frame moves; `live_delivery_tests` does.
|
||||
- **`prepare()` deliberately does not clear state** — a live parameter move must glide, not
|
||||
click. Call `reset()` at note-on. **Exception: the non-positive-rate bypass path.** There,
|
||||
`a1=1, a2=a3=0` makes both state updates the exact identity and `bypassMix()` never reads
|
||||
the state at all, so a stale nonzero `ic1`/`ic2` would otherwise latch `isSilent()` false
|
||||
forever with no audible effect either way — `prepare()` clears state on that path only,
|
||||
which costs nothing audibly since bypass ignores it.
|
||||
- **The morph endpoints are asserted on the folded mix, exactly.** `morphWeights` snaps the
|
||||
leg endpoints instead of trusting `cos`/`sin` to land on 0 and 1, which they miss by ~1e-17
|
||||
— enough to leave a -324 dB neighbour tap in what is specified as a pure response.
|
||||
- **A NaN morph position falls back per law, not to one shared value.** Every comparison
|
||||
against NaN is false, so it clamps to neither endpoint: `HighBandLow` lands on pure
|
||||
band-pass, `HighNotchLow` on pure high-pass, since it has no band tap to land on.
|
||||
- **Measuring a null needs a ring-time-adequate settle window.** At `Q = 10` the leftover
|
||||
transient alone reads as −52 dB after 0.15 s and would be mistaken for the noise floor.
|
||||
- **The call site is `Voice::advanceFrame`**, between the pitch stage and the amp multiply.
|
||||
It re-solves the corner on **every frame the modulated cutoff actually moves — unquantized**,
|
||||
so the corner glides rather than staircasing.
|
||||
- **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
|
||||
budget expressed in samples is therefore itself a rate assumption — a fixed 20000 samples
|
||||
is ample at 48k and expires mid-decay at 96k and above.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
|
||||
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
|
||||
# The frozen control-position laws are their own target: they are the filter's PARAMETER
|
||||
# surface, and the VST3 parameter layer reports Hz/Q/drive through them. Kept separable so
|
||||
# that consumer does not take a link edge onto the per-voice kernel — the extension's link
|
||||
# graph must never be able to reach the voice DSP (root CLAUDE.md, the bake invariant).
|
||||
reasampler_pure_library(filter_params SOURCES filter_params.cpp)
|
||||
|
||||
reasampler_pure_library(filter SOURCES
|
||||
filter_coeffs.cpp
|
||||
filter_morph.cpp
|
||||
voice_filter.cpp
|
||||
LINK PUBLIC filter_params)
|
||||
|
||||
# Four test targets along the module's own seams so each asserts one domain. filter_tests
|
||||
# alone owns the analytic reference and the steady-state gain measurement — a forked copy of
|
||||
# a measurement reference is a worse defect than a long file.
|
||||
reasampler_test(filter_params LINK filter)
|
||||
reasampler_test(filter_morph LINK filter)
|
||||
reasampler_test(filter_state LINK filter)
|
||||
reasampler_test(filter LINK filter)
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "core/instrument/engine/filter/filter_coeffs.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
namespace {
|
||||
|
||||
// M_PI is not standard C++ and is absent on MSVC without _USE_MATH_DEFINES.
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
double clampd(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
} // namespace
|
||||
|
||||
SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate) {
|
||||
const double qq = clampd(q, kFilterQMin, kFilterQMax);
|
||||
const double k = 1.0 / qq;
|
||||
|
||||
double g = 0.0;
|
||||
if (sampleRate > 0.0) {
|
||||
const double fc = clampd(cutoffHz, kFilterCutoffMinHz, kFilterNyquistFraction * sampleRate);
|
||||
g = std::tan(kPi * fc / sampleRate);
|
||||
}
|
||||
|
||||
// Solved in double and narrowed once. The intermediate g*(g+k) is the term that carries the
|
||||
// pole proximity, so forming it in float would throw away the conditioning TPT just bought.
|
||||
const double a1 = 1.0 / (1.0 + g * (g + k));
|
||||
const double a2 = g * a1;
|
||||
const double a3 = g * a2;
|
||||
|
||||
SvfCoeffs c;
|
||||
c.g = static_cast<float>(g);
|
||||
c.k = static_cast<float>(k);
|
||||
c.a1 = static_cast<float>(a1);
|
||||
c.a2 = static_cast<float>(a2);
|
||||
c.a3 = static_cast<float>(a3);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,42 @@
|
||||
// filter_coeffs.h — Zavalishin topology-preserving-transform state-variable coefficients.
|
||||
// The rate enters ONLY through g = tan(pi*fc/sr); there is no reference or calibration rate
|
||||
// anywhere in this module, and reintroducing one would restore the rate-dependent resonance
|
||||
// the TPT rewrite exists to remove.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// The two-integrator SVF's per-sample constants. a1/a2/a3 are the algebraic solution of the
|
||||
// zero-delay feedback loop, so the kernel needs no iteration.
|
||||
struct SvfCoeffs {
|
||||
float g = 0.0f; // tan(pi*fc/sr) — the ONLY place the sample rate appears
|
||||
float k = 1.0f; // 1/Q, the damping term
|
||||
float a1 = 1.0f;
|
||||
float a2 = 0.0f;
|
||||
float a3 = 0.0f;
|
||||
};
|
||||
|
||||
// Highest fraction of the sample rate the pre-warp stays well-conditioned at: tan() diverges
|
||||
// as fc approaches sr/2.
|
||||
inline constexpr double kFilterNyquistFraction = 0.48;
|
||||
|
||||
// cutoffHz is clamped into [kFilterCutoffMinHz, kFilterNyquistFraction*sampleRate] and q into
|
||||
// [kFilterQMin, kFilterQMax]. A non-positive sampleRate yields g == 0 — we refuse to invent a
|
||||
// rate rather than assume 44.1k.
|
||||
//
|
||||
// Float storage is safe HERE in a way it was not for the retired Direct Form I path. DF1 encoded
|
||||
// pole proximity in a1 -> -2, a2 -> +1 and cancelled them against each other every sample; at
|
||||
// fc/sr ~ 1e-4 that ~17-bit cancellation moved the measured 20 Hz/192 kHz LP peak by -27%
|
||||
// (true-peak scan) to -57% (point measurement at the analytic peak frequency, since the
|
||||
// degraded pole itself moves) -- and the error is non-monotone with rate, not a fixed percentage
|
||||
// (+5% high at 96 kHz). TPT encodes the same proximity in a1's small DEVIATION from 1, which
|
||||
// float resolves: measured against an exact-double evaluation of the same difference equation
|
||||
// (which matches the analytic target to within measurement noise), TPT's float32-narrowed
|
||||
// coefficients land genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz -- both
|
||||
// comfortably inside the test's 0.4% tolerance.
|
||||
SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate);
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,64 @@
|
||||
#include "core/instrument/engine/filter/filter_morph.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
namespace {
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
struct Pair {
|
||||
double a, b;
|
||||
};
|
||||
|
||||
// Equal-power crossfade, EXACT at both ends by construction rather than by rounding: cos and sin
|
||||
// of the leg's quarter turn are only 1e-17 from 0/1 at the endpoints, and the endpoints have to
|
||||
// be pure taps, not a pure tap plus a -324 dB neighbour.
|
||||
Pair equalPower(double t) {
|
||||
if (!(t > 0.0)) return {1.0, 0.0};
|
||||
if (t >= 1.0) return {0.0, 1.0};
|
||||
const double theta = 0.5 * kPi * t;
|
||||
return {std::cos(theta), std::sin(theta)};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MorphWeights morphWeights(float norm, MorphLaw law) {
|
||||
const double n = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : static_cast<double>(norm));
|
||||
|
||||
MorphWeights w;
|
||||
if (law == MorphLaw::HighNotchLow) {
|
||||
// ONE crossfade across the whole sweep rather than two legs, so HP and LP carry weight
|
||||
// together everywhere between the endpoints and are equal at the centre.
|
||||
const Pair p = equalPower(n);
|
||||
w.hp = static_cast<float>(p.a);
|
||||
w.bp = 0.0f;
|
||||
w.lp = static_cast<float>(p.b);
|
||||
return w;
|
||||
}
|
||||
|
||||
if (n <= 0.5) {
|
||||
const Pair p = equalPower(2.0 * n); // HP -> BP
|
||||
w.hp = static_cast<float>(p.a);
|
||||
w.bp = static_cast<float>(p.b);
|
||||
w.lp = 0.0f;
|
||||
} else {
|
||||
const Pair p = equalPower(2.0 * n - 1.0); // BP -> LP
|
||||
w.hp = 0.0f;
|
||||
w.bp = static_cast<float>(p.a);
|
||||
w.lp = static_cast<float>(p.b);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
MorphMix morphMix(const MorphWeights& w, float k) {
|
||||
MorphMix m;
|
||||
m.m0 = w.hp;
|
||||
m.m1 = w.bp - w.hp * k;
|
||||
m.m2 = w.lp - w.hp;
|
||||
return m;
|
||||
}
|
||||
|
||||
MorphMix bypassMix() { return MorphMix{1.0f, 0.0f, 0.0f}; }
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,70 @@
|
||||
// filter_morph.h — the continuous morph: normalized position to tap weights under one of two
|
||||
// laws, and the fold of those weights into the three multipliers the kernel actually applies.
|
||||
// An SVF produces all three taps from one state, so the morph is a blend, never a coefficient
|
||||
// switch. Weights are computed at prepare() cadence; the law never reaches the per-sample path.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// Which shape the sweep traces between its two fixed endpoints. This selects CHARACTER, not
|
||||
// topology — same SVF, same coefficients, same kernel under either law; only the centre differs.
|
||||
//
|
||||
// HighBandLow is enumerator 0 deliberately: a zero-initialized or absent persisted field then
|
||||
// lands on the default rather than on the SEM leg.
|
||||
enum class MorphLaw {
|
||||
// HP -> BP -> LP. Crossfades ADJACENT taps only, so the corner magnitude is flat at Q the
|
||||
// whole way across. The default.
|
||||
HighBandLow,
|
||||
// HP -> notch -> LP, the Oberheim SEM. One crossfade weighting HP and LP together, bp == 0
|
||||
// throughout; the notch falls out of the antiphase cancellation rather than being tuned in.
|
||||
HighNotchLow,
|
||||
};
|
||||
|
||||
// Weight on each SVF tap. Under HighBandLow exactly one of hp/lp is nonzero at a time — that law
|
||||
// crossfades adjacent taps only, never HP against LP. Under HighNotchLow bp is always zero and
|
||||
// hp/lp carry weight together, which is precisely what cuts the notch.
|
||||
struct MorphWeights {
|
||||
float hp = 0.0f;
|
||||
float bp = 0.0f;
|
||||
float lp = 1.0f;
|
||||
};
|
||||
|
||||
// HP at 0.0, LP at 1.0 under BOTH laws; the centre is a band-pass under HighBandLow and a notch
|
||||
// under HighNotchLow. Out-of-range norm clamps to the endpoints; NaN clamps to neither (every
|
||||
// comparison against it is false) and lands on the law's degenerate — pure band-pass under
|
||||
// HighBandLow, pure high-pass under HighNotchLow, which has no band tap to land on.
|
||||
//
|
||||
// Equal-power (cos/sin) in both laws rather than linear, and that choice is forced by the
|
||||
// topology rather than picked by ear. At the corner frequency the three taps are HP = jQ,
|
||||
// BP = Q, LP = -jQ, so ADJACENT taps are in exact QUADRATURE there (and the bilinear transform
|
||||
// preserves that exactly at the prewarped corner). Under HighBandLow's cos/sin pair the corner
|
||||
// magnitude is therefore Q*sqrt(cos^2 + sin^2) = Q at every morph position — algebraically flat
|
||||
// across the whole sweep. A linear crossfade of the same quadrature pair would sag to Q/sqrt(2),
|
||||
// a 3 dB hole mid-leg.
|
||||
//
|
||||
// HP and LP are exactly ANTIPHASE at the corner (+90 and -90 degrees), so a law giving both
|
||||
// simultaneous weight cancels there. HighBandLow avoids that by staying adjacent; HighNotchLow
|
||||
// uses it — one equal-power crossfade of HP against LP over the whole sweep puts equal weights
|
||||
// at the centre and the null is exact by construction, not tuned. That is why the corner-flat-at-Q
|
||||
// guarantee is specific to HighBandLow: on the SEM leg the corner magnitude deliberately goes to
|
||||
// zero at the centre. Equal power still holds off the notch frequency, so neither law's legs sag.
|
||||
MorphWeights morphWeights(float norm, MorphLaw law);
|
||||
|
||||
// The kernel applies out = m0*v0 + m1*v1 + m2*v2, where v0 is the input and v1/v2 are the SVF's
|
||||
// band and low outputs. Folding hp = v0 - k*v1 - v2 into the weights here keeps the per-sample
|
||||
// path at three multiplies and spares it ever forming the high tap.
|
||||
struct MorphMix {
|
||||
float m0 = 0.0f;
|
||||
float m1 = 0.0f;
|
||||
float m2 = 1.0f;
|
||||
};
|
||||
|
||||
MorphMix morphMix(const MorphWeights& w, float k);
|
||||
|
||||
// Passes the input through untouched, whatever the morph position asks for. Reserved for a
|
||||
// sample rate we cannot form a filter from: silencing an instrument is a worse failure than
|
||||
// ignoring the morph, and at g == 0 a low-pass tap is analytically silent.
|
||||
MorphMix bypassMix();
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
namespace {
|
||||
|
||||
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||
|
||||
// log Q = A + B*n + C*n^2, solved from the three anchor points. C is nonzero precisely
|
||||
// because the center anchor sqrt(2) is not the geometric mean of the endpoints (which is 1);
|
||||
// were they equal the curve would degenerate to a plain log sweep and the inverse below
|
||||
// would divide by zero.
|
||||
struct QCurve {
|
||||
double a, b, c;
|
||||
};
|
||||
|
||||
QCurve solveQCurve() {
|
||||
const double lo = std::log(static_cast<double>(kFilterQMin));
|
||||
const double mid = std::log(static_cast<double>(kFilterQCenter));
|
||||
const double hi = std::log(static_cast<double>(kFilterQMax));
|
||||
return {lo, 4.0 * mid - 3.0 * lo - hi, 2.0 * lo + 2.0 * hi - 4.0 * mid};
|
||||
}
|
||||
|
||||
// Functions of compile-time constants alone, so they resolve once at static init rather than
|
||||
// per call. Load-bearing rather than tidy: a modulated cutoff re-solves EVERY FRAME, and
|
||||
// recomputing these logarithms of literals cost more than the solve they feed.
|
||||
const double kLogCutoffMin = std::log(static_cast<double>(kFilterCutoffMinHz));
|
||||
const double kLogCutoffSpan =
|
||||
std::log(static_cast<double>(kFilterCutoffMaxHz)) - kLogCutoffMin;
|
||||
const QCurve kQCurve = solveQCurve();
|
||||
|
||||
} // namespace
|
||||
|
||||
float filterCutoffHzFromNorm(float norm) {
|
||||
return static_cast<float>(std::exp(kLogCutoffMin + clamp01(norm) * kLogCutoffSpan));
|
||||
}
|
||||
|
||||
float filterNormFromCutoffHz(float hz) {
|
||||
if (!(hz > 0.0f)) return 0.0f;
|
||||
return static_cast<float>(
|
||||
clamp01((std::log(static_cast<double>(hz)) - kLogCutoffMin) / kLogCutoffSpan));
|
||||
}
|
||||
|
||||
float filterQFromNorm(float norm) {
|
||||
const double n = clamp01(norm);
|
||||
return static_cast<float>(std::exp(kQCurve.a + n * (kQCurve.b + kQCurve.c * n)));
|
||||
}
|
||||
|
||||
float filterDriveDepthFromNorm(float norm) {
|
||||
const double n = clamp01(norm);
|
||||
return static_cast<float>(kFilterDriveDepthMax * n * n);
|
||||
}
|
||||
|
||||
float filterNormFromDriveDepth(float depth) {
|
||||
if (!(depth > 0.0f)) return 0.0f; // also catches NaN
|
||||
if (depth >= kFilterDriveDepthMax) return 1.0f;
|
||||
return static_cast<float>(
|
||||
std::sqrt(static_cast<double>(depth) / static_cast<double>(kFilterDriveDepthMax)));
|
||||
}
|
||||
|
||||
float filterNormFromQ(float q) {
|
||||
if (!(q > kFilterQMin)) return 0.0f;
|
||||
if (q >= kFilterQMax) return 1.0f;
|
||||
// Clamping first is load-bearing, not just tidy: the parabola peaks at log Q well below
|
||||
// an arbitrarily large q, so an unclamped out-of-range value has no real root at all.
|
||||
const QCurve& k = kQCurve;
|
||||
const double d = k.b * k.b - 4.0 * k.c * (k.a - std::log(static_cast<double>(q)));
|
||||
if (!(d >= 0.0)) return 0.0f;
|
||||
// Of the two roots only this one lies on the rising branch inside [0,1]; the parabola's
|
||||
// vertex sits well above 1 for the settled anchors.
|
||||
return static_cast<float>(clamp01((-k.b + std::sqrt(d)) / (2.0 * k.c)));
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,57 @@
|
||||
// filter_params.h — control-domain mapping for the voice filter: normalized [0,1] knob
|
||||
// positions to cutoff Hz, Q, and drive depth. Deliberately sample-rate-free — the Nyquist
|
||||
// clamp is a property of the bilinear transform and lives in filter_coeffs, so the persisted
|
||||
// normalized cutoff means the same frequency at every project rate.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// The audio band the cutoff control sweeps: three exact decades, so norm 1/3 is 200 Hz and
|
||||
// norm 2/3 is 2 kHz. NOT derived from the sample rate — a rate-dependent endpoint would make
|
||||
// one saved preset sound different at 44.1k and 96k, and at 44.1k the top of the travel would
|
||||
// be dead against the Nyquist clamp (the ported firmware's 23 kHz endpoint had exactly that
|
||||
// defect). 20 kHz sits under 0.48*sr at 44.1 kHz and above; below that (e.g. 32 kHz, 22.05 kHz)
|
||||
// the clamp still handles it correctly, it just eats the top of the knob travel at those rates.
|
||||
inline constexpr float kFilterCutoffMinHz = 20.0f;
|
||||
inline constexpr float kFilterCutoffMaxHz = 20000.0f;
|
||||
|
||||
// Q spans the full range with Butterworth (sqrt(2)) at the control's center detent.
|
||||
inline constexpr float kFilterQMin = 0.1f;
|
||||
inline constexpr float kFilterQMax = 10.0f;
|
||||
inline constexpr float kFilterQCenter = 1.41421356f;
|
||||
|
||||
// Depth at the top of the drive control. The limiter's knee is at 1/depth, and the resonance
|
||||
// swings the state to roughly 2*Q*level, so this is the range over which drive bites. Chosen
|
||||
// against measurement rather than by feel: at max drive, full-scale input and max resonance the
|
||||
// resonant peak lands ~10 dB under the passband — plainly crushed, which is the asked-for
|
||||
// "extreme". Raising it further inverts the filter's shape (measured 21 dB under passband at
|
||||
// depth 64), turning the peak the user dialled in into a notch.
|
||||
inline constexpr float kFilterDriveDepthMax = 4.0f;
|
||||
|
||||
// Out-of-range norm clamps to the endpoints.
|
||||
float filterCutoffHzFromNorm(float norm);
|
||||
|
||||
// Exact inverse of filterCutoffHzFromNorm over the band; out-of-band Hz clamps to 0 or 1.
|
||||
float filterNormFromCutoffHz(float hz);
|
||||
|
||||
// A single smooth curve — quadratic in log Q — through (0, kFilterQMin),
|
||||
// (0.5, kFilterQCenter), (1, kFilterQMax), rather than two spliced log segments. Same three
|
||||
// anchors either way, but the single curve has no slope kink at the center detent.
|
||||
float filterQFromNorm(float norm);
|
||||
|
||||
// Exact inverse of filterQFromNorm; out-of-range Q clamps to 0 or 1.
|
||||
float filterNormFromQ(float q);
|
||||
|
||||
// Drive depth for the in-loop limiter. Square law, not linear: the knee is 1/depth, so a linear
|
||||
// depth would spend most of the audible travel in the first tenth of the knob. Exactly 0 at
|
||||
// norm 0 — the limiter is then algebraically the identity, which is what makes drive=0 bit-exact
|
||||
// linear rather than merely close.
|
||||
float filterDriveDepthFromNorm(float norm);
|
||||
|
||||
// Exact inverse of filterDriveDepthFromNorm; out-of-range depth clamps to 0 or 1. The analytic
|
||||
// inverse of a frozen law is not a change to it — it has the standing the two inverses above
|
||||
// already have.
|
||||
float filterNormFromDriveDepth(float depth);
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,34 @@
|
||||
// filter_saturate.h — the drive stage's soft limiter. Header-inline: it sits inside the
|
||||
// per-voice per-sample recursion.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
// Odd, smooth, strictly monotone, bounded by 1/depth, with unit slope at the origin.
|
||||
//
|
||||
// Three properties are load-bearing and none of them are tuning:
|
||||
// - depth == 0 makes this ALGEBRAICALLY the identity (x / sqrt(1) == x, exact in IEEE), so
|
||||
// drive = 0 is bit-exact linear whether or not the caller special-cases it. (voice_filter.h
|
||||
// gates the call on drive != 0 anyway, but as a perf optimization, not because correctness
|
||||
// needs it.)
|
||||
// - |softLimit(x, d)| <= |x| for every d, so dropping it into the resonance state update can
|
||||
// only ever shrink the state. The filter therefore cannot gain energy from the drive stage:
|
||||
// stability at any Q and any cutoff is structural, not a tuned margin, and it can never
|
||||
// self-oscillate.
|
||||
// - Unit slope at the origin, so the shaper adds no gain of its own at any depth. What reaches
|
||||
// it is the resonance state, already multiplied by roughly 2*Q, which is why drive and
|
||||
// resonance interact: the same drive setting bites harder the more resonance is dialled in.
|
||||
//
|
||||
// The retired feedbackSaturate() is deliberately not carried forward: it had 0.75 slope at the
|
||||
// origin, a fixed +/-2.0 threshold calibrated for firmware excursion levels, and turned over
|
||||
// (non-monotone) past x = 6. That absolute threshold is the origin of the level-dependent
|
||||
// resonance this rewrite removes — do not reintroduce it.
|
||||
inline float softLimit(float x, float depth) {
|
||||
const float s = depth * x;
|
||||
return x / std::sqrt(1.0f + s * s);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
@@ -0,0 +1,37 @@
|
||||
#include "core/instrument/engine/filter/voice_filter.h"
|
||||
|
||||
namespace reasampler::instrument::engine::filter {
|
||||
|
||||
void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
|
||||
q_ = filterQFromNorm(settings.resonanceNorm);
|
||||
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(settings.cutoffNorm), q_, sampleRate);
|
||||
if (sampleRate > 0.0) {
|
||||
mix_ = morphMix(morphWeights(settings.morphNorm, settings.morphLaw), coeffs_.k);
|
||||
} else {
|
||||
// Bypass: a1=1, a2=a3=0 makes both state updates the exact identity, and bypassMix()
|
||||
// reads only the input, never the state -- so clearing here is audibly free (the state
|
||||
// was already going to be ignored) and prevents a stale nonzero ic1/ic2 from latching
|
||||
// isSilent() false forever, which prepare() otherwise deliberately never does.
|
||||
mix_ = bypassMix();
|
||||
for (State& s : state_) s = State{};
|
||||
}
|
||||
driveDepth_ = filterDriveDepthFromNorm(settings.driveNorm);
|
||||
driven_ = driveDepth_ != 0.0f;
|
||||
}
|
||||
|
||||
void VoiceFilter::setCutoffNorm(float cutoffNorm, double sampleRate) {
|
||||
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(cutoffNorm), q_, sampleRate);
|
||||
}
|
||||
|
||||
void VoiceFilter::reset() {
|
||||
for (State& s : state_) s = State{};
|
||||
}
|
||||
|
||||
bool VoiceFilter::isSilent() const {
|
||||
for (const State& s : state_) {
|
||||
if (s.ic1 != 0.0f || s.ic2 != 0.0f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine::filter
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user