diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index fc92f378..00000000 --- a/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "tabWidth": 4, - "semi": false, - "bracketSpacing": false -} diff --git a/packages/app/studio/src/ui/timeline/renderer/audio.ts b/packages/app/studio/src/ui/timeline/renderer/audio.ts index e6d34ec6..7719efea 100644 --- a/packages/app/studio/src/ui/timeline/renderer/audio.ts +++ b/packages/app/studio/src/ui/timeline/renderer/audio.ts @@ -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() - } -} +} \ No newline at end of file diff --git a/packages/app/studio/src/ui/timeline/renderer/fading.ts b/packages/app/studio/src/ui/timeline/renderer/fading.ts new file mode 100644 index 00000000..9b75e3a9 --- /dev/null +++ b/packages/app/studio/src/ui/timeline/renderer/fading.ts @@ -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() + } +} \ No newline at end of file diff --git a/packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionRenderer.ts b/packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionRenderer.ts index 749247f1..c62b2844 100644 --- a/packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionRenderer.ts +++ b/packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionRenderer.ts @@ -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) -} \ No newline at end of file +} diff --git a/plans/audio-region-fades.md b/plans/audio-region-fades.md index 36fe775d..d0fb41b2 100644 --- a/plans/audio-region-fades.md +++ b/plans/audio-region-fades.md @@ -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 = { - 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 {return this.#fading.in} - get outField(): MutableObservableValue {return this.#fading.out} - get inSlopeField(): MutableObservableValue {return this.#fading.inSlope} - get outSlopeField(): MutableObservableValue {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 + get outField(): MutableObservableValue + 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