quot;[MidiOut] port {i}: {probe.GetPortName(i)}"); for (int i = 0; i < count; i++) { string name = probe.GetPortName(i); bool match = string.IsNullOrEmpty(portNameContains) || name.IndexOf(portNameContains, StringComparison.OrdinalIgnoreCase) >= 0; if (!match) continue; var dev = MidiOut.Create(); dev.ErrorReceived = (type, msg) => Debug.LogWarning(quot;[MidiOut:{name}] {type}: {msg}"); dev.OpenPort(i); _out = dev; _openName = name; Debug.Log(quot;[MidiOut] opened: {name}"); return; } Debug.LogWarning(quot;[MidiOut] no port matched '{portNameContains}'. Is the interface plugged in and not owned by another app?"); } } void Close() { lock (_lock) { _out?.Dispose(); _out = null; } } // ---------- low-level sends ---------- void Send(byte[] buf, int len) { // caller holds _lock if (_out == null) return; _out.SendMessage(new ReadOnlySpan<byte>(buf, 0, len)); } public void SendRealtime(byte status) { lock (_lock) { _b1[0] = status; Send(_b1, 1); } } public void Send2(byte status, int d1) { lock (_lock) { _b2[0] = status; _b2[1] = (byte)(d1 & 0x7F); Send(_b2, 2); } } public void Send3(byte status, int d1, int d2) { lock (_lock) { _b3[0] = status; _b3[1] = (byte)(d1 & 0x7F); _b3[2] = (byte)(d2 & 0x7F); Send(_b3, 3); } } // ---------- channel messages (channel 0-15) ---------- public void NoteOn(int ch, int note, int velocity) { note = Mathf.Clamp(note, 0, 127); velocity = Mathf.Clamp(velocity, 1, 127); // velocity 0 would mean note-off lock (_lock) _held.Add(ch * 128 + note); Send3((byte)(0x90 | (ch & 0x0F)), note, velocity); } public void NoteOff(int ch, int note) { note = Mathf.Clamp(note, 0, 127); lock (_lock) _held.Remove(ch * 128 + note); Send3((byte)(0x80 | (ch & 0x0F)), note, 64); } public void ControlChange(int ch, int cc, int value) => Send3((byte)(0xB0 | (ch & 0x0F)), cc, Mathf.Clamp(value, 0, 127)); /// <summary>14-bit controller: MSB on cc (0-31), LSB on cc + 32. value is 0..16383.</summary> public void ControlChange14(int ch, int ccMsb, int value14) { value14 = Mathf.Clamp(value14, 0, 16383); ControlChange(ch, ccMsb, value14 >> 7); ControlChange(ch, ccMsb + 32, value14 & 0x7F); } /// <summary>Pitch bend, 0..16383, centre 8192.</summary> public void PitchBend(int ch, int value14) { value14 = Mathf.Clamp(value14, 0, 16383); Send3((byte)(0xE0 | (ch & 0x0F)), value14 & 0x7F, value14 >> 7); } public void ProgramChange(int ch, int program) => Send2((byte)(0xC0 | (ch & 0x0F)), Mathf.Clamp(program, 0, 127)); // ---------- realtime ---------- public void Clock() => SendRealtime(0xF8); public void Start_() => SendRealtime(0xFA); public void Continue_() => SendRealtime(0xFB); public void Stop_() => SendRealtime(0xFC); // ---------- safety ---------- /// <summary>Sends an explicit note-off for every note we turned on and have not turned off.</summary> public void ReleaseAll() { int[] snapshot; lock (_lock) { snapshot = new int[_held.Count]; _held.CopyTo(snapshot); _held.Clear(); } foreach (int key in snapshot) Send3((byte)(0x80 | ((key / 128) & 0x0F)), key % 128, 64); } } ``` **What to notice** - `Send3` masks data bytes with `0x7F`: a data byte with the high bit set would be read as a *status* byte and corrupt the stream. - `NoteOn` clamps velocity to at least 1 because velocity 0 is a note-off by MIDI convention. - `using var probe` disposes the probe port right after enumeration; the real port is a separate `MidiOut` (the same pattern as RtMidi for Unity's own `MidiOutTest.cs`). - `ErrorReceived` is assigned per device, as in the package's test script. If the log says the port is busy, another program (a DAW, a SysEx tool) owns it. Close it: classic Windows MIDI drivers commonly allow a single owner per port. ### 3.3 `MidiClockThread.cs` (tempo for the Subharmonicon) The Subharmonicon follows an incoming MIDI clock over its own and any analog clock (manual). MIDI clock is **24 pulses per quarter note**. A `Update`-driven clock would wobble with frame time, so this uses a dedicated thread with a `Stopwatch`. ```csharp using System.Diagnostics; using System.Threading; using UnityEngine; /// <summary> /// Sends MIDI clock (0xF8, 24 per quarter note) from a background thread. /// Start/Stop messages are optional: the Subharmonicon documents transport as SysEx, /// so press PLAY on the panel and use this for tempo. Test what your units do with 0xFA/0xFC. /// </summary> public sealed class MidiClockThread : MonoBehaviour { [SerializeField, Range(30f, 240f)] double bpm = 100; [SerializeField] bool sendStartStop = false; Thread _thread; volatile bool _running; readonly object _bpmLock = new object(); public double Bpm { get { lock (_bpmLock) return bpm; } set { lock (_bpmLock) bpm = System.Math.Clamp(value, 30.0, 300.0); } } public bool IsRunning => _running; public void StartClock() { if (_running) return; var svc = MidiOutService.Instance; if (svc == null) { UnityEngine.Debug.LogWarning("[MidiClock] no MidiOutService"); return; } if (sendStartStop) svc.Start_(); _running = true; _thread = new Thread(() => Run(svc)) { IsBackground = true, Name = "MidiClock", Priority = System.Threading.ThreadPriority.AboveNormal }; _thread.Start(); } public void StopClock() { if (!_running) return; _running = false; _thread?.Join(200); _thread = null; if (sendStartStop) MidiOutService.Instance?.Stop_(); } void Run(MidiOutService svc) { var sw = Stopwatch.StartNew(); double nextTick = 0.0; // seconds while (_running) { double intervalSec = 60.0 / (Bpm * 24.0); double now = sw.Elapsed.TotalSeconds; if (now >= nextTick) { svc.Clock(); nextTick += intervalSec; if (now - nextTick > 0.1) nextTick = now + intervalSec; // fell far behind, resync } else { double wait = nextTick - now; if (wait > 0.002) Thread.Sleep(1); else Thread.SpinWait(200); } } } void OnDisable() => StopClock(); } ``` `bpm` is read through a lock so the Inspector value and `Bpm` setter are consistent. The fully-qualified `System.Threading.ThreadPriority` avoids an ambiguity with `UnityEngine.ThreadPriority`. ## 4. Instrument controllers: Subharmonicon and Mother-32 ### 4.1 `Mother32Controller.cs` ```csharp using UnityEngine; /// <summary>Moog Mother-32 over MIDI. Default channel 1 (change in Setup mode page 2).</summary> public sealed class Mother32Controller : MonoBehaviour { [SerializeField, Range(1, 16)] int midiChannel = 1; // CCs from the Mother-32 manual's "MIDI Functions and Implementation". public const int CcPortamentoTime = 5; public const int CcPortamentoOnOff = 65; public const int CcSustain = 64; // CC 1, 2, 4, 7 feed the ASSIGN output when Setup page 1 is set to modes 13-16. public const int CcAssign1 = 1, CcAssign2 = 2, CcAssign3 = 4, CcAssign4 = 7; int Ch => midiChannel - 1; MidiOutService Out => MidiOutService.Instance; /// <summary>Note 0 = -5 V, note 120 = +5 V (1 V/oct). 121-127 repeat 109-115.</summary> public void NoteOn(int note, int velocity = 100) => Out?.NoteOn(Ch, Mathf.Clamp(note, 0, 120), velocity); public void NoteOff(int note) => Out?.NoteOff(Ch, Mathf.Clamp(note, 0, 120)); /// <summary>bend01 in -1..+1, scaled by the current bend range (default +/-1 octave).</summary> public void PitchBend(float bend01) { int v = Mathf.Clamp(Mathf.RoundToInt(8192 + bend01 * 8191f), 0, 16383); Out?.PitchBend(Ch, v); } /// <summary>Registered parameter 0 (pitch bend depth): CC101=0, CC100=0, CC6=semitones (0-12).</summary> public void SetPitchBendRangeSemitones(int semitones) { if (Out == null) return; Out.ControlChange(Ch, 101, 0); Out.ControlChange(Ch, 100, 0); Out.ControlChange(Ch, 6, Mathf.Clamp(semitones, 0, 12)); // Optional "RPN null" so later CC6 messages cannot change it by accident. Out.ControlChange(Ch, 101, 127); Out.ControlChange(Ch, 100, 127); } public void SetPortamento(bool on, float time01) { if (Out == null) return; Out.ControlChange(Ch, CcPortamentoTime, Mathf.RoundToInt(Mathf.Clamp01(time01) * 127f)); Out.ControlChange(Ch, CcPortamentoOnOff, on ? 127 : 0); } public void SetSustain(bool on) => Out?.ControlChange(Ch, CcSustain, on ? 127 : 0); /// <summary>Drives the ASSIGN jack (0..127 maps to -5..+5 V) once Setup page 1 selects the matching CC mode.</summary> public void SetAssignCc(int ccNumber, float value01) => Out?.ControlChange(Ch, ccNumber, Mathf.RoundToInt(Mathf.Clamp01(value01) * 127f)); /// <summary>Program changes 1-64 select a sequencer pattern (check zero- vs one-based on the bench).</summary> public void SelectPattern(int program) => Out?.ProgramChange(Ch, program); } ``` Practical Mother-32 setup: in **Setup page 1** choose the ASSIGN output source (for example mode 13 = MIDI CC 1). Then patch ASSIGN to the filter cutoff or VCO mod input, and Unity's `SetAssignCc(1, value)` becomes a **-5 V to +5 V** control voltage. Because there is only one ASSIGN jack, you get one live mapped CV at a time; program changes 71-86 select the ASSIGN mode over MIDI, which lets you re-purpose it from Unity (bench-test whether the mode change is instant). ### 4.2 `SubharmoniconController.cs` ```csharp using UnityEngine; /// <summary>Moog Subharmonicon over MIDI. Set to channel 2 here by sending Moog's SysEx channel file.</summary> public sealed class SubharmoniconController : MonoBehaviour { [SerializeField, Range(1, 16)] int midiChannel = 2; // CC map from the Subharmonicon manual (MIDI Operations). public const int CcVco1FreqMsb = 4, CcVco1FreqLsb = 36; public const int CcVco2FreqMsb = 12, CcVco2FreqLsb = 44; public const int CcVco1Sub1 = 103, CcVco1Sub2 = 104, CcVco2Sub1 = 105, CcVco2Sub2 = 106; public const int CcRhythmLogic = 113; // 0-63 OR, 64-127 XOR int Ch => midiChannel - 1; MidiOutService Out => MidiOutService.Instance; /// <summary> /// The unit treats note data as an OFFSET from C4 (MIDI 60). semitonesFromC4 = 0 plays "unchanged" /// when the VCO FREQ knobs are fully counter-clockwise. /// </summary> public void NoteOn(int semitonesFromC4, int velocity = 100) => Out?.NoteOn(Ch, 60 + semitonesFromC4, velocity); public void NoteOff(int semitonesFromC4) => Out?.NoteOff(Ch, 60 + semitonesFromC4); /// <summary> /// 14-bit VCO frequency CC added to the knob (+/-2.5 octaves total range per Moog). /// amount01: 0..1 across the CC range. I did not verify the power-on default; check by ear. /// </summary> public void SetVco1Freq(float amount01) => Out?.ControlChange14(Ch, CcVco1FreqMsb, To14(amount01)); public void SetVco2Freq(float amount01) => Out?.ControlChange14(Ch, CcVco2FreqMsb, To14(amount01)); /// <summary>vco 1 or 2, sub 1 or 2, divisor 1..16. The CC replaces the knob until you move the knob.</summary> public void SetSubDivision(int vco, int sub, int divisor) { divisor = Mathf.Clamp(divisor, 1, 16); // Moog: 0-7 = 16, 8-15 = 15, ... 120-127 = 1. Aim for the middle of each 8-wide band. int value = (16 - divisor) * 8 + 4; int cc = (vco == 1) ? (sub == 1 ? CcVco1Sub1 : CcVco1Sub2) : (sub == 1 ? CcVco2Sub1 : CcVco2Sub2); Out?.ControlChange(Ch, cc, value); } public void SetRhythmLogicXor(bool xor) => Out?.ControlChange(Ch, CcRhythmLogic, xor ? 127 : 0); static int To14(float a) => Mathf.Clamp(Mathf.RoundToInt(Mathf.Clamp01(a) * 16383f), 0, 16383); } ``` **Why the sub-division maths looks odd.** The manual maps value bands of eight (0-7 -> 16, 8-15 -> 15, ... 120-127 -> 1), so `(16 - divisor) * 8 + 4` lands in the middle of the right band for any divisor 1-16. Moog also notes that moving the physical SUB knob overrides the CC; when you demo from Unity, do not touch the panel. **Why transposition feels strange.** Because the Subharmonicon treats incoming note numbers as offsets from C4, a keyboard-style `NoteOn(60+n)` and `NoteOn(n)` are *different calls*. This controller uses semitone offsets so the API cannot get it wrong. ### 4.3 A first smoke test ```csharp using System.Collections; using UnityEngine; /// <summary>Plays a short phrase on both Moogs when you press Play. Remove after the bench test.</summary> public sealed class EurorackSmokeTest : MonoBehaviour { [SerializeField] Mother32Controller mother; [SerializeField] SubharmoniconController sub; IEnumerator Start() { yield return new WaitForSeconds(1f); int[] mother32Notes = { 36, 43, 48, 43 }; foreach (int n in mother32Notes) { mother.NoteOn(n, 110); yield return new WaitForSeconds(0.25f); mother.NoteOff(n); yield return new WaitForSeconds(0.05f); } sub.NoteOn(0); yield return new WaitForSeconds(1f); sub.NoteOff(0); } } ``` ## 5. Audio return: interface, Microphone API and monitoring ### 5.1 Why the Microphone API Unity's `Microphone` class records from any *input device the OS exposes*, including a class-compliant audio interface. `Microphone.devices` lists names; `Microphone.Start(deviceName, loop, lengthSec, frequency)` returns an `AudioClip` that is filled live; `Microphone.GetPosition(deviceName)` returns the current write position in samples. Per the 6000.5 docs: a null or empty device name selects the default microphone, and the docs recommend passing `AudioSettings.outputSampleRate` as the frequency to match the project. `GetDeviceCaps` gives the supported sample-rate range. ### 5.2 `AudioInputTap.cs` This is the foundation for [[Music Visualization]]: a looping one-second ring clip plus a method that copies the **most recent N samples** of one channel into your array. It reads straight from the clip with `AudioClip.GetData` (interleaved samples; offsets in frames; reads wrap around the clip, as documented), so it needs **no AudioSource** and therefore no feedback risk. ```csharp using System; using UnityEngine; /// <summary> /// Records an input device into a looping clip and exposes the latest samples of one channel. /// Main-thread only (call Read from Update or LateUpdate). /// </summary> public sealed class AudioInputTap : MonoBehaviour { [Tooltip("Case-insensitive part of the device name (e.g. 'ES-9', 'Scarlett', 'USB Audio'). Empty = default device.")] [SerializeField] string deviceContains = ""; [SerializeField, Range(1, 4)] int bufferSeconds = 1; [SerializeField] int channelToRead = 0; [SerializeField] bool logDevices = true; AudioClip _clip; string _device; float[] _scratch = new float[8192]; public bool IsRecording => _clip != null && Microphone.IsRecording(_device); public int SampleRate => _clip != null ? _clip.frequency : AudioSettings.outputSampleRate; public int Channels => _clip != null ? _clip.channels : 0; public string DeviceName => _device; void OnEnable() => StartRecording(); void OnDisable() => StopRecording(); void StartRecording() { if (logDevices) foreach (var d in Microphone.devices) Debug.Log(quot;[AudioInputTap] device: {d}"); _device = null; // null/empty = default microphone if (!string.IsNullOrEmpty(deviceContains)) { foreach (var d in Microphone.devices) if (d.IndexOf(deviceContains, StringComparison.OrdinalIgnoreCase) >= 0) { _device = d; break; } if (_device == null) Debug.LogWarning(quot;[AudioInputTap] no device contains '{deviceContains}', using default."); } int rate = AudioSettings.outputSampleRate; Microphone.GetDeviceCaps(_device, out int minF, out int maxF); if (maxF > 0) rate = Mathf.Clamp(rate, minF, maxF); // (0,0) means "any rate" _clip = Microphone.Start(_device, true, bufferSeconds, rate); if (_clip == null) Debug.LogError("[AudioInputTap] Microphone.Start returned null."); else Debug.Log(quot;[AudioInputTap] started '{_device ?? "default"}' at {_clip.frequency} Hz, {_clip.channels} channel(s)"); } void StopRecording() { if (_clip != null && Microphone.IsRecording(_device)) Microphone.End(_device); _clip = null; } /// <summary> /// Copies the most recent dest.Length mono samples of one channel into dest (channel -1 = the Inspector's channelToRead). /// Returns false until the device has delivered enough audio. /// </summary> public bool ReadLatest(float[] dest, int channel = -1) { if (_clip == null) return false; int pos = Microphone.GetPosition(_device); // frames written so far (wraps) int n = dest.Length; int ch = Mathf.Clamp(channel < 0 ? channelToRead : channel, 0, _clip.channels - 1); if (pos <= 0 || n > _clip.samples) return false; int start = pos - n; if (start < 0) start += _clip.samples; int needed = n * _clip.channels; // GetData fills the whole array, so keep the scratch buffer exactly sized. if (_scratch.Length != needed) _scratch = new float[needed]; if (!_clip.GetData(_scratch, start)) return false; for (int i = 0; i < n; i++) dest[i] = _scratch[i * _clip.channels + ch]; return true; } } ``` **Notes** - **Windows microphone permission.** Windows 10/11 has a *Privacy > Microphone* switch that also gates audio-interface inputs for desktop apps in some configurations. If `Microphone.devices` is empty or the clip is silent, check it first. (OS behaviour; not from the Unity docs.) - **Channels.** I did not verify how Unity numbers channels from a multichannel interface or whether it exposes more than two. The script logs `clip.channels` at start and clamps `channelToRead`. If you only get channels 1-2, set the interface's own routing/mixer app so the Moogs' mix lands on inputs 1-2. - **Latency.** The docs' own advice for latency control is to poll `GetPosition` until it has advanced by the number of samples you want and only then start playback. For *analysis* (visualization, envelope following) a few tens of milliseconds is fine. For *monitoring through Unity* it is too slow to play over; use the interface's **direct monitoring** or your mixer for the live sound and treat Unity's copy as data. - **Sample-rate mismatch.** Match the interface's driver rate with `AudioSettings.outputSampleRate` (Project Settings > Audio) so both ends agree; otherwise Windows may resample. ### 5.3 Optional: hear it through Unity If you must monitor in-engine (for example to run the live signal through a Unity mixer effect or spatializer, see [[Spatial Audio and Reverb Zones]]), the recipe from the Unity docs is: expose the recording clip from `AudioInputTap` (add a public `Clip` property returning `_clip`), assign it to an `AudioSource` with `loop = true`, wait until `Microphone.GetPosition` has advanced by your target latency in samples, then call `Play()`. I deliberately do not give a full script: monitoring through Unity adds tens of milliseconds and a feedback hazard (speakers feeding the same mics), and hardware **direct monitoring** on the interface is the right production answer. Whether `GetSpectrumData` on such a source sees the signal before or after a muted mixer group is not verified, which is one more reason [[Music Visualization]] analyses the clip directly instead. ## 6. DC-coupled CV: what works, what hurts Eurorack control voltages are **slowly changing or constant** voltages. Most consumer audio interfaces are **AC-coupled**: a high-pass filter in the input and output stages removes DC, so a steady 3 V CV decays to 0 V on the way in, and a constant "CV" you generate in Unity never reaches the rack as a level. Audio-rate signals work fine; slow LFOs, gates and held pitches do not. ### 6.1 What you need - A **DC-coupled** interface. Example: the Expert Sleepers **ES-9** advertises 14 DC-coupled 3.5 mm inputs and 8 DC-coupled 3.5 mm outputs of about +/-10 V, 16 in / 16 out USB, class-compliant. Its manual adds three details that apply to any such device: the input ADC has optional **DC-blocking filters that must be turned off for CV**, the 1/4" balanced main outs are *not* DC-coupled, and the default channel map puts the 3.5 mm outputs on channels **9-16**. - **Voltage safety.** Eurorack signals can swing +/-10 V or more. A normal line input expects about +/-2 V maximum. **Never** patch rack CV or audio into a non-modular interface input without attenuating, and never feed a DC-coupled output into something that expects line level. Mother-32 outputs include -5..+5 V signals (for example the ASSIGN output); the Subharmonicon's CLOCK/CV outputs are 0 to +10 V per its manual. ### 6.2 `CvOutput.cs`: a DC level out of an AudioSource This is the only reasonable way to produce CV from Unity *without* MIDI: write a constant (slewed) sample value into one output channel. Use it **only** with a DC-coupled interface, calibrated with a multimeter. ```csharp using UnityEngine; /// <summary> /// Writes a slewed DC level into one output channel via OnAudioFilterRead. /// Needs: a DC-coupled DAC path, calibration, and an AudioSource playing on the master output. /// Volts are approximate: set fullScaleVolts after measuring with a meter. /// </summary> [RequireComponent(typeof(AudioSource))] public sealed class CvOutput : MonoBehaviour { [Tooltip("Zero-based channel in the interleaved stream Unity is outputting.")] [SerializeField] int channel = 0; [Tooltip("Volts that correspond to a sample value of 1.0. Measure it; ~10 on an ES-9-class device.")] [SerializeField] float fullScaleVolts = 10f; [SerializeField] float maxVolts = 5f; [Tooltip("Slew limit in volts per second (protects your VCO from steps).")] [SerializeField] float slewVoltsPerSecond = 20f; volatile float _targetVolts; float _currentVolts; // audio thread only double _sampleRate; void Awake() { _sampleRate = AudioSettings.outputSampleRate; var src = GetComponent<AudioSource>(); src.spatialBlend = 0f; src.volume = 1f; src.loop = true; src.playOnAwake = true; } /// <summary>Main thread.</summary> public void SetVolts(float v) => _targetVolts = Mathf.Clamp(v, -maxVolts, maxVolts); /// <summary>1 V/oct helper: MIDI note 60 = 0 V (Mother-32 maps note 0 to -5 V, note 120 to +5 V).</summary> public void SetPitchNote(float midiNote) => SetVolts((midiNote - 60f) / 12f); void OnAudioFilterRead(float[] data, int channels) { if (channel >= channels) return; float step = slewVoltsPerSecond / (float)_sampleRate; float target = _targetVolts; int frames = data.Length / channels; for (int f = 0; f < frames; f++) { float d = target - _currentVolts; _currentVolts += Mathf.Clamp(d, -step, step); data[f * channels + channel] = _currentVolts / fullScaleVolts; } } } ``` **Caveats** - `OnAudioFilterRead` rules (audio thread, no allocation, interleaved data) are in [[2. OnAudioFilterRead Procedural Synth]]. This script follows them: a `volatile float` target, a cached sample rate. - With **no clip assigned** and this as the first filter, the filter acts as the generator (per the Unity docs quoted in that note), but `AudioSource.Play` must have been called; `playOnAwake` handles that. - **Unity's maximum output channel count on Windows** and its speaker-mode options are not something I verified. If the CV channel you need is beyond what Unity can address, remap channels inside the interface's own routing tool (the ES-9 manual says channel numbering can be reconfigured) so a CV output lands on channel 1 or 2. - Do not add Audio Mixer effects, limiters or spatializers to this source: they can filter DC. Keep it on the master output with the listener volume at 1. - For **pitch**, prefer MIDI. A MIDI note is exact; DAC-generated pitch CV depends on calibration and the VCO's tracking. ### 6.3 CV *into* Unity For an envelope or LFO from the rack, turn the interface's input DC-blocking **off** for that pair, attenuate to line-safe levels, then treat the input as audio samples, but **smooth and average**: the `AudioInputTap` buffer gives raw samples; take the mean of the last N and scale by your calibration. See the envelope follower in [[Music Visualization]]. ## 7. MIDI in with Minis, and game state to synth ### 7.1 `SynthMidiInput.cs` (Observer over Minis) Minis exposes each MIDI channel as an Input System device and raises events *before* control state updates, so use the event arguments, not control state (README). It only creates a device after a message arrives on that channel. ```csharp using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.InputSystem; /// <summary> /// Listens to every Minis MIDI device and republishes simple static events for the rest of the game /// (visualizers, stats, quest triggers). Minis channels are 0-based. /// </summary> public sealed class SynthMidiInput : MonoBehaviour { public static event Action<int, int, float> NoteOn; // channel0, note, velocity01 public static event Action<int, int> NoteOff; // channel0, note public static event Action<int, int, float> Control; // channel0, cc, value01 readonly List<Minis.MidiDevice> _devices = new(); void OnEnable() { InputSystem.onDeviceChange += OnDeviceChange; Connect(); } void OnDisable() { InputSystem.onDeviceChange -= OnDeviceChange; Disconnect(); } void OnDeviceChange(InputDevice device, InputDeviceChange change) { if (device is not Minis.MidiDevice) return; Disconnect(); Connect(); } void Connect() { foreach (var d in InputSystem.devices) { if (d is not Minis.MidiDevice md) continue; md.onWillNoteOn += OnNoteOn; md.onWillNoteOff += OnNoteOff; md.onWillControlChange += OnControl; _devices.Add(md); } } void Disconnect() { foreach (var md in _devices) { md.onWillNoteOn -= OnNoteOn; md.onWillNoteOff -= OnNoteOff; md.onWillControlChange -= OnControl; } _devices.Clear(); } static void OnNoteOn(Minis.MidiNoteControl note, float velocity) => NoteOn?.Invoke(note.channel, note.noteNumber, velocity); static void OnNoteOff(Minis.MidiNoteControl note) => NoteOff?.Invoke(note.channel, note.noteNumber); static void OnControl(Minis.MidiValueControl cc, float value) => Control?.Invoke(cc.channel, cc.controlNumber, value); } ``` **What this needs on your side.** A *MIDI input* that Unity can see: a keyboard or controller into the same interface, or the Moogs' MIDI output where they have one (check; Mother-32 has a MIDI IN per its manual, no output is mentioned in the pages I read). Without a source, no device appears. Hence the README's note: prompt the user to press a key. ### 7.2 `GameToSynthBridge.cs`: game state drives the rig Map a **tension** value (from the stats and mood systems in [[Music Mood Mixer]]) to controller moves, with a rate limit so we never flood a 31,250-baud cable. ```csharp using UnityEngine; /// <summary> /// Turns a 0..1 tension value into Mother-32 ASSIGN CC and Subharmonicon VCO/sub changes. /// Sends only when the MIDI value changes, at most sendHz times per second. /// </summary> public sealed class GameToSynthBridge : MonoBehaviour { [SerializeField] Mother32Controller mother; [SerializeField] SubharmoniconController sub; [SerializeField, Range(0f, 1f)] float tension; [SerializeField] float sendHz = 30f; [SerializeField] AnimationCurve motherCutoff = AnimationCurve.EaseInOut(0, 0.15f, 1, 0.9f); [SerializeField] AnimationCurve subVco2 = AnimationCurve.Linear(0, 0.5f, 1, 0.7f); [SerializeField] int subDivisionCalm = 8, subDivisionTense = 3; float _nextSend; int _lastMotherCc = -1, _lastVco2 = -1, _lastDiv = -1; public void SetTension(float t) => tension = Mathf.Clamp01(t); void Update() { if (Time.unscaledTime < _nextSend) return; _nextSend = Time.unscaledTime + 1f / Mathf.Max(1f, sendHz); int motherCc = Mathf.RoundToInt(motherCutoff.Evaluate(tension) * 127f); if (motherCc != _lastMotherCc) { mother?.SetAssignCc(Mother32Controller.CcAssign1, motherCc / 127f); _lastMotherCc = motherCc; } int vco2 = Mathf.RoundToInt(subVco2.Evaluate(tension) * 16383f); if (Mathf.Abs(vco2 - _lastVco2) > 64) // ~0.4% of the 14-bit range { sub?.SetVco2Freq(vco2 / 16383f); _lastVco2 = vco2; } int div = Mathf.RoundToInt(Mathf.Lerp(subDivisionCalm, subDivisionTense, tension)); if (div != _lastDiv) { sub?.SetSubDivision(1, 1, div); _lastDiv = div; } } } ``` Hook `SetTension` to whatever event your game already raises. If you use ScriptableObject event channels from [[ScriptableObject Architecture]], a `FloatVariable` listener can call it. For a **designer-facing** version, store the curves in a ScriptableObject `SynthMapping` asset and let each level reference its own. ## 8. OSC with OscJack OSC (Open Sound Control) is a UDP message format used by Max, VCV Rack, TouchDesigner, many phone apps and plugin hosts. Use it when the instrument is *software* or lives on another machine; hardware Moogs do not speak it. ### 8.1 `OscBridge.cs` OscJack's server invokes callbacks **on the server thread**, per its README, so we queue and drain on the main thread. We use the documented two-float forms for `Send` and `GetElementAsFloat(0/1)`. ```csharp using System.Collections.Concurrent; using OscJack; using UnityEngine; public sealed class OscBridge : MonoBehaviour { [SerializeField] int listenPort = 9000; [SerializeField] string remoteHost = "127.0.0.1"; [SerializeField] int remotePort = 9001; OscServer _server; OscClient _client; readonly ConcurrentQueue<(string address, float a, float b)> _inbox = new(); void OnEnable() { _client = new OscClient(remoteHost, remotePort); _server = new OscServer(listenPort); // Empty string = receive every address (per the OscJack README). _server.MessageDispatcher.AddCallback("", (address, data) => { // Runs on the server thread: copy values only, do not touch Unity objects. _inbox.Enqueue((address, data.GetElementAsFloat(0), data.GetElementAsFloat(1))); }); } void OnDisable() { _server?.Dispose(); _client?.Dispose(); _server = null; _client = null; } void Update() { while (_inbox.TryDequeue(out var m)) { switch (m.address) { case "/synth/tension": FindFirstObjectByType<GameToSynthBridge>()?.SetTension(m.a); break; default: Debug.Log(quot;[OSC] {m.address} {m.a} {m.b}"); break; } } } /// <summary>Send a pair of floats (second value can be 0).</summary> public void Send(string address, float a, float b = 0f) => _client?.Send(address, a, b); } ``` Use **OSC Monitor** (OscJack: Window > OSC Monitor) to see what the other app actually sends. `FindFirstObjectByType` per message is only acceptable for a demo; cache the reference in `OnEnable` for real use. OSC over UDP has no delivery guarantee, so use it for continuous values, not for "start the song now" commands. Firewall: allow Unity/Editor on the chosen UDP ports. ## 9. Bench test order, pitfalls, Level Up! and patterns ### 9.1 Bench test order (do these in sequence) 1. **Monitor MIDI out first.** Open a MIDI monitor app on another machine or use the interface's MIDI LED. In Unity press Play with `EurorackSmokeTest`; confirm the port opens (Console) and the Mother-32's **MIDI IN LED** flashes red (manual). 2. **Mother-32 on channel 1.** Notes sound on its VCO; pitch is musical. Lower the sequencer first: if the sequencer is running, incoming notes **transpose** it. 3. **Subharmonicon channel.** Confirm it responds to channel 2 only after you send the SysEx channel file; the MIDI light on its panel blinks on receipt. Set VCO FREQ knobs fully counter-clockwise to play "unchanged" note numbers. 4. **Clock.** Start `MidiClockThread`, press PLAY on the Subharmonicon, listen to the tempo follow your `Bpm`. Try `sendStartStop` and note what happens. 5. **CCs.** Turn the ASSIGN output mode to a CC mode on the Mother-32, patch it to a filter, move `tension`. 6. **Audio return.** Run `AudioInputTap`, log the channel count, confirm non-zero samples while playing a note. 7. **Last, CV.** Voltage check with a meter, then `CvOutput`. ### 9.2 Pitfalls - **Stuck notes** when you stop Play in the Editor: the service releases held notes in `OnDisable`; also verify after a script recompile or a crash by hitting the instruments' panel or sending `ReleaseAll` from a button. - **Port owned by another app.** Close DAWs and SysEx utilities before pressing Play. - **Channel base mismatch.** Minis counts channels from 0; humans count from 1. Services here are 0-based; controllers are 1-based. - **Subharmonicon transposition.** Notes are offsets from C4, not absolute pitches (1.2). - **CC flooding.** A knob sweep at frame rate saturates DIN MIDI. Rate-limit. - **Editor focus.** Run in Background ON. - **Unity thread rules.** MIDI-in handlers run through the Input System; OSC callbacks run on the OscJack server thread; neither may touch Unity objects off the main thread without queuing. - **Quest / Android.** Minis' README says Android needs the "Activity" entry point (not GameActivity) and notes a multi-port issue, and that platform's `Microphone` needs the runtime microphone permission. A USB audio interface on a Quest 2 is a project on its own; verify before promising it in class. - **Do not leave CV patched** when the Unity scene is not running: a floating or zero output may be a legal-but-unexpected voltage. Disconnect when done. ### 9.3 Level Up! tie-ins - **Level 16 - music with style.** Rogers argues that music should serve and heighten the experience. A **real analogue voice** answering to the player's tension is a very literal version: the Mother-32's filter opens as the Level Up! "tension" curve rises. Have students sketch the intensity curve first (as in [[Music Mood Mixer]]) and *then* map it to CCs. - **Level 16 - sound as feedback.** A hit gets a short gated note on the Mother-32 and a sub-division change on the Subharmonicon, immediately audible, physically in the room. See [[Interactable Audio]] for the software equivalent. - **Teaching angle.** MIDI is a tiny, brutally rate-limited protocol, which makes it a good way to teach *budgets*: bytes per frame, messages per second, what the player can perceive. ### 9.4 Game Programming Patterns - **Command** - each MIDI message is a small immutable command object (status, d1, d2); `MidiOutService` is an invoker. See [[Game Programming Patterns (Review)]]. - **Observer** - `SynthMidiInput` republishes device events as static events for the rest of the game. - **Event Queue** - the OSC inbox is a producer-consumer queue between the server thread and `Update`. - **Service Locator vs Singleton** - `MidiOutService.Instance` is a deliberately simple locator; the book argues against singletons because of global state. Here the hardware really is a single global resource, but keep the controllers decoupled (inject `MidiOutService` via a field if you test with a fake). ### 9.5 Exercises 1. Swap the smoke test for a **Mother-32 arpeggiator** that outputs a pattern chosen by the player's current stat build ([[Stat-Driven RPG Systems]]). 2. Make the Subharmonicon's sub-divisions follow enemy count in a room ([[Enemy AI State Machine]]). 3. Put the same tension variable on an **OSC** channel to a VCV Rack patch and compare how it feels against the hardware. 4. Add `SynthMidiInput` as a *controller input* for a puzzle: the player plays a four-note motif on a keyboard to open a door ([[Unity 2D Point-and-Click System]] for the interaction pattern). ## Related (existing) - [[Audio]] - series index - [[FMOD vs Built-in Mixer and Procedural Synth]] / [[2. OnAudioFilterRead Procedural Synth]] - the in-engine synth and the audio-thread rules `CvOutput` follows - [[Music Mood Mixer]] - the tension/mood signal this bridge consumes - [[Interactable Audio]] and [[Spatial Audio and Reverb Zones]] - software SFX and spatial treatment - [[Input System]] - Minis is an extension of it - [[ScriptableObject Architecture]] - event channels and mapping assets - [[Music Visualization]] - the next note; uses `AudioInputTap` - [[Game Programming Patterns (Review)]], [[Level Up! (Review)]] - [[0.Choosing a Unity Version]] - for any Quest 2 version of this project ## Official docs and sources (index) - Unity `Microphone.Start` (6000.5): https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Microphone.Start.html - Unity `Microphone.GetPosition`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Microphone.GetPosition.html - Unity `AudioClip.GetData`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/AudioClip.GetData.html - Minis: https://github.com/keijiro/Minis - RtMidi for Unity: https://github.com/keijiro/jp.keijiro.rtmidi - OscJack: https://github.com/keijiro/OscJack - Subharmonicon manual: https://cdn.inmusicbrands.com/Moog/Subharmonicon/Subharmonicon_Manual%20AMZ.pdf - Subharmonicon MIDI channel (SysEx): https://moogmusic-help.freshdesk.com/en/support/solutions/articles/69000840565-moog-subharmonicon-how-do-i-change-midi-channel-on-the-subharmonicon- - Subharmonicon CC table (community): https://midi.guide/d/moog/subharmonicon/ - Mother-32 manual: https://cdn.inmusicbrands.com/Moog/Mother32/Mother_32_Users_Manual.pdf - Expert Sleepers ES-9 (DC-coupled example) : https://www.expert-sleepers.co.uk/es9.html