Skip to content

Use generic wrapper for supporting more effect types#33

Open
jasongwartz wants to merge 7 commits intomainfrom
effects-generic
Open

Use generic wrapper for supporting more effect types#33
jasongwartz wants to merge 7 commits intomainfrom
effects-generic

Conversation

@jasongwartz
Copy link
Owner

No description provided.

@vercel
Copy link

vercel bot commented Sep 18, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
minuet Error Error Sep 18, 2025 5:45am

@jasongwartz
Copy link
Owner Author

Plan from Claude:

Strategy for a Type‑Safe, Flexible Tone.js Effects Wrapper

  1. Centralized Effect Registry

To support all Tone.js effect types without duplicating code, we maintain a single registry of effect names mapped to their Tone.js classes. This lets us instantiate any effect by name and easily extend to new effects. For example:

import * as Tone from "tone";

type EffectName =
| "AutoFilter" | "AutoPanner" | "AutoWah"
| "BitCrusher" | "Chebyshev" | "Chorus"
| "Distortion" | "FeedbackDelay" | "FrequencyShifter"
| "Freeverb" | "JCReverb" | "PingPongDelay"
| "PitchShift" | "Phaser" | "Reverb"
| "StereoWidener" | "Tremolo" | "Vibrato";

const EffectClasses: Record<EffectName, any> = {
AutoFilter: Tone.AutoFilter,
AutoPanner: Tone.AutoPanner,
AutoWah: Tone.AutoWah,
BitCrusher: Tone.BitCrusher,
Chebyshev: Tone.Chebyshev,
Chorus: Tone.Chorus,
Distortion: Tone.Distortion,
FeedbackDelay: Tone.FeedbackDelay,
FrequencyShifter: Tone.FrequencyShifter,
Freeverb: Tone.Freeverb,
JCReverb: Tone.JCReverb,
PingPongDelay: Tone.PingPongDelay,
PitchShift: Tone.PitchShift,
Phaser: Tone.Phaser,
Reverb: Tone.Reverb,
StereoWidener: Tone.StereoWidener,
Tremolo: Tone.Tremolo,
Vibrato: Tone.Vibrato
};

All Tone.js effect classes (e.g. Chorus, Reverb, PingPongDelay, etc.) can be listed here
app.unpkg.com
. This central mapping ensures we name effects ahead-of-time but only in one place. There’s no sprawling switch-case or separate wrapper for each effect – adding a new effect is as simple as adding it to this object.
2. Generic Effect Wrapper Class

We create a generic TypeScript class that wraps an effect instance. The class is parameterized by the EffectName so that all methods are type-safe for that specific effect type:

// Mapping from effect name to that effect’s options interface and class instance type
interface EffectOptionsMap {
AutoFilter: Tone.AutoFilterOptions;
AutoPanner: Tone.AutoPannerOptions;
AutoWah: Tone.AutoWahOptions;
// ... (include all effect option types)
Chorus: Tone.ChorusOptions;
Reverb: Tone.ReverbOptions;
// etc...
}
interface EffectInstanceMap {
AutoFilter: Tone.AutoFilter;
AutoPanner: Tone.AutoPanner;
AutoWah: Tone.AutoWah;
// ... (all effect class types)
Chorus: Tone.Chorus;
Reverb: Tone.Reverb;
// etc...
}

class EffectWrapper {
readonly name: Name;
readonly instance: EffectInstanceMap[Name];

constructor(name: Name, options?: Partial<EffectOptionsMap[Name]>) {
this.name = name;
const EffectClass = EffectClasses[name] as {
new(opts?: Partial<EffectOptionsMap[Name]>): EffectInstanceMap[Name]
};
this.instance = new EffectClass(options);
}

// ...
}

In the EffectWrapper constructor, we use the registry to find the Tone.js class and instantiate it. We pass in an options object that is typed to the correct Options interface for that effect (e.g. Tone.ChorusOptions for "Chorus"). Each Tone.js effect class has a corresponding Options type that defines its parameters (for example, ChorusOptions extends base effect options and includes properties like frequency, delayTime, depth, etc.
app.unpkg.com
). This generic mapping gives us compile-time type safety – if we try to pass an option that doesn’t exist for that effect, TypeScript will error.

