-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsampleManager.lua
More file actions
427 lines (381 loc) · 15.7 KB
/
sampleManager.lua
File metadata and controls
427 lines (381 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
-- See docs/sampleManager.md for the model.
-- @noindex
--invariant: cm is the sole authority for slot state ({path, start, end, name}); JSFX is a pure consumer
--invariant: cm holds project-relative paths; sm composes absolute via currentPrefix at every push so JSFX needs no prefix
--invariant: gmem layout mirrors Continuum_Sampler.jsfx; SLOT_BASE/BOOT_BASE constants must stay in lockstep with the JSFX side
--invariant: per-instance bundled mailbox at SLOT_BASE+id*SLOT_STRIDE; preview retains its own legacy magic-gated mailbox at PREVIEW_BASE
--invariant: at most one slot drained per track per tick — keeps last-write-wins consolidation simple
--invariant: instance ids are persisted via track P_EXT (PEXT_KEY) and mirrored into JSFX slider2 every getInstanceId call
--invariant: boot-token watcher (BOOT_BASE+id) detects fresh JSFX mem[] (project reload, recompile) and triggers full rehydrate
--invariant: JSFX user-string slot cap is 1023; PATH_MAX (1019) + SLOT_STRIDE bookkeeping must not push slot string writes past that ceiling
--shape: slotEntry = { path=string?, name=string?, start=number, ['end']=number } -- cm-stored, path is project-relative
--shape: pendingEntry = { slot=number, op=0|1, path=string?, name=string?, start=number?, ['end']=number? } -- mailbox queue entry; op=1 is clear
--shape: trackState = { fxGuid=string?, instanceId=number?, lastBootToken=number, slotSeq=number, pending={byOrder={int,...}, bySlot={[slot]=pendingEntry}} }
--shape: mailboxHeader = [seq, seq_ack, slot, op, start, end, pathLen, nameLen, <pathBytes...>, <nameBytes...>] -- gmem words at SLOT_BASE+id*SLOT_STRIDE
local util = require 'util'
local fs = require 'fs'
local SAMPLER_FX = 'Continuum Sampler'
local GMEM_NS = 'Continuum_sampler'
local MAGIC = 1717658484 -- 'CTML' as 32-bit ASCII
local MAX_INSTANCES = 128
local N_SAMPLES = 64
local PATH_MAX = 1019
local NAME_MAX = 64
local SLOT_STRIDE = 8 + PATH_MAX + NAME_MAX -- 1091
local PREVIEW_BASE = 1024
-- Numbers mirror Continuum_Sampler.jsfx; intermediate strides are no
-- longer derivable Lua-side.
local SLOT_BASE = 561152
local BOOT_BASE = SLOT_BASE + MAX_INSTANCES * SLOT_STRIDE -- 700800
local PREVIEW_SLOT_IDX = N_SAMPLES
local SLIDER_INSTANCE_ID = 1 -- slider2 in JSFX = param index 1 (0-based)
local PEXT_KEY = 'P_EXT:samplerInstanceId'
--contract: writePath emits NUL-terminated bytes; caller must ensure base+#path is within the addressable string region
local function writePath(base, path)
for i = 1, #path do reaper.gmem_write(base + i - 1, path:byte(i)) end
reaper.gmem_write(base + #path, 0)
end
local function findSamplerFx(track)
for i = 0, reaper.TrackFX_GetCount(track) - 1 do
local _, name = reaper.TrackFX_GetFXName(track, i, '')
if name:find(SAMPLER_FX, 1, true) then return i end
end
return nil
end
--contract: readInstanceId validates the P_EXT value into [0, MAX_INSTANCES); returns nil for missing/out-of-range/non-numeric
local function readInstanceId(track)
local _, val = reaper.GetSetMediaTrackInfo_String(track, PEXT_KEY, '', false)
local id = tonumber(val)
if id and id >= 0 and id < MAX_INSTANCES then return math.floor(id) end
return nil
end
--contract: gatherTakenIds skips skipTrack so a re-assignment of the same track doesn't see its own id as taken
local function gatherTakenIds(skipTrack)
local taken = {}
for i = 0, reaper.CountTracks(0) - 1 do
local t = reaper.GetTrack(0, i)
if t ~= skipTrack and findSamplerFx(t) then
local id = readInstanceId(t)
if id then taken[id] = true end
end
end
return taken
end
local function nextFreeId(taken)
for i = 0, MAX_INSTANCES - 1 do
if not taken[i] then return i end
end
return nil
end
local fileOps = (...).fileOps
local sm = {}
local trackStates = {}
local currentPrefix = nil
local function absFor(rel)
if not rel or rel == '' then return rel end
if not currentPrefix or currentPrefix == '' then return rel end
return currentPrefix .. '/' .. rel
end
local function ensureTrackState(track)
local s = trackStates[track]
if not s then
s = { fxGuid = nil, instanceId = nil, lastBootToken = 0,
slotSeq = 0,
pending = { byOrder = {}, bySlot = {} } }
trackStates[track] = s
end
return s
end
local function relForSrc(srcBase, hash)
local stem, ext = srcBase:match('^(.*)%.([^.]+)$')
stem = stem or srcBase
return ext
and 'Continuum/' .. stem .. '-' .. hash .. '.' .. ext
or 'Continuum/' .. stem .. '-' .. hash
end
local function setEntry(cm, idx, fields)
local entries = cm:get('slotEntries')
entries[idx] = entries[idx] or {}
for k, v in pairs(fields) do entries[idx][k] = v end
cm:set('track', 'slotEntries', entries)
end
local function clearEntry(cm, idx)
local entries = cm:get('slotEntries')
entries[idx] = nil
cm:set('track', 'slotEntries', entries)
end
--contract: pushSlot merges into the existing pendingEntry for slot; op=1 (clear) wipes path/name/start/end; op=0 only overrides explicitly-passed fields
--contract: byOrder records first-seen slot only (no duplicate enqueue); merges into the existing bySlot entry to preserve drain ordering
local function pushSlot(state, slot, opts)
local entry = state.pending.bySlot[slot]
if not entry then
entry = { slot = slot, op = 0 }
state.pending.bySlot[slot] = entry
state.pending.byOrder[#state.pending.byOrder + 1] = slot
end
if opts.op == 1 then
entry.op = 1
entry.path = nil
entry.name = nil
entry.start = 0
entry['end'] = 0
else
entry.op = 0
if opts.path ~= nil then entry.path = opts.path end
if opts.name ~= nil then entry.name = opts.name end
if opts.start ~= nil then entry.start = opts.start end
if opts['end'] ~= nil then entry['end'] = opts['end'] end
end
end
--contract: drain writes header words in order [slot, op, start, end, pathLen, nameLen, body...] then bumps seq last so JSFX only fires once the body is in
--contract: drain gates on seq == seq_ack at addr/addr+1 — JSFX must have consumed the prior write before the next one can land
--contract: drain is no-op while instanceId is nil (track lacks the FX) or queue is empty
local function drain(state)
local order = state.pending.byOrder
if #order == 0 or not state.instanceId then return end
local addr = SLOT_BASE + state.instanceId * SLOT_STRIDE
if reaper.gmem_read(addr) ~= reaper.gmem_read(addr + 1) then return end
local slot = table.remove(order, 1)
local entry = state.pending.bySlot[slot]
state.pending.bySlot[slot] = nil
local pathBytes = entry.path
local nameBytes = entry.name
local pathLen = pathBytes and #pathBytes or 0
local nameLen = nameBytes and #nameBytes or 0
reaper.gmem_write(addr + 2, slot)
reaper.gmem_write(addr + 3, entry.op)
reaper.gmem_write(addr + 4, entry.start or 0)
reaper.gmem_write(addr + 5, entry['end'] or 0)
reaper.gmem_write(addr + 6, pathLen)
reaper.gmem_write(addr + 7, nameLen)
for i = 1, pathLen do
reaper.gmem_write(addr + 7 + i, pathBytes:byte(i))
end
for i = 1, nameLen do
reaper.gmem_write(addr + 7 + pathLen + i, nameBytes:byte(i))
end
state.slotSeq = state.slotSeq + 1
reaper.gmem_write(addr, state.slotSeq)
end
function sm:isLive(track)
return findSamplerFx(track) ~= nil
end
function sm:getInstanceId(track)
local fxIdx = findSamplerFx(track)
if not fxIdx then return nil end
local id = readInstanceId(track)
if not id then
id = nextFreeId(gatherTakenIds(track))
if not id then return nil end
reaper.GetSetMediaTrackInfo_String(track, PEXT_KEY, tostring(id), true)
end
reaper.TrackFX_SetParam(track, fxIdx, SLIDER_INSTANCE_ID, id)
return id
end
function sm:rehydrateTrack(track, cm)
local state = ensureTrackState(track)
local entries = cm:readTrackKey(track, 'slotEntries') or {}
for slot = 0, N_SAMPLES - 1 do
local e = entries[slot]
if e and e.path then
pushSlot(state, slot, {
path = absFor(e.path),
name = e.name,
start = e.start or 0,
['end'] = e['end'] or 0,
})
end
end
end
function sm:syncSlot(track, slot, cm)
local state = ensureTrackState(track)
local e = (cm:get('slotEntries') or {})[slot]
if not e or not e.path then
pushSlot(state, slot, { op = 1 })
else
pushSlot(state, slot, {
path = absFor(e.path),
name = e.name,
start = e.start or 0,
['end'] = e['end'] or 0,
})
end
end
--contract: tick is the only caller of gmem_attach for the bundled mailbox path; coordinator must invoke per frame
--contract: tick reaps trackStates entries for tracks whose sampler FX has been removed (else closed-over state leaks)
--contract: first-sight FX-GUID binding does NOT reset state (lastBootToken=0 still triggers rehydrate via the boot-token branch)
function sm:tick(cm)
reaper.gmem_attach(GMEM_NS)
for i = 0, reaper.CountTracks(0) - 1 do
local track = reaper.GetTrack(0, i)
local fxIdx = findSamplerFx(track)
if fxIdx then
local state = ensureTrackState(track)
local guid = reaper.TrackFX_GetFXGUID(track, fxIdx)
if state.fxGuid == nil then
state.fxGuid = guid -- first sight: bind, don't reset
elseif state.fxGuid ~= guid then
state.fxGuid = guid
state.lastBootToken = 0
state.slotSeq = 0
state.pending = { byOrder = {}, bySlot = {} }
end
state.instanceId = self:getInstanceId(track)
if state.instanceId then
local token = reaper.gmem_read(BOOT_BASE + state.instanceId)
if token ~= 0 and token ~= state.lastBootToken then
state.lastBootToken = token
self:rehydrateTrack(track, cm)
end
drain(state)
end
else
trackStates[track] = nil
end
end
end
----- cm-authoritative slot operations
--contract: assign hashes srcPath, copies into projectPath/Continuum/<stem>-<hash>.<ext> if missing, writes cm rel path, queues mailbox push; returns false if hash or copy fails
function sm:assign(track, idx, srcPath, projectPath, cm)
local hash = fileOps.hash(srcPath)
if not hash then return false end
local rel = relForSrc(fs.basename(srcPath), hash)
local abs = projectPath .. '/' .. rel
fileOps.mkdir(projectPath .. '/Continuum')
if not fileOps.exists(abs) and not fileOps.copy(srcPath, abs) then return false end
local name = fs.basename(srcPath)
-- A fresh sample replaces the slot's source — trim from the previous
-- sample no longer applies. Clear start/end (other fields, e.g.
-- shStart, are unrelated and survive).
local entries = cm:get('slotEntries')
entries[idx] = entries[idx] or {}
entries[idx].path, entries[idx].name = rel, name
entries[idx].start, entries[idx]['end'] = nil, nil
cm:set('track', 'slotEntries', entries)
pushSlot(ensureTrackState(track), idx, {
path = absFor(rel), name = name, start = 0, ['end'] = 0,
})
return true
end
function sm:loadSlot(track, slot, relPath)
pushSlot(ensureTrackState(track), slot, {
path = absFor(relPath), start = 0, ['end'] = 0,
})
return true
end
function sm:clearSlot(track, slot, cm)
clearEntry(cm, slot)
pushSlot(ensureTrackState(track), slot, { op = 1 })
return true
end
--contract: setTrim sends start/end without path/name; pathLen=0/nameLen=0 in the wire payload means "leave alone" on the JSFX side
function sm:setTrim(track, slot, startFrames, endFrames, cm)
setEntry(cm, slot, { start = startFrames, ['end'] = endFrames })
pushSlot(ensureTrackState(track), slot, {
start = startFrames, ['end'] = endFrames,
})
return true
end
function sm:setName(track, slot, name, cm)
setEntry(cm, slot, { name = name })
pushSlot(ensureTrackState(track), slot, { name = name })
return true
end
function sm:stageInto(track, idx, srcPath, projectPath)
local hash = fileOps.hash(srcPath)
if not hash then return nil end
local rel = relForSrc(fs.basename(srcPath), hash)
local abs = projectPath .. '/' .. rel
fileOps.mkdir(projectPath .. '/Continuum')
if not fileOps.exists(abs) and not fileOps.copy(srcPath, abs) then return nil end
self:loadSlot(track, idx, rel)
return rel
end
----- Preview + prefix
function sm:setPrefix(prefix)
currentPrefix = prefix
return true
end
--contract: preview writes are silently dropped if the magic word at PREVIEW_BASE is non-zero (JSFX hasn't consumed the prior preview command)
--contract: preview header order: payload words first, magic last — JSFX dispatches on the MAGIC write
function sm:previewSlot(track, slot, bounds)
local id = self:getInstanceId(track); if not id then return false end
reaper.gmem_attach(GMEM_NS)
if reaper.gmem_read(PREVIEW_BASE) ~= 0 then return false end
reaper.gmem_write(PREVIEW_BASE + 2, slot)
reaper.gmem_write(PREVIEW_BASE + 3, bounds)
reaper.gmem_write(PREVIEW_BASE + 1, id)
reaper.gmem_write(PREVIEW_BASE, MAGIC)
return true
end
--contract: stopPreview encodes "stop" as slot=-1 in the same preview mailbox; same magic-gate as previewSlot
function sm:stopPreview(track)
local id = self:getInstanceId(track); if not id then return false end
reaper.gmem_attach(GMEM_NS)
if reaper.gmem_read(PREVIEW_BASE) ~= 0 then return false end
reaper.gmem_write(PREVIEW_BASE + 2, -1)
reaper.gmem_write(PREVIEW_BASE + 1, id)
reaper.gmem_write(PREVIEW_BASE, MAGIC)
return true
end
--contract: previewPath uses the dedicated PREVIEW_SLOT_IDX (=N_SAMPLES) so the JSFX side knows to load from the inline path bytes rather than a slot
function sm:previewPath(track, path)
local id = self:getInstanceId(track); if not id then return false end
reaper.gmem_attach(GMEM_NS)
if reaper.gmem_read(PREVIEW_BASE) ~= 0 then return false end
writePath(PREVIEW_BASE + 4, path)
reaper.gmem_write(PREVIEW_BASE + 2, PREVIEW_SLOT_IDX)
reaper.gmem_write(PREVIEW_BASE + 3, 0)
reaper.gmem_write(PREVIEW_BASE + 1, id)
reaper.gmem_write(PREVIEW_BASE, MAGIC)
return true
end
----- Track surface
function sm:listTracks()
local out = {}
for i = 0, reaper.CountTracks(0) - 1 do
local t = reaper.GetTrack(0, i)
if findSamplerFx(t) then
local _, trackName = reaper.GetTrackName(t)
out[#out + 1] = { track = t,
name = trackName ~= '' and trackName or '(unnamed)',
instanceId = self:getInstanceId(t) }
end
end
return out
end
--contract: probeMode flips cm.trackerMode (transient tier) based on whether the take's track has the sampler FX
--contract: probeMode forces I_PERFFLAGS bit 1 (anticipative FX off) on sampler tracks for tighter timing
function sm:probeMode(take, cm)
local track = reaper.GetMediaItemTake_Track(take)
local detected = findSamplerFx(track) ~= nil
if cm:get('trackerMode') ~= detected then
cm:set('transient', 'trackerMode', detected)
end
if detected then
self:getInstanceId(track)
local pf = reaper.GetMediaTrackInfo_Value(track, 'I_PERFFLAGS')
if (pf & 2) == 0 then
reaper.SetMediaTrackInfo_Value(track, 'I_PERFFLAGS', pf | 2)
end
end
end
--contract: migrate moves files when the project root changes; cm rel paths are preserved so no cm rewrite is needed
--contract: migrate is a no-op if oldProjectPath is missing or unchanged
function sm:migrate(projectPath, oldProjectPath, cm)
if not oldProjectPath or oldProjectPath == projectPath then return false end
local entries = cm:get('slotEntries')
local anyMoved = false
for _, e in pairs(entries) do
if e.path then
local oldAbs = oldProjectPath .. '/' .. e.path
local newAbs = projectPath .. '/' .. e.path
if oldAbs ~= newAbs then
fileOps.mkdir(projectPath .. '/Continuum')
if fileOps.move(oldAbs, newAbs) then anyMoved = true end
end
end
end
return anyMoved
end
return sm