scscm is SuperCollider in s-expressions: (SinOsc.ar 440) for
SinOsc.ar(440), (fn (freq 440) ...) for { |freq=440| ... }. tur-scscm
compiles it to sclang, SuperCollider's language. That is all it does: neither
scsynth nor hypercollider's hcsynth reads sclang, so the output always
runs through an sclang interpreter, which builds the SynthDefs and sends the
server its OSC.
scscm --[tur-scscm]--> sclang --[sclang | hclang]--> OSC, SynthDefs --> scsynth | hcsynth --> audio
Every command below was run on Ubuntu 24.04 with tur v0.63.9, SuperCollider 3.13.0 and hypercollider built from source; where a step was not run, the text says so.
In your project's build.tur:
:spices #{
"scscm" #{:url "https://github.com/turmeric-lang/turmeric-spices"
:ref "main"
:subdir "spices/scscm"}
}
Then tur fetch. Pure Turmeric plus inline C; no system libraries.
scscm/compile has two functions. Both return the lexer's ok/err box, not a
result:
(compile-text source :cstr) :ptr<void> ;; lex-ok(sclang) or lex-err(message)
(compile-file path :cstr) :ptr<void>
Test it with lex-ok?; the value is a :cstr cast to :int, read back with
scscm-int-as-cstr. Both need #fx{Unsafe} in the caller, and printing adds
IO:
(defmodule demo
(import scscm/compile :refer [compile-text])
(import scscm/lexer :refer [lex-ok? lex-ok-val lex-err-val scscm-int-as-cstr])
(defn main [] #fx{Unsafe IO} : int
(let [r (compile-text "(defn double (x) (* x 2)) (double 21)")]
(if (lex-ok? r)
(do (println (scscm-int-as-cstr (lex-ok-val r))) 0)
(do (println (scscm-int-as-cstr (lex-err-val r))) 1)))))
prints
var double = { |x| x * 2 };
double.(21);
An error is a message with a position: unclosed '(' opened at line 2 col 3,
unexpected ')' at line 1 col 6, unterminated string literal at line 1 col 4, cannot read x.scscm: No such file or directory.
The spice ships that as a command, examples/scscmc.tur. From the spice
directory (note the --: tur run passes nothing to the program without it):
tur run examples/scscmc.tur -- examples/beep.scscm > beep.scd
examples/beep.scscm is the program the rest of this guide runs:
(defsynth beep (freq 440 amp 0.2)
(Out.ar 0 (* (SinOsc.ar freq 0) (perc 0.01 0.99) amp)))
(defn play-beep (freq)
(Synth "beep" (dict :freq freq)))
(play-beep 440)
(in 0.5 (play-beep 660))
and beep.scd is
var play_beep;
SynthDef("beep", { |freq=440, amp=0.2| Out.ar(0, SinOsc.ar(freq, 0) * EnvGen.kr(Env.perc(0.01, 0.99), doneAction: 2) * amp) }).add;
play_beep = { |freq| Synth("beep", [\freq, freq]) };
play_beep.(440);
TempoClock.default.sched(0.5, { play_beep.(660) });
The language is hypercollider's scscm. Its reference is
docs/scscm/SCSCM_LANGUAGE_REFERENCE.md
in hypercollider, and its compiler is cli/lhc.js. The spice's
README has the
one-table summary. Two habits from other Lisps carry over: parameter and
binding lists may be written (a b) or [a b], and comments are ;.
The spice does not reproduce lhc.js's text. Most of lhc.js's output
does not parse in SuperCollider: run through stock sclang 3.13, 19 of
hypercollider's 21 example programs fail, against none of the 19 that are
valid scscm when compiled by the spice
(tests/corpus/README.md has
the per-program list). Where the two differ, the spice follows what the
reference documents the form means and emits sclang that says it:
| Form | lhc.js | tur-scscm | Why |
|---|---|---|---|
(defn f (x) ...), (var x 1) |
f = {...}, var x = 1 wherever it falls |
var declarations first; a later one is hoisted to a leading var x; |
sclang accepts var only at the start of a block, and f is undeclared |
(f 1), f a variable |
f(1) |
f.(1) |
f(1) is the message 1.f |
(* a b c) |
*(a, b, c) |
a * b * c |
not sclang |
(* a (+ b c)) |
a * b + c |
a * (b + c) |
sclang has no precedence: left to right |
(do a b) |
{ a; b }() |
a; b, or (a; b) in an expression |
{ }() is not a call |
(fn (a . rest) ...) |
{ |a, ...rest| } |
{ |a ...rest| } |
the comma does not parse |
(dict :doneAction 2) as a last argument |
(doneAction: 2), positionally |
doneAction: 2, a keyword argument |
positionally it is EnvGen's levelScale |
(Synth "x" (dict :freq 440)) |
Synth("x", (freq: 440)) |
Synth("x", [\freq, 440]) |
an Event gives Synth no controls |
(pbind :dur 0.25) |
Pbind.new([\dur, 0.25]) |
Pbind(\dur, 0.25) |
the former throws at runtime |
(defsynth n ...) |
SynthDef(...) |
SynthDef(...).add |
as the reference documents it |
'foo, '(1 2) |
'foo, '1(2) |
\foo, #[1, 2] |
|
+5, (- x) |
+5, -(x) |
5, x.neg |
|
| generated names | __args |
scscm_args |
a leading _ is primitive syntax |
Names map as in the reference: foo-bar -> foo_bar, done? -> done_q,
set! -> set, :cut-off -> \cut_off. A (dict ...) that is not a
call's last argument is an Event, (freq: 440).
defmacro is a template, not hypercollider's macro evaluator: parameters are
replaced by the argument forms, then ` / ~ / ~@ apply. Macros are
per program.
sudo apt-get install supercollider-server supercollider-language gives 3.13.0.supercollider Homebrew cask. Not verified here.sclang without a display needs QT_QPA_PLATFORM=offscreen, and as root
also QTWEBENGINE_DISABLE_SANDBOX=1 (QtWebEngine refuses to start
otherwise):
printf '"hi".postln;\n0.exit;\n' > hi.scd
QT_QPA_PLATFORM=offscreen sclang hi.scd # ... prints hi
Boot a server, then run the compiled program:
// play.scd
s.waitForBoot { thisProcess.interpreter.executeFile("beep.scd".resolveRelative) };
sclang play.scd
Run this way, beep.scd's first note fails: *** ERROR: SynthDef beep not found. SynthDef(...).add is asynchronous, so the Synth sent right
behind it reaches the server before the definition has loaded. (The offline
renders below never show this, because they apply every message in order.)
Wait for the server between adding and playing. In scscm that is (. s sync) inside a (routine ...); s is sclang's default server. That is
examples/beep-live.scscm:
(routine
(. s sync) ; wait until the server has beep
(play-beep 440)
(wait 0.5)
(play-beep 660))
which compiles to
Routine({ s.sync; play_beep.(440); 0.5.wait; play_beep.(660) }).play;
and plays both notes live. The machine these were run on has no audio
device: jackd -d dummy -r 48000 stood in for one, and s.record captured
what the server played.
tests/host/capture.scd runs a program in sclang against a stand-in for the
server's address: SynthDef.add, Synth, Pbind.play and
TempoClock-scheduled code all run as they would live, for as many seconds of
real time as you ask, and every message is recorded with its time. It writes
those as a score for scsynth -N:
QT_QPA_PLATFORM=offscreen sclang spices/scscm/tests/host/capture.scd beep.scd beep.osc 2.0
scsynth -N beep.osc _ beep.wav 48000 WAV int16 -o 2
python3 spices/scscm/tests/host/wavcheck.py beep.wav \
pitch=440@0.05-0.45 pitch=660@1.05-1.45 silent@1.6-1.9
captured 3 messages
ok pitch=440@0.05-0.45: pitch 440.0 Hz, want 440
ok pitch=660@1.05-1.45: pitch 660.0 Hz, want 660
ok silent@1.6-1.9: rms 0.0000, want < 0.001
The score uses the SynthDefs this sclang writes, so use the sclang that matches the scsynth.
hypercollider is a SuperCollider fork (3.15-dev) compiled to WebAssembly:
hclang is its sclang, hcsynth its server.
No hypercollider release boots today: v0.1.5 and v0.1.6 stop with class library compilation FAILED, and their --classlib-dir does not get around
it (the embedded pack still shadows the files). The fixes are on the
claude/scscm-host-testing-plan-8s8b5c branch of rjungemann/hypercollider
(hypercollider PRs #1
and #2; the commit is
in spices/scscm/tests/host/HOSTS). Built with emsdk 5.0.5, the version
hypercollider's native CI uses:
git clone https://github.com/emscripten-core/emsdk.git && cd emsdk
./emsdk install 5.0.5 && ./emsdk activate 5.0.5 && source ./emsdk_env.sh
cd ../hypercollider
(cd cli && npm install) # the build runs hclang once, under node
cmake --preset wasm_release
cmake --build build/wasm --target hclang hcsynth -j"$(nproc)"
The same branch also builds hclang_native, a single native binary that
runs the WASI build of hclang under WAMR, with no Node:
cmake --preset wasi_release
cmake --build build/wasi --target hclang hcsynth -j"$(nproc)"
cmake -B build/native -S native -DCMAKE_BUILD_TYPE=Release -DHC_EMBED_WASM=ON
cmake --build build/native --target hclang_native -j"$(nproc)"
build/native/hclang_host/hclang_native --script hello.scd
The host tests below use the Node hclang, whose --output capture
hcsynth renders.
Run hclang from the checkout's root (it finds build/wasm/ from the
working directory), and pass --verbose to see what the program posts:
node cli/hclang.js --verbose --no-zeroconf --script hello.scd --output hello.json
hclang --output writes the OSC the program sent while it was evaluated;
hcsynth --commands renders it:
node cli/hclang.js --no-zeroconf --script beep.scd --output beep.json
node cli/hcsynth.js --commands beep.json --output beep.wav --duration 2 --block-size 64
✓ Wrote 2 OSC packets to beep.json
[OK] Rendered 2 packets to beep.wav
Two packets: the SynthDef and the first beep. The capture has no clock
(beep-live.scscm captures only its SynthDef, since everything it plays is
inside a routine):
hclang exits after evaluating the script, so nothing scheduled -- the
second beep, a Pbind's events, (in ...), (at ...) -- is ever sent. Use
it for programs that make their sound immediately; render the rest on
scsynth.
Pass --block-size 64. hcsynth's default block is 512 samples, which also
sets its control rate: EnvGen.kr then steps at 94 Hz instead of scsynth's
750 Hz, and the first 100 ms of an envelope differ from scsynth's by 7%.
hclang --scsynth-host 127.0.0.1 --scsynth-port 57110 sends the OSC to a
running server instead. Before hypercollider #2 it sent nothing: hclang
closed its socket while the packets were still queued. With #2 they arrive.
Against stock SuperCollider 3.13 that still does not play anything (verified
with a live scsynth): hypercollider's sclang writes version-3 SynthDefs
(SynthDef.writeDef defaults to version=3), and scsynth 3.13 accepts the
/d_recv but does not load the definition, so every /s_new fails with
SynthDef not found, even a second later. hcsynth's own server mode (node cli/hcsynth.js --server) is the matching server; not verified.
hclang --script prog.scscm (or --lang scscm) compiles the program with
hypercollider's own lhc.js, not with this spice, so it inherits the
differences in section 3. --scscm-debug writes what
lhc.js produced to prog.scscm.compiled.sc. To run the spice's output,
compile first and pass the .scd.
Three layers, cheapest first, all in spices/scscm/tests/:
| Layer | Command | Needs | Checks |
|---|---|---|---|
| Compile | tur test tests |
tur | compile_test.tur (50 cases), and corpus_test.tur: hypercollider's 21 programs against corpus/*.expected.scd, byte for byte |
| sclang | tests/corpus/check-sclang.sh |
sclang |
every expected file parses (two invalid inputs must not) |
| Audio | tests/host/render.sh |
sclang + scsynth, and/or HC_DIR |
renders host/PROGRAMS and checks pitch, level and silence; on both hosts, the WAVs must agree |
render.sh prints SKIP: no scsynth / SKIP: no hcsynth for a missing host
rather than failing. With both:
HC_DIR=/path/to/hypercollider spices/scscm/tests/host/render.sh
ok example_quick_start_1 scsynth: pitch=440@0.2-0.9: pitch 440.0 Hz, want 440
ok example_quick_start_1 scsynth: rms=0.0566@0.2-0.9: rms 0.0566, want 0.0566
ok example_quick_start_1 hcsynth: pitch=440@0.2-0.9: pitch 440.0 Hz, want 440
...
ok example_quick_start_1 scsynth~hcsynth: agree over 0-0.9s
...
all checks passed
The tolerances, from measurement: a pitch within 2% (zero crossings located to a fraction of a sample measure a held sine to 0.1 Hz), a level within 15% of its expected value, and two renders agree when every 0.1 s window's RMS is within 5% and its pitch within 2%. Rendered at the same block size, scsynth 3.13 and hcsynth agree to within 0.0006 RMS per 0.1 s window on the two programs both can render.
CI (.github/workflows/scscm-hosts.yml) runs the sclang and scsynth layers on
Ubuntu with the SuperCollider version pinned in tests/host/HOSTS. The
hcsynth layer runs by hand until a hypercollider release boots.
hclang: class library compilation FAILED, or hc_wasm_eval_boot_sequence failed (-1). The release's class library does not compile; build from the
branch in section 5. The errors come from HC/Scscm*.sc,
HCNetAddrZeroconf.sc, Quarks.sc and String.sc.
hclang_native: filesystem_error was thrown in -fno-exceptions mode, or
out of bounds memory access after compile done. Both are fixed on the
branch: the first was the WASI directory walk, the second a host import that
shifted its arguments (it failed for some environments and not others, e.g.
under env -i).
hclang: Wrote 0 OSC packets, and Primitive '_NetAddr_SendMsg' failed
with --verbose. The CLI installed its OSC bridge under a name the WASM side
does not read. Fixed on the same branch.
hclang prints nothing. It shows the program's posts only with
--verbose.
hclang: Module JS not found: .../build/wasm/lang/hclang/hclang.js. Run it
from the hypercollider checkout's root.
hcsynth: the sound does not stop when the synth frees. Older hcsynth kept repeating the last 512-sample block; fixed on the branch. With the fix, a freed synth is silence.
sclang: Variable 'foo' not defined. The program uses a name nothing
defines; sclang has no free variables. (a to z are predefined.)
scsynth: SynthDef not found. Either the program never adds the
SynthDef ((SynthDef "x" ...) builds one; (. (SynthDef "x" ...) add) or
defsynth sends it), or it plays one before the asynchronous add has
finished -- live only; wait with (. s sync) in a routine (section 4) --
or the SynthDef is version 3 (from hclang) on a 3.13 scsynth.
No sound from a pbind. A Pbind is a description; (. (pbind ...) play)
plays it.
sclang will not start headless. QT_QPA_PLATFORM=offscreen, and as root
QTWEBENGINE_DISABLE_SANDBOX=1.