[
  {
    "date": "2026-10-05",
    "id": "2026-10-05-iron-cloud",
    "slug": "iron-cloud",
    "name": "Iron Cloud",
    "version": "1.4.0",
    "tagline": "A tapped saturation feedback loop",
    "concept": "driven saturation feedback loop (dub / tape-delay resonator) — the loop is the voice",
    "dsp": {
      "family": "feedback delay / resonator",
      "description": "One shared feedback loop: delay line -> damping lowpass (Butterworth, Q = -3.01 dB) -> tanh saturation -> explicit feedback gain. Playing a note injects a short burst into the loop rather than ringing out on its own, so the instrument's sustain comes from the loop, not from the note.",
      "novelty": "Contrast with instrument #1 (per-note plucked waveguide). Here there is a single shared resonator and notes are taps; pushing Return toward the ceiling converts a decaying echo into a sustained drone.",
      "graph": "note osc -> voiceGain -> drive -> inputMix -> [ delay -> damp -> sat -> fb ] -> tap -> master",
      "stability": "Loop gain is provably < 1 at every frequency and every slider position. Bounded by three independent mechanisms: the damping lowpass has unity peak gain (Q in dB), the tanh curve is normalised so |y| <= 1 exactly, and the explicit feedback gain is capped at 0.98. The loop therefore cannot self-oscillate into a runaway.",
      "saturation": "tanh curve normalised by tanh(k), giving |y| <= 1 exactly for every drive setting."
    },
    "controls": [
      {
        "id": "ret",
        "label": "Return (feedback)",
        "default": 0.62,
        "range": [
          0,
          0.98
        ]
      },
      {
        "id": "tape",
        "label": "Tape loss (damping)",
        "default": 0.38,
        "range": [
          0,
          1
        ]
      },
      {
        "id": "drive",
        "label": "Drive (saturation)",
        "default": 0.45,
        "range": [
          0,
          1
        ]
      },
      {
        "id": "time",
        "label": "Loop time",
        "default": 0.4,
        "range": [
          0.02,
          1.2
        ]
      },
      {
        "id": "wobble",
        "label": "Wobble (wow)",
        "default": 0.18,
        "range": [
          0,
          1
        ]
      }
    ],
    "inputs": {
      "midi": {
        "supported": "Chrome 43+, Edge, Opera, Firefox 108+ over HTTPS; NOT in Safari/iOS",
        "features": [
          "note on/off",
          "velocity (shapes level AND saturation drive)",
          "sustain pedal (CC64)",
          "pitch bend (14-bit, +/-2 semitones)",
          "mod wheel (CC1)",
          "all-notes-off panic (CC120/123)"
        ],
        "rangeMapping": "deterministic octave fold from MIDI note 40 (E1); all 128 note numbers reach all 16 visible pitches"
      },
      "touch": {
        "features": [
          "multi-touch",
          "tap to inject into the loop"
        ]
      },
      "tilt": {
        "features": [
          "DeviceOrientation beta -> pitch bend +/-2 semitones"
        ],
        "note": "iOS requires a user-gesture permission prompt"
      },
      "compass": {
        "features": [
          "absolute orientation -> note selection"
        ],
        "note": "needs DeviceOrientationEvent.requestPermission(true) for the magnetometer"
      },
      "octave": {
        "control": "Settings modal (+/-), plus a quick stepper on the page",
        "rangeSemitones": [
          -24,
          24
        ],
        "default": 22,
        "note": "Defaults +22 semitones: a 110 Hz loop is below the useful range of a phone speaker, so without this the instrument is inaudible on mobile."
      }
    },
    "verification": {
      "structural": "tools/verify.mjs",
      "graph": "tools/audio_graph_test.mjs",
      "loopStability": "tools/loop_gain_test.mjs",
      "saturationBound": "tools/audio_graph_test.mjs (curve peak |y| <= 1)",
      "inputDrift": "tools/sync_input_layer.mjs",
      "midi": "tools/midi_behaviour_test.mjs",
      "consistency": "tools/version_consistency.mjs"
    },
    "known_limits": [
      "Not a physical model: the loop's timbre is a filter-and-saturation approximation, not a measured tape path.",
      "Wobble is a free-running LFO, so it never locks to the performance. A varispeed/tape-speed design would be more faithful.",
      "Web MIDI is unavailable in Safari and iOS; those users get touch and sensors only.",
      "No automatic tempo sync.",
      "Verified by offline render (tools/render_offline.mjs): peak -3.8 dBFS, decays cleanly, velocity measurably changes timbre. But a rendered measurement is still not a listening test - no human has heard it."
    ],
    "changelog": "v1.4.0: fixed a double-trigger on touch. A touch contact fires BOTH pointerdown and touchstart, so the instrument handling its own pointerdown while the shared layer also handled touch on the same element played TWO notes per tap. The pointer path is now guarded to mouse/pen only, and a new gate check fails any instrument that binds pointerdown on an element it also gives to enableTouch without that guard. Also: the settings-modal setOct re-entry guard, which had silently failed to apply. v1.3.0: added an output saturator. An offline render showed the shipped graph peaking at +12.8 dBFS: the tap was taken from the delay line BEFORE the loop saturator, so the saturator only bounded the feedback path while the raw loop signal reached the output unbounded. Peak is now -3.8 dBFS. An offline render also proved the v1.2.0 curve fix independently (the old curve shows runaway tail growth, the new one decays). v1.2.0: CRITICAL FIX. The drive saturation curve was y=tanh(kx)/tanh(k), which is bounded to [-1,1] but has small-signal slope k/tanh(k) > 1, so it EXPANDED small signals instead of compressing them. Inside the feedback loop that gave a round-trip gain of about 2.5 at default Drive and 7.8 at maximum, so the loop grew rather than decayed: notes could self-sustain into a clipped oscillation that ignored STOP. The curve is now y=tanh(kx)/k, whose slope is exactly 1 at the origin and <1 elsewhere, so it can only reduce gain. The verification gate that should have caught this asserted the wrong property (output bound instead of small-signal gain) and is fixed. Also: reference-counted MIDI pitch ownership so two keys folding to one pitch both release; note-off now releases the pitch actually sounded if the octave changed mid-note; compass honours the octave; keyOct hoisted to module scope so QWERTY/pointer track the transposition. v1.1.0: added a shared Settings modal and an octave control. v1.0.0: initial release.",
    "ui": {
      "configuration": "Moved behind a Settings modal so the page shows only the instrument. Modal is the shared S8Settings component, identical across all instruments."
    }
  },
  {
    "id": "2026-10-04-tension-wire",
    "date": "2026-10-04",
    "title": "Tension Wire",
    "slug": "tension-wire",
    "concept": "Dispersive digital waveguide — a Karplus-Strong comb whose delay line is dispersed by a cascaded allpass chain, so stiffness makes upper partials decay faster than the fundamental.",
    "family": "physical modelling",
    "controls": [
      {
        "id": "stiff",
        "label": "Stiffness (dispersion)",
        "range": "0-100"
      },
      {
        "id": "tension",
        "label": "Tension (pitch), 0.5x-1.5x",
        "range": "0-100"
      },
      {
        "id": "decay",
        "label": "Decay (loop attenuation)",
        "range": "10-100"
      },
      {
        "id": "damp",
        "label": "Bridge damping",
        "range": "0-100"
      },
      {
        "id": "bright",
        "label": "Excitation brightness",
        "range": "0-100"
      }
    ],
    "playable": {
      "keyboard": "A W S E D F T G Y H U J K O L P (two octaves)",
      "pointer": "click the string; release the pointer clears that note"
    },
    "version": "1.5.0",
    "author": "grandMA",
    "status": "published",
    "build": "single self-contained index.html, no dependencies",
    "changelog": "v1.1.0: loop Q 0.5 dB -> -3.01 dB Butterworth (the old value made the feedback loop amplify +1.6 dB per round trip); added explicit loopGain < 1 mapped to Decay; compensated biquad phase delay in the delay time; fixed pointer offsetX measuring against the wrong element; removed window-level pointerup that muted voices on slider release; full voice teardown now disconnects the whole loop; scope buffer hoisted out of the RAF loop. v1.2.0: added the shared S8 input layer — Web MIDI (notes, velocity, sustain, pitch bend, mod wheel, panic), multi-touch chords with slide-to-glide, tilt-to-bend and compass note-selection; velocity now shapes brightness as well as level. Fixed a fold bug found by the new MIDI test: clamping an 0-127 MIDI range onto a 16-semitone window collapsed every key above the window onto one pitch, so an 88-key keyboard played a single note. v1.3.0: added a shared Settings modal and an octave control; touch devices now boot two octaves up because low E is inaudible on a phone speaker. Configuration moved off the page. v1.4.0: input-layer correctness fixes from an adversarial review — reference-counted MIDI pitch ownership (two keys folding to one pitch now both release), note-off releases the pitch actually sounded if the octave changed mid-note, compass honours the octave, and keyOct hoisted to module scope so the QWERTY/pointer path actually tracks the transposition (it previously always read 0). v1.3.0: added the shared Settings modal and octave control. v1.5.0: same touch double-trigger fix (mouse/pen only for the pointer path) plus the corrected setOct re-entry guard.",
    "inputs": {
      "midi": {
        "supported": "Chrome 43+, Edge, Opera, Firefox 108+ over HTTPS; NOT in Safari/iOS",
        "features": [
          "note on/off",
          "velocity (shapes level AND brightness)",
          "sustain pedal (CC64)",
          "pitch bend (14-bit, +/-2 semitones)",
          "mod wheel (CC1)",
          "all-notes-off panic (CC120/123)"
        ],
        "rangeMapping": "deterministic octave fold from MIDI note 40 (E1); adjacent keys are always one semitone apart; all 128 note numbers reach all 16 visible pitches"
      },
      "touch": {
        "features": [
          "multi-touch chords",
          "slide-to-glide",
          "tap-to-pluck"
        ]
      },
      "tilt": {
        "features": [
          "DeviceOrientation beta -> pitch bend +/-2 semitones"
        ],
        "note": "iOS requires a user-gesture permission prompt"
      },
      "compass": {
        "features": [
          "absolute orientation -> note selection, 12-degree buckets"
        ],
        "note": "needs DeviceOrientationEvent.requestPermission(true) for the magnetometer"
      },
      "octave": {
        "control": "Settings modal (+/-), plus a quick stepper on the page",
        "rangeSemitones": [
          -24,
          24
        ],
        "default": "+24 on touch devices, 0 on desktop",
        "note": "Low E is 82 Hz, below the useful range of a phone speaker, so touch devices boot two octaves up."
      }
    },
    "ui": {
      "configuration": "Moved behind a Settings modal so the page shows only the instrument."
    }
  }
]
