A mouse click sound player, built so that a burst at twenty clicks per second stays indistinguishable from a real hand on a real mouse.
#include"Orion.h"
orion::Player player;
player.start({}); // hook installed, fallback synth loaded
player.loadBank("gpro"); // or a recorded bank
player.notifyClick(orion::Button::left); // from an autoclickerMost implementations play one sample per click with a bit of gain jitter. It sounds fake, and the reasons are nameable:
- The release is frozen. A take that contains the whole cycle bakes in a constant press to release delay. At twenty clicks per second that delay is wrong; on a held button it is absurd. Here they are two separate events, and the scheduled release lands on the exact sample.
- The waveform is identical every time. What gives a burst away is not that it is a sample, it is the spectrally identical fingerprint from one click to the next. Gain jitter does not hide that. Here: anti repetition round robin over a three deep history, pitch variation through Hermite interpolation, gain variation, and a random start offset of a few dozen samples that decorrelates the phase and breaks the comb.
- Nothing ties the strike to the rate. A click chained fast is louder and sharper than a settled one. Velocity comes from the interval since the last click, and drives gain and brightness.
- Butterfly is not modelled. Two fingers alternate, and they do not hit the same spot on the shell. What you hear as butterfly is two timbres alternating, not disorder. With slip, because perfect alternation sounds mechanical.
The output bus runs through a fixed order chain, the one you would follow by hand: shape the transient, tint it, saturate, compress once the timbre is settled, place it in space, and leave through a ceiling.
| stage | what it does |
|---|---|
| transient | attack and tail separately. On clicks this is the most effective tool in the chain |
| EQ | high pass, low shelf, bell, high shelf. A click lives between 2 and 10 kHz |
| drive | normalised tanh, with a mix control |
| compressor | soft knee, linked detection across channels, auto makeup, gain reduction metering |
| room | early reflections, eight irregular taps, damped |
| echo | filtered delay in the loop, feedback, ping pong |
| output | mid/side width, gain, ceiling |
Two choices are worth calling out.
Early reflections rather than a reverb. A real click reaches your ears with the room's reflections in the first few tens of milliseconds, and that is exactly what a close miked sample is missing. A reverb tail would only push it further away.
The transient shaper rather than the compressor for punch. On a twenty millisecond event a compressor is always late: by the time its detector reacts, the transient is already out.
Everything is neutral by default. At rest the chain does nothing but bound the bus, and it is the chain that does it: the mixer sums without limiting, so the effects see the real signal.
| target | what | platform |
|---|---|---|
orion | bank, selector, voice, mixer, synth. No hardware calls | all |
orion_input | low level mouse hook | useful on Windows, empty elsewhere |
orion_device | miniaudio output, and the Player facade | all |
clickbank | slicing, checking and packing banks | all |
orion_demo | listening and tuning bench | all |
orion never touches the sound card: everything in it renders and verifies
offline, and the tests link it alone. That is what lets verification run with no
screen and no audio device.
Nothing in the render path allocates, locks or calls the system. Everything is sized at load time, takes are decoded and converted to the device rate once and for all, and the event queue refuses rather than waits: blocking in the callback would cost a hole in the sound, which is worse than a dropped click.
cmake -B build
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failureThe only dependencies are vendored or fetched on demand: miniaudio as a single
header in external/, and imgui plus glfw for the demo only, through
FetchContent. orion itself has no dependency at all, WAV decoder included.
Options: ORION_BUILD_TESTS, ORION_BUILD_TOOLS, ORION_BUILD_DEMO.
One bank ships, banks/op1v2: an Endgame Gear OP1 v2 recorded here with a close
mic. Normal on both buttons, jitter on the left, no butterfly. It is what both
presets name and what the worked examples in the docs run against, so the thing
you hear out of the box is a real mouse and not the fallback.
The library is never silent either way: with no bank at all it plays a synthesised profile. That is a reference point, not a replacement, and the a/b toggle in the demo exists to make the gap obvious.
To build your own, record your own mouse. It takes twenty minutes and beats any third party source: docs/RECORDING.md walks through the session, docs/BANKS.md through what happens to the material afterwards.
clickbank slice session.wav -o banks/gpro --technique normal
clickbank check banks/gproAny other bank stays local. banks/* is excluded apart from op1v2, because a
bank sliced out of someone else's recording is not ours to hand on.
presets/ holds two, both for the same mouse, and they are there as worked
examples rather than as something to load blindly:
| preset | what it is for |
|---|---|
op1v2-canon.json | flat. No effects, no listening geometry. This is the one to render against the original recording with clickbank match, because it judges the bank and nothing else |
op1v2-bureau2.json | the full thing. EQ, drive, echo, and the listening geometry of a mouse sitting to the right on a desk. This is what a tuned setup looks like |
A preset names the bank it was tuned on. Loaded against a different bank, the numbers still apply but they no longer mean what they meant: the variation the engine adds is meant to complete what the bank already carries, not to stack on top of it.
clickbank inspect presets/op1v2-bureau2.json resolves one all the way down and
says what the engine will actually do with it, clamps and unchecked boxes
included.
src/Orion.h is the only header to include.
For a click emitter that notifies directly, set EngineConfig::injected to
ignore: the emitter's SendInput comes back through the hook flagged as
injected, and would sound twice.
ClickEvent::holdSec carries three cases:
- positive: the engine schedules the release at that deadline
- zero: no automatic release, the producer will send its own. This is the hook's case, and the default
- negative: the engine draws a plausible duration for the current technique
Conventions are in docs/CONVENTIONS.md. They are short, and following the neighbouring file is usually enough. Before pushing:
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failureMIT, see LICENSE.
miniaudio (external/miniaudio) is public domain or MIT-0, your choice. imgui
and glfw are fetched for the demo only.