Why not one big union class? All Tone effects inherit from a common ToneEffect base, but their specific parameters differ. By keeping the effect’s real type (EffectInstanceMap[Name]), our wrapper can expose the correct properties and accepted options for each effect without needing ugly type assertions.

Note: We explicitly list effect names and types in the mapping, but this is a one-time definition. It’s the only place we “hard-code” effect names – the wrapper logic itself remains generic for any effect in the list, satisfying requirement (1) with minimal duplication. 
  1. Controlling Arbitrary Parameters via Generics

To allow adjusting any numeric parameter on the effect, we add a method like setParam to our wrapper. This uses generics to ensure the parameter name is valid for the chosen effect and that the value is of the correct type (usually a number):

class EffectWrapper {
// ... (as above)

/** Set a numeric parameter on the effect in a type-safe way */
setParam<Key extends keyof EffectOptionsMap[Name]>(
paramName: Key,
value: EffectOptionsMap[Name][Key]
): void {
this.instance.set({ [paramName]: value } as Partial<EffectOptionsMap[Name]>);
}
}

How this works: The generic Key is constrained to keys of the effect’s Options. For example, if Name is "Chorus", then Key can only be one of "frequency", "delayTime", "depth", "spread", "feedback", "wet", etc., because those are keys in ChorusOptions
app.unpkg.com
. The value type is automatically the correct type for that parameter (e.g. a number, or other unit). This means at compile time you cannot call setParam("distortion", ...) on a Chorus (since Chorus has no such parameter), and you cannot accidentally pass a string where a number is expected. No as casts are needed anywhere – the compiler keeps us honest.

Under the hood we leverage Tone’s built-in parameter system. Specifically, we call the Tone.js node’s .set() method, which accepts an object of parameter values. All Tone audio nodes (including effects) inherit a .set() method from ToneAudioNode that knows how to route values to the right AudioParam or property
tonejs.github.io
. For example:

If the parameter is a simple property with a setter (e.g. effect.delayTime), Tone will call the setter.

If the parameter is a Signal/Param (e.g. effect.wet which is a Signal<"normalRange"> on the base Effect class
app.unpkg.com
app.unpkg.com
), Tone’s .set() will assign the numeric value to it internally (equivalent to effect.wet.value = X).

This works even if the option type allows strings or other units – for instance, an effect’s frequency might accept a note name or milliseconds, but if you supply a number, Tone interprets it as Hz.

By using .set(), we avoid writing special-case logic for each parameter type. We simply do:

effectWrapper.setParam("feedback", 0.5); // e.g. set 50% feedback on a delay
effectWrapper.setParam("frequency", 4); // e.g. set LFO frequency to 4 Hz on a chorus
effectWrapper.setParam("wet", 0.8); // set wet mix to 80% on any effect

Each of these calls is type-checked: "feedback" must be a valid key on that effect’s options and 0.5 must be a valid value type (feedback is a NormalRange number
app.unpkg.com
), etc. This design covers any adjustable numeric parameter exposed by Tone.js effects, not just a hard-coded subset.

Exclude non-numeric parameters: We primarily intend this for continuous numeric controls (knobs/sliders). Most effect parameters are numeric (gains, frequencies, times, etc.). If an effect has a parameter that isn’t numeric (e.g. a waveform type string or an on/off boolean), those would appear in the Options as other types. We can choose to exclude or ignore those in our MIDI mapping logic. (One way to enforce this at compile time is to filter out keys whose type isn’t assignable to number – this can be done with a conditional mapped type if needed. For simplicity, the code above allows any Options key, but attempting to set a non-numeric parameter with a number will cause a TypeScript error anyway.)
4. Integrating with MIDI Controller Updates

With the above EffectWrapper in place, wiring it to MIDI hardware input becomes straightforward. We can maintain instances of EffectWrapper and update them from MIDI events:

// Example: create a chorus effect and a reverb effect
const chorus = new EffectWrapper("Chorus", { depth: 0.5, wet: 0.5 });
const reverb = new EffectWrapper("Reverb", { decay: 1.5, wet: 0.3 });

// Suppose we have a mapping from MIDI CC to a specific effect parameter:
const midiMapping = {
21: { effect: chorus, param: "depth" },
22: { effect: chorus, param: "frequency" },
23: { effect: chorus, param: "wet" },
24: { effect: reverb, param: "decay" },
25: { effect: reverb, param: "wet" },
// ... etc.
};

