Worked Example: Green Scream

A line-by-line tour of a real device UI: photographic panel, knobs, bypass, and the shared parameter dock.

The Green Scream overdrive (an example bundle named green-scream) is a compact, complete device UI: a photographic pedal panel with three knobs, a footswitch, and declarative hardware-encoder control. This page walks through how it’s built.

The panel: full-bleed artwork

The whole UI is two stacked images filling the viewport: the “on” panel, and a “bypassed” panel faded in over it. Both are served from the device’s own ui/ folder:

<template>
  <div class="green-scream-ui">
    <link rel="preload" as="image" href="/Content/Devices/green-scream/ui/green-scream-off.jpg" />
    <img src="/Content/Devices/green-scream/ui/green-scream-on.jpg" class="full-screen-image" />
    <img src="/Content/Devices/green-scream/ui/green-scream-off.jpg"
         class="full-screen-image bypass-overlay" :class="{ active: isBypassed }" />

    <div class="control-knobs">
      <UIKnob class="knob-drive touchable" :size="88"
              :model-value="driveValue" :min="driveMin" :max="driveMax" @change="onDriveChange" />
      <UIKnob class="knob-tone touchable"  :size="88"
              :model-value="toneValue"  :min="toneMin"  :max="toneMax"  @change="onToneChange" />
      <UIKnob class="knob-level touchable" :size="88"
              :model-value="levelValue" :min="levelMin" :max="levelMax" @change="onLevelChange" />
    </div>

    <div class="power-switch touchable" @click="toggleBypass"></div>
  </div>
</template>

Notes:

  • Asset paths are the served paths (/Content/Devices/green-scream/ui/…), not relative imports. Everything referenced must live in the bundle’s ui/ folder. The off image is <link rel="preload">d so the bypass toggle is instant.
  • UIKnob is a built-in control, available without importing it. Bind :model-value, :min, :max, and handle @change (the event carries event.value). It’s positioned by the CSS classes, not layout flow.
  • touchable marks an element as an interactive hit target for the touchscreen.

Props and the SDK handle

import { computed } from 'vue';
import { useParameterDock } from 'axionSDK';

const props = defineProps({ instanceId: String, deviceInfo: Object });

const sdk = window.axionSDK;      // low-level handle for reads/writes

Green Scream uses the window.axionSDK global directly (an equally valid alternative to useDeviceInstance). Either works; the composable is a thin wrapper over the same parameterValues mutation.

Reading parameters (reactive)

Each knob value is a computed that reads the live parameterValues off the instance, so the knob tracks changes from anywhere: the touchscreen, the hardware encoder, or a preset load:

const driveValue = computed(() => {
  const device = sdk?.getDeviceInstance(props.instanceId);
  return device?.parameterValues?.['drive'] ?? 0.5;
});

const isBypassed = computed(() => {
  const device = sdk?.getDeviceInstance(props.instanceId);
  return (device?.parameterValues?.['device.bypassed'] ?? 0) >= 0.5;
});

device.bypassed is a built-in per-instance parameter every device has; treat it as a 0/1 value.

Knob ranges from metadata

The knob min/max come from the device definition in system state, never hard-coded. This is what keeps an LV2 device (real units) and a VST3 device (normalized [0,1]) both correct:

function getParameterMetadata(paramName) {
  const systemState = sdk.getSystemState?.();
  const deviceDef = systemState?.devices?.categories
    ?.flatMap(c => c.devices ?? [])
    ?.find(d => d.id === props.deviceInfo?.id);
  const p = deviceDef?.parameters?.[paramName];
  return { min: p?.min ?? 0, max: p?.max ?? 1 };
}

const driveMin = computed(() => getParameterMetadata('drive').min);
const driveMax = computed(() => getParameterMetadata('drive').max);

With useDeviceInstance this is a one-liner:
device.getParameterMetadata('drive') returns { min, max, … }. Green Scream
predates that convenience and reads system state directly; both are fine.

Writing parameters

A knob @change writes by mutating parameterValues; Axion sends the change to the audio engine. The shared dock observes the same reactive value and updates its readout and encoder LED:

function onDriveChange(event) {
  const device = sdk.getDeviceInstance(props.instanceId);
  if (device?.parameterValues) {
    device.parameterValues['drive'] = event.value;
  }
}

The bypass footswitch toggles the same way:

function toggleBypass() {
  const device = sdk?.getDeviceInstance(props.instanceId);
  const current = device.parameterValues['device.bypassed'] ?? 0;
  device.parameterValues['device.bypassed'] = current >= 0.5 ? 0 : 1;
}

Declaring the parameter dock

Axion can build a dock automatically from manifest parameters. Green Scream provides a small runtime layout because its artwork places three knobs over the middle encoders. null deliberately leaves encoder 0 empty; mode uses encoder 4. The standard controls live on the second swipeable page:

useParameterDock(() => props.instanceId, [
  [null, 'drive', 'tone', 'level', 'mode'],
  ['device.gain', 'device.mix', 'device.volume'],
]);

That one declaration replaces encoder subscriptions, delta scaling, page state, LED ownership, and unmount cleanup. The dock also keeps hardware changes and touchscreen knob changes synchronized through the same parameter values.

What to copy

  • Reactive reads via computed so the UI follows the source of truth.
  • Write by mutating parameterValues (or setParameter); never message the engine yourself.
  • Ranges from metadata, so the same UI works regardless of the backend’s value convention.
  • Declare custom dock pages only when needed; otherwise let the manifest parameter order supply them automatically.

Next: Styling: laying this out for the touchscreen.