guzheng-sound

Tiny dependency-free Web Audio engine that makes the browser sound like a guzheng (古筝): plucked notes, press-bends, tremolo, vibrato and glissando.


Keywords
guzheng, guzheng-sound, 古筝, zither, web-audio, webaudio, audio, synthesis, karplus-strong, plucked-string, instrument, tremolo, vibrato, music, browser
License
MIT
Install
npm install guzheng-sound@0.1.0

Documentation

guzheng-sound

npm version CI license: MIT

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-sound

Or straight from a CDN, no build step:

<script type="module">
  import { Guzheng } from "https://esm.sh/guzheng-sound@0.1.0";
  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 AudioContext is created lazily on your first sound. Make that first call happen inside a click/keydown/pointer handler and everything just works. You can also call await 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 down

Tremolo (摇指)

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 down

Bring 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 pentatonic

Cleanup

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 stop
  • voice.stop() — stop immediately

Helpers

  • standardTuning(stringCount?, rootMidi?)number[]
  • midiToFrequency(midi)number
  • noteToMidi("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 one Guzheng) 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 --write

See CONTRIBUTING.md before opening a pull request.

License

MIT © 2026 ericphamhoangdev