// On MIDI controller input:
midiInput.onmidimessage = (msg) => {
const cc = msg.data[1]; // e.g. control number
const value = msg.data[2]; // MIDI value 0-127
if (cc in midiMapping) {
const { effect, param } = midiMapping[cc];
// Scale MIDI 0-127 to the parameter’s expected range:
let newVal = value / 127;
if (param === "frequency") {
newVal = newVal * 10; // example scaling: 0-10 Hz LFO
}
effect.setParam(param as any, newVal);
}
};

In this pseudo-code, midiMapping associates MIDI CC numbers with an effect and parameter name. When a MIDI message comes in, we look up the mapping and call effect.setParam(param, newVal). Because our EffectWrapper.setParam is strongly typed, we ensure at design-time that the mapping uses a valid parameter name for that effect. (In the snippet above we used as any for brevity when calling setParam since the code path might not easily infer the generic type of param; in a real implementation, you can structure the mapping object with generics too so that param is known to match the effect type, avoiding the cast.)

Chaining the effects is handled at a higher level (requirement 2). The EffectWrapper simply holds this.instance, which is the underlying Tone. object. You can connect these instances as needed, for example: chorus.instance.connect(reverb.instance); or using Tone’s chain/fan utilities elsewhere. By not baking chaining into the wrapper, we keep this layer focused on parameter control. The code above assumes the effects are already inserted appropriately in the audio signal chain (e.g., each EffectWrapper could have a method to connect to the next, or the chaining is done by whoever manages these wrappers).
5. Compile-Time Safety and Extensibility

This design guarantees compile-time type safety for both effect selection and parameter control:

If you try to use an effect name that isn’t in our registry, you get a type error. Adding a new effect requires adding it to the EffectClasses and type maps, so it’s explicit and hard to mess up.

The setParam method won’t compile if the parameter name or value type is wrong for that effect. This avoids many runtime errors and eliminates the need for manual type assertions (as X) in normal usage – the TypeScript compiler acts as our guardian.

It’s okay that we list effect names/types ahead of time, because this list covers all Tone.js effects (and it’s easy to update when Tone.js adds new ones). Internally, the wrapper logic is generic and does not need to be copied for each effect. We have one EffectWrapper class that works for any effect.
6. Runtime Validation (Future Considerations)

At this stage, we rely on TypeScript to prevent invalid parameters, and we trust that our MIDI mappings are configured correctly. If we later need runtime validation (e.g. if parameter names or values might come from dynamic sources), we can add checks such as:

Parameter existence: Use Tone’s get() or has() methods to verify the effect actually has the parameter. For example, effect.instance.get() returns an object of all attributes (or we could check paramName in effect.instance.get())
tonejs.github.io
. Tone.js might also expose a has() method to test if a property is configurable
tonejs.github.io
. With our design, this is rarely needed since the mapping is static – but if you accept arbitrary strings (e.g. from a user script), you’d want to guard against typos.

Value bounds: We could enforce numeric ranges if needed. For instance, if we know a parameter is a NormalRange (0.0–1.0) or Frequency etc., we could clamp the value or warn if out of range. Tone.js’s Param objects do clamp internally for some units, but additional validation could be added by consulting the Options type or the Param’s .min/.max properties (if available). For example, effect.instance.feedback is a Param<"normalRange">
app.unpkg.com
– we know valid values are 0 to 1, so we could ensure our scaling never goes outside that.

However, since compile-time safety is our primary goal, we have designed the system to avoid invalid inputs in the first place. In practice, development and testing can ensure the MIDI-to-param mappings make sense (e.g. mapping a 0–127 CC to a 0.0–1.0 param). If something goes wrong at runtime (say a mis-scaled value), it’s straightforward to adjust the mapping or add a clamp.

In summary, this strategy provides a flexible, extensible wrapper for Tone.js effects with full type safety. All effect types are supported through a unified class, and any numeric parameter can be updated on the fly via a simple setParam call. This meets the requirements by avoiding repetitive boilerplate (one generic class covers all effects), enabling MIDI-driven control of arbitrary effect parameters, and leveraging TypeScript’s type system (rather than “as” hacks) to catch errors early. As a result, we get a robust foundation for real-time effect manipulation – ready to be integrated with a MIDI callback system or any other interactive control mechanism. The design cleanly separates concerns (effect parameter control vs. effect chaining) and can be extended or validated further as needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant