Lyte is a simple, statically-typed programming language designed for writing Audulus nodes. It compiles to native code via Cranelift or LLVM, with a VM backend for environments where JIT isn't available (iOS).
- Familiar syntax (a mix of Rust and Swift)
- Function and operator overloading
- Generics constrained by interfaces
- Type inference
- No GC — no recursive data structures, no heap allocation
- Memory safety with static bounds checking
- Cranelift and LLVM JIT backends
- VM backend for sandboxed environments (iOS)
- Safe cancellation of long-running programs
# Build
cargo build
# Run a file with JIT
cargo run --bin lyte hello.lyte -c
# Run a file with VM
cargo run --bin lyte hello.lyte -r
# Run all tests
cargo test --workspaceLyte includes an optional LLVM JIT backend alongside the default Cranelift backend. To use it, you need LLVM 18 installed:
# macOS (Homebrew)
brew install llvm@18
# Build with LLVM support (requires the "llvm" feature)
LLVM_SYS_180_PREFIX=/opt/homebrew/opt/llvm@18 LIBRARY_PATH="/opt/homebrew/lib:$LIBRARY_PATH" \
cargo build --features llvm
# Run a file with the LLVM backend
LLVM_SYS_180_PREFIX=/opt/homebrew/opt/llvm@18 LIBRARY_PATH="/opt/homebrew/lib:$LIBRARY_PATH" \
cargo run -p lyte-cli --features llvm -- hello.lyte -lOn Linux, install LLVM 18 via your package manager (e.g. apt install llvm-18-dev libzstd-dev) and set LLVM_SYS_180_PREFIX to the install prefix (e.g. /usr/lib/llvm-18).
main {
println("hello world")
}main {
var x = 42
let y = x + 1 // immutable binding
if x > 0 {
println("positive")
} else {
println("non-positive")
}
var sum = 0
for i in 0 .. 10 {
sum = sum + i
}
}Functions support type annotations, overloading, and implicit returns (the last expression is the return value).
add(a: i32, b: i32) -> i32 {
a + b
}
add(a: f32, b: f32) -> f32 {
a + b
}
fact(x: i32) -> i32 {
if x == 1 { return 1 }
x * fact(x - 1)
}
main {
assert(add(2, 3) == 5)
assert(add(1.0, 2.0) == 3.0)
assert(fact(5) == 120)
}struct Point {
x: f32,
y: f32
}
length(p: Point) -> f32 {
sqrt(p.x * p.x + p.y * p.y)
}
main {
var p: Point
p.x = 3.0
p.y = 4.0
assert(length(p) == 5.0)
}Define __add, __sub, __mul, etc. for custom types:
__add(lhs: Point, rhs: Point) -> Point {
var p: Point
p.x = lhs.x + rhs.x
p.y = lhs.y + rhs.y
p
}
__mul(lhs: Point, rhs: f32) -> Point {
var p: Point
p.x = lhs.x * rhs
p.y = lhs.y * rhs
p
}enum Direction { Up, Down, Left, Right }
is_vertical(d: Direction) -> bool {
(d == .Up) || (d == .Down)
}
main {
var d = Direction.Up
assert(is_vertical(d))
assert(!is_vertical(.Left))
}Fixed-size arrays with static bounds checking:
main {
var a = [1, 2, 3, 4, 5]
assert(a[0] == 1)
assert(a.len == 5)
var grid: [f32; 64]
for i in 0 .. 64 {
grid[i] = 0.0
}
}main {
var pair = (1, 2)
assert(pair.0 == 1)
assert(pair.1 == 2)
}Slices are dynamically-sized views into arrays, passed by reference:
sum(a: [i32]) -> i32 {
var s = 0
for i in 0 .. a.len {
s = s + a[i]
}
s
}
main {
assert(sum([1, 2, 3]) == 6)
var data = [10, 20, 30, 40, 50]
assert(sum(data) == 150)
}apply(x: i32, f: i32 -> i32) -> i32 {
f(x)
}
main {
var f = |x| x * 2
assert(f(3) == 6)
assert(apply(5, |x| x + 1) == 6)
// closures capture variables by reference
var count = 0
var inc = || count = count + 1
inc()
inc()
assert(count == 2)
}Generic functions infer type parameters from arguments:
id<T>(x: T) -> T { x }
map<T0, T1, N>(a: [T0; N], f: T0 -> T1) -> [T1; N] {
var i = 0
var b: [T1; N]
while i < a.len {
b[i] = f(a[i])
i = i + 1
}
b
}
main {
assert(id(42) == 42)
assert(id("hello") == "hello")
assert(map([1, 2, 3], |x| x + 1) == [2, 3, 4])
}struct Pair<A, B> {
first: A,
second: B
}
main {
var p: Pair<i32, f32>
p.first = 1
p.second = 2.0
}Interfaces constrain generics and enable static dispatch:
interface Compare<A> {
cmp(lhs: A, rhs: A) -> i32
}
sort<T, N>(array: [T; N]) -> [T; N] where Compare<T> {
var a = array
var i = 0
while i < a.len {
var j = 0
while j < a.len - 1 - i {
if cmp(a[j], a[j + 1]) > 0 {
var tmp = a[j]
a[j] = a[j + 1]
a[j + 1] = tmp
}
j = j + 1
}
i = i + 1
}
a
}
// Implement Compare for i32
cmp(lhs: i32, rhs: i32) -> i32 { lhs - rhs }
main {
assert(sort([3, 1, 2]) == [1, 2, 3])
}struct Biquad {
b0: f32, b1: f32, b2: f32,
a1: f32, a2: f32,
x1: f32, x2: f32,
y1: f32, y2: f32
}
lpf(fc: f32, fs: f32, q: f32) -> Biquad {
var w0 = 2.0 * 3.14159265 * fc / fs
var alpha = sin(w0) / (2.0 * q)
var cs = cos(w0)
var a0 = 1.0 + alpha
var inv = 1.0 / a0
var bq: Biquad
bq.b1 = (1.0 - cs) * inv
bq.b0 = bq.b1 / 2.0
bq.b2 = bq.b0
bq.a1 = (0.0 - 2.0 * cs) * inv
bq.a2 = (1.0 - alpha) * inv
return bq
}
process(bq: Biquad, x: f32) -> (Biquad, f32) {
var y = bq.b0*x + bq.b1*bq.x1 + bq.b2*bq.x2
- bq.a1*bq.y1 - bq.a2*bq.y2
bq.x2 = bq.x1
bq.x1 = x
bq.y2 = bq.y1
bq.y1 = y
(bq, y)
}Lyte includes an LSP server (lyte-lsp) that provides IDE features for any editor that supports the Language Server Protocol.
- Diagnostics — parse errors, type errors, and safety warnings as you type
- Hover — show inferred types for expressions and variables
- Go to Definition — jump to function and struct field definitions
cargo build -p lyte-lspThe binary is at target/debug/lyte-lsp (or target/release/lyte-lsp with --release).
A VS Code extension is included in vscode-lyte/. To install it locally:
cd vscode-lyte
npm install
code --install-extension .The extension activates on .lyte files and starts the language server automatically. Set lyte.lspPath in VS Code settings if the lyte-lsp binary isn't on your PATH.
Point your editor's LSP client at the lyte-lsp binary. It communicates over stdin/stdout using the standard LSP protocol. For example, in Neovim with nvim-lspconfig:
vim.lsp.start({
name = 'lyte',
cmd = { 'lyte-lsp' },
filetypes = { 'lyte' },
})Lyte is available as a Swift package for embedding in macOS and iOS apps. Add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/audulus/lyte.git", from: "0.12.0")
]The package auto-selects the best backend: LLVM JIT on ARM macOS, VM on iOS and other platforms.
let compiler = LyteCompiler(entryPoints: ["init", "process"])
try compiler.addSource(source, filename: "node.lyte")
let program = try compiler.compile()
let globals = program.allocGlobals()
let initEP = program.entryPoint(named: "init")!
let processEP = program.entryPoint(named: "process")!
initEP.call(globals: globals) // initialize state
processEP.call(globals: globals) // run on audio threadEntry point handles are resolved once at compile time and called with zero overhead — no string lookups on the hot path.
Run the Release xcframework workflow from the Actions tab (or with gh),
passing the version number:
gh workflow run release.yml -f version=0.36Do not create the release or the tag by hand — the workflow creates both, and creating them yourself produces a release with no xcframework attached.
The release.yml CI workflow:
- Fails fast if the version is malformed or the tag/release already exists
- Builds
CLyte.xcframeworkfor macOS (ARM64 + x86_64) and iOS - Builds the Swift package against that xcframework, before publishing anything
- Commits the new URL + checksum to
Package.swifton arelease/<version>branch - Creates the release and tag at that commit, uploading
CLyte.xcframework.zip - Clones the fresh tag and runs
swift buildagainst it - Fast-forwards
mainonto the released commit, and deletes the staging branch
The tag comes last because Package.swift's binaryTarget has to name the URL
and checksum of the artifact for the release being cut, so the commit updating
it must exist before the tag does. Tagging first leaves the tag pointing at the
previous release's xcframework, which breaks consumers who pin a version as soon
as the Swift wrapper starts calling a newly added FFI symbol (see issue #37).
main comes last for the mirror-image reason: a Package.swift on main naming
an artifact that does not exist yet breaks everyone resolving by branch. Hence
the staging branch — nothing reaches main until the release is published and
verified. If a release fails or the version is wrong, the release and tag are
deleted so the version can be re-run, and main was never touched.
If main moves while the xcframework is building, the workflow aborts before
publishing rather than tagging sources the binary was not built from. Re-run it.
The xcframework is self-contained — LLVM dependencies (zstd, ffi) are statically linked into the ARM64 macOS library. Swift consumers only need system libraries (libc++, libz, libcurses).
The compiler pipeline:
- Lexer — tokenizes source text
- Parser — builds an AST
- Type Checker — Hindley-Milner inference with constraint solving
- Safety Checker — static bounds checking and division-by-zero detection via abstract interpretation
- Code Generation — Cranelift JIT, LLVM JIT, or VM bytecode
See src/README.md for detailed design notes.
Three benchmarks compare Lyte (VM, ARM64 VM, JIT, LLVM) against C, Lua 5.5, and LuaJIT:
| Benchmark | Description |
|---|---|
| Biquad | 10M samples through a 1kHz lowpass filter (DSP) |
| Sort | Quicksort 10K elements × 50 iterations |
| FFT | 1024-point radix-2 FFT × 2000 iterations |
Results from the latest CI run are in the Actions workflow summary (click the latest run, scroll to "Benchmark Results").
./benchmark/run.sh # run locally (requires lua and luajit)