npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 onNoteDown and onNoteUp event 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 liminalis

Or with yarn:

yarn add liminalis

Or with pnpm:

pnpm add liminalis

Quick 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:

  1. Install canvas-sketch CLI globally (if not already installed):
npm install -g canvas-sketch-cli
  1. Build your TypeScript code:
npx tsc
  1. Run with canvas-sketch:
canvas-sketch dist/your-file.js --hot

Or 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 dev

TypeScript 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 interaction
  • sustained: After attack, before release
  • releasing: During release phase
  • released: After release completes

Lifecycle Properties

Your renderer receives these properties automatically:

  • status: Current lifecycle state
  • attackValue: Attack velocity (0.0 to 1.0)
  • releaseFactor: Opacity multiplier during release (1.0 → 0.0)
  • timeAttacked: Timestamp when attack occurred
  • timeReleased: Timestamp when release occurred
  • timeFirstRender: 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:

  1. Detects the overlap
  2. Calculates the interpolated value at the moment of release
  3. 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 onRender with 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 initialization
  • onNoteDown(callback) - Handle MIDI note press
  • onNoteUp(callback) - Handle MIDI note release
  • onRender(callback) - Render static content each frame
  • atTime(time, callback) - Schedule callback at specific time
  • state - Access state object (if using .withState())
  • width, height - Canvas dimensions
  • center - { 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 1000ms

Canvas 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 callback

translate(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 value
  • to - End value
  • startTime - When to start (ms, or null to skip)
  • duration - Duration in milliseconds
  • endTime - Alternative to duration (absolute time)
  • delay - Delay before starting
  • easing - Easing function (t: number) => number
  • reverse - Reverse the animation

Common Easing Functions (via easing-utils):

  • easeOutBounce
  • easeOutBack
  • easeInCubic
  • easeOutCubic
  • easeInOutCubic

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:

  1. Clone the repository:
git clone https://github.com/twray/liminalis.git
cd liminalis
  1. Install dependencies:
npm install
  1. Build the project:
npm run build
  1. Run in development mode:
npm run dev
  1. 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.md

Building for Production

npm run build

Publishing

The package is configured with automatic build on publish:

npm version patch  # or minor, or major
npm publish

MIDI Setup

Liminalis uses WebMIDI to connect to MIDI devices. To use MIDI:

  1. Connect a MIDI controller to your computer (via USB or Bluetooth)
  2. Allow MIDI access when prompted by your browser
  3. 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-9 to 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