formatting

This commit is contained in:
André Michelle
2026-01-24 21:45:13 +01:00
parent 177e26a6b6
commit cc036a9849
5 changed files with 265 additions and 588 deletions
-5
View File
@@ -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
View File
@@ -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