formatting
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"bracketSpacing": false
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
import {Peaks, PeaksPainter} from "@opendaw/lib-fusion"
|
||||
import {TimelineRange} from "@opendaw/studio-core"
|
||||
import {AudioFileBoxAdapter, AudioPlayMode} from "@opendaw/studio-adapters"
|
||||
import {Curve, Option, TAU} from "@opendaw/lib-std"
|
||||
import {Option} from "@opendaw/lib-std"
|
||||
import {RegionBound} from "@/ui/timeline/renderer/env"
|
||||
import {
|
||||
dbToGain,
|
||||
FadingEnvelope,
|
||||
LoopableRegion,
|
||||
PPQN,
|
||||
TempoChangeGrid,
|
||||
TempoMap,
|
||||
} from "@opendaw/lib-dsp"
|
||||
import {dbToGain, LoopableRegion, PPQN, TempoChangeGrid, TempoMap} from "@opendaw/lib-dsp"
|
||||
|
||||
type Segment = {
|
||||
x0: number
|
||||
@@ -30,7 +23,7 @@ export const renderAudio = (
|
||||
{top, bottom}: RegionBound,
|
||||
contentColor: string,
|
||||
{rawStart, resultStart, resultEnd}: LoopableRegion.LoopCycle,
|
||||
clip: boolean = true,
|
||||
clip: boolean = true
|
||||
) => {
|
||||
if (file.peaks.isEmpty()) {
|
||||
return
|
||||
@@ -64,7 +57,7 @@ export const renderAudio = (
|
||||
posEnd: number,
|
||||
audioStart: number,
|
||||
audioEnd: number,
|
||||
outside: boolean,
|
||||
outside: boolean
|
||||
) => {
|
||||
if (posStart >= posEnd) {
|
||||
return
|
||||
@@ -74,7 +67,7 @@ export const renderAudio = (
|
||||
}
|
||||
const clippedStart = Math.max(
|
||||
posStart,
|
||||
range.unitMin - range.unitPadding,
|
||||
range.unitMin - range.unitPadding
|
||||
)
|
||||
const clippedEnd = Math.min(posEnd, range.unitMax)
|
||||
if (clippedStart >= clippedEnd) {
|
||||
@@ -106,14 +99,14 @@ export const renderAudio = (
|
||||
x1,
|
||||
u0: (aStart / durationInSeconds) * numFrames,
|
||||
u1: (aEnd / durationInSeconds) * numFrames,
|
||||
outside,
|
||||
outside
|
||||
})
|
||||
}
|
||||
const handleSegment = (
|
||||
segmentStart: number,
|
||||
segmentEnd: number,
|
||||
audioStartSeconds: number,
|
||||
audioEndSeconds: number,
|
||||
audioEndSeconds: number
|
||||
) => {
|
||||
if (segmentStart >= segmentEnd) {
|
||||
return
|
||||
@@ -149,7 +142,7 @@ export const renderAudio = (
|
||||
endPos,
|
||||
audioStartSeconds,
|
||||
aEnd,
|
||||
true,
|
||||
true
|
||||
)
|
||||
}
|
||||
// Audible
|
||||
@@ -172,7 +165,7 @@ export const renderAudio = (
|
||||
segmentEnd,
|
||||
aStart,
|
||||
audioEndSeconds,
|
||||
true,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -188,11 +181,11 @@ export const renderAudio = (
|
||||
waveformOffset < 0 ? -waveformOffset / lastRate : 0
|
||||
const extrapolateStartLocal = Math.min(
|
||||
visibleLocalStart,
|
||||
first.position - extraNeededBefore,
|
||||
first.position - extraNeededBefore
|
||||
)
|
||||
const extrapolateEndLocal = Math.max(
|
||||
visibleLocalEnd,
|
||||
last.position + extraNeededAfter,
|
||||
last.position + extraNeededAfter
|
||||
)
|
||||
// Extrapolate before the first warp marker
|
||||
if (extrapolateStartLocal < first.position) {
|
||||
@@ -203,13 +196,13 @@ export const renderAudio = (
|
||||
rawStart + extrapolateStartLocal,
|
||||
rawStart + first.position,
|
||||
audioStart,
|
||||
first.seconds,
|
||||
first.seconds
|
||||
)
|
||||
}
|
||||
// Interior warp segments - only iterate visible range
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
warpMarkers.floorLastIndex(visibleLocalStart),
|
||||
warpMarkers.floorLastIndex(visibleLocalStart)
|
||||
)
|
||||
for (let i = startIndex; i < markers.length - 1; i++) {
|
||||
const w0 = markers[i]
|
||||
@@ -221,7 +214,7 @@ export const renderAudio = (
|
||||
rawStart + w0.position,
|
||||
rawStart + w1.position,
|
||||
w0.seconds,
|
||||
w1.seconds,
|
||||
w1.seconds
|
||||
)
|
||||
}
|
||||
// Extrapolate after the last warp marker
|
||||
@@ -232,7 +225,7 @@ export const renderAudio = (
|
||||
rawStart + last.position,
|
||||
rawStart + extrapolateEndLocal,
|
||||
last.seconds,
|
||||
audioEnd,
|
||||
audioEnd
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -251,7 +244,7 @@ export const renderAudio = (
|
||||
ppqnEnd: number,
|
||||
audioStart: number,
|
||||
audioEnd: number,
|
||||
outside: boolean,
|
||||
outside: boolean
|
||||
) => {
|
||||
if (ppqnStart >= ppqnEnd) {
|
||||
return
|
||||
@@ -264,7 +257,7 @@ export const renderAudio = (
|
||||
}
|
||||
const clippedStart = Math.max(
|
||||
ppqnStart,
|
||||
range.unitMin - range.unitPadding,
|
||||
range.unitMin - range.unitPadding
|
||||
)
|
||||
const clippedEnd = Math.min(ppqnEnd, range.unitMax)
|
||||
if (clippedStart >= clippedEnd) {
|
||||
@@ -302,7 +295,7 @@ export const renderAudio = (
|
||||
segStart: number,
|
||||
segEnd: number,
|
||||
audioStart: number,
|
||||
audioEnd: number,
|
||||
audioEnd: number
|
||||
) => {
|
||||
if (segStart >= segEnd) {
|
||||
return
|
||||
@@ -346,32 +339,32 @@ export const renderAudio = (
|
||||
// Calculate iteration bounds
|
||||
// Where does audioTime = 0? Solve: ppqnToSeconds(ppqn) - regionStartSeconds + waveformOffset = 0
|
||||
const audioStartPPQN = tempoMap.secondsToPPQN(
|
||||
regionStartSeconds - waveformOffset,
|
||||
regionStartSeconds - waveformOffset
|
||||
)
|
||||
// Where does audioTime = durationInSeconds?
|
||||
const audioEndPPQN = tempoMap.secondsToPPQN(
|
||||
regionStartSeconds - waveformOffset + durationInSeconds,
|
||||
regionStartSeconds - waveformOffset + durationInSeconds
|
||||
)
|
||||
|
||||
// Determine visible iteration range (include padding on the left for smooth scrolling)
|
||||
const iterStart = clip
|
||||
? Math.max(resultStart, range.unitMin - range.unitPadding)
|
||||
: Math.max(
|
||||
Math.min(audioStartPPQN, resultStart),
|
||||
range.unitMin - range.unitPadding,
|
||||
)
|
||||
Math.min(audioStartPPQN, resultStart),
|
||||
range.unitMin - range.unitPadding
|
||||
)
|
||||
const iterEnd = clip
|
||||
? Math.min(resultEnd, range.unitMax + TempoChangeGrid)
|
||||
: Math.min(
|
||||
Math.max(audioEndPPQN, resultEnd),
|
||||
range.unitMax + TempoChangeGrid,
|
||||
)
|
||||
Math.max(audioEndPPQN, resultEnd),
|
||||
range.unitMax + TempoChangeGrid
|
||||
)
|
||||
|
||||
// Dynamic step size: ensure each step is at least 1 device pixel wide
|
||||
const minStepSize = range.unitsPerPixel * devicePixelRatio
|
||||
const stepSize = Math.max(
|
||||
TempoChangeGrid,
|
||||
Math.ceil(minStepSize / TempoChangeGrid) * TempoChangeGrid,
|
||||
Math.ceil(minStepSize / TempoChangeGrid) * TempoChangeGrid
|
||||
)
|
||||
|
||||
// Align to grid for consistent rendering across zoom levels
|
||||
@@ -385,7 +378,7 @@ export const renderAudio = (
|
||||
// Incremental: get tempo at current position and compute step duration
|
||||
const stepSeconds = PPQN.pulsesToSeconds(
|
||||
stepSize,
|
||||
tempoMap.getTempoAt(currentPPQN),
|
||||
tempoMap.getTempoAt(currentPPQN)
|
||||
)
|
||||
const nextAudioTime = currentAudioTime + stepSeconds
|
||||
|
||||
@@ -395,7 +388,7 @@ export const renderAudio = (
|
||||
currentPPQN,
|
||||
nextPPQN,
|
||||
currentAudioTime,
|
||||
nextAudioTime,
|
||||
nextAudioTime
|
||||
)
|
||||
}
|
||||
currentPPQN = nextPPQN
|
||||
@@ -415,74 +408,8 @@ export const renderAudio = (
|
||||
x0,
|
||||
x1,
|
||||
y0: 3 + top + channel * peaksHeight,
|
||||
y1: 3 + top + (channel + 1) * peaksHeight,
|
||||
y1: 3 + top + (channel + 1) * peaksHeight
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const renderFading = (
|
||||
context: CanvasRenderingContext2D,
|
||||
range: TimelineRange,
|
||||
fading: FadingEnvelope.Config,
|
||||
{top, bottom}: RegionBound,
|
||||
startPPQN: number,
|
||||
endPPQN: number,
|
||||
color: string,
|
||||
handleColor: string,
|
||||
): void => {
|
||||
const {
|
||||
in: fadeIn,
|
||||
out: fadeOut,
|
||||
inSlope: fadeInSlope,
|
||||
outSlope: fadeOutSlope,
|
||||
} = fading
|
||||
const dpr = devicePixelRatio
|
||||
const height = bottom - top
|
||||
const handleRadius = 3 * dpr
|
||||
context.fillStyle = color
|
||||
if (fadeIn > 0) {
|
||||
const fadeInEndPPQN = startPPQN + fadeIn
|
||||
const x0 = range.unitToX(startPPQN) * dpr
|
||||
const x1 = range.unitToX(fadeInEndPPQN) * dpr
|
||||
const xn = x1 - x0
|
||||
context.beginPath()
|
||||
context.moveTo(x0, top)
|
||||
let x = x0
|
||||
for (const y of Curve.walk(fadeInSlope, xn, top + height, top)) {
|
||||
context.lineTo(++x, y)
|
||||
}
|
||||
context.lineTo(x1, top)
|
||||
context.closePath()
|
||||
context.fill()
|
||||
}
|
||||
if (fadeOut > 0) {
|
||||
const x0 = range.unitToX(endPPQN - fadeOut) * dpr
|
||||
const x1 = range.unitToX(endPPQN) * dpr
|
||||
const xn = Math.abs(x1 - x0)
|
||||
context.beginPath()
|
||||
context.moveTo(x0, top)
|
||||
let x = x0
|
||||
for (const y of Curve.walk(fadeOutSlope, xn, top, top + height)) {
|
||||
context.lineTo(++x, y)
|
||||
}
|
||||
context.lineTo(x1, top)
|
||||
context.closePath()
|
||||
context.fill()
|
||||
}
|
||||
const fadeInHandleX = range.unitToX(startPPQN + fadeIn) * dpr
|
||||
const fadeOutHandleX = range.unitToX(endPPQN - fadeOut) * dpr
|
||||
const regionStartX = range.unitToX(startPPQN) * dpr
|
||||
const regionEndX = range.unitToX(endPPQN) * dpr
|
||||
const adjustedFadeInX = Math.max(fadeInHandleX, regionStartX)
|
||||
const adjustedFadeOutX = Math.min(fadeOutHandleX, regionEndX)
|
||||
if (adjustedFadeOutX - adjustedFadeInX > handleRadius * 4) {
|
||||
context.fillStyle = handleColor
|
||||
context.beginPath()
|
||||
context.arc(adjustedFadeInX, top, handleRadius, 0, TAU)
|
||||
context.fill()
|
||||
context.beginPath()
|
||||
context.arc(adjustedFadeOutX, top, handleRadius, 0, TAU)
|
||||
context.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {Curve, TAU} from "@opendaw/lib-std"
|
||||
import {TimelineRange} from "@opendaw/studio-core"
|
||||
import {FadingEnvelope} from "@opendaw/lib-dsp"
|
||||
import {RegionBound} from "@/ui/timeline/renderer/env"
|
||||
|
||||
export const renderFading = (context: CanvasRenderingContext2D,
|
||||
range: TimelineRange,
|
||||
fading: FadingEnvelope.Config,
|
||||
{top, bottom}: RegionBound,
|
||||
startPPQN: number,
|
||||
endPPQN: number,
|
||||
color: string,
|
||||
handleColor: string): void => {
|
||||
const {in: fadeIn, out: fadeOut, inSlope: fadeInSlope, outSlope: fadeOutSlope} = fading
|
||||
const dpr = devicePixelRatio
|
||||
const height = bottom - top
|
||||
const handleRadius = 3 * dpr
|
||||
context.fillStyle = color
|
||||
if (fadeIn > 0) {
|
||||
const fadeInEndPPQN = startPPQN + fadeIn
|
||||
const x0 = range.unitToX(startPPQN) * dpr
|
||||
const x1 = range.unitToX(fadeInEndPPQN) * dpr
|
||||
const xn = x1 - x0
|
||||
context.beginPath()
|
||||
context.moveTo(x0, top)
|
||||
let x = x0
|
||||
for (const y of Curve.walk(fadeInSlope, xn, top + height, top)) {
|
||||
context.lineTo(++x, y)
|
||||
}
|
||||
context.lineTo(x1, top)
|
||||
context.closePath()
|
||||
context.fill()
|
||||
}
|
||||
if (fadeOut > 0) {
|
||||
const x0 = range.unitToX(endPPQN - fadeOut) * dpr
|
||||
const x1 = range.unitToX(endPPQN) * dpr
|
||||
const xn = Math.abs(x1 - x0)
|
||||
context.beginPath()
|
||||
context.moveTo(x0, top)
|
||||
let x = x0
|
||||
for (const y of Curve.walk(fadeOutSlope, xn, top, top + height)) {
|
||||
context.lineTo(++x, y)
|
||||
}
|
||||
context.lineTo(x1, top)
|
||||
context.closePath()
|
||||
context.fill()
|
||||
}
|
||||
const fadeInHandleX = range.unitToX(startPPQN + fadeIn) * dpr
|
||||
const fadeOutHandleX = range.unitToX(endPPQN - fadeOut) * dpr
|
||||
const regionStartX = range.unitToX(startPPQN) * dpr
|
||||
const regionEndX = range.unitToX(endPPQN) * dpr
|
||||
const adjustedFadeInX = Math.max(fadeInHandleX, regionStartX)
|
||||
const adjustedFadeOutX = Math.min(fadeOutHandleX, regionEndX)
|
||||
if (adjustedFadeOutX - adjustedFadeInX > handleRadius * 4) {
|
||||
context.fillStyle = handleColor
|
||||
context.beginPath()
|
||||
context.arc(adjustedFadeInX, top, handleRadius, 0, TAU)
|
||||
context.fill()
|
||||
context.beginPath()
|
||||
context.arc(adjustedFadeOutX, top, handleRadius, 0, TAU)
|
||||
context.fill()
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import {RegionModifyStrategies, RegionModifyStrategy, TimeGrid, TimelineRange} f
|
||||
import {TracksManager} from "@/ui/timeline/tracks/audio-unit/TracksManager.ts"
|
||||
import {renderNotes} from "@/ui/timeline/renderer/notes.ts"
|
||||
import {RegionBound} from "@/ui/timeline/renderer/env.ts"
|
||||
import {renderAudio, renderFading} from "@/ui/timeline/renderer/audio.ts"
|
||||
import {renderAudio} from "@/ui/timeline/renderer/audio.ts"
|
||||
import {renderFading} from "@/ui/timeline/renderer/fading.ts"
|
||||
import {renderValueStream} from "@/ui/timeline/renderer/value.ts"
|
||||
import {Context2d} from "@opendaw/lib-dom"
|
||||
import {RegionPaintBucket} from "@/ui/timeline/tracks/audio-unit/regions/RegionPaintBucket"
|
||||
@@ -33,38 +34,48 @@ export const renderRegions = (context: CanvasRenderingContext2D,
|
||||
|
||||
const grid = true
|
||||
if (grid) {
|
||||
const {timelineBoxAdapter: {signatureTrack}} = tracks.service.project
|
||||
const {
|
||||
timelineBoxAdapter: {signatureTrack}
|
||||
} = tracks.service.project
|
||||
context.fillStyle = "rgba(0, 0, 0, 0.3)"
|
||||
TimeGrid.fragment(signatureTrack, range, ({pulse}) => {
|
||||
const x0 = Math.floor(range.unitToX(pulse) * devicePixelRatio)
|
||||
context.fillRect(x0, 0, devicePixelRatio, height)
|
||||
}, {minLength: 32})
|
||||
TimeGrid.fragment(
|
||||
signatureTrack,
|
||||
range,
|
||||
({pulse}) => {
|
||||
const x0 = Math.floor(range.unitToX(pulse) * devicePixelRatio)
|
||||
context.fillRect(x0, 0, devicePixelRatio, height)
|
||||
},
|
||||
{minLength: 32}
|
||||
)
|
||||
}
|
||||
const renderRegions = (strategy: RegionModifyStrategy, filterSelected: boolean, hideSelected: boolean): void => {
|
||||
const optTrack = tracks.getByIndex(strategy.translateTrackIndex(index))
|
||||
if (optTrack.isEmpty()) {return}
|
||||
if (optTrack.isEmpty()) {
|
||||
return
|
||||
}
|
||||
const trackBoxAdapter = optTrack.unwrap().trackBoxAdapter
|
||||
const trackDisabled = !trackBoxAdapter.enabled.getValue()
|
||||
const regions = strategy.iterateRange(trackBoxAdapter.regions.collection, unitMin, unitMax)
|
||||
const dpr = devicePixelRatio
|
||||
|
||||
for (const [region, next] of Iterables.pairWise(regions)) {
|
||||
if (region.isSelected ? hideSelected : !filterSelected) {continue}
|
||||
if (region.isSelected ? hideSelected : !filterSelected) {
|
||||
continue
|
||||
}
|
||||
|
||||
const actualComplete = strategy.readComplete(region)
|
||||
const position = strategy.readPosition(region)
|
||||
const complete = region.isSelected
|
||||
? actualComplete
|
||||
// for audio region with playback auto-fit
|
||||
: Math.min(actualComplete, next?.position ?? Number.POSITIVE_INFINITY) - unitsPerPixel
|
||||
: // for no-stretched audio region
|
||||
Math.min(actualComplete, next?.position ?? Number.POSITIVE_INFINITY) - unitsPerPixel
|
||||
|
||||
const x0Int = Math.floor(range.unitToX(Math.max(position, unitMin)) * dpr)
|
||||
const x1Int = Math.max(Math.floor(range.unitToX(Math.min(complete, unitMax)) * dpr), x0Int + dpr)
|
||||
const xnInt = x1Int - x0Int
|
||||
|
||||
const {
|
||||
labelColor, labelBackground, contentColor, contentBackground, loopStrokeColor
|
||||
} = RegionPaintBucket.create(region, region.isSelected && !filterSelected, trackDisabled)
|
||||
const {labelColor, labelBackground, contentColor, contentBackground, loopStrokeColor} =
|
||||
RegionPaintBucket.create(region, region.isSelected && !filterSelected, trackDisabled)
|
||||
|
||||
context.clearRect(x0Int, 0, xnInt, height)
|
||||
context.fillStyle = labelBackground
|
||||
@@ -80,13 +91,14 @@ export const renderRegions = (context: CanvasRenderingContext2D,
|
||||
}
|
||||
const text = region.label.length === 0 ? "◻" : region.label
|
||||
context.fillText(Context2d.truncateText(context, text, maxTextWidth).text, x0Int + 1, 1 + labelHeight / 2)
|
||||
if (!region.hasCollection) {continue}
|
||||
if (!region.hasCollection) {
|
||||
continue
|
||||
}
|
||||
context.fillStyle = contentColor
|
||||
region.accept({
|
||||
visitNoteRegionBoxAdapter: (region: NoteRegionBoxAdapter): void => {
|
||||
for (const pass of LoopableRegion.locateLoops({
|
||||
position,
|
||||
complete,
|
||||
position, complete,
|
||||
loopOffset: strategy.readLoopOffset(region),
|
||||
loopDuration: strategy.readLoopDuration(region)
|
||||
}, unitMin, unitMax)) {
|
||||
@@ -100,8 +112,7 @@ export const renderRegions = (context: CanvasRenderingContext2D,
|
||||
},
|
||||
visitAudioRegionBoxAdapter: (region: AudioRegionBoxAdapter): void => {
|
||||
for (const pass of LoopableRegion.locateLoops({
|
||||
position,
|
||||
complete,
|
||||
position, complete,
|
||||
loopOffset: strategy.readLoopOffset(region),
|
||||
loopDuration: strategy.readLoopDuration(region)
|
||||
}, unitMin, unitMax)) {
|
||||
@@ -111,12 +122,14 @@ export const renderRegions = (context: CanvasRenderingContext2D,
|
||||
context.fillRect(x, labelHeight, 1, height - labelHeight)
|
||||
}
|
||||
const tempoMap = region.trackBoxAdapter.unwrap().context.tempoMap
|
||||
renderAudio(context, range, region.file, tempoMap, region.observableOptPlayMode, region.waveformOffset.getValue(),
|
||||
region.gain.getValue(), bound, contentColor, pass)
|
||||
renderAudio(context, range, region.file, tempoMap,
|
||||
region.observableOptPlayMode, region.waveformOffset.getValue(),
|
||||
region.gain.getValue(), bound, contentColor, pass
|
||||
)
|
||||
}
|
||||
renderFading(context, range, region.fading,
|
||||
bound, position, position + region.duration, `hsla(${region.hue}, 60%, 30%, 0.5)`,
|
||||
`hsla(${region.hue}, 80%, 50%, 1.0)`)
|
||||
renderFading(context, range, region.fading, bound, position, complete,
|
||||
`rgba(0, 0, 0, 0.5)`, labelBackground
|
||||
)
|
||||
const isRecording = region.file.getOrCreateLoader().state.type === "record"
|
||||
if (isRecording) {}
|
||||
},
|
||||
@@ -131,8 +144,7 @@ export const renderRegions = (context: CanvasRenderingContext2D,
|
||||
const valueToY = (value: unitValue): number => bottom + value * (top - bottom)
|
||||
const events = region.events.unwrap()
|
||||
for (const pass of LoopableRegion.locateLoops({
|
||||
position: position,
|
||||
complete: complete,
|
||||
position, complete,
|
||||
loopOffset: strategy.readLoopOffset(region),
|
||||
loopDuration: strategy.readLoopDuration(region)
|
||||
}, unitMin, unitMax)) {
|
||||
@@ -167,4 +179,4 @@ export const renderRegions = (context: CanvasRenderingContext2D,
|
||||
|
||||
renderRegions(strategy.unselectedModifyStrategy(), true, !strategy.showOrigin())
|
||||
renderRegions(strategy.selectedModifyStrategy(), false, false)
|
||||
}
|
||||
}
|
||||
|
||||
+133
-453
@@ -1,537 +1,217 @@
|
||||
# Audio Region Fade-In/Fade-Out Implementation Plan
|
||||
|
||||
## Status: IMPLEMENTED ✓
|
||||
|
||||
All phases complete. Fading uses PPQN durations (not ratios).
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implement fade-in and fade-out functionality for audio regions. Fades are defined as normalized ratios (0.0-1.0) representing the percentage of the region duration, with configurable slopes using the existing Curve system for smooth exponential transitions.
|
||||
Fade-in and fade-out for audio regions using PPQN durations with configurable slopes.
|
||||
|
||||
### Key Behaviors
|
||||
- **Fade-in**: 0.0 = no fade, 0.25 = 25% of region duration fades in from silence
|
||||
- **Fade-out**: 0.75 = fade-out starts at 75% and reaches silence at 100%
|
||||
- **Overlap handling**: When fades overlap, take `Math.min(fadeInGain, fadeOutGain)`
|
||||
- **Slope control**: Uses `Curve.normalizedAt(x, slope)` where 0.5 = linear, <0.5 = logarithmic, >0.5 = exponential
|
||||
- **Fade-in (`in`)**: PPQN duration from region start (0 = no fade)
|
||||
- **Fade-out (`out`)**: PPQN duration counted backwards from region end (0 = no fade)
|
||||
- **Overlap handling**: `Math.min(fadeInGain, fadeOutGain)`
|
||||
- **Slope control**: `Curve.walk(slope, ...)` where 0.5 = linear
|
||||
- **Fading applies to region position/duration only** - loops are ignored
|
||||
|
||||
---
|
||||
|
||||
## 1. Schema Changes
|
||||
## Implementation Summary
|
||||
|
||||
### File: `packages/studio/forge-boxes/src/schema/std/timeline/AudioRegionBox.ts`
|
||||
|
||||
Add a `fading` object field containing the four fade properties:
|
||||
### 1. Schema (`AudioRegionBox.ts`)
|
||||
|
||||
```typescript
|
||||
export const AudioRegionBox: BoxSchema<Pointers> = {
|
||||
type: "box",
|
||||
class: {
|
||||
name: "AudioRegionBox",
|
||||
18: {
|
||||
type: "object", name: "fading", class: {
|
||||
name: "Fading",
|
||||
fields: {
|
||||
// ... existing fields 1-17 ...
|
||||
18: {
|
||||
type: "object", name: "fading", class: {
|
||||
name: "Fading",
|
||||
fields: {
|
||||
1: {type: "float32", name: "in", value: 0.0, constraints: "unipolar", unit: "ratio"},
|
||||
2: {type: "float32", name: "out", value: 1.0, constraints: "unipolar", unit: "ratio"},
|
||||
3: {type: "float32", name: "in-slope", value: 0.5, constraints: "unipolar", unit: "ratio"},
|
||||
4: {type: "float32", name: "out-slope", value: 0.5, constraints: "unipolar", unit: "ratio"}
|
||||
}
|
||||
}
|
||||
}
|
||||
1: {type: "float32", name: "in", value: 0.0, constraints: "positive", unit: "ppqn"},
|
||||
2: {type: "float32", name: "out", value: 0.0, constraints: "positive", unit: "ppqn"},
|
||||
3: {type: "float32", name: "in-slope", value: 0.5, constraints: "unipolar", unit: "ratio"},
|
||||
4: {type: "float32", name: "out-slope", value: 0.5, constraints: "unipolar", unit: "ratio"}
|
||||
}
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Field Definitions:**
|
||||
- `fading.in` (1): Ratio 0.0-1.0, percentage of duration for fade-in (default: 0.0 = no fade)
|
||||
- `fading.out` (2): Ratio 0.0-1.0, position where fade-out starts (default: 1.0 = no fade)
|
||||
- `fading.in-slope` (3): Curve slope 0.0-1.0 (default: 0.5 = linear)
|
||||
- `fading.out-slope` (4): Curve slope 0.0-1.0 (default: 0.5 = linear)
|
||||
|
||||
The `unipolar` constraint already exists in `Defaults.ts` and constrains values to 0.0-1.0.
|
||||
|
||||
---
|
||||
|
||||
## 2. Box Type Generation
|
||||
|
||||
After schema changes, regenerate box types:
|
||||
|
||||
```bash
|
||||
pnpm --filter @opendaw/studio-boxes forge
|
||||
```
|
||||
|
||||
This will update `packages/studio/boxes/src/AudioRegionBox.ts` with the new fields.
|
||||
|
||||
---
|
||||
|
||||
## 3. Adapter Changes
|
||||
|
||||
### 3.1 Create FadingAdapter
|
||||
|
||||
#### File: `packages/studio/adapters/src/timeline/region/FadingAdapter.ts` (new file)
|
||||
|
||||
The `Fading` class will be auto-generated with field accessors. Create a `FadingAdapter` to wrap it with convenience methods:
|
||||
|
||||
```typescript
|
||||
import {Fading} from "@opendaw/studio-boxes"
|
||||
import {FadingEnvelope} from "@opendaw/lib-dsp"
|
||||
import {MutableObservableValue, unitValue} from "@opendaw/lib-std"
|
||||
|
||||
export class FadingAdapter implements FadingEnvelope.Config {
|
||||
readonly #fading: Fading
|
||||
constructor(fading: Fading) {this.#fading = fading}
|
||||
get inField(): MutableObservableValue<number> {return this.#fading.in}
|
||||
get outField(): MutableObservableValue<number> {return this.#fading.out}
|
||||
get inSlopeField(): MutableObservableValue<number> {return this.#fading.inSlope}
|
||||
get outSlopeField(): MutableObservableValue<number> {return this.#fading.outSlope}
|
||||
get in(): unitValue {return this.#fading.in.getValue()}
|
||||
get out(): unitValue {return this.#fading.out.getValue()}
|
||||
get inSlope(): unitValue {return this.#fading.inSlope.getValue()}
|
||||
get outSlope(): unitValue {return this.#fading.outSlope.getValue()}
|
||||
get hasFading(): boolean {return this.in > 0.0 || this.out < 1.0}
|
||||
gainAt(normalizedPosition: unitValue): number {return FadingEnvelope.gainAt(normalizedPosition, this)}
|
||||
fillGainBuffer(gainBuffer: Float32Array, startNormalized: number, endNormalized: number, sampleCount: number): void {
|
||||
FadingEnvelope.fillGainBuffer(gainBuffer, startNormalized, endNormalized, sampleCount, this)
|
||||
}
|
||||
copyTo(target: Fading): void {
|
||||
target.in.setValue(this.in)
|
||||
target.out.setValue(this.out)
|
||||
target.inSlope.setValue(this.inSlope)
|
||||
target.outSlope.setValue(this.outSlope)
|
||||
}
|
||||
reset(): void {
|
||||
this.#fading.in.setValue(0.0)
|
||||
this.#fading.out.setValue(1.0)
|
||||
this.#fading.inSlope.setValue(0.5)
|
||||
this.#fading.outSlope.setValue(0.5)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Update AudioRegionBoxAdapter
|
||||
|
||||
#### File: `packages/studio/adapters/src/timeline/region/AudioRegionBoxAdapter.ts`
|
||||
|
||||
Minimal changes - just add a getter that returns a `FadingAdapter`:
|
||||
### 2. FadingEnvelope (`packages/lib/dsp/src/fading.ts`)
|
||||
|
||||
```typescript
|
||||
import {FadingAdapter} from "./FadingAdapter"
|
||||
|
||||
export class AudioRegionBoxAdapter implements ... {
|
||||
readonly #fadingAdapter: FadingAdapter
|
||||
|
||||
constructor(context: BoxAdaptersContext, box: AudioRegionBox) {
|
||||
// ... existing code ...
|
||||
this.#fadingAdapter = new FadingAdapter(box.fading)
|
||||
}
|
||||
|
||||
// Return the FadingAdapter
|
||||
get fading(): FadingAdapter {return this.#fadingAdapter}
|
||||
|
||||
// Update copyTo method
|
||||
copyTo(params?: CopyToParams): AudioRegionBoxAdapter {
|
||||
// ... existing code ...
|
||||
const adapter = this.#context.boxAdapters.adapterFor(
|
||||
AudioRegionBox.create(this.#context.boxGraph, UUID.generate(), box => {
|
||||
// ... existing field copies ...
|
||||
this.#fadingAdapter.copyTo(box.fading)
|
||||
}), AudioRegionBoxAdapter)
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Audio Processing Changes
|
||||
|
||||
### 4.1 Create Fading Envelope Helper
|
||||
|
||||
#### File: `packages/lib/dsp/src/fading.ts` (new file)
|
||||
|
||||
```typescript
|
||||
import {Curve, int, unitValue} from "@opendaw/lib-std"
|
||||
|
||||
export namespace FadingEnvelope {
|
||||
export interface Config {
|
||||
readonly in: unitValue
|
||||
readonly out: unitValue
|
||||
readonly in: ppqn // duration from start
|
||||
readonly out: ppqn // duration from end (backwards)
|
||||
readonly inSlope: unitValue
|
||||
readonly outSlope: unitValue
|
||||
}
|
||||
|
||||
export const gainAt = (normalizedPosition: unitValue, config: Config): number => {
|
||||
const {in: fadeIn, out: fadeOut, inSlope, outSlope} = config
|
||||
let fadeInGain = 1.0
|
||||
let fadeOutGain = 1.0
|
||||
if (fadeIn > 0.0 && normalizedPosition < fadeIn) {
|
||||
fadeInGain = Curve.normalizedAt(normalizedPosition / fadeIn, inSlope)
|
||||
}
|
||||
if (fadeOut < 1.0 && normalizedPosition > fadeOut) {
|
||||
const progress = (normalizedPosition - fadeOut) / (1.0 - fadeOut)
|
||||
fadeOutGain = 1.0 - Curve.normalizedAt(progress, outSlope)
|
||||
}
|
||||
return Math.min(fadeInGain, fadeOutGain)
|
||||
}
|
||||
export const hasFading = (config: Config): boolean => config.in > 0.0 || config.out > 0.0
|
||||
|
||||
export const fillGainBuffer = (
|
||||
gainBuffer: Float32Array,
|
||||
startNormalized: number,
|
||||
endNormalized: number,
|
||||
startPpqn: ppqn,
|
||||
endPpqn: ppqn,
|
||||
durationPpqn: ppqn,
|
||||
sampleCount: int,
|
||||
config: Config
|
||||
): void => {
|
||||
const {in: fadeIn, out: fadeOut, inSlope, outSlope} = config
|
||||
gainBuffer.fill(1.0, 0, sampleCount)
|
||||
if (fadeIn <= 0.0 && fadeOut >= 1.0) {return}
|
||||
if (startNormalized >= fadeIn && endNormalized <= fadeOut) {return}
|
||||
const normalizedPerSample = (endNormalized - startNormalized) / sampleCount
|
||||
if (fadeIn > 0.0 && startNormalized < fadeIn) {
|
||||
const fadeInEndNorm = Math.min(endNormalized, fadeIn)
|
||||
const fadeInEndSample = Math.min(sampleCount, Math.ceil((fadeInEndNorm - startNormalized) / normalizedPerSample))
|
||||
if (fadeInEndSample > 0) {
|
||||
const startProgress = startNormalized / fadeIn
|
||||
const endProgress = fadeInEndNorm / fadeIn
|
||||
const iterator = Curve.walk(inSlope, fadeInEndSample, startProgress, endProgress)
|
||||
for (let i = 0; i < fadeInEndSample; i++) {
|
||||
gainBuffer[i] = iterator.next().value
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fadeOut < 1.0 && endNormalized > fadeOut) {
|
||||
const fadeOutStartNorm = Math.max(startNormalized, fadeOut)
|
||||
const fadeOutStartSample = Math.max(0, Math.floor((fadeOutStartNorm - startNormalized) / normalizedPerSample))
|
||||
const steps = sampleCount - fadeOutStartSample
|
||||
if (steps > 0) {
|
||||
const startProgress = (fadeOutStartNorm - fadeOut) / (1.0 - fadeOut)
|
||||
const endProgress = (endNormalized - fadeOut) / (1.0 - fadeOut)
|
||||
const iterator = Curve.walk(outSlope, steps, 1.0 - startProgress, 1.0 - endProgress)
|
||||
for (let i = fadeOutStartSample; i < sampleCount; i++) {
|
||||
gainBuffer[i] = Math.min(gainBuffer[i], iterator.next().value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
): void => { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Update TapeDeviceProcessor
|
||||
|
||||
#### File: `packages/studio/core-processors/src/devices/instruments/TapeDeviceProcessor.ts`
|
||||
|
||||
Add a shared gain buffer and compute fading envelope once per region:
|
||||
### 3. FadingAdapter (`packages/studio/adapters/src/timeline/region/FadingAdapter.ts`)
|
||||
|
||||
```typescript
|
||||
import {RenderQuantum} from "@opendaw/lib-dsp"
|
||||
|
||||
export class TapeDeviceProcessor extends AbstractProcessor implements DeviceProcessor, AudioGenerator {
|
||||
readonly #fadingGainBuffer: Float32Array = new Float32Array(RenderQuantum)
|
||||
#processBlock(lane: Lane, block: Block): void {
|
||||
for (const region of adapter.regions.collection.iterateRange(p0, p1)) {
|
||||
if (region.mute || !isInstanceOf(region, AudioRegionBoxAdapter)) {continue}
|
||||
const {fading} = region
|
||||
fading.fillGainBuffer(this.#fadingGainBuffer, startNormalized, endNormalized, sampleCount)
|
||||
// Pass to voices
|
||||
}
|
||||
}
|
||||
export class FadingAdapter implements FadingEnvelope.Config {
|
||||
get in(): ppqn { /* PPQN from start */ }
|
||||
get out(): ppqn { /* PPQN from end */ }
|
||||
get inSlope(): unitValue
|
||||
get outSlope(): unitValue
|
||||
get hasFading(): boolean { return FadingEnvelope.hasFading(this) }
|
||||
get inField(): MutableObservableValue<number>
|
||||
get outField(): MutableObservableValue<number>
|
||||
reset(): void { /* sets in=0, out=0 */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Update DirectVoice
|
||||
|
||||
#### File: `packages/studio/core-processors/src/devices/instruments/Tape/DirectVoice.ts`
|
||||
|
||||
Update `process` method to accept the gain buffer:
|
||||
### 4. TapeDeviceProcessor
|
||||
|
||||
**`#processPassPitch`**:
|
||||
```typescript
|
||||
export class DirectVoice {
|
||||
process(bufferStart: int, bufferCount: int, fadingGainBuffer: Float32Array): void {
|
||||
const [outL, outR] = this.#output.channels()
|
||||
const framesL = frames[0]
|
||||
const framesR = frames.length === 1 ? frames[0] : frames[1]
|
||||
for (let i = 0; i < bufferCount; i++) {
|
||||
// ... existing voice amplitude calculation ...
|
||||
if (readPosition >= 0 && readPosition < numberOfFrames) {
|
||||
const finalAmplitude = amplitude * fadingGainBuffer[i]
|
||||
outL[j] += framesL[readPosition] * finalAmplitude
|
||||
outR[j] += framesR[readPosition] * finalAmplitude
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isInstanceOf(adapter, AudioRegionBoxAdapter) && adapter.fading.hasFading) {
|
||||
const regionPosition = adapter.position
|
||||
const regionDuration = adapter.duration
|
||||
const startPpqn = cycle.resultStart - regionPosition
|
||||
const endPpqn = cycle.resultEnd - regionPosition
|
||||
FadingEnvelope.fillGainBuffer(this.#fadingGainBuffer, startPpqn, endPpqn, regionDuration, bpn, adapter.fading)
|
||||
}
|
||||
```
|
||||
|
||||
Apply similar changes to `PitchVoice.ts` and `TimeStretchSequencer.ts` - all voice types receive the fadingGainBuffer.
|
||||
**`#processPassTimestretch`** signature:
|
||||
```typescript
|
||||
#processPassTimestretch(lane, block, cycle, data, timeStretch, transients, waveformOffset,
|
||||
fadingConfig: FadingEnvelope.Config | null,
|
||||
regionPosition: number,
|
||||
regionDuration: number): void
|
||||
```
|
||||
|
||||
---
|
||||
### 5. Voice Updates
|
||||
|
||||
## 5. Visual Rendering
|
||||
All voice types accept `fadingGainBuffer: Float32Array` in `process()`:
|
||||
- `DirectVoice.ts`
|
||||
- `PitchVoice.ts`
|
||||
- `OnceVoice.ts`
|
||||
- `RepeatVoice.ts`
|
||||
- `PingpongVoice.ts`
|
||||
- `TimeStretchSequencer.ts`
|
||||
|
||||
### 5.1 Render Fading Overlay on Regions
|
||||
|
||||
#### File: `packages/app/studio/src/ui/timeline/renderer/audio.ts`
|
||||
|
||||
Add fading rendering after waveform:
|
||||
### 6. Visual Rendering (`audio.ts`)
|
||||
|
||||
```typescript
|
||||
import {Curve} from "@opendaw/lib-std"
|
||||
|
||||
export const renderFading = (
|
||||
context: CanvasRenderingContext2D,
|
||||
range: TimelineRange,
|
||||
fadeIn: number,
|
||||
fadeOut: number,
|
||||
fadeInSlope: number,
|
||||
fadeOutSlope: number,
|
||||
fading: FadingEnvelope.Config,
|
||||
{top, bottom}: RegionBound,
|
||||
startPPQN: number,
|
||||
endPPQN: number,
|
||||
color: string
|
||||
) => {
|
||||
const height = bottom - top
|
||||
const dpr = devicePixelRatio
|
||||
|
||||
// Render fading in curve
|
||||
if (fadeIn > 0) {
|
||||
const fadeInEndPPQN = startPPQN + (endPPQN - startPPQN) * fadeIn
|
||||
const x0 = range.unitToX(startPPQN) * dpr
|
||||
const x1 = range.unitToX(fadeInEndPPQN) * dpr
|
||||
|
||||
context.beginPath()
|
||||
context.moveTo(x0, bottom)
|
||||
|
||||
const steps = Math.max(10, Math.abs(x1 - x0) / 2)
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps
|
||||
const x = x0 + (x1 - x0) * t
|
||||
const gain = Curve.normalizedAt(t, fadeInSlope)
|
||||
const y = bottom - height * gain
|
||||
context.lineTo(x, y)
|
||||
}
|
||||
|
||||
context.lineTo(x0, bottom)
|
||||
context.closePath()
|
||||
context.fillStyle = color
|
||||
context.fill()
|
||||
}
|
||||
|
||||
// Render fading out curve
|
||||
if (fadeOut < 1) {
|
||||
const fadeOutStartPPQN = startPPQN + (endPPQN - startPPQN) * fadeOut
|
||||
const x0 = range.unitToX(fadeOutStartPPQN) * dpr
|
||||
const x1 = range.unitToX(endPPQN) * dpr
|
||||
|
||||
context.beginPath()
|
||||
context.moveTo(x0, top)
|
||||
|
||||
const steps = Math.max(10, Math.abs(x1 - x0) / 2)
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps
|
||||
const x = x0 + (x1 - x0) * t
|
||||
const gain = 1.0 - Curve.normalizedAt(t, fadeOutSlope)
|
||||
const y = top + height * (1 - gain)
|
||||
context.lineTo(x, y)
|
||||
}
|
||||
|
||||
context.lineTo(x1, bottom)
|
||||
context.lineTo(x0, bottom)
|
||||
context.closePath()
|
||||
context.fillStyle = color
|
||||
context.fill()
|
||||
}
|
||||
color: string,
|
||||
handleColor: string,
|
||||
): void => {
|
||||
// fadeIn: draws shadow from startPPQN to startPPQN + fadeIn
|
||||
// fadeOut: draws shadow from endPPQN - fadeOut to endPPQN
|
||||
// Circle handles at fade positions
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Update Region Renderer
|
||||
|
||||
#### File: `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionRenderer.ts`
|
||||
|
||||
Call `renderFading` after rendering the waveform, using a semi-transparent overlay color.
|
||||
|
||||
---
|
||||
|
||||
## 6. Editor Implementation
|
||||
|
||||
### 6.1 Fading Handle Capturing
|
||||
|
||||
#### File: `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionCapturing.ts`
|
||||
|
||||
Add capture targets for fading circle handles (slope editing deferred to future iteration):
|
||||
### 7. Hit Detection (`RegionCapturing.ts`)
|
||||
|
||||
```typescript
|
||||
export type RegionCaptureTarget =
|
||||
| {type: "fading-in", reader: RegionReader}
|
||||
| {type: "fading-out", reader: RegionReader}
|
||||
// Handle Y position matches renderer (device pixel calculation)
|
||||
const dpr = devicePixelRatio
|
||||
const labelHeightDp = Math.ceil(9 * dpr * 1.5)
|
||||
const handleYFromTrackTop = (labelHeightDp + 1.0 + 5 * dpr) / dpr
|
||||
const handleY = track.position + handleYFromTrackTop
|
||||
|
||||
// X positions use PPQN
|
||||
const fadeInX = range.unitToX(region.position + fading.in)
|
||||
const fadeOutX = range.unitToX(region.position + region.duration - fading.out)
|
||||
```
|
||||
|
||||
#### File: `packages/lib/std/src/geom.ts`
|
||||
### 8. Drag Handling (`RegionsArea.tsx`)
|
||||
|
||||
```typescript
|
||||
export namespace Geom {
|
||||
export const isInsideCircle = (x: number, y: number, cx: number, cy: number, radius: number): boolean => {
|
||||
const dx = x - cx
|
||||
const dy = y - cy
|
||||
return dx * dx + dy * dy <= radius * radius
|
||||
}
|
||||
case "fading-in":
|
||||
case "fading-out": {
|
||||
const audioRegion = target.region
|
||||
const isFadeIn = target.part === "fading-in"
|
||||
const originalValue = isFadeIn ? audioRegion.fading.in : audioRegion.fading.out
|
||||
const field = isFadeIn ? audioRegion.fading.inField : audioRegion.fading.outField
|
||||
return Option.wrap({
|
||||
update: (dragEvent: Dragging.Event) => {
|
||||
const pointerPpqn = range.xToUnit(dragEvent.clientX - clientRect.left)
|
||||
editing.modify(() => {
|
||||
if (isFadeIn) {
|
||||
// fadeIn = distance from region start
|
||||
const fadeInPpqn = clamp(pointerPpqn - regionPosition, 0, regionDuration - audioRegion.fading.out)
|
||||
field.setValue(fadeInPpqn)
|
||||
} else {
|
||||
// fadeOut = distance from region end (backwards)
|
||||
const fadeOutPpqn = clamp(regionPosition + regionDuration - pointerPpqn, 0, regionDuration - audioRegion.fading.in)
|
||||
field.setValue(fadeOutPpqn)
|
||||
}
|
||||
}, false)
|
||||
},
|
||||
approve: () => editing.mark(),
|
||||
cancel: () => editing.modify(() => field.setValue(originalValue))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Fading Editor Integration (Main Timeline)
|
||||
|
||||
#### File: `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionRenderer.ts`
|
||||
|
||||
Add draggable fading circle handles on regions in the main timeline:
|
||||
|
||||
```typescript
|
||||
const {fading} = region
|
||||
if (fading.hasFading) {
|
||||
renderFading(context, range, fading.in, fading.out, fading.inSlope, fading.outSlope,
|
||||
{top, bottom}, region.position, region.position + region.duration,
|
||||
`hsla(${region.hue}, 60%, 30%, 0.5)`)
|
||||
const handleRadius = 5 * devicePixelRatio
|
||||
if (fading.in > 0) {
|
||||
const x = range.unitToX(region.position + region.duration * fading.in) * devicePixelRatio
|
||||
context.beginPath()
|
||||
context.arc(x, top, handleRadius, 0, Math.PI * 2)
|
||||
context.fill()
|
||||
}
|
||||
if (fading.out < 1) {
|
||||
const x = range.unitToX(region.position + region.duration * fading.out) * devicePixelRatio
|
||||
context.beginPath()
|
||||
context.arc(x, top, handleRadius, 0, Math.PI * 2)
|
||||
context.fill()
|
||||
}
|
||||
}
|
||||
Dragging.attach(canvas, event => {
|
||||
const target = capturing.captureEvent(event)
|
||||
if (target?.type === "fading-in") { /* drag to adjust fading.inField */ }
|
||||
if (target?.type === "fading-out") { /* drag to adjust fading.outField */ }
|
||||
})
|
||||
```
|
||||
|
||||
### 6.3 Region Context Menu
|
||||
|
||||
Add fading option to the region context menu:
|
||||
|
||||
```typescript
|
||||
MenuItem.default({label: "Reset Fading"})
|
||||
.setTriggerProcedure(() => editing.modify(() => region.fading.reset()))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Order
|
||||
|
||||
### Phase 1: Schema & Types
|
||||
1. Add `fading` object to `AudioRegionBox.ts` schema
|
||||
2. Run `pnpm --filter @opendaw/studio-boxes forge`
|
||||
3. Create `FadingAdapter.ts` with convenience methods and `gainAt()`
|
||||
4. Update `AudioRegionBoxAdapter.ts` with `fading` getter
|
||||
|
||||
### Phase 2: Audio Processing
|
||||
4. Create `FadeEnvelope` helper in `packages/lib/dsp/src/fade.ts`
|
||||
5. Export from `packages/lib/dsp/src/index.ts`
|
||||
6. Update `DirectVoice.ts` to apply fade envelope
|
||||
7. Update `PitchVoice.ts` to apply fade envelope
|
||||
8. Update `TapeDeviceProcessor.ts` to pass fade config
|
||||
|
||||
### Phase 3: Visual Rendering
|
||||
9. Add `renderFading` function in `audio.ts`
|
||||
10. Integrate into region rendering pipeline
|
||||
11. Update waveform rendering to visually reflect fades
|
||||
|
||||
### Phase 4: Editor
|
||||
12. Add fade capture targets
|
||||
13. Implement fade handle dragging
|
||||
14. Add context menu options
|
||||
|
||||
### Phase 5: Testing & Polish
|
||||
15. Test with various fade combinations
|
||||
16. Test overlap behavior (min of both fades)
|
||||
17. Verify playback accuracy
|
||||
18. Verify visual accuracy
|
||||
|
||||
---
|
||||
|
||||
## 8. Files to Modify
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `packages/studio/forge-boxes/src/schema/std/timeline/AudioRegionBox.ts` | Add `fading` object with 4 fields |
|
||||
| `packages/studio/adapters/src/timeline/region/FadingAdapter.ts` | Create new file implementing FadingEnvelope.Config |
|
||||
| `packages/studio/adapters/src/timeline/region/AudioRegionBoxAdapter.ts` | Add `fading` getter returning FadingAdapter |
|
||||
| `packages/lib/dsp/src/fading.ts` | Create new file with FadingEnvelope namespace |
|
||||
| `packages/lib/dsp/src/index.ts` | Export FadingEnvelope |
|
||||
| `packages/lib/std/src/geom.ts` | Add `isInsideCircle` to Geom namespace |
|
||||
| `packages/studio/core-processors/src/devices/instruments/Tape/DirectVoice.ts` | Add fadingGainBuffer parameter |
|
||||
| `packages/studio/core-processors/src/devices/instruments/Tape/PitchVoice.ts` | Add fadingGainBuffer parameter |
|
||||
| `packages/studio/core-processors/src/devices/instruments/Tape/TimeStretchSequencer.ts` | Add fadingGainBuffer parameter |
|
||||
| `packages/studio/core-processors/src/devices/instruments/TapeDeviceProcessor.ts` | Add shared gain buffer, fill and pass to voices |
|
||||
| `packages/app/studio/src/ui/timeline/renderer/audio.ts` | Add renderFading |
|
||||
| `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionRenderer.ts` | Add fading circle handles and editing |
|
||||
| `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionCapturing.ts` | Add fading circle capture targets |
|
||||
| `packages/studio/forge-boxes/src/schema/std/timeline/AudioRegionBox.ts` | Added `fading` object with PPQN fields |
|
||||
| `packages/lib/dsp/src/fading.ts` | Created FadingEnvelope namespace with PPQN-based API |
|
||||
| `packages/lib/dsp/src/index.ts` | Export FadingEnvelope and ppqn type |
|
||||
| `packages/studio/adapters/src/timeline/region/FadingAdapter.ts` | Created adapter implementing Config |
|
||||
| `packages/studio/adapters/src/timeline/region/AudioRegionBoxAdapter.ts` | Added fading getter |
|
||||
| `packages/lib/std/src/geom.ts` | Added `Geom.isInsideCircle` |
|
||||
| `packages/studio/core-processors/src/devices/instruments/TapeDeviceProcessor.ts` | Added gain buffer, updated both process methods |
|
||||
| `packages/studio/core-processors/src/devices/instruments/Tape/DirectVoice.ts` | Accept fadingGainBuffer |
|
||||
| `packages/studio/core-processors/src/devices/instruments/Tape/PitchVoice.ts` | Accept fadingGainBuffer |
|
||||
| `packages/studio/core-processors/src/devices/instruments/Tape/OnceVoice.ts` | Accept fadingGainBuffer |
|
||||
| `packages/studio/core-processors/src/devices/instruments/Tape/RepeatVoice.ts` | Accept fadingGainBuffer |
|
||||
| `packages/studio/core-processors/src/devices/instruments/Tape/PingpongVoice.ts` | Accept fadingGainBuffer |
|
||||
| `packages/studio/core-processors/src/devices/instruments/Tape/TimeStretchSequencer.ts` | Accept fadingGainBuffer |
|
||||
| `packages/app/studio/src/ui/timeline/renderer/audio.ts` | Added renderFading with Curve.walk |
|
||||
| `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionRenderer.ts` | Call renderFading for audio regions |
|
||||
| `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionCapturing.ts` | Added fading-in/fading-out capture targets |
|
||||
| `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionsArea.tsx` | Added drag handling with editing.modify |
|
||||
|
||||
---
|
||||
|
||||
## 9. API Summary
|
||||
## Key Design Decisions
|
||||
|
||||
### Fading Gain Calculation
|
||||
1. **PPQN instead of ratios**: Fade durations are absolute PPQN values, not percentages. This is more intuitive for users and works consistently regardless of region duration.
|
||||
|
||||
```typescript
|
||||
// Single sample (for UI/debugging):
|
||||
const gain = region.fading.gainAt(normalizedPosition)
|
||||
2. **fadeOut counts from end**: `out` is the duration before the region end where fade-out starts, not a position. This makes it independent of region resizing.
|
||||
|
||||
// Block processing (efficient, uses Curve.walk):
|
||||
// 1. TapeDeviceProcessor fills shared gain buffer once per region
|
||||
region.fading.fillGainBuffer(gainBuffer, startNormalized, endNormalized, sampleCount)
|
||||
3. **Fading ignores loops**: Fade envelope is calculated from `region.position` to `region.position + region.duration`, regardless of loop settings.
|
||||
|
||||
// 2. Pass to voices - they multiply both channels by gainBuffer
|
||||
voice.process(bufferStart, bufferCount, gainBuffer)
|
||||
```
|
||||
4. **Shared gain buffer**: TapeDeviceProcessor owns a single `#fadingGainBuffer` that's filled once per region and passed to all voices.
|
||||
|
||||
The underlying calculation:
|
||||
- Fading in: ramps 0.0 → 1.0 over `in` percentage using Curve.walk
|
||||
- Fading out: ramps 1.0 → 0.0 from `out` to end using Curve.walk
|
||||
- Overlap: takes `Math.min(fadeInGain, fadeOutGain)`
|
||||
|
||||
### Default Values (via FadingAdapter)
|
||||
- `fading.in`: 0.0 (no fading in)
|
||||
- `fading.out`: 1.0 (no fading out)
|
||||
- `fading.inSlope`: 0.5 (linear)
|
||||
- `fading.outSlope`: 0.5 (linear)
|
||||
|
||||
### Field Access (for subscriptions/mutations)
|
||||
- `fading.inField` - MutableObservableValue for fading in
|
||||
- `fading.outField` - MutableObservableValue for fading out
|
||||
- `fading.inSlopeField` - MutableObservableValue for fading in slope
|
||||
- `fading.outSlopeField` - MutableObservableValue for fading out slope
|
||||
|
||||
### Block Processing
|
||||
FadingAdapter can be passed directly to FadingEnvelope functions (implements Config):
|
||||
```typescript
|
||||
// Fill gain buffer efficiently using Curve.walk (computed once per region)
|
||||
region.fading.fillGainBuffer(gainBuffer, startNormalized, endNormalized, sampleCount)
|
||||
|
||||
// Or use FadingEnvelope directly
|
||||
FadingEnvelope.fillGainBuffer(gainBuffer, startNormalized, endNormalized, sampleCount, region.fading)
|
||||
|
||||
// Pass gain buffer to voices for both channels
|
||||
voice.process(bufferStart, bufferCount, gainBuffer)
|
||||
```
|
||||
5. **editing.modify for undo/redo**: All drag operations use `editing.modify(() => ..., false)` during drag and `editing.mark()` on approve.
|
||||
|
||||
---
|
||||
|
||||
## 10. Notes
|
||||
## Future Enhancements
|
||||
|
||||
### Looping Regions
|
||||
Fades are based on `position` and `duration` only - loops are ignored. The fade envelope spans the full region duration regardless of loop settings.
|
||||
|
||||
### Time Base
|
||||
Fading ratios are independent of time base (Musical vs Seconds). They always represent a percentage of the region duration.
|
||||
|
||||
### Rendering Location
|
||||
Fading curves are rendered ONLY in the main timeline view on regions, NOT in the audio editor (which shows a single loop cycle).
|
||||
|
||||
### Slope Editing
|
||||
Slope adjustment will be implemented in a future iteration. This iteration focuses on fading in/out positions only.
|
||||
- [ ] Slope adjustment via UI (currently only via code/API)
|
||||
- [ ] Context menu "Reset Fading" option
|
||||
- [ ] Keyboard modifiers for constrained dragging
|
||||
|
||||
Reference in New Issue
Block a user