For the complete index, see /llms.txt. A Markdown version of any documentation page is available by appending .md to its URL or by sending an Accept: text/markdown header.

Browse documentation

Sound geometry

The closures in a machine that makes a sound by moving something: a spiral groove, a pivoted tonearm's tracking error, an exponential horn, a spring governor, a tuned comb, and a pinned barrel.

33457833 RPM · 0% · ERR 1.29°DECK / 01
view
variant
drive
cue
rpm

Drag the platter to scrub. The stylus walks back out up the spiral, because the groove gears the arm to the platter.

side
25%
Theming

Set a role and the same CSS goes in your own app — every robot under it follows.

Install

bunx --bun shadcn@latest add https://robocn.dev/r/sound-geometry.json

Notes

  • Pure functions over plain objects. No React, no dependencies, and no acoustics: nothing here computes a frequency response, a horn's cutoff, a radiation impedance, a spring's torque curve or a decay, and nothing plays a sound.
  • The tonearm is the piece that earns the file. Its tracking error is a number a drawing would otherwise quietly get wrong, and it is what lets `turntable-deck` and `gramophone-horn` share one solver while telling the truth about how differently they track.
  • A step sequencer is a pinned barrel unrolled flat, which is why `combLift` drives a music box's tine and a droid's beater alike.

Usage

import { groovePose, tonearmPose, governorPose, pinBarrel, combLift } from "@/lib/robocn/sound"

const side = groovePose(0.4, { outer: 146, inner: 60, pitch: 0.12 })
const arm = tonearmPose(side.radius, { mounting: 220.3, effective: 239.3, offset: 24.2 })
arm.trackingError                       // degrees, and it changes sign between the nulls

const governor = governorPose(0.15)     // { speed: 0.5, spread: 0.25, governing: false }
const barrel = pinBarrel(["x...x...", "..x...x."])
combLift(barrel, 0, 4)                  // 1 at the pin, 0 the instant after

API

exports
PropTypeDefaultDescription
groovePose(progress, geometry) => GroovePose—Where the stylus is standing and how many revolutions that is. The spiral gears the arm to the platter: one revolution moves the stylus in by exactly one groove pitch.
grooveTurns(geometry) => number—Revolutions in a whole side: the playable band divided by the groove pitch.
grooveProgress(radius, geometry) => number—The inverse, for a caller that knows where the stylus is standing.
grooveSpiralPath(geometry, turns?, stepsPerTurn?) => string—The groove as artwork: one Archimedean spiral drawn with the number of visible turns you ask for.
tonearmPose(radius, geometry) => TonearmPose—A triangle with two fixed sides, so a groove radius fixes the arm angle. Returns the stylus, the arm angle, the overhang and the tracking error — the angle between the cartridge and the groove's tangent.
tonearmNulls(geometry, inner, outer, samples?) => number[]—The radii where the cartridge sits exactly square to the groove. A well set up arm has two inside the record; an acoustic one may have none.
hornProfile(geometry, steps?) => HornSection[]—An exponential or conical horn sampled into sections, throat first, ready to be built into a solid.
hornRadius(along, geometry) => number—The radius at one station along the axis. Exponential by default: the area doubles over a constant distance.
flareRate(geometry) => number—The flare constant, so the area doubles every ln 2 divided by it. Zero for a cone.
governorPose(wind, options?) => GovernorPose—What a wind-up drive does: the speed holds while the spring has torque and sags proportionally below the knee, and the flyweights stand out with the square of the speed until they reach their stop.
combTines(count, options?) => CombTine[]—A comb tuned by length, up a scale. A cantilever's frequency goes as one over the length squared, so an octave up is one over root two the length.
pinBarrel(pattern, steps?) => Barrel—Pins laid out from a pattern, one row per tine: any non-blank character is a pin at that step. Malformed rows produce a barrel that turns and plucks nothing.
combLift(barrel, tine, position, options?) => number—How far a pin has bent its tine: rising as the pin comes round, exactly 1 at the step, and nothing after. That discontinuity is the pluck.
combRelease(barrel, tine, position, options?) => number—The other half: 1 at the instant of release, falling away over the tail.
barrelStep(barrel, position) => number—The step standing under the comb, wrapped into the barrel in both directions.

Source

