{
  "$type": "at.atmosynth.module",
  "descriptionVersion": 1,
  "moduleId": "osc-sync",
  "versionKey": "1",
  "name": "Sync & FM oscillator",
  "description": "One oscillator started over by another, which is the oldest way of making a sound too bright for the wave it is made of. Two phases run here: the master is the note, and the slave is what you hear, running at the note times Detune. Every time the master comes round it takes hold of the slave, and because the pitch is the master's, Detune moves the timbre rather than the note — sweep it and the sound climbs without the pitch going anywhere. That is the sync sweep, and an envelope or an LFO on the Detune input plays it.\n\nSync is off at nothing, soft at one and hard at two. Hard throws the slave's phase back to the start mid-cycle, and the wave restarts wherever it had got to — the hard, vocal, tearing sound. Soft reverses the slave's direction instead, so the wave folds back and runs the way it came: nothing jumps, only the slope turns, which is gentler, hollower, and comes round every second master turn, so soft sits an octave under hard.\n\nTuned above the note the slave gets through more of its wave between one reset and the next, which is where the sweep's brightness comes from. Tuned below it the master catches the slave before it has finished, so what comes out is a fraction of the wave and quieter for it — half the swing an octave down, a quarter of it two octaves down. That is sync at those tunings rather than a fault, and it is why the bottom of the knob is most use with sync off, where an octave down is the sub-oscillator most patches want.\n\nFM is linear and in hertz rather than a detuning in cents, and it goes through zero: driven below nothing the phase runs backwards instead of stopping there, which keeps the sidebands even once the depth passes the note being modulated. It moves master and slave together, so the sync ratio survives it. Patch an oscillator in for the bells and clangs FM is known for, or an LFO for a vibrato in hertz rather than semitones.\n\nWave travels sine, triangle, sawtooth, square at nothing to three, and any two mixed between; sync is loudest on the last two, which have the most to be cut off. Sync is an input as well: whatever is patched there takes the master over on its rising zero crossings, and silence never crosses, so an empty one leaves the note the job.\n\nThe code is mostly band-limiting. A reset is a step, it almost never lands on a sample instant, and one rounded to the nearest sample throws off tones at no harmonic of anything — the grit that gives a digital sync away. So every discontinuity is corrected where it actually happened and by however much the wave actually jumped, and soft sync's corner by that correction integrated once. The output is centred on the way out, for the reason an analog oscillator has a capacitor on its own: a wave cut off mid-cycle no longer sits either side of nothing, and two octaves down three quarters of what left would be a constant — which no speaker moves for, which eats the headroom the rest of the patch needs, and which thumps at both ends of the note.",
  "audioInputs": [
    {
      "id": "sync",
      "label": "Sync"
    },
    {
      "id": "fm",
      "label": "FM"
    },
    {
      "id": "detune",
      "label": "Detune"
    }
  ],
  "audioOutputs": [
    {
      "id": "out",
      "label": "Out"
    }
  ],
  "midiInputs": [
    {
      "id": "midi",
      "label": "Note"
    }
  ],
  "nodes": [
    {
      "id": "note",
      "kind": "midiInput",
      "options": {
        "midiInput": "midi"
      }
    },
    {
      "id": "gen",
      "kind": "audioWorklet",
      "options": {
        "code": "// Hard sync, soft sync, and a linear FM that goes through zero — three things\n// the vocabulary's own `oscillator` cannot be asked for. It has no phase for\n// anything to reset, its `frequency` is a rate rather than a signed one, and\n// nothing a node graph can say names the moment one wave starts another over.\n//\n// Two phases run here. The **master** is the note: when sync is on, the pitch\n// heard is the master's, because it is what comes round at the note's rate. The\n// **slave** is what is actually sounding, running at the note times Detune, and\n// what the master does to it every time it comes round is the whole module:\n//\n//   · hard — the slave's phase is thrown back to the start mid-cycle, and the\n//     wave restarts from wherever it had got to. That is a step: one value on\n//     one sample and a different one on the next.\n//   · soft — the slave's *direction* reverses instead, so the wave folds back\n//     and runs the way it came. Nothing jumps; only the slope does, which is\n//     what makes it the gentler of the two, and the fold means the wave comes\n//     round every second master turn — soft sync sits an octave under hard.\n//\n// **A step at an arbitrary moment is where a digital oscillator gives itself\n// away.** The reset almost never lands on a sample instant, and a step rounded\n// to the nearest one carries energy far above what the rate can hold; it folds\n// back down as tones at no harmonic of anything, which is the grit a naive sync\n// sounds like. So every discontinuity here — the reset, the slave's own turn,\n// the square's half cycle — is corrected with a polyBLEP at the *fractional*\n// position it happened at and scaled by the size of the jump it actually made,\n// which for a reset is the wave's own value where the reset caught it. Soft\n// sync's corner takes the integral of that same residual, a polyBLAMP: a slope\n// that jumps needs the shape a step's correction integrates to.\n//\n// FM is linear and in hertz, added to the frequency **before** Detune multiplies\n// it, so master and slave move together and the sync ratio survives it. It is\n// allowed through zero — a frequency driven under nothing runs the phase\n// backwards rather than stopping there — which is what keeps FM's sidebands\n// where they belong once the depth passes the note being modulated.\n//\n// **A wave cut off mid-cycle is not centred any more, and that is why every\n// analog sync oscillator is AC-coupled.** A sawtooth reset before it has\n// finished rising never gets to the top of its travel, so what comes out sits\n// below nothing by however much of the wave was cut: a third of full scale at a\n// fifth down, three quarters of it two octaves down. A constant is not a sound\n// — no speaker moves for it — but it is still there, taking the headroom the\n// rest of the patch needs, thumping when the note begins and ends, and pushing\n// whatever it reaches next off its own centre. So the output leaves through the\n// one-pole an oscillator's output capacitor is, with a corner at 5 Hz: under\n// the lowest note anybody can play, and so under everything but the offset.\n\nconst TWO_PI = Math.PI * 2;\n\n/** A phase back into [0, 1), whichever end it left by. */\nconst wrap = (phase) => phase - Math.floor(phase);\n\n/**\n * Where a phase is left after a crossing: a hair past the boundary, on the\n * side the wave carries on from. Left exactly *on* one, a direction that\n * reverses later in the same sample finds the crossing back at no distance at\n * all, takes it for the one already handled, and skips it — and the step it\n * owed goes missing while its correction does not, which is a click once every\n * turn of the master.\n */\nconst NUDGE = Number.EPSILON;\n\n/**\n * The phase moved from one event to the next. It cannot cross a boundary on\n * the way — a crossing would have *been* the next event — so a result outside\n * the cycle is rounding and not travel, and the phase is held where it was.\n * Wrapping it would carry the wave over a discontinuity nothing emitted a step\n * for, which is the same click from the other direction.\n */\nconst slide = (phase, by) => {\n  const moved = phase + by;\n  return moved >= 1 || moved < 0 ? phase : moved;\n};\n\n/** No more than a wave a sample: past that a phase could turn twice in one. */\nconst limit = (step) => (!Number.isFinite(step) ? 0 : step > 0.49 ? 0.49 : step < -0.49 ? -0.49 : step);\n\n/**\n * The four waves, as functions of phase — and every one of them −1 at phase\n * nothing. That agreement is not tidiness: a hard reset lands the slave at\n * phase nothing, so waves that all start at the same value let the size of the\n * jump be read off where the wave *was* and nothing else.\n */\nconst waveAt = (phase, w) =>\n  w[0] * -Math.cos(TWO_PI * phase) +\n  w[1] * (1 - 4 * Math.abs(phase - 0.5)) +\n  w[2] * (2 * phase - 1) +\n  w[3] * (phase < 0.5 ? -1 : 1);\n\n/** The same four differentiated, per turn of phase. A square is flat between edges. */\nconst slopeAt = (phase, w) =>\n  w[0] * TWO_PI * Math.sin(TWO_PI * phase) + w[1] * (phase < 0.5 ? 4 : -4) + w[2] * 2;\n\n/** The Wave knob's travel, as a weight on each of the four, two at a time. */\nconst weightsOf = (position, w) => {\n  const place = position < 0 ? 0 : position > 3 ? 3 : position;\n  const first = Math.min(Math.floor(place), 2);\n  const blend = place - first;\n  w[0] = 0;\n  w[1] = 0;\n  w[2] = 0;\n  w[3] = 0;\n  w[first] = 1 - blend;\n  w[first + 1] += blend;\n};\n\nclass AtmosynthSync extends AudioWorkletProcessor {\n  static get parameterDescriptors() {\n    return [\n      // The note, in hertz, off the module's MIDI input.\n      { name: 'frequency', defaultValue: 0, minValue: 0, maxValue: 24000, automationRate: 'a-rate' },\n      // The slave's own tuning: four octaves either way, because under sync\n      // this is a timbre rather than a pitch and a sweep wants the room.\n      { name: 'detune', defaultValue: 0, minValue: -9600, maxValue: 9600, automationRate: 'a-rate' },\n      // Linear FM in hertz, and signed: the sum is allowed under nothing.\n      { name: 'fm', defaultValue: 0, minValue: -24000, maxValue: 24000, automationRate: 'a-rate' },\n      // Whatever is patched into Sync. Its rising zero crossings take the\n      // master over from the note; silence never crosses, so an input with\n      // nothing in it is not a source of anything.\n      { name: 'reset', defaultValue: 0, minValue: -1000, maxValue: 1000, automationRate: 'a-rate' },\n      // 0 off, 1 soft, 2 hard.\n      { name: 'mode', defaultValue: 2, minValue: 0, maxValue: 2, automationRate: 'a-rate' },\n      // 0 sine, 1 triangle, 2 sawtooth, 3 square, and every blend between.\n      { name: 'wave', defaultValue: 2, minValue: 0, maxValue: 3, automationRate: 'a-rate' },\n    ];\n  }\n\n  constructor() {\n    super();\n    this.phase = 0;\n    this.master = 0;\n    this.direction = 1;\n    /** The AC coupling: its pole, and the two samples it remembers. */\n    this.pole = 1 - (2 * Math.PI * 5) / sampleRate;\n    this.lastRaw = 0;\n    this.centred = 0;\n    this.lastLevel = 0;\n    /** Samples left for which the Sync input still counts as driving. */\n    this.external = 0;\n    /** Band-limiting owed to this sample, and to the one after it. */\n    this.owed = 0;\n    this.carry = 0;\n    this.weights = [0, 0, 1, 0];\n  }\n\n  /** A band-limited step of `height`, `when` samples after this instant. */\n  jump(height, when) {\n    const half = height / 2;\n    this.owed += half * (1 - when) * (1 - when);\n    this.carry -= half * when * when;\n  }\n\n  /** A band-limited corner: the slope changes by `change` a sample. */\n  bend(change, when) {\n    const sixth = change / 6;\n    this.owed += sixth * (1 - when) * (1 - when) * (1 - when);\n    this.carry += sixth * when * when * when;\n  }\n\n  process(inputs, outputs, parameters) {\n    const output = outputs[0];\n    const channel = output[0];\n    const at = (values, index) => (values.length > 1 ? values[index] : values[0]);\n    const w = this.weights;\n\n    for (let index = 0; index < channel.length; index += 1) {\n      weightsOf(at(parameters.wave, index), w);\n      const mode = Math.round(at(parameters.mode, index));\n\n      // FM before Detune, so the two phases keep their ratio through it.\n      const hertz = at(parameters.frequency, index) + at(parameters.fm, index);\n      const step = limit((hertz * Math.pow(2, at(parameters.detune, index) / 1200)) / sampleRate);\n      const masterStep = limit(hertz / sampleRate);\n\n      // What the last sample's discontinuities left for this one.\n      this.owed = this.carry;\n      this.carry = 0;\n\n      // The Sync input's rising zero crossings. The crossing is placed the\n      // same fraction into *this* sample as it fell into the last, so the\n      // reset is a sample late and never a fraction of one jittery — a reset\n      // whose moment wandered would be a pitch that wandered with it.\n      let syncAt = -1;\n      const level = at(parameters.reset, index);\n      if (this.lastLevel < 0 && level >= 0) {\n        syncAt = level === this.lastLevel ? 0 : -this.lastLevel / (level - this.lastLevel);\n        this.external = sampleRate | 0;\n      }\n      this.lastLevel = level;\n\n      // The note's own master. It keeps turning while something else is\n      // driving, so the two never fight and giving the input up hands it back.\n      let masterAt = -1;\n      const before = this.master;\n      const after = before + masterStep;\n      if (after >= 1) {\n        masterAt = (1 - before) / masterStep;\n        this.master = after - 1;\n      } else if (after < 0) {\n        masterAt = -before / masterStep;\n        this.master = after + 1;\n      } else {\n        this.master = after;\n      }\n      if (this.external > 0) {\n        this.external -= 1;\n        masterAt = -1;\n      }\n      if (syncAt < 0) syncAt = masterAt;\n      if (syncAt >= 1) syncAt = 0.999999;\n      if (mode === 0) syncAt = -1;\n\n      // The value at this instant, before the interval to the next one is\n      // walked for everything that happens in it.\n      const value = waveAt(this.phase, w);\n\n      let phase = this.phase;\n      let direction = this.direction;\n      let where = 0;\n      for (let guard = 0; guard < 6; guard += 1) {\n        const travel = step * direction;\n        let next = 1;\n        let kind = 0;\n        if (travel > 0) {\n          const turn = where + (1 - phase) / travel;\n          if (turn > where && turn < next) {\n            next = turn;\n            kind = 1;\n          }\n          const half = where + ((phase < 0.5 ? 0.5 : 1.5) - phase) / travel;\n          if (half > where && half < next) {\n            next = half;\n            kind = 2;\n          }\n        } else if (travel < 0) {\n          const turn = where + -phase / travel;\n          if (turn > where && turn < next) {\n            next = turn;\n            kind = 1;\n          }\n          const half = where + ((phase > 0.5 ? 0.5 : -0.5) - phase) / travel;\n          if (half > where && half < next) {\n            next = half;\n            kind = 2;\n          }\n        }\n        if (syncAt >= where && syncAt < next) {\n          next = syncAt;\n          kind = 3;\n        }\n        if (kind === 0) break;\n\n        phase = slide(phase, (next - where) * travel);\n        where = next;\n\n        const reach = travel > 0 ? travel : -travel;\n        if (kind === 1) {\n          // The slave's own turn. The saw and the square fall the whole way\n          // across their travel; the triangle does not jump but turns a\n          // corner, and the sine does neither.\n          this.jump(travel > 0 ? -2 * (w[2] + w[3]) : 2 * (w[2] + w[3]), where);\n          this.bend(8 * w[1] * reach, where);\n          phase = travel > 0 ? NUDGE : 1 - NUDGE;\n        } else if (kind === 2) {\n          // Half way: where a square changes sides and a triangle its mind.\n          this.jump(travel > 0 ? 2 * w[3] : -2 * w[3], where);\n          this.bend(-8 * w[1] * reach, where);\n          phase = travel > 0 ? 0.5 + NUDGE : 0.5 - NUDGE;\n        } else if (mode === 2) {\n          // Hard: from wherever the wave had got to, back to where it starts.\n          // Every wave here is −1 there, so that is the whole of the height\n          // — and the slope it restarts with is a corner of its own.\n          this.jump(-1 - waveAt(phase, w), where);\n          this.bend((4 * w[1] + 2 * w[2] - slopeAt(phase, w)) * travel, where);\n          phase = NUDGE;\n          syncAt = -1;\n        } else {\n          // Soft: nothing jumps, so nothing steps. The direction reverses,\n          // and the slope of whatever wave is running reverses with it.\n          this.bend(-2 * slopeAt(phase, w) * travel, where);\n          direction = -direction;\n          syncAt = -1;\n        }\n      }\n\n      this.phase = wrap(phase + (1 - where) * step * direction);\n      this.direction = direction;\n\n      const raw = value + this.owed;\n      this.centred = raw - this.lastRaw + this.pole * this.centred;\n      this.lastRaw = raw;\n      channel[index] = this.centred;\n    }\n\n    for (let other = 1; other < output.length; other += 1) output[other].set(channel);\n    return true;\n  }\n}\n\nregisterProcessor('atmosynth-sync', AtmosynthSync);\n",
        "declaredParams": [
          {
            "default": 0,
            "max": 24000,
            "min": 0,
            "name": "frequency"
          },
          {
            "default": 0,
            "max": 9600,
            "min": -9600,
            "name": "detune"
          },
          {
            "default": 0,
            "max": 24000,
            "min": -24000,
            "name": "fm"
          },
          {
            "default": 0,
            "max": 1000,
            "min": -1000,
            "name": "reset"
          },
          {
            "default": 2,
            "max": 2,
            "min": 0,
            "name": "mode"
          },
          {
            "default": 2,
            "max": 3,
            "min": 0,
            "name": "wave"
          }
        ],
        "numberOfInputs": 0,
        "numberOfOutputs": 1,
        "processorName": "atmosynth-sync"
      }
    },
    {
      "id": "syncin",
      "kind": "moduleInput",
      "options": {
        "input": "sync"
      }
    },
    {
      "id": "fmin",
      "kind": "moduleInput",
      "options": {
        "input": "fm"
      }
    },
    {
      "id": "fmdepth",
      "kind": "gain",
      "options": {}
    },
    {
      "id": "detunein",
      "kind": "moduleInput",
      "options": {
        "input": "detune"
      }
    },
    {
      "id": "detunedepth",
      "kind": "gain",
      "options": {}
    },
    {
      "id": "out",
      "kind": "moduleOutput",
      "options": {
        "output": "out"
      }
    }
  ],
  "connections": [
    {
      "from": {
        "node": "note",
        "output": 0
      },
      "to": {
        "node": "gen",
        "param": "frequency"
      }
    },
    {
      "from": {
        "node": "gen",
        "output": 0
      },
      "to": {
        "node": "out",
        "input": 0
      }
    },
    {
      "from": {
        "node": "syncin",
        "output": 0
      },
      "to": {
        "node": "gen",
        "param": "reset"
      }
    },
    {
      "from": {
        "node": "fmin",
        "output": 0
      },
      "to": {
        "node": "fmdepth",
        "input": 0
      }
    },
    {
      "from": {
        "node": "fmdepth",
        "output": 0
      },
      "to": {
        "node": "gen",
        "param": "fm"
      }
    },
    {
      "from": {
        "node": "detunein",
        "output": 0
      },
      "to": {
        "node": "detunedepth",
        "input": 0
      }
    },
    {
      "from": {
        "node": "detunedepth",
        "output": 0
      },
      "to": {
        "node": "gen",
        "param": "detune"
      }
    }
  ],
  "automation": [],
  "parameters": [
    {
      "id": "wave",
      "label": "Wave (0 sine, 1 triangle, 2 saw, 3 square)",
      "range": {
        "min": "0",
        "max": "3"
      },
      "default": "2",
      "targets": [
        {
          "node": "gen",
          "param": "wave"
        }
      ]
    },
    {
      "id": "sync",
      "label": "Sync (0 off, 1 soft, 2 hard)",
      "range": {
        "min": "0",
        "max": "2"
      },
      "default": "2",
      "targets": [
        {
          "node": "gen",
          "param": "mode"
        }
      ]
    },
    {
      "id": "detune",
      "label": "Detune (cents)",
      "range": {
        "min": "-2400",
        "max": "4800"
      },
      "default": "700",
      "targets": [
        {
          "node": "gen",
          "param": "detune"
        }
      ]
    },
    {
      "id": "detuneAmount",
      "label": "Detune amount (cents)",
      "range": {
        "min": "0",
        "max": "4800"
      },
      "default": "1200",
      "targets": [
        {
          "node": "detunedepth",
          "param": "gain"
        }
      ]
    },
    {
      "id": "fmAmount",
      "label": "FM amount (Hz)",
      "range": {
        "min": "0",
        "max": "4000"
      },
      "default": "400",
      "targets": [
        {
          "node": "fmdepth",
          "param": "gain"
        }
      ]
    }
  ],
  "sampleSlots": [],
  "createdAt": "2026-09-05T00:00:00.000Z"
}