guzheng-sound
v0.1.1
Published
Tiny dependency-free Web Audio engine that makes the browser sound like a guzheng (古筝): plucked notes, press-bends, tremolo, vibrato and glissando.
Maintainers
Readme
guzheng-sound
A tiny, dependency-free Web Audio engine that makes the browser sound like a guzheng (古筝). It uses Karplus–Strong plucked-string synthesis — no samples, no network, ~5 KB gzip (ESM build) — and supports the techniques that make a guzheng a guzheng:
- 🎵 Notes — pluck any of the 21 strings (or your own tuning)
- ↗️ Bendable — press-bends (the left-hand technique: mi→4, la→7) and live pitch glides
- 〰️ Tremolo (摇指) — rapid repeated plucking
- 🌊 Vibrato — LFO pitch wobble
- ✨ Glissando (花指) — string sweeps
Ships as ESM and CommonJS with TypeScript types. It targets any browser with the Web Audio API (all current Chromium, Firefox, and WebKit browsers); a CI-tested browser matrix is tracked for a future release. Use it in plain HTML, React, Vue, Svelte, or any bundler.
Install
npm install guzheng-soundOr straight from a CDN, no build step:
<script type="module">
import { Guzheng } from "https://esm.sh/[email protected]";
const gz = new Guzheng();
document.querySelector("button").addEventListener("click", () => gz.pluck(10));
</script>Quick start
import { Guzheng } from "guzheng-sound";
const gz = new Guzheng();
// Browsers only allow audio after a user gesture — pluck inside a click/tap.
button.addEventListener("pointerdown", () => {
gz.pluck(10); // string 10 = middle D (D4) in the default tuning
});Autoplay: the
AudioContextis created lazily on your first sound. Make that first call happen inside a click/keydown/pointer handler and everything just works. You can also callawait gz.resume()from a gesture.
Notes
Strings are indexed 0 (lowest) to 20 (highest). You can also target a pitch:
gz.pluck(0); // lowest string
gz.pluck(20, { velocity: 0.6 }); // highest string, softer
gz.pluckNote("A4"); // nearest string to A4
gz.pluckMidi(62); // nearest string to MIDI 62 (D4)
gz.setTranspose(2); // capo up a whole tone (affects later plucks)Bendable strings
Guzheng players press a string behind the bridge to raise its pitch — that's how
the pentatonic instrument produces 4 and 7. Bend during the attack, or bend a
ringing string live:
// Press-bend up a semitone as it's plucked (string 3 "mi" → "fa"/4):
gz.pluck(2, { bend: 1 });
// Whole-tone bend (string "la" → "7"), in cents if you prefer:
gz.pluck(4, { bendCents: 200 });
// Bend a note *while* it rings, then release it back:
const voice = gz.pluck(7);
setTimeout(() => voice?.bend(1), 200); // glide up a semitone
setTimeout(() => voice?.bend(0), 600); // ease back downTremolo (摇指)
const roll = gz.tremolo(10, { intervalMs: 55 });
// ...later
roll.stop();
// Or roll for a fixed time:
gz.tremolo(10, { durationMs: 1500 });Vibrato
gz.pluck(13, { vibrato: true });
gz.pluck(13, { vibrato: { rateHz: 6, depthCents: 20 } });Glissando (花指)
gz.glissando(0, 20); // sweep up the whole instrument
gz.glissando(20, 0, { stepMs: 24 }); // faster sweep downBring your own AudioContext / tuning
import { Guzheng, standardTuning, noteToMidi } from "guzheng-sound";
const ctx = new AudioContext();
const gz = new Guzheng({
audioContext: ctx, // share a context with the rest of your app
tuning: standardTuning(21, noteToMidi("D2")!), // 21 strings from D2 (the default)
reverb: true,
reverbMix: 0.25,
masterVolume: 0.9,
sustainSeconds: 8
});Any tuning works — pass an array of MIDI note numbers, one per string:
const gzG = new Guzheng({ tuning: standardTuning(21, noteToMidi("G2")!) }); // G pentatonicCleanup
gz.stopAll(); // silence every ringing string
gz.dispose(); // stop everything and close the context (if this instance made it)API
new Guzheng(options?)
| option | type | default | description |
| --- | --- | --- | --- |
| audioContext | AudioContext | created lazily | share an existing context |
| tuning | number[] | 21-string D pentatonic | MIDI note per string, low→high |
| reverb | boolean | true | add a subtle convolution reverb |
| reverbMix | number | 0.25 | reverb wet amount (0–1) |
| masterVolume | number | 0.9 | master gain (0–1) |
| sustainSeconds | number | 8 | rendered decay length per string |
Methods: pluck(i, opts?), pluckMidi(midi, opts?), pluckNote(name, opts?),
tremolo(i, opts?), glissando(from, to, opts?), nearestString(midi),
setTranspose(semitones), getTranspose(), resume(), stopAll(), dispose().
Properties: context, tuning, stringCount.
Static: Guzheng.isSupported(), Guzheng.DEFAULT_TUNING.
pluck / tremolo / glissando options
velocity (0–1), bend (semitones) / bendCents, vibrato (true or
{ rateHz, depthCents }), transpose (semitones), when (context time).
tremolo adds intervalMs, durationMs. glissando adds stepMs.
Voice (returned by pluck)
voice.bend(semitones, glideSeconds?)— glide the pitch (0 = un-bent)voice.release(fadeSeconds?)— fade out then stopvoice.stop()— stop immediately
Helpers
standardTuning(stringCount?, rootMidi?)→number[]midiToFrequency(midi)→numbernoteToMidi("F#3")→number | null
Performance & memory
Buffers are synthesised lazily and cached per string, per instance. The first
pluck of a given string renders that string's decay buffer on the main thread;
subsequent plucks reuse it. With the defaults (21 strings, sustainSeconds: 8,
48 kHz) a fully-exercised instance holds roughly 32 MB of mono
buffers, plus the reverb impulse. To reduce first-note latency and memory:
- lower
sustainSeconds(shorter tail, smaller buffers); - share one
AudioContext(and ideally oneGuzheng) across your app; - pluck the strings you need once up front to pre-warm their buffers.
A first-note latency/memory benchmark across low-end mobile browsers is tracked for a future release.
Development
npm install # also builds via the prepare hook
npm run build # emit ESM + CJS + types into dist/
npm test # build, then run the node:test suite
npm run typecheck # type-only check
npm run lint # typecheck + prettier --check
npm run format # prettier --writeSee CONTRIBUTING.md before opening a pull request.
License
MIT © 2026 ericphamhoangdev
