diff --git a/CLAUDE.md b/CLAUDE.md index 23832080..af26083e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ ## Coding Style +- **Minimize comments.** Code should be self-explanatory. Only add comments when the logic is truly non-obvious. +- **No blank lines inside methods.** Keep method bodies compact without empty line separators. - **Never use single-letter abbreviations in lambdas.** Use descriptive names like `entry`, `text`, `value`, `event`, etc. - **Use types and functions from `@opendaw/lib-std` instead of inline checks:** - Use `Optional` instead of `T | undefined` diff --git a/packages/studio/forge-boxes/src/schema/std/timeline/AudioRegionBox.ts b/packages/studio/forge-boxes/src/schema/std/timeline/AudioRegionBox.ts index 276af8b2..ac25b206 100644 --- a/packages/studio/forge-boxes/src/schema/std/timeline/AudioRegionBox.ts +++ b/packages/studio/forge-boxes/src/schema/std/timeline/AudioRegionBox.ts @@ -23,7 +23,18 @@ export const AudioRegionBox: BoxSchema = { 14: {type: "boolean", name: "mute"}, 15: {type: "string", name: "label"}, 16: {type: "int32", name: "hue", ...HueConstraints}, - 17: {type: "float32", name: "gain", constraints: "decibel", unit: "db"} + 17: {type: "float32", name: "gain", constraints: "decibel", unit: "db"}, + 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"} + } + } + } } }, pointerRules: {accepts: [Pointers.Selection, Pointers.Editing, Pointers.MetaData], mandatory: false} } \ No newline at end of file diff --git a/plans/audio-region-fades.md b/plans/audio-region-fades.md index f4e93259..36fe775d 100644 --- a/plans/audio-region-fades.md +++ b/plans/audio-region-fades.md @@ -77,53 +77,28 @@ import {Fading} from "@opendaw/studio-boxes" import {FadingEnvelope} from "@opendaw/lib-dsp" import {MutableObservableValue, unitValue} from "@opendaw/lib-std" -/** - * Adapter for the generated Fading object. - * Implements FadingEnvelope.Config so it can be passed directly to DSP functions. - */ export class FadingAdapter implements FadingEnvelope.Config { readonly #fading: Fading - - constructor(fading: Fading) { - this.#fading = fading - } - - // Direct field accessors (postfix "Field") for subscriptions/mutations + 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} - - // FadingEnvelope.Config implementation - value getters 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()} - - // Check if any fading is active - get hasFading(): boolean { - return this.in > 0.0 || this.out < 1.0 - } - - // Delegate to FadingEnvelope for gain calculation - gainAt(normalizedPosition: unitValue): number { - return FadingEnvelope.gainAt(normalizedPosition, this) - } - - // Fill a gain buffer with fading envelope (delegates to FadingEnvelope) + 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) } - - // Copy values to another Fading object 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 to defaults (no fading) reset(): void { this.#fading.in.setValue(0.0) this.#fading.out.setValue(1.0) @@ -179,50 +154,27 @@ export class AudioRegionBoxAdapter implements ... { import {Curve, int, unitValue} from "@opendaw/lib-std" export namespace FadingEnvelope { - /** - * Interface for fading configuration. - * FadingAdapter implements this interface directly. - */ export interface Config { - readonly in: unitValue // 0.0-1.0: normalized end of fade-in - readonly out: unitValue // 0.0-1.0: normalized start of fade-out - readonly inSlope: unitValue // 0.5 = linear + readonly in: unitValue + readonly out: unitValue + readonly inSlope: unitValue readonly outSlope: unitValue } - /** - * Calculate fade gain at a normalized position within region (0.0-1.0) - * Returns gain multiplier 0.0-1.0 - */ 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) } - /** - * Fill a gain buffer with the fading envelope. - * Uses Curve.walk for efficient envelope generation without per-sample pow() calls. - * The gain buffer can then be applied to audio by voices. - * - * @param gainBuffer - Float32Array to fill with gain values (length = RenderQuantum) - * @param startNormalized - Normalized position at block start (0.0-1.0) - * @param endNormalized - Normalized position at block end (0.0-1.0) - * @param sampleCount - Number of samples to fill - * @param config - Fading configuration (FadingAdapter implements this) - */ export const fillGainBuffer = ( gainBuffer: Float32Array, startNormalized: number, @@ -231,26 +183,13 @@ export namespace FadingEnvelope { config: Config ): void => { const {in: fadeIn, out: fadeOut, inSlope, outSlope} = config - - // Initialize to 1.0 (no attenuation) gainBuffer.fill(1.0, 0, sampleCount) - - // No fading active - buffer already filled with 1.0 if (fadeIn <= 0.0 && fadeOut >= 1.0) {return} - - // Block entirely in unity region - buffer already filled with 1.0 if (startNormalized >= fadeIn && endNormalized <= fadeOut) {return} - const normalizedPerSample = (endNormalized - startNormalized) / sampleCount - - // Fill fade-in region using Curve.walk if (fadeIn > 0.0 && startNormalized < fadeIn) { const fadeInEndNorm = Math.min(endNormalized, fadeIn) - const fadeInEndSample = Math.min( - sampleCount, - Math.ceil((fadeInEndNorm - startNormalized) / normalizedPerSample) - ) - + const fadeInEndSample = Math.min(sampleCount, Math.ceil((fadeInEndNorm - startNormalized) / normalizedPerSample)) if (fadeInEndSample > 0) { const startProgress = startNormalized / fadeIn const endProgress = fadeInEndNorm / fadeIn @@ -260,28 +199,19 @@ export namespace FadingEnvelope { } } } - - // Fill fade-out region using Curve.walk if (fadeOut < 1.0 && endNormalized > fadeOut) { const fadeOutStartNorm = Math.max(startNormalized, fadeOut) - const fadeOutStartSample = Math.max( - 0, - Math.floor((fadeOutStartNorm - startNormalized) / normalizedPerSample) - ) + 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) - // Walk from 1.0 down to 0.0 (inverted) 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) } } } - - // Handle overlap region (already handled by Math.min above) } } ``` @@ -294,35 +224,15 @@ Add a shared gain buffer and compute fading envelope once per region: ```typescript import {RenderQuantum} from "@opendaw/lib-dsp" -import {FadingEnvelope} from "@opendaw/lib-dsp" export class TapeDeviceProcessor extends AbstractProcessor implements DeviceProcessor, AudioGenerator { - // Shared gain buffer - reused each block to avoid allocations 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 - - // Compute fading envelope into shared gain buffer (once per region) - if (fading.hasFading) { - const startNormalized = /* compute from cycle */ - const endNormalized = /* compute from cycle */ - FadingEnvelope.fillGainBuffer( - this.#fadingGainBuffer, - startNormalized, - endNormalized, - fading - ) - } - - // Pass gain buffer to voice processing - // Voices apply: buffer[i] *= gainBuffer[i] for both channels + fading.fillGainBuffer(this.#fadingGainBuffer, startNormalized, endNormalized, sampleCount) + // Pass to voices } } } @@ -332,40 +242,27 @@ export class TapeDeviceProcessor extends AbstractProcessor implements DeviceProc #### File: `packages/studio/core-processors/src/devices/instruments/Tape/DirectVoice.ts` -Update `process` method to accept an optional gain buffer: +Update `process` method to accept the gain buffer: ```typescript export class DirectVoice { - // ... existing fields ... - - /** - * Renders audio samples to the output buffer. - * @param bufferStart - Start position in the output buffer - * @param bufferCount - Number of samples to process - * @param fadingGainBuffer - Pre-computed fading gain buffer (filled with 1.0 if no fading) - */ process(bufferStart: int, bufferCount: int, fadingGainBuffer: Float32Array): void { const [outL, outR] = this.#output.channels() - const {frames, numberOfFrames} = this.#data const framesL = frames[0] const framesR = frames.length === 1 ? frames[0] : frames[1] - // ... existing state variables ... - for (let i = 0; i < bufferCount; i++) { - // ... existing amplitude calculation (voice fade-in/fade-out) ... - + // ... existing voice amplitude calculation ... if (readPosition >= 0 && readPosition < numberOfFrames) { const finalAmplitude = amplitude * fadingGainBuffer[i] outL[j] += framesL[readPosition] * finalAmplitude outR[j] += framesR[readPosition] * finalAmplitude } - // ... rest of processing ... } } } ``` -Apply similar changes to `PitchVoice.ts` and the time-stretch sequencer. +Apply similar changes to `PitchVoice.ts` and `TimeStretchSequencer.ts` - all voice types receive the fadingGainBuffer. --- @@ -458,32 +355,20 @@ Call `renderFading` after rendering the waveform, using a semi-transparent overl ### 6.1 Fading Handle Capturing -#### File: `packages/app/studio/src/ui/timeline/editors/audio/AudioCapturing.ts` +#### File: `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionCapturing.ts` -Add capture targets for fading circle handles: +Add capture targets for fading circle handles (slope editing deferred to future iteration): ```typescript -export type AudioCaptureTarget = - | {type: "loop-duration", reader: AudioEventOwnerReader} - | {type: "fading-in", reader: AudioEventOwnerReader} - | {type: "fading-out", reader: AudioEventOwnerReader} - | {type: "fading-in-slope", reader: AudioEventOwnerReader} - | {type: "fading-out-slope", reader: AudioEventOwnerReader} - -// Use Geom.isInsideCircle for hit detection (see geom.ts addition below) -// Fading in handle: circle at (fadeIn position, top of curve) -// Fading out handle: circle at (fadeOut position, top of curve) -// Slope handles: circles on the curve midpoint for bending +export type RegionCaptureTarget = + | {type: "fading-in", reader: RegionReader} + | {type: "fading-out", reader: RegionReader} ``` #### File: `packages/lib/std/src/geom.ts` -Add circle hit detection to the Geom namespace: - ```typescript export namespace Geom { - // ... existing code ... - export const isInsideCircle = (x: number, y: number, cx: number, cy: number, radius: number): boolean => { const dx = x - cx const dy = y - cy @@ -492,60 +377,45 @@ export namespace Geom { } ``` -### 6.2 Fading Editor Integration +### 6.2 Fading Editor Integration (Main Timeline) -#### File: `packages/app/studio/src/ui/timeline/editors/audio/AudioEditorCanvas.tsx` +#### File: `packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionRenderer.ts` -Add draggable fading circle handles: +Add draggable fading circle handles on regions in the main timeline: ```typescript -// In the canvas render callback: -const {fading} = reader.audioContent - -// Render fading curves and circle handles +const {fading} = region if (fading.hasFading) { - renderFading(context, range, - fading.in, fading.out, - fading.inSlope, fading.outSlope, - {top: 0, bottom: actualHeight}, - reader.offset, - reader.offset + reader.loopDuration, - `hsla(${reader.hue}, 60%, 30%, 0.5)`) - - // Render circle handles at fading positions + 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(reader.offset + reader.loopDuration * fading.in) * devicePixelRatio + 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(reader.offset + reader.loopDuration * fading.out) * devicePixelRatio + const x = range.unitToX(region.position + region.duration * fading.out) * devicePixelRatio context.beginPath() context.arc(x, top, handleRadius, 0, Math.PI * 2) context.fill() } } - -// Add dragging handlers for fading circle handles Dragging.attach(canvas, event => { const target = capturing.captureEvent(event) - if (target?.type === "fading-in") { - // Drag horizontally to adjust fading.in ratio - } - if (target?.type === "fading-out") { - // Drag horizontally to adjust fading.out ratio - } + 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 options to the region context menu for quick access: +Add fading option to the region context menu: ```typescript -MenuItem.default({label: "Reset Fades"}) +MenuItem.default({label: "Reset Fading"}) .setTriggerProcedure(() => editing.modify(() => region.fading.reset())) ``` @@ -596,10 +466,11 @@ MenuItem.default({label: "Reset Fades"}) | `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/editors/audio/AudioEditorCanvas.tsx` | Add fading circle handles and editing | -| `packages/app/studio/src/ui/timeline/editors/audio/AudioCapturing.ts` | Add fading circle capture targets | +| `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 | --- @@ -654,10 +525,13 @@ voice.process(bufferStart, bufferCount, gainBuffer) ## 10. Notes ### Looping Regions -Fades apply to each loop cycle independently. When a region loops, the fade-in and fade-out are computed relative to each individual loop iteration, not the total audible duration. +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 -Fade ratios are independent of time base (Musical vs Seconds). They always represent a percentage of the current region/loop duration. +Fading ratios are independent of time base (Musical vs Seconds). They always represent a percentage of the region duration. -### Clips -Consider whether `AudioClipBoxAdapter` should also support fades. If so, the same pattern can be applied to clips for consistency. +### 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.