A small, dependency-free JavaScript GM synthesizer and Standard MIDI File sequencer. Sounds are generated with Web Audio oscillators and buffers; no sample downloads are needed. Quality 0 uses lightweight chiptune sounds, and quality 1 uses FM sounds with multiple operators.
pocket-synth is Provable Games' modified fork of webaudio-tinysynth by Tatsuya Shinyagaito (g200kg). It removes the GUI and custom element for compact offline/onchain NFT animation_url embedding. It retains the JavaScript API, CommonJS/AMD exports and window.WebAudioTinySynth. Fork additions include validated MIDI parsing, fractional tempo, loop boundaries, context lifecycle controls, seeded noise/reverb, custom waves and operator filters. NOTICE records attribution and modifications; LICENSE is Apache-2.0. Report bugs or ask for help in GitHub issues.
Copy webaudio-tinysynth.js or the minified build into your page. Each works as a single classic script. Upstream CDN copies and the webaudio-tinysynth npm package include the upstream GUI; use this fork's files for the behavior documented here.
<button id="play">Play a note</button>
<script src="webaudio-tinysynth.min.js"></script>
<script>
const synth = new WebAudioTinySynth({quality: 1, voices: 64, lazy: true});
document.getElementById("play").onclick = async () => {
try {
await synth.resume(); // start audio from a user gesture
synth.prewarm(); // build metallic noise before live notes
const t = synth.getAudioContext().currentTime;
synth.noteOn(0, 60, 100, t);
synth.noteOff(0, 60, t + 0.5);
} catch (error) {
console.error(error);
}
};
</script>CommonJS: const WebAudioTinySynth = require("./webaudio-tinysynth.js");.
After starting audio, load and play MIDI bytes:
synth.loadMIDI(midiArrayBuffer);
synth.setLoop(1);
synth.setLoopEnd(4 * 4 * 480); // four bars of 4/4 at 480 ticks/quarter
synth.playMIDI();
// Raw MIDI is also supported: synth.send([0x90, 60, 100]);
// Stop playback with synth.stopMIDI(); release resources with await synth.dispose().Serve the repository root, for example with python3 -m http.server 0, and open simple.html, soundedit.html (timbre editor/Web MIDI keyboard) or jstest.html. The demos use local assets and start audio on a click, tap or key press. They display audio and URL-load errors. The editor applies changes with setTimbre() and reinstalls them after a quality change; its Patch output is a JavaScript array literal, not JSON.
Modern Chrome, Edge, Firefox and Safari support the library. Browsers require a user gesture for realtime audio; Web MIDI availability and permissions vary. No network access is needed for synthesis or playback from supplied MIDI bytes. loadMIDIUrl() explicitly fetches the URL you provide.
SMF formats 0 and 1 with ticks-per-quarter division are supported; format 2 and SMPTE division are rejected. On an OfflineAudioContext, schedule notes with explicit timestamps; the realtime sequencer is unavailable. Noise/reverb buffers are reproducible for the same seed, bufferVersion and sample rate, but rendered PCM can differ between engines. Short notes release each operator from the attack level reached at note-off; zero-length notes retain the release of operators with no attack.
Settings are changed with the functions below (setMasterVol(), setReverbLev(), setQuality(), setLoop(), setVoices(), setTsMode()) or with constructor options. The instance also keeps them as plain properties:
| Property | Default | Description |
|---|---|---|
| masterVol | 0.5 | master volume |
| reverbLev | 0.3 | reverb level |
| useReverb | 1 | 0 disables reverb: no impulse buffer or convolver is created. Read when a context is installed; set it before setAudioContext() or lazy start. |
| quality | 1 | 0: 1osc/note chiptune like 1: 2 or more oscs/note FM based |
| loop | 0 | loop playMIDI |
| loopEnd | 0 | loop length in MIDI ticks; 0 = loop on the last event (see setLoopEnd()) |
| tsmode | 0 | default timestamp mode |
| voices | 64 | Max number of simultaneous voices. Large number needs more CPU. |
| seed | 0 | seed of the reverb and noise buffers (constructor option, read-only) |
| bufferVersion | 1 | version of the buffer generation (read-only). A library change that alters the generated buffers increments it. |
- Assigning
masterVol,reverbLevorqualitydirectly does not apply the change; callsetMasterVol(),setReverbLev()orsetQuality(). - The constructor creates an AudioContext unless
lazy: trueorcontextis supplied. UsesetAudioContext()to switch contexts. - The synth is ready as soon as the constructor returns (
isReadyis 1).ready()is kept for compatibility: it returns aPromisethat resolves once the synth is initialized. n1(the metallic noise of cymbals and hi-hats) is generated once per context installation, when it is first needed: byplayMIDI()(before it reads the clock), byprewarm(), or by the first note that plays it. Until then the install does not pay for it, and disposing or replacing the context first never generates it.- The buffers are generated with mulberry32. The seed is first mixed with murmur3's
fmix32, then each buffer gets its own stream, starting atfmix32(seed) + k·2^30(k = 0 reverb, 1n0, 2n1). So changing or skipping one buffer never changes another.
These functions are available on a WebAudioTinySynth instance. Volume, quality, voice-limit, loop-end and channel-message functions validate before changing state and throw TypeError or RangeError on invalid arguments. setLoop() and setTsMode() assign their values without validation; use the modes documented below. Numeric strings are accepted. Channels are 0–15; notes, velocities, controllers and GM programs are 0–127; bends and bend ranges are 0–16383; times are finite and ≥ 0. Volumes and reverb levels must also fit a 32-bit float. setVoices() requires an integer ≥ 1 and setLoopEnd() integer ticks ≥ 0. Malformed raw MIDI byte messages are ignored; invalid send() argument types or timestamps can throw.
Constructor options:
quality : Specify timbre quality same as setQuality(). default is
1.
useReverb : If zero, disable reverb function.
voices : max number of voices.
context : an AudioContext or OfflineAudioContext to use instead of creating one. It stays yours: the synth never closes it.
destination : withcontext, the AudioNode to play into. default iscontext.destination.
autoResume : boolean, defaulttrue. Withfalse, MIDI sends never resume a suspended or interrupted AudioContext, and the sequencer waits while its context is not running. Call publicresume()from the user gesture to start or resume audio.getAudioContext().suspend()pauses the audio clock, envelopes and scheduled voices without reloading the song; resume does not rebuild them. This option applies to eager, lazy and supplied contexts.
lazy : iftrue, no AudioContext is created untilresume(), or until the first call that plays a note or sets MIDI state:send(),noteOn(), the channelset...functions,reset(),loadMIDI(),locateMIDI()orplayMIDI(). Other calls do not create it, andgetAudioContext()returnsnulluntil then. Callresume()from the click that starts audio, and callloadMIDI()andreset()in that click handler or afterresume(): called earlier, they create the AudioContext outside the gesture (it then starts suspended untilresume()).
seed : an integer from0to4294967295that fixes the reverb impulse and the noise buffers (n0,n1). default is0(also fornull). The same seed,bufferVersionand sample rate give the same buffer data on every load and in every instance; the data differs between sample rates (the buffers are 0.5 s long).
On an OfflineAudioContext, schedule notes with explicit times;playMIDI()throws there. Every note scheduled before the render counts againstvoices, so callsetVoices()with at least the number of notes, or the earliest ones are dropped.For example,
new WebAudioTinySynth({quality:0, useReverb:0, voices:32})
An invalidcontext(seesetAudioContext()for what it needs),destination,lazyorautoResumethrows aTypeError, and an invalidseedaTypeError(not a number) or aRangeError(not an integer from 0 to 4294967295), before anything is created.
Get current in-use AudioContext.
Replaces the current context with
audioContext. It must have adestinationand the factories the synth uses (createGain,createDynamicsCompressor,createBuffer,createOscillator,createBufferSource,createBiquadFilter,createPeriodicWave, andcreateConvolverwhenuseReverbis on;createStereoPanneris optional); otherwise, or for adestinationNodewithoutconnect()or of another context, it throws aTypeErrorand the current context stays installed and open. Routes sounds todestinationNode, or toaudioContext.destinationif omitted. Get the installed context withgetAudioContext(). The previous context's sounds stop, and the synth closes it if it created it. CallstopMIDI()before switching contexts during playback: the sequencer keeps the old context's clock, as upstream, so playback on a new context waits until that context's clock catches up.
Generates the metallic noise
n1now (once per context installation) instead of at the firstn1note, so live notes start on time:await synth.resume(); synth.prewarm();before sending notes. It is synchronous, returnsundefined, plays nothing and neither creates nor resumes a context: it does nothing before a lazy synth has one, afterdispose(), or whenn1is already built.playMIDI()calls it itself, before it reads the clock. It removes this deferred generation only, not other first-use or scheduling costs.
Returns a
Promisethat resolves once the AudioContext is running. Browsers start audio only after a user gesture, so call it from a click, key or pointer handler. Withlazy, it creates the AudioContext first. It rejects with the browser's error, or with anErrorwhosecodeisAUDIO_CONTEXT_CLOSED(the context is closed) orSYNTH_DISPOSED. On an OfflineAudioContext it resolves at once.
button.onclick = () => synth.resume().then(() => synth.playMIDI(), (e) => console.error(e));
Releases the synth: stops every sound, disconnects its nodes, clears its timers, and closes the AudioContext if the synth created it (a context you passed in stays open). Returns a
Promisethat resolves when that is done; calling it again returns the samePromise. Afterwards the synth cannot be used again: functions that play or change MIDI state do nothing,getAudioContext()returnsnull,ready()resolves andresume()rejects.
Switch timbre set.
q=0 : chip tune like 1 osc / note.
q=1 : FM based 2 (or more) osc / note.
qis 0 or 1 (a numeric string is accepted). Reinstalls the built-in timbres, so custom timbres must be set again afterwards.
Master volume setting. default=0.5.
Reverb Level setting. default=0.3.
set max voices that simultaneous sounds, default is 64.
Set timestamp mode used by
send()and channel-message functions. Use0or1; the value is assigned without validation.
Ifmode=0timestamp is a time of in-use audioContext's currentTime timeline. Ifmode=1timestamp is HighResolutionTime timeline.
Withmode=1, a time that converts to before the context's current time (for example aperformance.now()value taken before the context was created) is processed at once.
get name of specified timbre. m=0:normal channel voice,n=prog#. or m=1:drum track,n=note#
n is 0–127 for programs and 35–81 for drums; other values throw aRangeError. A program slot above 127 that a caller added itself andsetProgram()accepts (seesetProgram) is also named.
Registers a single-cycle sample table under
name(nfollowed by a letter or_, then up to 30 letters, digits or_;n0,n1and other names with a digit second are reserved).samplesis an array or typed array of one or more numbers in −1..1, copied. Each sample is held fork = max(1, round(sampleRate/(440·N)))frames, so the wave plays near its stored rate with sharp steps, and the table's home pitch issampleRate/(N·k): an operator's frequency (note × t + f) is the frequency of one whole cycle, for playback, the pitch envelope and FM. Steps are exact at the home pitch; at other pitches the browser resamples, so edges are interpolated (Chromium and Safari linearly, Firefox band-limited). Long tables (for example a 32,767-step LFSR) are accepted; at a literaltthey play at very high rates, which Firefox renders slowly, so sett/ffor the cycle rate you want. Registering a name again replaces it for later notes. A one-sample table plays as a constant (DC). ThrowsTypeErrorfor an invalid name or a non-array,RangeErrorfor other invalid data.
Registers a periodic wave under
name(wfollowed by a letter or_, then up to 30 letters, digits or_;w9999and other names with a digit second are reserved), built withcreatePeriodicWave(real, imag): elementiis harmonici(cosine inreal, sine inimag), element 0 (DC) is ignored, and the peak is normalized to 1.realandimagare arrays or typed arrays of equal length, at least 2, of numbers that are finite as 32-bit floats; they are copied. Only the coefficients' ratios matter: very large or very small sets are rescaled before use.
Even webaudio-tinysynth has defaultly GM mapped timbre set, This function can overwrite with user-definable timbre.
m=0: timbre for normal channel.
m=1: timbre for rhythm channel (ch=9).
n: program number for normal channel or notenumber for rhythm channel.
p: timbre, a non-empty array of operator objects (see Timbre Object Structure); soundedit.html can create one.
setTimbrechecks every operator before changing anything.wmust be a built-in waveform (sine,square,sawtooth,triangle,w9999,n0,n1) or a registered name.gmust be 0 (output),n(FM into operatorn−1,n≤ 10) or10+n(AM into operatorn−1), where operatorn−1comes earlier in the array.a,h,d,randqmust be ≥ 0 and finite as 32-bit floats (up to about 3.4e38);t,f,v,s,pandkfinite; filter fields as described there. Numeric strings are read withNumber(), and missing fields take their defaults.pis copied: unknown keys are kept and ignored, and later changes to your objects do not reach the synth. An invalidp, anmother than 0 or 1, or annout of range (0–127 form=0, 35–81 form=1) throws aTypeErrororRangeErrorand changes nothing. A note whose computed pitch, pitch-envelope target, level or sustain level is outside the 32-bit float range is not played. For compatibility, a name that code has written into the synth's internalnoiseBuforwaveobjects is still accepted and plays as before. This is unsupported: those objects are rebuilt whenever the AudioContext changes, and they do not exist before a lazy synth's first use. Register waves withsetSampleWave()orsetHarmonicWave()instead.
Use
0to play once or1to loop MIDI playback. The value is assigned without validation; playback tests its truthiness.
Where a looped song wraps, in MIDI ticks from the start of the song (the same unit as
getPlayStatus().maxTick). Only matters whilesetLoop()is on.
0(default): the next pass starts on the song's last event, as upstream does. Any rest after that event is dropped.- Non-zero: each pass starts
max(ticks, tick of the last event)ticks after the previous one. A value below the last event's tick never cuts the song short.For whole-bar looping, use
bars × quarter notes per bar × ppq, where ppq is the file's ticks per quarter note. For example, 4 bars of 4/4 at ppq 480 issetLoopEnd(7680).setLoopEnd(synth.getPlayStatus().maxTick)loops at the file's end-of-track marker.Timing: the rest after the last event is timed at the tempo in effect at the end of the song. The next pass then starts again at the song's starting tempo (120 BPM until its first tempo event), so a rest before the first event, and any music before the first tempo event, keep their length on every pass. The first pass keeps that rest too when it starts from tick 0 (after loadMIDI(), locateMIDI(0), or on a finished song).
load MIDI data to built-in sequencer. mididata is a arraybuffer of SMF (.mid file contents). A successful load stops the current song as
stopMIDI()does, so none of its scheduled notes, drum hits or channel volume, pan and modulation changes reach the new song.
Parsing uses bounded reads. Errors preserve the previous song, playback and channel state, and includecode, byteoffsetand, inside a track, 0-basedtrack:
Code Reason SMF_INVALID_HEADERInvalid MThdheaderSMF_UNSUPPORTED_FORMATFormat 2 or an unknown format SMF_UNSUPPORTED_DIVISIONSMPTE or zero time division SMF_TRUNCATEDIncomplete header, chunk or event, or fewer tracks than declared SMF_MALFORMEDInvalid event data This is not a strict validator: tracks ending on an event boundary without End-of-Track, bytes after End-of-Track, and zero declared tracks still load. Use
try/catchfor untrusted files.
Loads a Standard MIDI File from
urland returns a promise that resolves with the response'sArrayBufferonce the song is installed. It rejects, leaving the current song, with anErrorwhosecodeisHTTP_STATUS(withstatus; statuses 200–299 succeed),NETWORK_ERROR,LOAD_SUPERSEDED(a newerloadMIDIUrl()or a directloadMIDI()came first),SYNTH_DISPOSED, or anSMF_*code fromloadMIDI(); withopts.signal's reason when thatAbortSignalaborts (anAbortErrorif the reason is missing or falsy); or with aTypeErrorfor a missingurlor anopts.signalthat is not anAbortSignal(for example anAbortControllerpassed instead of its.signal). It never throws (anoptswhosesignalcannot be read rejects with that error). Fire and forget is safe:synth.loadMIDIUrl("song.mid")leaves no unhandled rejection. To show errors:synth.loadMIDIUrl(url).then(() => synth.playMIDI(), (e) => show(e.code || e.name)).
play loaded MIDI data. On a finished song (every event sent, even while its last notes still ring), starts again from the beginning at the song's initial tempo and channel state. Does nothing for a song with no playable events. A looping song that cannot advance (all events on one tick with
loopEndunset) plays once and stops. Each timer callback processes at most 1000 events; the rest follow in order. Replay does not cut notes still sounding from the previous pass; those ringing tails share their channels with the new pass, so they take on its channel reset and the new pass's volume, pan and modulation changes. The firstplayMIDI()on each context installation first generates the metallic noise (seeprewarm()) and only then reads the clock, so the song starts slightly later and nothing within it shifts.
stop playing MIDI data. Every sounding or scheduled note and drum hit stops, and channel volume, pan and modulation changes scheduled for later are cancelled, so nothing changes after the stop. When
playMIDI()resumes, it first gives every channel its latest volume, expression, pan and modulation: the values the song had set up to the resume position (including changes it had sent ahead of the stop) and your own, including a change you had scheduled for later with a time. Notes that were due in the 0.2 s after the stop are not replayed, as upstream.
locate current playing position in tick. Playback resumes at the first event at or after
tick. Rebuilds tempo, programs, controllers, bend/range, RPN and SysEx tuning from the song up totick, replacing manual and queued channel changes. Engine settings and timbres are kept. Does nothing without a loaded song.
get current MIDI sequence play status. return value is a object
{play:playstatus, maxTick:maxtick, curTick:currenttick, startTime:time}.startTimeis new in this fork.startTimeis the AudioContext time at which tick 0 of the current pass sounds, ornullwhen not playing. WithloopEndset, an event at tick T of any pass sounds atstartTimeplus the time from tick 0 to T under the song's tempo map (120 BPM until its first tempo event). WithloopEndunset this holds only in the first pass: a later pass keeps the tempo the previous one ended on until its first tempo event (as upstream), sostartTimeis extrapolated at that tempo. UseloopEndfor exact sync. WithloopEndset and play from tick 0, tick 0 sounds 0.1 s afterplayMIDI(). Otherwise (loopEndunset, after a seek or a resume) the next event plays 0.1 s afterplayMIDI(), andstartTimeis when tick 0 would have sounded, which can be in the past. LikecurTick, it moves to the next pass when the sequencer reaches the end of the current one, up to 0.2 s before the last event sounds and before any rest up toloopEnd, so it can be later thancurrentTime: while it is, the previous pass is still sounding. To start visuals with the music:const st = synth.getPlayStatus().startTime; setTimeout(start, (st - ctx.currentTime + (ctx.outputLatency || 0)) * 1000);
Reset all channel to initial state. Including all controllers, program, chVol, pan and bendRange.
midi-message is an array of midi data-bytes for one message. For example,
send([0x90, 60, 100], t)is for NoteOn ch=1 note#=60 velocity=100.
tis a timestamp that this message should be processed.
The timeline oftis depends on timestampmode that is set by setTsMode() function. If timestampmode == 0 (default),tis a time (sec) in timeline of the in use audioContext.currentTime. If timestampmode == 1,tis a time (msec) in HighResolutionTime (performance.now()) timeline.
In both timestamp mode, this message will be immediately processed ift=0 or omitted. If timestampmode is omitted, the mode depends on thetsmodeproperty (default 0, seesetTsMode()).
A message that is too short for its status byte (an array-like message withlength0 included), or has a data byte that is not 0–127, is ignored. Invalid argument types or timestamps can throwTypeErrororRangeError.tmust be omitted, 0 or a finite time ≥ 0.
Followings are voicing functions that controls each note directly. Each function is almost equivalent to corresponding send([MIDI-message],t) but prepared for human readability.
In these functions, the ch parameter specify the MIDI channel. Each channel has individual timbre and set of control parameters, for example bend, modulation, expression, and so on. ch range is 0-15. (In MIDI spec., called 'channel 1-16')
ch==9 is a special channel for rhythm. In this channel, each note number is assigned to individual percussive instruments according to GM drummap (Note number 35-81).
Almost function has the timestamp, t parameter. That specify accurate timing of the effect occur. Refer send() function for details of timestamp. Anyway the command is immediately processed if t is 0 or omitted.
Generate a note in specified channel.
noteis the note number that specify pitch.60is middle 'C'.velois velocity that control the volume of the note. velocity range is 0-127. this function is processed same as noteOff() ifvelois0.
stop the note that is generated by noteOn(). One noteOff() (or equivalent noteOn with velo=0) should be called corresponding to one noteOn() call.
set modulation (vibrato) depth.
valrange is 0-127. +- 100 cent depth if val=127.
set volume of the channel.
valrange is 0-127. Default value is 100.
Set pan of the channel.
valrange is 0-127. Default value=64.
0:left
64:center
127:right.
Set expression level.
valrange is 0-127. Both Expression and ChVol are control the Channel volume, but Expression is mainly used as note's articulation.
Set sustain pedal state. While sustain is on, generated notes in that channel are sustained even corresponding noteOff() is called. Note that
valis judged as 'on' ifval>=64. Usually use0and127as a value.
Set timbre for that channel.
pgrange is 0-127 that is timbre number in GM map, or a slot you added tosynth.programyourself (an unsupported compatibility path, as TinyChip uses); a program that does not exist throws aRangeErrorand changes nothing. A MIDI program change throughsend()is 0–127.
Set pitch bend state. Notes in this channel are all affected to this pitch modification.
valrange is 0 to 16383 and the center with no bend is 8192. sensitivity is depends onsetBendRange()setting. Default state is8192.
Set bend sensitivity for that channel.
valunit is 100/127 cent. That means +-1 octave if 0x600, +-1 semitone if 0x80. Default value is 0x100 that means +-200 cent (2 semitone) range.
Immediately stop current and scheduled voices on channel
ch, zeroing their gain and bypassing release envelopes. Existing effect tails may continue.
Control parameters of specified ch are reset. It includes Bend / Modulation / Expression / Sustain.
| Recognized | Description | |
|---|---|---|
| Basic Channel | Yes | 1-16. ch10 = drum track |
| NoteOn / NoteOff | Yes | note# 0-127 / velocity 0-127 |
| Polyphonic Pressure | No | |
| Control Change | Yes | see below |
| Program Change | Yes | program 0-127 |
| Channel Pressure | No | |
| Pitch Bend | Yes | -8192 - +8191 |
| ------------------------ | ---------- | -------------------------------- |
| Control Number | ||
| 1 | Yes | Modulation |
| 6 / 38 | Yes | Data Entry |
| 7 | Yes | Channel Volume |
| 10 | Yes | Pan |
| 11 | Yes | Expression |
| 64 | Yes | Sustain |
| 100/101 | Yes | RPN Index |
| ------------------------ | ---------- | -------------------------------- |
| Channel Mode Message | ||
| 120 | Yes | all sound off |
| 121 | Yes | reset all controller |
| 123 | Yes | all note off |
| ------------------------ | ---------- | -------------------------------- |
| RPN | ||
| 0 | Yes | Bend Range |
| 1 | Yes | Channel Fine Tuning |
| 2 | Yes | Channel Coarse Tuning |
| ------------------------ | ---------- | -------------------------------- |
| Universal SysEx | ||
| F0 F7 xx 04 03 lsb msb F7 | Yes | Master Fine Tuning |
| F0 F7 xx 04 04 00 msb F7 | Yes | Master Coarse Tuning |
| ------------------------ | ---------- | -------------------------------- |
| GS Exclusive | ||
| F0 41 xx 42 12 40 00 05 xx xx xx xx sum F7 | Yes | Master Tuning |
| F0 41 xx 42 12 40 00 05 xx sum F7 | Yes | Master Transpose |
| F0 41 xx 42 12 40 1x 15 xx sum F7 | Yes | Use For Rhythm Part |
| F0 41 xx 42 12 40 1x 4x xx sum F7 | Yes | Scale Tuning |
| ------------------------ | ---------- | -------------------------------- |
As you can see that in source code, each timbre is represented as a object array. Fore example the program# 1 "Acoustic Grand Piano" is like this :
[{w:"sine",v:.4,d:0.7,r:0.1,},{w:"triangle",v:3,d:0.7,s:0.1,g:1,a:0.01,k:-1.2}]
Each element of the array means a oscillator and object member means :
- g: output destination 0=final output / n=FM to specified osc
- w: waveform
"sine"/"sawtooth"/"square"/"triangle" (basic waveforms)
"w9999" (summing 1-4 harmonics)
"n0" (white noise)
"n1" (metalic noise)
"n…" (a wave registered withsetSampleWave)
"w…" (a wave registered withsetHarmonicWave) - v: volume
- t: tune factor according to note#
- f: fixed frequency in Hz
- a: attack time
- h: hold time
- d: decay time
- s: sustain level
- r: release time (5 params make AHDSR envelope)
- p: pitch bend
- q: pitch bend speed factor
- k: volume key tracking factor
- fl: optional fixed filter on this operator's output,
"lowpass","highpass"or"bandpass". Only on an operator withg:0. Withoutflthere is no filter. - ff: cutoff (low- and high-pass) or centre (band-pass) frequency, a normal positive 32-bit float (at least 2^-126): Hz, or with
fk:1a multiple of the note's frequency (including master, channel and scale tuning; not the operator'storf, bend or pitch envelope). Required withfl. Clamped to 0.45 × the sample rate. - fq: resonance as a linear Q, a normal positive 32-bit float (at least 2^-126), default 0.7071 (
Math.SQRT1_2). Low- and high-pass are given20·log10(fq)dB, band-passfq. - fk: 1 to track the note frequency, 0 (default) for fixed Hz.
Like the other numeric fields, ff, fq and fk also accept a numeric string such as "1000", read with Number().
The filter is fixed for the life of the note, and is released with the voice. Example, a hi-hat through a 3 kHz high-pass: [{w:"n1",t:0,f:440,v:0.3,d:0.04,fl:"highpass",ff:3000}].
Use the Timbre Editor in soundedit.html to audition parameters and generate a setTimbre() array.
Use the Node version in .nvmrc and npm ci to install locked development tools. The runtime library has no dependencies. Test runners use POSIX process groups and run on Linux/macOS.
| Command | Purpose |
|---|---|
npm run lint |
ESLint recommended rules |
npm run build -- .build |
Fresh minified file and map in an ignored directory |
npm test |
Unit, native Node and regression suites |
npm run test:unit / npm run test:node / npm run test:regression |
Individual suites |
npm run test:browser |
Offline Chromium smoke test of source and fresh minified builds |
npm run test:browser:matrix |
Chromium, Firefox and WebKit assertion matrix |
npm run test:browser:demos |
Demo interactions; select an engine with -- --engines=chromium |
npm run size |
Raw/gzip sizes and SHA-256 of source and fresh builds |
npm run bench -- node / npm run bench -- browser |
Initialization benchmarks; see scripts/bench-init.js for options |
npm run pack:check |
Verify package contents and install/require a freshly built tarball |
npm run verify |
Check committed distribution bytes against a fresh build and inline-safety rules |
Tests, size and pack:check build current source into ignored .build/ and leave tracked distributions unchanged. To run a test file directly, refresh .build/ with node scripts/test-build.js; TINYSYNTH_MIN selects another minified file. Regressions read upstream commit 3d75aee4b3f43cbd932265e7d60201fd5b770397 from full Git history, or from TINYSYNTH_REFERENCE. MIDI fixtures originate from jazz-soft/test-midi-files.
For browser checks, install the pinned Playwright browsers (npx playwright-core install --with-deps; Chromium smoke needs only --only-shell chromium). CI runs lint, build checks, tests, browser smoke and the sharded browser matrix. The smoke test allows autoplay and does not verify the user-gesture requirement.
Ordinary browser CI accepts only the narrowly approved exceptions in tests/browser/lib/known-failures.js, tracked in #96 and #97. Local defaults remain strict. Original failed assertions, tolerances and first-attempt artifacts remain preserved; clean diagnostic rerenders do not clear a failed first attempt. Unknown failures remain blocking. The manual full-mix-qualification workflow lane remains strict and does not use these exceptions; incomplete references or failed first captures prevent qualification. Native macOS runtime behavior remains unverified.
Agent work commands, repository constraints and AI-review configuration are in AGENTS.md.
Feature PRs never commit webaudio-tinysynth.min.js or its map. After each merge into improve/integration, Dist CI builds, tests and commits both generated files. On main, distributions change only through a manual release PR; an integration-to-main PR can serve as the release PR when its version and generated files are current. Between releases they may lag source. The PR build check permits distribution changes only from improve/integration or release/*, and requires fresh build bytes.
To verify an embedded copy, check out a release tag or CI rebuild commit with full history and LF line endings, run npm ci and npm run verify, then compare your copy's SHA-256. Verification fails when committed distributions lag source, including the interval before integration's rebuild lands. The build keeps the license header, public names and ASCII-only inline-safe output (no </script, <script or <!--); the map is separate, with no sourceMappingURL comment.
Releases are manual:
- Use the integration-to-main PR if it already contains the release version and fresh generated files. Otherwise, create
release/vX.Y.Zfrommain, set the version withnpm version X.Y.Z --no-git-tag-version, runnpm run build, commit the generated files, and open a PR intomain. - After the release PR passes checks and the maintainer merges it, verify the clean merge with
npm ciandnpm run verify. The maintainer signs and pushes the tag:git tag -s vX.Y.Z <merge-sha>andgit push origin vX.Y.Z. - From the tag, the maintainer publishes with
gh release create vX.Y.Z webaudio-tinysynth.min.js webaudio-tinysynth.min.js.map, including sizes and SHA-256 fromnpm run sizein the notes. Focused listening is part of normal release checks.