src/lib/robocn/sound.ts
/**
 * robocn — sound geometry.
 *
 * The closures in a machine that makes a sound by moving something. A spiral
 * groove that gears the stylus to the platter, a pivoted tonearm whose angle is
 * solved from the groove radius and whose tracking error falls out of it, an
 * exponential horn, a spring governor, a comb tuned by length, and a pinned
 * barrel — which is also a step sequencer, unrolled flat.
 *
 * Pure functions over plain objects. No React, no dependencies, and no
 * acoustics: nothing here computes a frequency response, a horn's cutoff, a
 * spring's torque curve or a decay. Geometry only.
 */

import { clamp, lerp, toDegrees, toRadians, type Vec2 } from "@/lib/robocn/kinematics"
import { px } from "@/lib/robocn/style"

const finite = (value: number | undefined, fallback: number) =>
  Number.isFinite(value) ? (value as number) : fallback

/** Wrap into `[0, span)`, for negatives as well as positives. */
const wrap = (value: number, span: number) =>
  span > 0 ? ((value % span) + span) % span : 0

/* -------------------------------------------------------------------------- */
/* the groove                                                                  */
/* -------------------------------------------------------------------------- */

export interface GrooveGeometry {
  /** Radius of the outermost groove — where the stylus lands. */
  outer: number
  /** Radius of the innermost groove, at the run-out. */
  inner: number
  /** How far in the stylus walks per revolution of the platter. */
  pitch: number
}

export interface GroovePose {
  /** Where the stylus is standing, as a radius from the spindle. */
  radius: number
  /** How far through the side, 0 at the lead-in and 1 at the run-out. */
  progress: number
  /** Revolutions of the platter since the lead-in. */
  revolutions: number
  /** Revolutions left before the run-out. */
  remaining: number
}

/** Revolutions in a whole side: the playable band divided by the groove pitch. */
export function grooveTurns({ outer, inner, pitch }: GrooveGeometry) {
  const band = Math.max(0, finite(outer, 0) - finite(inner, 0))
  const step = Math.max(1e-6, Math.abs(finite(pitch, 1)))
  return band / step
}

/**
 * The spiral is what gears the arm to the platter: one revolution moves the
 * stylus inward by exactly one groove pitch, so progress and platter angle are
 * one number at two scales rather than two animations that drift apart.
 */
export function groovePose(progress: number, geometry: GrooveGeometry): GroovePose {
  const turns = grooveTurns(geometry)
  const at = clamp(finite(progress, 0), 0, 1)
  const outer = finite(geometry.outer, 0)
  const inner = finite(geometry.inner, 0)
  return {
    radius: lerp(outer, inner, at),
    progress: at,
    revolutions: turns * at,
    remaining: turns * (1 - at),
  }
}

/** Where a stylus standing at `radius` has got to through the side. */
export function grooveProgress(radius: number, geometry: GrooveGeometry) {
  const outer = finite(geometry.outer, 0)
  const inner = finite(geometry.inner, 0)
  const band = outer - inner
  if (Math.abs(band) < 1e-9) return 0
  return clamp((outer - finite(radius, outer)) / band, 0, 1)
}

/**
 * The groove as artwork: one Archimedean spiral from the outer radius to the
 * inner one, drawn with `turns` visible turns. A side holds hundreds; this is
 * the drawing of it, and the count is the caller's choice.
 */
export function grooveSpiralPath(
  geometry: GrooveGeometry,
  turns = 18,
  stepsPerTurn = 24,
): string {
  const laps = Math.max(0, Math.floor(finite(turns, 18)))
  const per = Math.max(4, Math.floor(finite(stepsPerTurn, 24)))
  if (laps < 1) return ""
  const outer = finite(geometry.outer, 0)
  const inner = finite(geometry.inner, 0)
  const total = laps * per
  const points: string[] = []
  for (let step = 0; step <= total; step += 1) {
    const at = step / total
    const radius = lerp(outer, inner, at)
    const angle = at * laps * Math.PI * 2
    points.push(
      `${step ? "L" : "M"} ${px(Math.cos(angle) * radius)} ${px(Math.sin(angle) * radius)}`,
    )
  }
  return points.join(" ")
}

/* -------------------------------------------------------------------------- */
/* the tonearm                                                                 */
/* -------------------------------------------------------------------------- */

