All loaders

Canvas 2D · 3.7 KB gzip

Waveform Scope

An oscilloscope trace whose amplitude, frequency and shape reflect state: sine, busy signal, stepped wave, green flatline, or red spikes.

waveformoscilloscopeaudioscopestreaming
Interactive preview

Usage

Download HTML

Packages are @vikast908/core and @vikast908/loaders. Prefer 0.1.1+ (0.1.0 loaders is broken on npm).

npm install @vikast908/loaders@0.1.1 @vikast908/core@0.1.1
import '@vikast908/loaders/waveform-scope';

<orbux-waveform-scope state="thinking"></orbux-waveform-scope>

Browser-only alternative: Download HTML (self-contained) or load the hosted ESM bundle:

<script type="module" src="http://localhost:4321/orbux/waveform-scope.js"></script>

<orbux-waveform-scope state="thinking"></orbux-waveform-scope>

Component source waveform-scope.ts

import { type AgentState, Canvas2DElement, lerp, noise1D, type OrbuxFrameInfo } from '@vikast908/core';

interface Tune {
  amp: number; // amplitude fraction
  freq: number; // cycles across width
  harm: number; // harmonic richness
  square: number; // 0 sine .. 1 square
  erratic: number; // random spikes
}

const STATE_TUNE: Record<AgentState, Tune> = {
  idle: { amp: 0.06, freq: 2, harm: 0, square: 0, erratic: 0 },
  thinking: { amp: 0.4, freq: 2.4, harm: 0.2, square: 0, erratic: 0 },
  streaming: { amp: 0.8, freq: 3.6, harm: 0.7, square: 0, erratic: 0 },
  'tool-calling': { amp: 0.55, freq: 2.2, harm: 0.1, square: 0.85, erratic: 0 },
  waiting: { amp: 0.12, freq: 1.6, harm: 0, square: 0, erratic: 0 },
  done: { amp: 0.04, freq: 1, harm: 0, square: 0, erratic: 0 },
  error: { amp: 0.7, freq: 3, harm: 0.4, square: 0, erratic: 0.9 },
};

/**
 * Waveform Scope: an oscilloscope trace whose amplitude, frequency and shape
 * reflect state: a gentle sine while thinking, a busy signal while streaming, a
 * stepped wave while calling a tool, a green flatline on `done`, red spikes on
 * `error`.
 */
export class OrbuxWaveformScope extends Canvas2DElement {
  #cur: Tune = { ...STATE_TUNE.thinking };

  protected draw(info: OrbuxFrameInfo): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = STATE_TUNE[info.state];
    const rate = info.state === 'done' || info.state === 'error' ? 18 : 3;
    const k = info.dt === 0 ? 1 : 1 - Math.exp(-rate * info.dt);
    this.#cur.amp = lerp(this.#cur.amp, t.amp, k);
    this.#cur.freq = lerp(this.#cur.freq, t.freq, k);
    this.#cur.harm = lerp(this.#cur.harm, t.harm, k);
    this.#cur.square = lerp(this.#cur.square, t.square, k);
    this.#cur.erratic = lerp(this.#cur.erratic, t.erratic, k);

    const color =
      info.state === 'done'
        ? this.cssColor('--orbux-color-success', '#22c55e')
        : info.state === 'error'
          ? this.cssColor('--orbux-color-error', '#ef4444')
          : this.cssColor('--orbux-color', '#6366f1');

    const w = this.width;
    const h = this.height;
    const cy = h / 2;
    const time = info.elapsed * info.speed * 1.6;

    this.clear();

    // center axis
    ctx.strokeStyle = color;
    ctx.globalAlpha = 0.14;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(0, cy);
    ctx.lineTo(w, cy);
    ctx.stroke();

    // waveform
    const samples = Math.max(28, Math.floor(w / 2.5));
    ctx.globalAlpha = 1;
    ctx.strokeStyle = color;
    ctx.lineWidth = Math.max(1.5, h * 0.03);
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';
    ctx.shadowBlur = h * 0.12;
    ctx.shadowColor = color;
    ctx.beginPath();
    for (let i = 0; i < samples; i++) {
      const nx = i / (samples - 1);
      const phase = nx * this.#cur.freq * Math.PI * 2 + time * 3;
      let v = Math.sin(phase);
      v += this.#cur.harm * Math.sin(phase * 2.3 + time * 2);
      if (this.#cur.square > 0) v = lerp(v, Math.sign(Math.sin(phase)), this.#cur.square);
      if (this.#cur.erratic > 0) {
        v += noise1D(i + 1, info.elapsed * 12) * this.#cur.erratic;
      }
      // window the ends so the trace tapers to the axis
      const window = Math.sin(nx * Math.PI);
      const y = cy - v * this.#cur.amp * (h * 0.42) * window;
      if (i === 0) ctx.moveTo(nx * w, y);
      else ctx.lineTo(nx * w, y);
    }
    ctx.stroke();
    ctx.shadowBlur = 0;
  }
}

if (typeof customElements !== 'undefined' && !customElements.get('orbux-waveform-scope')) {
  customElements.define('orbux-waveform-scope', OrbuxWaveformScope);
}

declare global {
  interface HTMLElementTagNameMap {
    'orbux-waveform-scope': OrbuxWaveformScope;
  }
}

Depends on @vikast908/core. Paste the source, add the base element, and set state from your agent code.

Attributes & properties

NameTypeDescription
stateAgentState idle · thinking · streaming · tool-calling · waiting · done · error
sizeCSS lengthOverall square size.
--orbux-widthCSS lengthOptional inline-size override.
--orbux-heightCSS lengthOptional block-size override.
speednumberAnimation speed multiplier (1 = default).
labelstringAccessible label (defaults per state).
pausedbooleanFreeze the animation.

Respects prefers-reduced-motion, exposes status/progressbar semantics with a live label, and auto-pauses (sets data-paused) when offscreen or the tab is hidden.