Use generic wrapper for supporting more effect types#33
Use generic wrapper for supporting more effect types#33jasongwartz wants to merge 7 commits intomainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Plan from Claude: Strategy for a Type‑Safe, Flexible Tone.js Effects Wrapper
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 = const EffectClasses: Record<EffectName, any> = { All Tone.js effect classes (e.g. Chorus, Reverb, PingPongDelay, etc.) can be listed here 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 class EffectWrapper { constructor(name: Name, options?: Partial<EffectOptionsMap[Name]>) { // ... 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. 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.
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 { /** Set a numeric parameter on the effect in a type-safe way */ 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 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 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 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 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.) 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 // Suppose we have a mapping from MIDI CC to a specific effect parameter: // On MIDI controller input: 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). This design guarantees compile-time type safety for both effect selection and parameter control: 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. 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: 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. |
No description provided.