export interface TonearmGeometry {
  /** Pivot to spindle: where the arm is bolted to the plinth. */
  mounting: number
  /** Pivot to stylus, the effective length. Overhang is the difference. */
  effective: number
  /** How far the headshell twists the cartridge out of the arm's axis, degrees. */
  offset: number
}

export interface TonearmPose {
  /** Groove radius actually reached, clamped into what the arm can sweep. */
  radius: number
  /**
   * The arm's rotation about its pivot, degrees. Zero points from the pivot
   * straight at the spindle, and it grows as the stylus moves outward.
   */
  angle: number
  /** Pivot position, with the spindle at the origin and the pivot at +x. */
  pivot: Vec2
  /** Stylus position in the same frame. The arm sweeps below the axis. */
  stylus: Vec2
  /**
   * Angle between the cartridge's axis and the groove's tangent, degrees. The
   * number the whole geometry exists to minimise; it changes sign between the
   * two null radii.
   */
  trackingError: number
  /** How far the stylus reaches past the spindle: effective minus mounting. */
  overhang: number
  /** True when the radius asked for is outside the band the arm can reach. */
  offGroove: boolean
}

/**
 * A triangle with two fixed sides. The pivot is `mounting` from the spindle and
 * the stylus is `effective` from the pivot, so asking for a groove radius fixes
 * the arm angle — it is solved, never tweened between an inner pose and an
 * outer one.
 */
export function tonearmPose(radius: number, geometry: TonearmGeometry): TonearmPose {
  const mounting = Math.max(1e-6, finite(geometry.mounting, 0))
  const effective = Math.max(1e-6, finite(geometry.effective, 0))
  const offset = finite(geometry.offset, 0)
  const wanted = finite(radius, mounting)
  // The stylus can only stand between |L − m| and L + m from the spindle.
  const near = Math.abs(effective - mounting)
  const far = effective + mounting
  const reached = clamp(wanted, near, far)
  const cosPivot = clamp(
    (mounting * mounting + effective * effective - reached * reached) /
      (2 * mounting * effective),
    -1,
    1,
  )
  const angle = Math.acos(cosPivot)
  // The arm sweeps into y < 0, so the stylus swings below the pivot-spindle line.
  const pivot = { x: mounting, y: 0 }
  const stylus = {
    x: mounting - Math.cos(angle) * effective,
    y: -Math.sin(angle) * effective,
  }
  // Angle at the stylus between stylus→pivot and stylus→spindle. The cartridge
  // points the other way down the arm, and the tangent is square to the radius.
  const cosStylus = clamp(
    (effective * effective + reached * reached - mounting * mounting) /
      (2 * effective * Math.max(1e-6, reached)),
    -1,
    1,
  )
  return {
    radius: reached,
    angle: toDegrees(angle),
    pivot,
    stylus,
    trackingError: 90 - toDegrees(Math.acos(cosStylus)) - offset,
    overhang: effective - mounting,
    offGroove: Math.abs(reached - wanted) > 1e-9,
  }
}

/**
 * The radii where the cartridge sits exactly square to the groove. A well set
 * up arm has two of them inside the record; a badly set up one, or an acoustic
 * gramophone's, may have none.
 */
export function tonearmNulls(
  geometry: TonearmGeometry,
  inner: number,
  outer: number,
  samples = 240,
): number[] {
  const from = Math.max(1e-6, finite(inner, 0))
  const to = Math.max(from, finite(outer, 0))
  const steps = Math.max(8, Math.floor(finite(samples, 240)))
  const error = (radius: number) => tonearmPose(radius, geometry).trackingError
  const found: number[] = []
  let previousRadius = from
  let previous = error(previousRadius)
  for (let step = 1; step <= steps; step += 1) {
    const radius = lerp(from, to, step / steps)
    const current = error(radius)
    if (previous === 0) found.push(previousRadius)
    else if (previous * current < 0) {
      // Bisect: the error is smooth in the radius, so this converges fast.
      let low = previousRadius
      let high = radius
      for (let iteration = 0; iteration < 40; iteration += 1) {
        const middle = (low + high) / 2
        if (error(low) * error(middle) <= 0) high = middle
        else low = middle
      }
      found.push((low + high) / 2)
    }
    previousRadius = radius
    previous = current
  }
  if (previous === 0) found.push(previousRadius)
  return found
}

/* -------------------------------------------------------------------------- */
/* the horn                                                                    */
/* -------------------------------------------------------------------------- */

export type HornFlare = "exponential" | "conical"

