liminalis
v1.0.0
Published
A creative coding framework for building real-time music visualizations with native MIDI support, lifecycle-driven animations, and timeline-based rendering
Maintainers
Readme
Liminalis
A creative coding framework for building real-time music visualizations in TypeScript. Liminalis provides first-class support for MIDI events, animatable objects with lifecycle hooks, and a powerful timeline animation system—all designed to create responsive, interactive visual experiences.
Features
- 🎹 Native MIDI Support: Built-in
onNoteDownandonNoteUpevent handlers for seamless MIDI integration - 🎨 Lifecycle-Driven Animations: Objects respond to attack, sustain, and release phases with automatic state management
- ⏱️ Timeline Animation System: Create smooth, overlapping animations with event-based timing
- 🖼️ Dual Rendering Modes: Render dynamic objects via lifecycle callbacks OR static content via
onRender - 🎭 Canvas Primitives: Expressive API with stateful styling, transformations, and easing functions
Table of Contents
Installation
Install Liminalis via npm:
npm install liminalisOr with yarn:
yarn add liminalisOr with pnpm:
pnpm add liminalisQuick Start
Create your first MIDI-driven visualization:
import { createVisualisation, animatable } from "liminalis";
import { easeOutBounce } from "easing-utils";
createVisualisation
.setup(({ atStart, onNoteDown, onNoteUp }) => {
// Create a circle that responds to MIDI
atStart(({ visualisation }) => {
visualisation.addPermanently(
"circle",
animatable().withRenderer(({ circle, center, animate, timeAttacked, timeReleased }) => {
circle({
cx: center.x,
cy: center.y,
radius: animate([
{
startTime: timeAttacked,
from: 50,
to: 150,
duration: 1000,
easing: easeOutBounce,
},
{
startTime: timeReleased,
from: 150,
to: 50,
duration: 1000,
},
]),
strokeStyle: "#666",
});
})
);
});
// Trigger attack on MIDI note press
onNoteDown(({ visualisation }) => {
visualisation.get("circle")?.attack(1);
});
// Trigger release on MIDI note release
onNoteUp(({ visualisation }) => {
visualisation.get("circle")?.release();
});
})
.render();Running Your Visualization
Liminalis uses canvas-sketch for rendering. To run your visualization:
- Install canvas-sketch CLI globally (if not already installed):
npm install -g canvas-sketch-cli- Build your TypeScript code:
npx tsc- Run with canvas-sketch:
canvas-sketch dist/your-file.js --hotOr set up your package.json scripts:
{
"scripts": {
"build": "tsc",
"dev": "tsc --watch & sleep 2 && canvas-sketch dist/index.js --hot",
"start": "canvas-sketch dist/index.js"
}
}Then run:
npm run devTypeScript Configuration
Create a tsconfig.json for your project:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ES2020",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Core Concepts
MIDI Event Handling
Liminalis provides native MIDI event handlers that make it trivial to respond to musical input. The framework automatically manages MIDI connections via WebMIDI and provides clean callback interfaces.
onNoteDown - Triggered when a MIDI note is pressed
createVisualisation
.setup(({ onNoteDown, visualisation }) => {
onNoteDown(({ note, attack, visualisation }) => {
// 'note' is the MIDI note name (e.g., "C4", "A#3")
// 'attack' is normalized velocity (0.0 to 1.0)
console.log(`Note: ${note}, Velocity: ${attack}`);
});
})
.render();onNoteUp - Triggered when a MIDI note is released
createVisualisation
.setup(({ onNoteUp, visualisation }) => {
onNoteUp(({ note, visualisation }) => {
// Handle note release
visualisation.get(note)?.release();
});
})
.render();Example: Piano Keyboard Visualization
createVisualisation
.setup(({ atStart, onNoteDown, onNoteUp }) => {
const notes = ["C4", "D4", "E4", "F4", "G4", "A4", "B4"];
atStart(({ visualisation }) => {
// Create a piano key for each note
notes.forEach((note, index) => {
visualisation.addPermanently(
note,
pianoKey().withProps({ x: index * 60, y: 100 })
);
});
});
onNoteDown(({ visualisation, note, attack }) => {
// Trigger attack animation on the corresponding key
visualisation.get(note)?.attack(attack);
});
onNoteUp(({ visualisation, note }) => {
// Trigger release animation
visualisation.get(note)?.release();
});
})
.render();Animatable Objects & Lifecycle
The heart of Liminalis is the animatable object system. Objects can respond to MIDI lifecycle events (attack, sustain, release) with automatic state tracking and timing.
Creating Animatable Objects
import { animatable } from "./core";
const springCircle = () => {
return animatable<{ xOffset: number }>().withRenderer(
({ props, circle, center, attackValue, releaseFactor, animate }) => {
const { xOffset = 0 } = props;
const { x: cx, y: cy } = center;
circle({
cx: cx + xOffset,
cy,
radius: animate({
from: 0,
to: 100 * attackValue, // Scale by attack velocity
duration: 1000,
easing: easeOutBounce,
}),
strokeStyle: "#666",
opacity: releaseFactor, // Fade during release
});
}
);
};Lifecycle States
Animatable objects automatically track their lifecycle state:
idle: Before any interactionsustained: After attack, before releasereleasing: During release phasereleased: After release completes
Lifecycle Properties
Your renderer receives these properties automatically:
status: Current lifecycle stateattackValue: Attack velocity (0.0 to 1.0)releaseFactor: Opacity multiplier during release (1.0 → 0.0)timeAttacked: Timestamp when attack occurredtimeReleased: Timestamp when release occurredtimeFirstRender: Timestamp of first render
Example: State-Based Rendering
const pianoKey = () => {
return animatable<{ x: number; y: number }>().withRenderer(
({ props, rect, status, animate, timeAttacked, timeReleased }) => {
const { x, y } = props;
let heightExtension = 0;
// Render differently based on lifecycle state
switch (status) {
case "sustained":
heightExtension = animate({
startTime: timeAttacked,
from: 0,
to: 20,
duration: 500,
easing: easeOutBack,
});
break;
case "releasing":
heightExtension = animate({
startTime: timeReleased,
from: 20,
to: 0,
duration: 500,
easing: easeOutBack,
});
break;
}
rect({
x,
y,
width: 60,
height: 200 + heightExtension,
strokeStyle: "#666",
});
}
);
};Managing Objects
// Add an object permanently (persists across frames)
visualisation.addPermanently(
"my-circle",
springCircle().withProps({ xOffset: 50 })
);
// Add an object temporarily (removed after release completes)
visualisation.add("temp-circle", springCircle().withProps({ xOffset: 100 }));
// Trigger lifecycle events
visualisation.get("my-circle")?.attack(0.8); // Attack with velocity 0.8
visualisation.get("my-circle")?.release(1000); // Release over 1000ms
// Retrieve current object
const obj = visualisation.get("my-circle");Rendering Strategies
Liminalis supports two complementary rendering approaches:
1. Lifecycle-Based Rendering (Dynamic Objects)
Use animatable objects with lifecycle callbacks for interactive elements that respond to MIDI events:
createVisualisation
.setup(({ atStart, onNoteDown, onNoteUp }) => {
atStart(({ visualisation }) => {
// Add animatable object
visualisation.addPermanently(
"note",
animatable().withRenderer(
({ circle, center, animate, timeAttacked, timeReleased }) => {
circle({
cx: center.x,
cy: center.y,
radius: animate([
{
startTime: timeAttacked,
from: 50,
to: 100,
duration: 1000,
},
{
startTime: timeReleased,
from: 100,
to: 50,
duration: 1000,
},
]),
});
}
)
);
});
onNoteDown(({ visualisation }) => {
visualisation.get("note")?.attack(1);
});
onNoteUp(({ visualisation }) => {
visualisation.get("note")?.release();
});
})
.render();2. Static Rendering (Per-Frame)
Use onRender for static elements that don't need lifecycle management:
createVisualisation
.setup(({ onRender }) => {
onRender(({ background, rect, circle, withStyles, time }) => {
background({ color: "#F7F2E7" });
// Draw static UI elements
withStyles({ strokeStyle: "#666", strokeWidth: 3 }, () => {
rect({ x: 100, y: 100, width: 800, height: 500, cornerRadius: 30 });
// Draw window buttons
const buttonColors = ["#FF605C", "#FFBD44", "#00CA4E"];
buttonColors.forEach((color, i) => {
circle({
cx: 50 + i * 45,
cy: 50,
radius: 15,
fillStyle: color,
strokeStyle: color,
});
});
});
});
})
.render();Combined Example: Piano with UI
createVisualisation
.setup(({ atStart, onRender, onNoteDown, onNoteUp }) => {
// Static UI rendered every frame
onRender(({ background, rect, line, withStyles }) => {
background({ color: "#F7F2E7" });
withStyles({ strokeStyle: "#666", strokeWidth: 3 }, () => {
rect({ x: 100, y: 100, width: 800, height: 500, cornerRadius: 30 });
line({ start: { x: 100, y: 170 }, end: { x: 900, y: 170 } });
});
});
// Dynamic piano keys respond to MIDI
atStart(({ visualisation }) => {
const notes = ["C4", "D4", "E4", "F4", "G4"];
notes.forEach((note, i) => {
visualisation.addPermanently(
note,
pianoKey().withProps({ x: 200 + i * 65, y: 250 })
);
});
});
onNoteDown(({ visualisation, note, attack }) => {
visualisation.get(note)?.attack(attack);
});
onNoteUp(({ visualisation, note }) => {
visualisation.get(note)?.release(1000);
});
})
.render();Timeline Animations
Liminalis features a powerful timeline animation system that supports:
- Event-based timing using
timeAttacked,timeReleased,timeFirstRender - Smooth overlapping - animations blend seamlessly when events occur rapidly
- Cumulative properties - timeline segments inherit properties from previous segments
Single Animation
animate({
from: 0,
to: 100,
duration: 1000,
easing: easeOutBounce,
delay: 200,
});Timeline Array (Attack → Release)
animate([
{
startTime: timeAttacked, // Event-based timing
from: 50,
to: 100,
duration: 1000,
},
{
startTime: timeReleased,
from: 100, // Explicit from value
to: 50,
duration: 1000,
},
]);Smooth Overlap Handling
When timeReleased occurs before the attack animation completes, Liminalis automatically:
- Detects the overlap
- Calculates the interpolated value at the moment of release
- Uses that value as the starting point for the release animation
Example: If attack animates 50→100 over 1000ms, but release occurs at 200ms (when value is ~60), the release animation smoothly continues from 60→50.
Animation Options
interface AnimationOptions {
from: number; // Start value
to: number; // End value
startTime?: number | null; // When to start (ms or event time)
duration?: number; // Duration in ms
endTime?: number; // Alternative: absolute end time
delay?: number; // Delay before starting
easing?: (t: number) => number; // Easing function (0→1)
reverse?: boolean; // Reverse the animation
}Examples
1. Simple Circles (/examples/animatable-circles)
Demonstrates both rendering strategies in one visualization:
- Dynamic circles that respond to MIDI attack/release
- Static circles animated via
onRenderwith staggered delays
createVisualisation
.setup(({ atStart, onRender, onNoteDown, onNoteUp }) => {
// Dynamic MIDI-responsive circle
atStart(({ visualisation }) => {
visualisation.addPermanently("note", animatable().withRenderer(...));
});
// Static animated circles
onRender(({ circle, animate, center }) => {
for (let i = 0; i < 3; i++) {
circle({
cx: center.x + i * 40 - 40,
cy: animate({
from: center.y - 200,
to: center.y - 100,
duration: 1000,
delay: 500 + i * 250,
}),
radius: 10,
});
}
});
onNoteDown(({ visualisation }) => visualisation.get("note")?.attack(1));
onNoteUp(({ visualisation }) => visualisation.get("note")?.release());
})
.render();2. Spring Circles (/examples/circles)
Creates circles on note press that bounce in with spring easing:
createVisualisation
.withState({ index: 0 })
.setup(({ onNoteDown, onNoteUp, state }) => {
onNoteDown(({ visualisation, note, attack }) => {
const { index } = state;
state.index = (state.index + 1) % 7;
visualisation.add(
note,
springCircle()
.withProps({ xOffset: -150 + index * 50 })
.attack(attack)
);
});
onNoteUp(({ visualisation, note }) => {
visualisation.get(note)?.release();
});
})
.render();3. Animated Bars (/examples/bars)
Vertical bars that spring up from the bottom with note-based positioning:
createVisualisation
.setup(({ atStart, onNoteDown, onNoteUp }) => {
const notes = ["C", "D", "E", "F", "G", "A", "B"];
atStart(({ visualisation }) => {
notes.forEach((note, index) => {
visualisation.addPermanently(
note,
springRectangle().withProps({
x: 100 + index * 120,
y: 500,
width: 80,
height: 800,
})
);
});
});
onNoteDown(({ visualisation, note, attack }) => {
visualisation.get(note[0])?.attack(attack); // Use base note (C, D, etc.)
});
onNoteUp(({ visualisation, note }) => {
visualisation.get(note[0])?.release(2000); // 2-second release
});
})
.render();4. Interactive Piano (/examples/piano)
Full piano keyboard with attack/release animations:
- Static UI (window, buttons) rendered via
onRender - Dynamic piano keys (white/black) as animatable objects
- Keys extend downward on press, retract on release
createVisualisation
.setup(({ atStart, onRender, onNoteDown, onNoteUp }) => {
// Static window UI
onRender(({ background, rect, circle, withStyles }) => {
background({ color: "#F7F2E7" });
withStyles({ strokeStyle: "#666", strokeWidth: 3 }, () => {
rect({ x: 100, y: 100, width: 800, height: 500, cornerRadius: 30 });
});
});
// Dynamic piano keys
atStart(({ visualisation }) => {
const notes = ["C4", "C#4", "D4", "D#4", "E4", "F4", "F#4", "G4", ...];
notes.forEach((note) => {
const keyType = note.includes("#") ? "black" : "white";
visualisation.addPermanently(
note,
pianoKey().withProps({ keyType, ... })
);
});
});
onNoteDown(({ visualisation, note, attack }) => {
visualisation.get(note)?.attack(attack);
});
onNoteUp(({ visualisation, note }) => {
visualisation.get(note)?.release(1000);
});
})
.render();API Reference
createVisualisation
Main entry point for creating visualizations.
Methods
.withSettings(settings)
Configure canvas dimensions and behavior:
createVisualisation.withSettings({
width: 1080,
height: 1920,
fps: 60,
computerKeyboardDebugEnabled: true,
});.withState(initialState)
Provide stateful data that persists across renders:
createVisualisation.withState({ index: 0, score: 0 }).setup(({ state }) => {
state.index += 1; // Mutate state directly
});.setup(setupFunction)
Configure event handlers and initialize objects:
createVisualisation.setup(({ atStart, onNoteDown, onNoteUp, onRender }) => {
// Setup code
});Setup Function Parameters:
atStart(callback)- Run once on initializationonNoteDown(callback)- Handle MIDI note pressonNoteUp(callback)- Handle MIDI note releaseonRender(callback)- Render static content each frameatTime(time, callback)- Schedule callback at specific timestate- Access state object (if using.withState())width,height- Canvas dimensionscenter-{ x, y }center point
.render()
Start the visualization loop:
createVisualisation
.setup(...)
.render();animatable<TProps>()
Create an animatable object with custom properties.
const myObject = animatable<{ color: string; size: number }>().withRenderer(
({ props, circle, center }) => {
circle({
cx: center.x,
cy: center.y,
radius: props.size,
fillStyle: props.color,
});
}
);Methods
.withRenderer(renderFunction)
Define how the object should be drawn:
.withRenderer((context) => {
// Render using context
})Render Context:
- Lifecycle:
status,attackValue,releaseFactor,timeAttacked,timeReleased,timeFirstRender - Properties:
props(custom props passed via.withProps()) - Canvas:
context,width,height,center - Primitives:
background,rect,circle,line - Styling:
withStyles,translate,rotate,scale - Animation:
animate(options) - Timing:
beforeTime,afterTime,duringTimeInterval
.withProps(properties)
Attach custom properties to the object:
springCircle().withProps({ xOffset: 100, color: "#FF0000" });.attack(velocity)
Trigger attack phase (typically called in onNoteDown):
myObject.attack(0.8); // Attack with velocity 0.8.release(duration?)
Trigger release phase (typically called in onNoteUp):
myObject.release(1000); // Release over 1000msCanvas Primitives
All primitives are available in both onRender and animatable renderers.
background({ color })
background({ color: "#F7F2E7" });
background({ color: "beige" });rect({ x?, y?, width, height, fillStyle?, strokeStyle?, cornerRadius?, opacity? })
rect({
x: 100,
y: 100,
width: 800,
height: 500,
cornerRadius: 30,
fillStyle: "transparent",
strokeStyle: "#666",
strokeWidth: 3,
opacity: 0.8,
});circle({ cx, cy, radius, fillStyle?, strokeStyle?, strokeWidth?, opacity? })
circle({
cx: 400,
cy: 300,
radius: 50,
fillStyle: "#FF605C",
strokeStyle: "#666",
strokeWidth: 2,
opacity: 1,
});line({ start, end, strokeStyle?, strokeWidth? })
line({
start: { x: 100, y: 100 },
end: { x: 500, y: 100 },
strokeStyle: "#666",
strokeWidth: 3,
});Styling & Transformations
withStyles(styles, callback)
Apply styles within a scope:
withStyles({ strokeStyle: "#666", strokeWidth: 3 }, () => {
circle({ cx: 100, cy: 100, radius: 50 });
rect({ x: 200, y: 200, width: 100, height: 100 });
});
// Styles automatically restored after callbacktranslate(offset, callback)
Translate origin within a scope:
translate({ x: 100, y: 50 }, () => {
circle({ cx: 0, cy: 0, radius: 50 }); // Drawn at (100, 50)
});rotate(angle, callback)
Rotate canvas (angle in radians):
rotate(Math.PI / 4, () => {
rect({ x: 0, y: 0, width: 100, height: 100 });
});scale(factor, callback)
Scale canvas:
scale({ x: 2, y: 2 }, () => {
circle({ cx: 50, cy: 50, radius: 25 }); // Drawn twice as large
});Animation System
animate(options | options[])
Animate a value over time:
Single Animation:
const radius = animate({
from: 0,
to: 100,
duration: 1000,
easing: easeOutBounce,
});Timeline (Array):
const radius = animate([
{
startTime: timeAttacked,
from: 50,
to: 100,
duration: 1000,
},
{
startTime: timeReleased,
from: 100,
to: 50,
duration: 1000,
},
]);Options:
from- Start valueto- End valuestartTime- When to start (ms, ornullto skip)duration- Duration in millisecondsendTime- Alternative to duration (absolute time)delay- Delay before startingeasing- Easing function(t: number) => numberreverse- Reverse the animation
Common Easing Functions (via easing-utils):
easeOutBounceeaseOutBackeaseInCubiceaseOutCubiceaseInOutCubic
Visualisation Manager
Manages lifecycle of animatable objects.
visualisation.addPermanently(id, object)
Add object that persists until explicitly removed:
visualisation.addPermanently(
"my-circle",
springCircle().withProps({ xOffset: 50 })
);visualisation.add(id, object)
Add object that's removed after release completes:
visualisation.add(note, springCircle().withProps({ xOffset: 100 }));visualisation.get(id)
Retrieve an object by ID:
const obj = visualisation.get("my-circle");
obj?.attack(0.8);
obj?.release(1000);Development
Contributing to Liminalis
If you want to contribute or run the examples locally:
- Clone the repository:
git clone https://github.com/twray/liminalis.git
cd liminalis- Install dependencies:
npm install- Build the project:
npm run build- Run in development mode:
npm run dev- Run examples:
Edit src/index.ts to import different examples:
// Run the piano example
import "./examples/piano";
// Or run the circles example
import "./examples/circles";Then run npm run dev to see the visualization.
Project Structure
liminalis/
├── src/
│ ├── core/ # Core framework code
│ ├── types/ # TypeScript type definitions
│ ├── util/ # Utility functions
│ ├── views/ # View rendering logic
│ ├── data/ # Color palettes and key mappings
│ ├── examples/ # Example visualizations
│ └── lib.ts # Main library export
├── types/ # Additional type declarations
├── dist/ # Compiled output
└── README.mdBuilding for Production
npm run buildPublishing
The package is configured with automatic build on publish:
npm version patch # or minor, or major
npm publishMIDI Setup
Liminalis uses WebMIDI to connect to MIDI devices. To use MIDI:
- Connect a MIDI controller to your computer (via USB or Bluetooth)
- Allow MIDI access when prompted by your browser
- Play notes on your controller to trigger visualizations
Computer Keyboard Debug Mode
For testing without a MIDI controller, Liminalis includes keyboard debug mode (enabled by default):
- Press number keys
1-9to simulate different attack velocities - The framework maps computer keys to MIDI note equivalents
Disable keyboard debug mode:
createVisualisation
.withSettings({
computerKeyboardDebugEnabled: false,
})
.setup(...)
.render();Browser Compatibility
Liminalis requires a modern browser with support for:
- WebMIDI API (Chrome, Edge, Opera)
- Canvas 2D rendering
- ES2020+ JavaScript features
For browsers without WebMIDI support, use a polyfill like webmidi.
License
MIT © Tim Wray
