6.1 KiB
Record Automation
Context
Record parameter value changes as value regions while the engine is in recording mode. Clip BEFORE creating/resizing — only the delta range — so the recording region is never inside the mask.
Step 1: ParameterWriteEvent
getUnitValue() already returns the new value by the time subscribeWrites fires. Capture the previous unit value in setValue() before the field write.
packages/studio/adapters/src/ParameterFieldAdapters.ts — export ParameterWriteEvent = { adapter, previousUnitValue }, use as Notifier/Observer type
packages/studio/adapters/src/AutomatableParameterFieldAdapter.ts — in setValue(): const old = this.getUnitValue() before this.#field.setValue(value), then notifyWrite(this, old)
Step 2: RecordAutomation
packages/studio/core/src/capture/RecordAutomation.ts
State
type RecordingState = {
adapter: AutomatableParameterFieldAdapter
regionAdapter: ValueRegionBoxAdapter
floating: boolean
lastValue: unitValue
lastRelativePosition: ppqn
lastEventBox: ValueEventBox
}
Derived from regionAdapter:
regionAdapter.position→ startPosition (set once)regionAdapter.box→ regionBoxregionAdapter.trackBoxAdapter.unwrap()→ trackBoxAdapterregionAdapter.optCollection.unwrap().box.events→ collection events ref for new ValueEventBoxes
Active recordings: Address.newSet<RecordingState>(state => state.adapter.address)
Quantization
- Position:
quantizeFloor(position, PPQN.SemiQuaver) - Duration:
quantizeCeil(rawDuration, PPQN.SemiQuaver), minimum =PPQN.SemiQuaver - Event positions: exact relative time (no quantization)
Write Handler — First Write
Receives {adapter, previousUnitValue}:
startPos = quantizeFloor(position, PPQN.SemiQuaver)- Find/create track (existing logic)
- Deselect selected regions on same track:
project.selection.deselect(...trackBoxAdapter.regions.collection.asArray().filter(r => r.isSelected).map(r => r.box)) fromRange(trackBoxAdapter, startPos, startPos + PPQN.SemiQuaver)()— clip BEFORE creating- Create collectionBox, regionBox (position = startPos, duration = SemiQuaver, loopDuration = SemiQuaver)
project.selection.select(regionBox)regionAdapter = boxAdapters.adapterFor(regionBox, ValueRegionBoxAdapter)floating = adapter.valueMapping.floating()interpolation = floating ? Interpolation.Linear : Interpolation.None- Events at position 0:
- If
previousUnitValue !== value: event(index=0, value=previousUnitValue, interp=None), event(index=1, value=value, interp=interpolation) - If equal: event(index=0, value=value, interp=interpolation)
- If
- Set interpolation via
InterpolationFieldAdapter.write(eventBox.interpolation, ...)
Write Handler — Subsequent Writes
Same dedup logic (same position → update lastEventBox, new position → create new event). Set InterpolationFieldAdapter.write(eventBox.interpolation, state.floating ? Interpolation.Linear : Interpolation.None) on each new event.
Position Handler
if not recording or no active recordings: return
currentPosition = owner.getValue()
// Loop detection
if loopEnabled && currentPosition < lastPosition:
for each state:
finalize region (set duration to loopTo - startPos, add hold event, clip delta, deselect)
restart at loopFrom (deselect track, clip, create new region/collection/events, select)
lastPosition = currentPosition
// Delta-range clip + resize
for each state:
oldDuration = regionAdapter.box.duration.getValue()
maxDuration = loopEnabled ? loopTo - regionAdapter.position : Infinity
newDuration = max(SemiQuaver, quantizeCeil(min(maxDuration, currentPosition - regionAdapter.position), SemiQuaver))
if newDuration > oldDuration:
fromRange(trackBoxAdapter, regionAdapter.position + oldDuration, regionAdapter.position + newDuration)()
regionAdapter.box.duration.setValue(newDuration)
regionAdapter.box.loopDuration.setValue(newDuration)
Termination Handler
finalPosition = engine.position.getValue()
for each state:
finalDuration = max(0, quantizeCeil(finalPosition - regionAdapter.position, SemiQuaver))
if finalDuration <= 0: regionAdapter.box.delete(); continue
oldDuration = regionAdapter.box.duration.getValue()
if finalDuration > oldDuration:
fromRange(trackBoxAdapter, regionAdapter.position + oldDuration, regionAdapter.position + finalDuration)()
if finalDuration !== state.lastRelativePosition:
create hold event at finalDuration with state.lastValue
regionAdapter.box.duration.setValue(finalDuration)
regionAdapter.box.loopDuration.setValue(finalDuration)
Files
| File | Change |
|---|---|
packages/studio/adapters/src/ParameterFieldAdapters.ts |
ParameterWriteEvent type, change notifier/observer generic |
packages/studio/adapters/src/AutomatableParameterFieldAdapter.ts |
Capture old unit value in setValue() |
packages/studio/core/src/capture/RecordAutomation.ts |
Complete implementation |
APIs
InterpolationFieldAdapter.write(field, interpolation)—studio-adapters/.../InterpolationFieldAdapter.tsInterpolation.None/.Linear—lib-dsp/src/value.tsValueMapping.floating()—lib-std/src/value-mapping.tsPPQN.SemiQuaver(240) —lib-dsp/src/ppqn.tsquantizeFloor/quantizeCeil—lib-std/src/math.tsAddress.newSet()—lib-box/src/address.tsRegionClipResolver.fromRange(track, pos, complete)—studio-core/.../RegionClipResolver.tsproject.selection.select/deselect—studio-adapters/.../VertexSelection.tsproject.timelineBox.loopArea(enabled/from/to) —studio-boxes/src/TimelineBox.ts
Verification
npx tsc -b packages/studio/adapters/tsconfig.jsonthennpx tsc -b packages/studio/core/tsconfig.json- Record continuous param → linear interpolation; discrete → step
- Different value at start → two events at position 0 (hold + new)
- Regions clipped as recording grows (delta clipping)
- Loop: finalize at loop-end, new at loop-from, previous kept
- Semiquaver-quantized position (floor) and duration (ceil)
- Track selection managed correctly