export interface HornGeometry {
  /** Radius where the horn meets the soundbox. */
  throat: number
  /** Radius at the mouth. */
  mouth: number
  /** Distance from throat to mouth along the axis. */
  length: number
  flare?: HornFlare
}

export interface HornSection {
  /** Distance from the throat along the axis. */
  along: number
  radius: number
}

/**
 * The flare constant: the exponential horn's area doubles every `ln 2 / rate`
 * of axial length. Zero for a cone, which grows by a constant radius instead.
 */
export function flareRate({ throat, mouth, length, flare = "exponential" }: HornGeometry) {
  const start = Math.max(1e-6, finite(throat, 1))
  const end = Math.max(1e-6, finite(mouth, 1))
  const run = Math.max(1e-6, finite(length, 1))
  return flare === "conical" ? 0 : Math.log(end / start) / run
}

/** Horn radius `along` the axis from the throat. */
export function hornRadius(along: number, geometry: HornGeometry) {
  const start = Math.max(1e-6, finite(geometry.throat, 1))
  const end = Math.max(1e-6, finite(geometry.mouth, 1))
  const run = Math.max(1e-6, finite(geometry.length, 1))
  const at = clamp(finite(along, 0), 0, run)
  if (geometry.flare === "conical") return lerp(start, end, at / run)
  return start * Math.exp(flareRate(geometry) * at)
}

/** The horn sampled into sections, throat first, ready to be drawn as a solid. */
export function hornProfile(geometry: HornGeometry, steps = 14): HornSection[] {
  const run = Math.max(1e-6, finite(geometry.length, 1))
  const count = Math.max(2, Math.floor(finite(steps, 14)))
  return Array.from({ length: count }, (_, index) => {
    const along = (index / (count - 1)) * run
    return { along, radius: hornRadius(along, geometry) }
  })
}

/* -------------------------------------------------------------------------- */
/* the governor                                                                */
/* -------------------------------------------------------------------------- */

export interface GovernorOptions {
  /** Wind below which the mainspring can no longer hold the speed up. */
  knee?: number
  /** Flyweight arm angle from the shaft at rest and against its stop, degrees. */
  minAngle?: number
  maxAngle?: number
}

export interface GovernorPose {
  /** Regulated speed, 0 stalled to 1 at the governed rate. */
  speed: number
  /** How far the flyweights have flown out, 0 in to 1 against the stop. */
  spread: number
  /** The flyweight arms' angle from the shaft, degrees. */
  angle: number
  /** True once the spring is run right down. */
  stalled: boolean
  /** True while the governor is actually regulating rather than being dragged. */
  governing: boolean
}

/**
 * What a wind-up drive actually does: the mainspring holds the governed speed
 * while it has torque to spare and sags proportionally once it does not, and
 * the flyweights stand out with the square of the speed until they reach their
 * stop. No spring model — this is the shape of the behaviour, not a torque
 * curve.
 */
export function governorPose(
  wind: number,
  { knee = 0.3, minAngle = 8, maxAngle = 46 }: GovernorOptions = {},
): GovernorPose {
  const wound = clamp(finite(wind, 0), 0, 1)
  const sag = clamp(finite(knee, 0.3), 1e-3, 1)
  const speed = wound >= sag ? 1 : wound / sag
  const spread = clamp(speed * speed, 0, 1)
  return {
    speed,
    spread,
    angle: lerp(finite(minAngle, 8), finite(maxAngle, 46), spread),
    stalled: wound <= 0,
    governing: wound >= sag,
  }
}

/* -------------------------------------------------------------------------- */
/* the comb                                                                    */
/* -------------------------------------------------------------------------- */

export interface CombOptions {
  /** Length of the lowest tine. Everything else is tuned against it. */
  longest?: number
  /** Semitones above the root, repeating up the octaves. */
  scale?: readonly number[]
}

export interface CombTine {
  index: number
  /** Semitones above the lowest tine. */
  semitones: number
  /** Free length of the tine. */
  length: number
}

/**
 * A comb is tuned by length. A cantilever's frequency goes as one over its
 * length squared, so a semitone is a factor of 2^(−1/24) and an octave up is
 * exactly one over root two the length — which is why the fan is curved rather
 * than a straight taper.
 */
