Per-note 3D: position handlers and swarms

Per-note 3D: position handlers and swarms#

Goal: give every note its own position in space, so a single chord becomes a swarm of independently-moving sound sources orbiting the listener.

A synth is rendered behind one positioned YSE::sound, so by default every voice shares that one position. Attach a YSE::SYNTH::positionHandler and each voice gets its own 3D position, updated every block — the basis of the “swarm” effect. libYSE ships three handlers: static, random-spread and orbit (the swarm workhorse), or you can derive your own.

This tutorial walks through Demo20_Swarm: a chord of sine voices orbiting the listener, steerable live.

Source: Demo20_Swarm.cpp.

Attaching a handler#

Build a voice prototype and a handler prototype, then chain positionHandler after addVoices. The synth clones the handler once per voice slot, so each voice steers independently:

  voiceProto_ = std::make_unique<YSE::SYNTH::sineVoice>();
  voiceProto_->attack(0.02f).decay(0.1f).sustain(0.7f).release(0.4f);

  handlerProto_ = std::make_unique<YSE::SYNTH::orbitHandler>();
  handlerProto_->radius(1.0f).velocityRadius(2.0f).aftertouchWiden(1.5f).rate(2.5f).releaseSlow(
      0.5f);

  synth_ = std::make_unique<YSE::synth>();
  synth_->create().addVoices(*voiceProto_, 8).positionHandler(*handlerProto_);

  sound_ = std::make_unique<YSE::sound>();
  sound_->create(*synth_);
  sound_->play();

The orbit handler reads its base radius from radius, widens with note velocity (velocityRadius) and aftertouch (aftertouchWiden), spins at rate radians per second, and slows on release (releaseSlow). Like a voice, a handler prototype must outlive setup — the engine reads it to clone but neither copies nor owns it.

Note

With no handler attached, positionHandler is simply never called and every voice uses the synth’s aggregate position — so adding per-note positioning is purely additive to the Your first synth flow.

Steering the swarm live#

Two live controls, both bounded and allocation-free, so they are safe to call every frame:

void DemoSwarm::CenterLeft() {
  centerX_ -= 1.0f;
  synth_->handlerParam(YSE::SYNTH::HP_CENTER_X, centerX_);
}

void DemoSwarm::CenterRight() {
  centerX_ += 1.0f;
  synth_->handlerParam(YSE::SYNTH::HP_CENTER_X, centerX_);
}

void DemoSwarm::MoreAftertouch() {
  aftertouch_ = aftertouch_ + 0.2f > 1.0f ? 1.0f : aftertouch_ + 0.2f;
  synth_->aftertouch(kMidiChannel, -1, aftertouch_); // channel-wide
}

void DemoSwarm::LessAftertouch() {
  aftertouch_ = aftertouch_ - 0.2f < 0.0f ? 0.0f : aftertouch_ - 0.2f;
  synth_->aftertouch(kMidiChannel, -1, aftertouch_);
  • handlerParam(index, value) sets a shared handler parameter every live handler reads next block. Indices 0–2 are the swarm centre (YSE::SYNTH::HP_CENTER_X and friends), so one call recentres the entire swarm.

  • aftertouch(channel, -1, value) applies channel-wide pressure; the orbit handler widens the radius with it.

To place a single note explicitly instead (app-driven trajectories rather than a handler), use YSE::synth::notePosition().

Reading a voice back#

getVoicePosition returns a best-effort snapshot of where a sounding voice currently is — handy for driving visuals off the audio:

void DemoSwarm::ShowStatus() {
  if (!playing_) return;
  YSE::Pos p = synth_->getVoicePosition(kMidiChannel, kChord[0]);
  std::cout << "\rcentreX=" << centerX_ << "  aftertouch=" << aftertouch_ << "  voice[" << kChord[0]
            << "] pos=(" << p.x << ", " << p.z << ")        " << std::flush;
}

What you learned#

  • Attach a positionHandler with synth::positionHandler(prototype) to give every voice its own 3D position; the prototype must outlive setup.

  • The orbitHandler makes a chord orbit the listener; velocity and aftertouch shape the radius.

  • Steer the whole swarm with handlerParam (centre at indices 0–2), place one note with notePosition, and read a voice back with getVoicePosition.

Next#