sinentropy — Language Manual
sinentropy is a tiny text-based language for additive synthesis. You type a short program describing one or more oscillators (with optional Csound-style envelopes), press Run, and it plays in the browser via the Web Audio API while a live spectral-entropy meter, spectrum display and waveform (oscilloscope) update in real time.
This manual documents every language feature, in the order you'll likely want to learn them, plus a full grammar reference at the end.
1. Quick start
Type a single oscillator and press Run (or the "Run" button):
sin(440, 0.5, 0)
This plays a 440 Hz sine wave at amplitude 0.5, phase 0, forever (until you press Stop) — because there's no envelope, the note simply sustains.
Add an envelope to make it start and stop on its own:
sin(440, 0.5, 0) * env(0, 0.05,1, 0.5,0)
env(0, 0.05,1, 0.5,0) is a Csound linseg-style envelope: start at 0,
ramp to 1 over 0.05s, then ramp to 0 over the next 0.5s. The note now has a
fast attack and a half-second decay, and stops automatically.
2. The oscillator: sin(...) / ~(...)
sin(freq, amp, phase[, pan])
- freq — frequency in Hz
- amp — amplitude (linear, not dB)
- phase — starting phase, in radians
- pan — stereo position, from
-1(hard left) through0(center, the default) to1(hard right). Optional — omit it and you get0.
sin can also be spelled ~. They are exactly the same token — two
spellings so you can pick whichever reads better in the moment:
~(440, 0.5, 0)
3. Envelopes: env(...) / ^(...)
env(v0, t1,v1, t2,v2, t3,v3, ...)
A linseg-style envelope: a starting value v0, followed by any number of
(time, value) pairs. Each time is a duration in seconds relative to the
previous point, not an absolute timestamp — so env(0, 0.05,1, 0.5,0)
means "start at 0, take 0.05s to reach 1, then take another 0.5s to reach
0" (ending at t = 0.55s total, not t = 0.5s).
env can also be spelled ^ — same token, two spellings:
sin(440, 0.5, 0) * ^(0, 0.05,1, 0.5,0)
The envelope is attached to an oscillator with *:
sin(freq, amp, phase[, pan]) * env(v0, t1,v1, ...)
The envelope is optional. A bare sin(...)/~(...) with no * env(...)
sustains at constant amplitude until you press Stop. This is useful for
drones, pads, or FM/AM modulators that just need to keep oscillating (see
§4) — they don't need their own envelope if the carrier's envelope is what
actually shapes the sound.
4. Signal-rate modulation (FM / AM / PM / pan)
Any of freq, amp, phase, or pan can be another sin(...)/~(...)
instead of a plain number. That nested oscillator becomes a true
audio-rate signal driving the parameter — not a slow LFO approximation,
but the same phase-accumulator engine recursively evaluated sample by
sample. This gives you real frequency modulation (FM), amplitude modulation
(AM), phase modulation (PM), and even pan modulation, and it nests to any
depth.
Frequency modulation with a slow modulator (5 Hz) sweeping ±50 Hz around a carrier, itself shaped by an envelope:
sin(220 + sin(5, 50, 0), 1, 0) * env(0, 0.05,1, 1.5,0)
The same thing with the short aliases, and no envelope on the modulator (it just needs to keep oscillating for as long as the carrier's envelope runs):
~(~(5, 50, 0), 1, 0) * ^(0, 0.05,1, 1.5,0)
Numbers and signals can be freely mixed with + - * /:
sin(220 + sin(5, 50, 0), 1, 0)
Because this is genuine sample-by-sample evaluation, nesting depth costs
CPU, not extra audio nodes — the whole modulation tree for one voice runs
inside a single AudioWorkletNode.
5. Summing terms
Every top-level oscillator term in your program is summed into the output.
The + between terms is entirely optional — it's there for readability
only:
sin(220, 0.2, 0)
sin(330, 0.2, 0)
sin(440, 0.2, 0)
is identical to:
sin(220, 0.2, 0) +
sin(330, 0.2, 0) +
sin(440, 0.2, 0)
6. Variables and arrays
let name = expr
let name = [a, b, c]
let binds a name to a number, an array, or any expression. Arrays are
indexed with arr[i], 0-based:
let freqs = [220, 277, 330, 415]
sin(freqs[0], 0.2, 0)
7. Random numbers: rand(...) / randi(...)
rand(min, max) # a float, uniformly distributed in [min, max]
randi(min, max) # a whole number, min and max both inclusive
Both draw a fresh number every time that line is actually evaluated —
they're not a value you compute once and reuse, they're a dice roll each
time. That's what makes them useful inside a for loop: every iteration
gets its own independent draw.
A single random frequency between 200 and 400 Hz:
sin(rand(200, 400), 0.4, 0) * env(0, 0.05,1, 0.5,0)
Ten short notes, each at a different random pitch:
for i in 1..10 {
sin(randi(200, 800), 0.15, 0) * env(0, 0.01,1, 0.2,0)
}
rand/randi work anywhere a number is allowed — inside sin(...),
inside env(...), inside an array literal, as a loop bound, mixed into
arithmetic:
let freqs = [rand(100,200), rand(200,300), rand(300,400)]
sin(freqs[0] + rand(-5,5), 0.3, 0)
randi(5, 5) (equal bounds) always returns 5; non-integer bounds are
rounded first, so randi(1.2, 4.8) draws a whole number from 1 to 5.
max must be >= min for both, and neither accepts an oscillator/signal
as an argument — only plain numbers, since the draw happens once, before
the sound starts, not sample-by-sample.
8. for loops
for x in a..b { ... } # integer range, inclusive of both ends
for x in array { ... } # loop over an array's elements
At the top level of a program, a for loop's body is a list of statements
(oscillators, more lets, nested fors, seq/overlap blocks, calls —
anything a top-level statement can be):
A detuned chord swept left→right by index, plus a harmonic series swept right→left, both built with loops:
let freqs = [220, 277, 330, 415]
for i in 0..3 {
sin(freqs[i], 0.2, 0, -1 + i*(2/3)) * env(0, 0.02,1, 0.4,0.5, 0.3,0)
}
for i in 1..8 {
sin(220*i, 1/i, i*0.3, 1 - (i-1)*(2/7)) * env(0, 0.01,1, 0.5,0.4, 0.3,0)
}
Note two limitations: a range a..b only counts upward (you can't
write 3..0 to count down — build a reversed array instead, see §11), and
there is no modulo (%) operator.
9. Named, reusable sounds: name(params)_{ ... }
name(p1, p2, ...)_{
...body...
}
Defines a reusable, parametric sound — pure substitution, no closures. A definition's parameters are just names that get replaced with whatever values the call passes:
gianni(f)_{
sin(f, 1, 0) * env(0, 0.05,1, 0.2,0)
}
gianni(220)
gianni(330)
Each call — gianni(220), gianni(330) — is one event: every
sin(...)/~(...) the body contains fires together, using that call's own
argument values.
Rules:
- Only definitions at the top level of the program are registered. A
name(...)_{ ... }written inside aforloop, or inside another definition's body, is silently ignored (never registered) — this is intentional: definitions are meant to be a fixed, top-level vocabulary of reusable sounds, not something generated dynamically. - Because registration happens as a pre-pass over the whole program, order doesn't matter — a definition can be called before it appears later in the file, and it may call itself or another definition.
- Recursion is capped at 64 nested calls, so a call cycle that never bottoms out fails loudly with an error instead of freezing the audio thread.
- A definition's body can itself contain a
seq/overlapblock (§10) — so calling that name schedules a whole nested pattern at once, not just a single event. - A definition's body can use
rand/randi(§7) — each call to that name gets its own fresh draw.
10. The sequencer: seq(hop) { ... } / overlap(hop) { ... }
This is the tool for anything that isn't "everything starts at t = 0" —
rhythms, arpeggios, granular textures. Every step inside a seq/overlap
block fires at its own sample-accurate scheduled onset, computed
entirely inside the audio engine (not with setTimeout or any other
JS-thread timer, which would drift).
seq(hop) {
step
step
...
}
seq and overlap are two spellings of the exact same construct — see
"Why two names?" below.
The hop
hop, in parentheses, controls the spacing between step onsets. It's
either:
- a single number — a constant number of seconds between onsets:
seq(0.2) { 220 277 330 } - an array of numbers — a rhythmic pattern. If there are more steps
than hop values, the hop array cycles:
seq([0.2, 0.2, 0.4]) { 220 277 330 415 440 494 }
Here step 1 uses hop 0.2, step 2 uses 0.2, step 3 uses 0.4, step 4
wraps back around to 0.2, and so on.
What a step can be
- A bare frequency number or arithmetic expression —
220,base*1.5,220 + i*10,randi(200,600). Frequencies only, never note names. - An array of frequencies —
[220, 277, 330]— fired together as a chord, one voice per frequency, each at reduced amplitude so the chord doesn't clip. - A full
sin(...)/~(...)term, envelope and all — you get complete control over that one step's sound. - A
name(...)call — every voice the named sound's body produces fires together at that step's onset. - A
forloop — see §11.
Implicit envelopes and the seq/overlap distinction
A bare number or array step automatically gets a short implicit attack/release envelope that lasts exactly one hop — so plain notes in a sequence don't click on and don't spill past their own slot by default.
A full sin(...)/~(...) or name(...) step, by contrast, keeps
whatever envelope duration it already carries (or none, if you wrote
none). If that duration is longer than the hop, the note simply rings on
into the next slot — and the next, if its envelope is longer still.
That's the entire difference between "seq" and "overlap": the exact same mechanism, with the outcome depending purely on how each step's own duration compares to the hop. Write short hops and short/implicit envelopes and you get ordinary rhythmic sequencing. Write a small hop with notes whose envelopes deliberately outlast it, and the same mechanism produces dense, granular, overlapping textures:
seq(0.2) {
220
277
330
[220, 277, 330]
}
overlap(0.03) {
sin(440, 0.3, 0) * env(0, 0.01,1, 0.15,0)
sin(550, 0.3, 0) * env(0, 0.01,1, 0.15,0)
sin(660, 0.3, 0) * env(0, 0.01,1, 0.15,0)
}
The second block's hop is 0.03s but each note's envelope lasts 0.16s, so five or six grains are always sounding at once — that's granular synthesis, built from nothing but the sequencer you already know.
Restrictions
- A definition (
name(...)_{ ... }) cannot be written inside aseq/overlapblock. Define your sounds at the top level, then call them as steps. - A
seq/overlapblock needs at least one step. seq/overlapcan itself appear inside aname(...)_{ ... }body — so calling that name schedules a whole nested pattern at once (§9).
11. Loops inside seq/overlap — arpeggios and repeated patterns
A for loop can appear inside a seq/overlap block, wrapped around
any step type. Each iteration of the loop generates one more step in the
same sequence — it does not start a separate timeline. The shared
onset clock, and the hop-array cycling index, both keep advancing across
every iteration exactly as if you'd written each iteration's steps out by
hand.
This is your numeric-loop module for arpeggios and repeated patterns. The simplest arpeggio — step through a chord's notes in order, using array indexing:
let freqs = [220, 277, 330, 415]
seq(0.15) {
for i in 0..3 {
freqs[i]
}
}
Repeat the arpeggio several times by nesting another for — nesting works
to any depth, and each nested level just keeps generating more steps in
the one sequence:
seq(0.15) {
for rep in 0..3 {
for i in 0..3 {
freqs[i]
}
}
}
This plays 4 full passes of the 4-note arpeggio — 16 steps total, all scheduled sample-accurately on one continuous clock.
Since ranges only count upward and there's no modulo operator (§8), an up-down (ping-pong) arpeggio is easiest to write as two arrays, one already reversed:
let up = [220, 277, 330, 415]
let down = [415, 330, 277, 220]
seq(0.15) {
for i in 0..3 { up[i] }
for i in 0..3 { down[i] }
}
A loop step can be anything a step can be — a bare frequency, a full
sin(...)/~(...) term with its own envelope, a name(...) call, or a
random draw:
blip(f)_{ sin(f, 1, 0) * env(0, 0.02,1, 0.05,0) }
seq(0.1) {
for i in 1..8 {
blip(110*i)
}
}
overlap(0.05) {
for i in 1..40 {
randi(150, 900)
}
}
— the first is an 8-partial arpeggio built entirely from one named sound and one loop; the second is 40 short random grains, a one-line granular cloud.
12. Comments
# this is a comment, to end of line
13. Running your program
The page has two independent editors, Track A and Track B, each with its
own Run and Stop button and its own status line. Pressing Run on
one track parses and plays only that track's code; it never stops or
restarts the other track. Both tracks' audio is simply mixed together into
the same output, so you can, for example, run a drone or pad in Track A
and layer a seq/overlap pattern on top in Track B, starting and
stopping each independently. Pressing Stop on a track silences only that
track's voices — instantly, unless you've declared a release time (below).
Fading out instead of cutting: let release = ...
By default, Stop (and pressing Run again while something's still playing) cuts whatever's currently sounding immediately — including a drone with no envelope, which would otherwise sustain forever. If you'd rather it faded out, declare a release time anywhere at the top level of your program:
let release = 0.4 # seconds
sin(220, 0.5, 0)
With release declared, Stop fades every currently-playing voice on that
track to silence over that many seconds instead of cutting it — this works
whether the voice is a drone or already partway through its own env(...),
since the fade is a separate multiplier on top of whatever the voice is
already doing. A voice that hasn't started playing yet when you press Stop
(still waiting its turn in a long seq/overlap) is simply dropped —
there's nothing audible to fade, so it never sounds at all rather than
playing anyway. Pressing Run again behaves the same way toward whatever
was playing before: the old voices fade out over their own release time
while the new ones start immediately at full volume, so pressing Run
repeatedly layers a trail of fading-out generations under each fresh one
rather than hard-cutting between them. Without let release, both Stop
and Run behave exactly as before (instant cut) — this is purely opt-in.
release is a plain top-level variable, not new grammar: it's read once,
right after your program is parsed, so it must be a plain number in
seconds. If you don't declare it, the release time is 0 (instant, today's
default).
While either track is running, the spectral-entropy meter, spectrum display and waveform display (§14) update live from the combined signal of both tracks.
Keyboard shortcuts
Typing one of these two-character sequences anywhere in EITHER track's code editor removes it and immediately fires that action on THAT track, as if you'd pressed its own button (it never affects the other track):
~~— Run, echoing the oscillator's~alias!!— Stop
14. Spectral entropy, spectrum and waveform
The number shown while a program plays is the Shannon entropy of the
normalized power spectrum of the summed signal (both tracks combined): the
Web Audio AnalyserNode's FFT is converted from dB to linear magnitude,
normalized so it sums to 1, then
H = -Σ p_i · log2(p_i)
normalized again by log2(bins) to a 0–1 range. It's a rough measure of
how spread across the spectrum vs. how concentrated on a few frequencies
the sound is — a single sine sits low (~0.19), a spray of 40 partials sits
high (~0.72).
It's a read-only readout, not something you dial in: you don't set an
entropy value, you build a program, and the number tells you where you
landed. Use it as feedback while you compose — stack more partials in a
for loop, add a modulator, detune a chord, and watch the number climb as
the sound moves from a pure tone toward a dense, noise-like texture; strip
things back down and watch it fall.
Next to the spectrum display is a live waveform (oscilloscope) view: the
same combined signal read as a time-domain snapshot
(getByteTimeDomainData) instead of a frequency-domain one, so it costs no
extra audio nodes — it's the same single AnalyserNode powering the
entropy meter and spectrum bars, just read a second way.
15. How it's built (for the curious)
Everything is computed sample-by-sample inside a custom AudioWorklet — a
phase-accumulator oscillator, evaluated recursively for nested modulators.
Frequency, amplitude, phase, and pan are all true audio-rate signals, not
AudioParam automation curves, which is what makes genuine through-zero
FM/AM/PM/pan modulation possible.
Rather than one AudioWorkletNode per voice, each track uses a single
persistent node — a "voice pool" — holding a plain JS array of live
Voice objects (Sine/Envelope/Voice/VoicePoolProcessor classes,
under src/worklet/). Pressing Run sends that track's newly-interpreted
voices to its pool node by message instead of creating new nodes, so a
3-step program and a 300-step program cost the same one node per track. A
voice not yet due to play costs O(1) per render block, not O(block size) —
so a program with thousands of far-future scheduled voices stays cheap
until each one's own turn actually arrives. A voice is dropped from the
pool's internal list the instant its own envelope ends (or its release
fade completes) — no lingering node to disconnect, no waiting on the audio
graph, just one object removed from an array. A seq/overlap step's
scheduled onset is still passed along as a sample-accurate startOffset,
so timing never depends on the JS thread.
rand/randi (§7) are resolved once, on the main thread, while your
program is being interpreted (compiled into the plain numbers the worklet
receives) — never inside the audio thread's per-sample loop, so they cost
nothing at playback time.
No build step, no dependencies: plain HTML/CSS/JS on the Web Audio API,
split into small, single-purpose files under src/ (language:
tokenizer/parser/compiler/interpreter; audio: engine/visualizer; worklet:
the four classes above) and loaded via plain <script> tags — no
bundler, just files in a folder.
16. Grammar reference
program := statement*
statement := letStmt | forStmt | oscExpr | defStmt | callStmt | seqStmt
letStmt := "let" IDENT "=" (arrayLit | expr)
-- "let release = <seconds>" at the top level is a reserved
convention (not separate grammar): it sets how long
Stop/Run fade out instead of cutting instantly (§13).
forStmt := "for" IDENT "in" (expr ".." expr | expr) "{" statement* "}"
oscExpr := SIN "(" expr "," expr "," expr ("," expr)? ")"
("*" ENV "(" expr ("," expr)+ ")")?
-- SIN is "sin" or "~"; ENV is "env" or "^" (same token, two
spellings each). sin(freq, amp, phase[, pan]); the
* env(...) part is OPTIONAL. pan defaults to 0.
freq/amp/phase/pan can each be a plain expression OR
another oscExpr, nested to any depth.
defStmt := IDENT "(" (IDENT ("," IDENT)*)? ")" "_" "{" statement* "}"
-- defines a reusable, parametric sound. Pure substitution
(no closures). Only registered at the top level of the
program. May call itself or another def (capped at 64
nested calls).
callStmt := IDENT "(" (expr ("," expr)*)? ")"
-- plays a previously defined sound; every osc(...) it
contains fires together as one event.
seqStmt := SEQ "(" expr ")" "{" seqStep* "}"
-- SEQ is "seq" or "overlap" (same token, two spellings).
The parenthesized expr is the hop: one number (constant
seconds between step onsets) or an array of numbers (a
rhythmic pattern, cycled if there are more steps than hop
values). Needs at least one step.
seqStep := oscExpr | callStmt | seqForStmt | expr
-- a bare expr must resolve to a frequency (number) or an
array of frequencies (a chord fired together). It gets an
implicit short attack/release envelope lasting exactly
one hop. A defStmt is not allowed inside a seq/overlap
block.
seqForStmt := "for" IDENT "in" (expr ".." expr | expr) "{" seqStep* "}"
-- generates a run of steps, one per loop iteration, each
still consuming its own onset (and its own place in a
cycled hop array) in the surrounding seq/overlap. Nests
to any depth.
expr := term (("+" | "-") term)*
term := factor (("*" | "/") factor)*
factor := "-" factor | primary
primary := primaryBase ("[" expr "]")*
primaryBase := NUMBER | IDENT | "(" expr ")" | arrayLit | oscExpr | randExpr
randExpr := ("rand"|"randi") "(" expr "," expr ")"
-- a fresh random number, drawn right when this line is
evaluated (so a rand(...) written inside a for-loop body
draws a new one every iteration). rand(min,max) is a
float uniformly in [min,max]; randi(min,max) is an
integer, both ends inclusive (rounded if min/max aren't
already whole numbers).
arrayLit := "[" (expr ("," expr)*)? "]"
arr[i] indexes into an array, 0-based. + between top-level statements
(and between seq/overlap steps) is optional and purely cosmetic — terms
sum automatically. There is no modulo (%) operator and no downward
(b..a) range.
Appendix: why two spellings for everything?
sin/~, env/^, and seq/overlap are each exactly one token with
two spellings — the parser treats them identically. This exists purely for
readability in the moment: ~ and ^ read fast and dense when you're
sketching or nesting modulators (~(~(5,50,0),1,0) * ^(0,0.05,1,1.5,0)),
while sin/env/seq/overlap read more clearly in longer, more
deliberate programs. Use whichever fits what you're writing — you can even
mix both spellings freely in the same program.