export function combTines(count: number, options: CombOptions = {}): CombTine[] {
  const tines = Number.isFinite(count) ? clamp(Math.round(count), 0, 64) : 0
  const longest = Math.max(1e-6, finite(options.longest, 1))
  const scale = options.scale?.length ? options.scale : [0, 2, 4, 5, 7, 9, 11]
  return Array.from({ length: tines }, (_, index) => {
    const degree = scale[index % scale.length] ?? 0
    const semitones = finite(degree, 0) + 12 * Math.floor(index / scale.length)
    return { index, semitones, length: longest * Math.pow(2, -semitones / 24) }
  })
}

/* -------------------------------------------------------------------------- */
/* the pinned barrel                                                           */
/* -------------------------------------------------------------------------- */

export interface BarrelPin {
  /** Which tine — or which voice — this pin lifts. */
  tine: number
  /** Where round the barrel it stands, in steps. */
  step: number
  /** The same position as an angle about the barrel, degrees. */
  angle: number
}

export interface Barrel {
  /** Positions round the barrel. One revolution is this many steps. */
  steps: number
  /** Rows in the pattern: tines on a comb, voices on a kit. */
  tines: number
  pins: BarrelPin[]
}

const BLANK = new Set([" ", ".", "-", "_", "\t"])

export interface BarrelLiftOptions {
  /** Steps over which a pin lifts its tine before letting go. */
  engage?: number
  /** Steps over which a released tine rings down. */
  tail?: number
}

/**
 * Pins laid out from a pattern: one row per tine, any non-blank character a
 * pin at that step. It is data, so a malformed or empty row gives a barrel that
 * turns and plucks nothing rather than one that throws.
 */
export function pinBarrel(pattern: readonly string[], steps?: number): Barrel {
  const rows = Array.isArray(pattern) ? pattern : []
  const widest = rows.reduce((longest, row) => Math.max(longest, row?.length ?? 0), 0)
  const span = Number.isFinite(steps) ? Math.max(0, Math.round(steps as number)) : widest
  const pins: BarrelPin[] = []
  rows.forEach((row, tine) => {
    for (let step = 0; step < Math.min(row?.length ?? 0, span); step += 1) {
      if (BLANK.has(row[step]!)) continue
      pins.push({ tine, step, angle: span > 0 ? (step / span) * 360 : 0 })
    }
  })
  return { steps: span, tines: rows.length, pins }
}

/** The step standing under the comb at `position`, wrapped into the barrel. */
export function barrelStep(barrel: Barrel, position: number) {
  return Math.floor(wrap(finite(position, 0), barrel.steps))
}

/**
 * How far a pin has lifted its tine: 0 free, rising as the pin comes round and
 * exactly 1 at the step itself, then nothing — that discontinuity is the pluck.
 */
export function combLift(
  barrel: Barrel,
  tine: number,
  position: number,
  { engage = 0.7 }: BarrelLiftOptions = {},
) {
  const span = barrel.steps
  if (span <= 0) return 0
  const window = clamp(finite(engage, 0.7), 1e-3, span)
  const at = finite(position, 0)
  let lift = 0
  for (const pin of barrel.pins) {
    if (pin.tine !== tine) continue
    const away = wrap(pin.step - at, span)
    if (away < window) lift = Math.max(lift, 1 - away / window)
  }
  return lift
}

/**
 * The other half of the pluck: 1 at the instant of release and falling away
 * over `tail` steps. A tine at full lift and a tine just let go are the same
 * moment, which is why both are 1 exactly at the pin.
 */
export function combRelease(
  barrel: Barrel,
  tine: number,
  position: number,
  { tail = 2 }: BarrelLiftOptions = {},
) {
  const span = barrel.steps
  if (span <= 0) return 0
  const window = clamp(finite(tail, 2), 1e-3, span)
  const at = finite(position, 0)
  let ring = 0
  for (const pin of barrel.pins) {
    if (pin.tine !== tine) continue
    const since = wrap(at - pin.step, span)
    if (since < window) ring = Math.max(ring, 1 - since / window)
  }
  return ring
}

/** Where a pin stands on the barrel's surface, in the barrel's own section. */
export function pinPoint(angle: number, radius: number, height = 0): Vec2 {
  const turn = toRadians(finite(angle, 0))
  const reach = Math.max(0, finite(radius, 0)) + Math.max(0, finite(height, 0))
  return { x: Math.sin(turn) * reach, y: -Math.cos(turn) * reach }
}