Skip to content

Repository files navigation

lyte

build status dependency status

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).

Goals

  • 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

Quick Start

# 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 --workspace

LLVM JIT Backend (Optional)

Lyte 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 -l

On 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).

Language Tour

Hello World

main {
    println("hello world")
}

Variables and Control Flow

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

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)
}

Structs

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)
}

Operator Overloading

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
}

Enums

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))
}

Arrays

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

Tuples

main {
    var pair = (1, 2)
    assert(pair.0 == 1)
    assert(pair.1 == 2)
}

Slices

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)
}

Lambdas and Closures

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)
}

Generics

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])
}

Generic Structs

struct Pair<A, B> {
    first: A,
    second: B
}

main {
    var p: Pair<i32, f32>
    p.first = 1
    p.second = 2.0
}

Interfaces

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])
}

A Real-World Example: Biquad Filter

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)
}

Language Server

Lyte includes an LSP server (lyte-lsp) that provides IDE features for any editor that supports the Language Server Protocol.

Features

  • 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

Building

cargo build -p lyte-lsp

The binary is at target/debug/lyte-lsp (or target/release/lyte-lsp with --release).

VS Code

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.

Other Editors

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' },
})

Embedding via Swift Package

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 thread

Entry point handles are resolved once at compile time and called with zero overhead — no string lookups on the hot path.

Releasing

Run the Release xcframework workflow from the Actions tab (or with gh), passing the version number:

gh workflow run release.yml -f version=0.36

Do 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:

  1. Fails fast if the version is malformed or the tag/release already exists
  2. Builds CLyte.xcframework for macOS (ARM64 + x86_64) and iOS
  3. Builds the Swift package against that xcframework, before publishing anything
  4. Commits the new URL + checksum to Package.swift on a release/<version> branch
  5. Creates the release and tag at that commit, uploading CLyte.xcframework.zip
  6. Clones the fresh tag and runs swift build against it
  7. Fast-forwards main onto 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).

Architecture

The compiler pipeline:

  1. Lexer — tokenizes source text
  2. Parser — builds an AST
  3. Type Checker — Hindley-Milner inference with constraint solving
  4. Safety Checker — static bounds checking and division-by-zero detection via abstract interpretation
  5. Code Generation — Cranelift JIT, LLVM JIT, or VM bytecode

See src/README.md for detailed design notes.

Benchmarks

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)

About

A programming language for Audulus nodes

Resources

Stars

42 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages