Mandelbrot fractal with red and cyan gradient background and film grain noise

Generating Images

More old magic


I have a bottleneck.

I write and edit faster than I throw some color on my Procreate and AirDrop it over.

So I put my brain to use.

One solution, I can get better at drawing. Solid idea, but don’t have time right now.

Other solution, use an LLM. Pass, not an efficient use case.

Instead I looked to more old magic.

Using math to procedurally generate images.

Here are my constraints. My goal is to learn some applied math. The how is by building a cli utility to generate a set-size fractal. It takes in up to four flags. -colorX for the x axis, -colorY for the y axis, -colorZ for the z axis, and -noise for a noise factor. It puts out a 960 x 480 pixel image. It has to be fast enough that I can’t make a coffee between builds or runs. I don’t want a complex toolchain. My nice to have is learning a low level language.

I considered a couple options.

Ruled out Python and JVM languages.

C++. Top notch for the math, very fast, great for learning how to manage memory, but tooling is too complex.

Go and Deno are the frontrunners.

Zig and Rust are the underdogs.

So here’s the experiment.

Testing

I gave a simple task to a couple bots. Implement a program to accept some inputs, expectations changed so I added some more. colorX for coloring from the left. colorY for coloring from the top. colorZ to color the fractal boundaries. noise to define how grainy it is starting from 0. gamma for contrast around the fractal edges. seed to preselect a random noise pattern. And out to determine where it should go.

First parse the flags. Read the CLI args and fall back to defaults if they are not present.

Then two for loops, one nested inside another to go through every cell in the grid. For y outside of For x.

This is where it started to get scary. Complex numbers. I haven’t seen an imaginary or complex number since Algebra classes in school, maybe Calc in undergrad. Turns out you can write a (x, y) coordinate as a+bi. The a is how far right the item is, and b is how far up. i is notation to denote the “imaginary” portion, but in our case it maps to verticality. Don’t ask me to explain the math for why, this is already adding a good amount of friction to the wheels I’m used to driving. But for this case, zx is the x-coordinate. And zy is the y-coordinate.

We’re gonna focus on the Go implementation.

cxv := float64(x)/float64(W)*3.5 - 2.5
cyv := float64(y)/float64(H)*2.0 - 1.0

Our 2d array is 960 units by 480 units. The Mandelbrot set lives in specific regions of the plane. Roughly from -2.5 to 1.0 on the x and -1.0 to 1.0 for the y. So pixel 0 becomes -2.5. And pixel 959 becomes 1.0. Why do we need to do this? The Mandelbrot set is a mathematical object that exists on a two-dimensional number line. We need to apply a transformation to map over the coordinates.

zx, zy := 0.0, 0.0
i := 0

for zx*zx+zy*zy < 4 && i < MAX {
    tmp := zx*zx - zy*zy + cxv
    zy = 2*zx*zy + cyv
    zx = tmp
    i++
}

Now the escape loop. Starting from our origin. We repeatedly apply z = z^2 + c where c is the pixel’s mapped position. zx and zy come from algebraically expanding the complex number. This isn’t embedded, so we’re not resource constrained the way we are with dedicated hardware. tmp to keep track of the updated zx in the computation. i is the count of how many steps we took, stopping at a MAX of 100. We use zx^2 + zy^2 < 4 to stop when the point is beyond a 2 radii limit from the origin.

nx := float64(x) / float64(W)
ny := float64(y) / float64(H)
nz := math.Pow(float64(i)/float64(MAX), *gamma)

Here we normalize the values. Where the pixel is and how quickly it escaped. The gamma flag curves the normalized z value, nz, where values greater than 1.0 push the weight towards the boundary’s edge, and below 1.0 flattens it. In case I lost anyone, normalize is ten-dollar-word for converting units.

t := nx + ny + nz + 1e-6

r := (nx*float64(cx[0]) + ny*float64(cy[0]) + nz*float64(cz[0])) / t
g := (nx*float64(cx[1]) + ny*float64(cy[1]) + nz*float64(cz[1])) / t
b := (nx*float64(cx[2]) + ny*float64(cy[2]) + nz*float64(cz[2])) / t

Coloring and blending. Each color channel, R, G, and B, is the weighted average of the three input colors. t is the sum; dividing by it normalizes the result. The 1e-6 helps avoid a division-by-zero.

n := (rng.Next() - 0.5) * (*noise) * 255

// Unified: floor-style clamp
cr := uint8(math.Floor(math.Max(0, math.Min(255, r+n))))
cg := uint8(math.Floor(math.Max(0, math.Min(255, g+n))))
cb := uint8(math.Floor(math.Max(0, math.Min(255, b+n))))

Adding noise. rng.Next() will return a numerical value between 0.0 and 1.0. - 0.5 centers it at zero. The result is that n ends up as a small random positive or negative. This is applied to all three color channels so the brightness gets shifted without the hue. Without it, the banding on the gradient looks artificial, adding noise breaks it up to appear more organic. Quick definitions, Brightness is how light or dark a pixel is, Hue is the explicit combination of colors.

Clamping. Floors, Mins, and Maxs keep the final value between 0 and 255 before casting to a byte (uint8).

After assembling the image pixel by pixel, output to a file. Go can go to PNG directly. The other languages output to PPM. And a sidecar is produced containing the input parameters used.

Go

package main

import (
	"flag"
	"image"
	"image/color"
	"image/png"
	"math"
	"os"
	"path/filepath"
	"strconv"
	"strings"
)

const W = 960
const H = 480
const MAX = 100

type Mulberry32 struct {
	state uint32
}

func (m *Mulberry32) Next() float64 {
	m.state += 0x6D2B79F5
	t := m.state
	t = (t ^ (t >> 15)) * (1 | t)
	t ^= t + ((t ^ (t >> 7)) * (61 | t))
	result := t ^ (t >> 14)
	return float64(result) / 4294967296.0
}

func main() {
	colorX := flag.String("colorX", "#ff0000", "")
	colorY := flag.String("colorY", "#00ff00", "")
	colorZ := flag.String("colorZ", "#0000ff", "")
	noise := flag.Float64("noise", 0.1, "")
	gamma := flag.Float64("gamma", 1.0, "")
	seed := flag.Int64("seed", 1, "")
	out := flag.String("out", "out.png", "")
	flag.Parse()

	rng := Mulberry32{state: uint32(*seed)}

	cx := parseColor(*colorX)
	cy := parseColor(*colorY)
	cz := parseColor(*colorZ)

	img := image.NewRGBA(image.Rect(0, 0, W, H))

	for y := 0; y < H; y++ {
		for x := 0; x < W; x++ {
			cxv := float64(x)/float64(W)*3.5 - 2.5
			cyv := float64(y)/float64(H)*2.0 - 1.0

			zx, zy := 0.0, 0.0
			i := 0

			for zx*zx+zy*zy < 4 && i < MAX {
				tmp := zx*zx - zy*zy + cxv
				zy = 2*zx*zy + cyv
				zx = tmp
				i++
			}

			nx := float64(x) / float64(W)
			ny := float64(y) / float64(H)
			nz := math.Pow(float64(i)/float64(MAX), *gamma)

			t := nx + ny + nz + 1e-6

			r := (nx*float64(cx[0]) + ny*float64(cy[0]) + nz*float64(cz[0])) / t
			g := (nx*float64(cx[1]) + ny*float64(cy[1]) + nz*float64(cz[1])) / t
			b := (nx*float64(cx[2]) + ny*float64(cy[2]) + nz*float64(cz[2])) / t

			n := (rng.Next() - 0.5) * (*noise) * 255

			// Unified: floor-style clamp
			cr := uint8(math.Floor(math.Max(0, math.Min(255, r+n))))
			cg := uint8(math.Floor(math.Max(0, math.Min(255, g+n))))
			cb := uint8(math.Floor(math.Max(0, math.Min(255, b+n))))

			img.Set(x, y, color.RGBA{cr, cg, cb, 255})
		}
	}

	f, _ := os.Create(*out)
	defer f.Close()
	png.Encode(f, img)

	writeMeta(*out, *colorX, *colorY, *colorZ, *noise, *gamma, *seed)
}

func parseColor(s string) [3]int {
	if strings.HasPrefix(s, "#") {
		r, _ := strconv.ParseInt(s[1:3], 16, 0)
		g, _ := strconv.ParseInt(s[3:5], 16, 0)
		b, _ := strconv.ParseInt(s[5:7], 16, 0)
		return [3]int{int(r), int(g), int(b)}
	}
	parts := strings.Split(s, ",")
	return [3]int{
		atoi(parts[0]),
		atoi(parts[1]),
		atoi(parts[2]),
	}
}

func atoi(s string) int {
	v, _ := strconv.Atoi(s)
	return v
}

func writeMeta(out, x, y, z string, n, g float64, s int64) {
	meta := strings.TrimSuffix(out, filepath.Ext(out)) + ".txt"
	data := "colorX=" + x + "\n" +
		"colorY=" + y + "\n" +
		"colorZ=" + z + "\n" +
		"noise=" + strconv.FormatFloat(n, 'f', -1, 64) + "\n" +
		"gamma=" + strconv.FormatFloat(g, 'f', -1, 64) + "\n" +
		"seed=" + strconv.FormatInt(s, 10)

	os.WriteFile(meta, []byte(data), 0644)
}

Deno

const W = 960, H = 480, MAX = 100;

function arg(name: string, def: string) {
  const i = Deno.args.indexOf(name);
  return i !== -1 ? Deno.args[i + 1] : def;
}

function parse(s: string) {
  if (s.startsWith("#")) {
    return [
      parseInt(s.slice(1, 3), 16),
      parseInt(s.slice(3, 5), 16),
      parseInt(s.slice(5, 7), 16),
    ];
  }
  return s.split(",").map(Number);
}

const out = arg("--out", "out.ppm");
const seed = Number(arg("--seed", "1"));
let rand = mulberry32(seed);

const cx = parse(arg("--colorX", "#ff0000"));
const cy = parse(arg("--colorY", "#00ff00"));
const cz = parse(arg("--colorZ", "#0000ff"));

const noise = Number(arg("--noise", "0.1"));
const gamma = Number(arg("--gamma", "1.0"));

let data = `P3\n${W} ${H}\n255\n`;

for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    let cxv = x / W * 3.5 - 2.5;
    let cyv = y / H * 2.0 - 1.0;

    let zx = 0, zy = 0, i = 0;
    while (zx * zx + zy * zy < 4 && i < MAX) {
      let tmp = zx * zx - zy * zy + cxv;
      zy = 2 * zx * zy + cyv;
      zx = tmp;
      i++;
    }

    let nx = x / W, ny = y / H, nz = Math.pow(i / MAX, gamma);
    let t = nx + ny + nz + 1e-6;

    let r = (nx * cx[0] + ny * cy[0] + nz * cz[0]) / t;
    let g = (nx * cx[1] + ny * cy[1] + nz * cz[1]) / t;
    let b = (nx * cx[2] + ny * cy[2] + nz * cz[2]) / t;

    let n = (rand() - 0.5) * noise * 255;

    data += `${clamp(r + n)} ${clamp(g + n)} ${clamp(b + n)}\n`;
  }
}

await Deno.writeTextFile(out, data);

await Deno.writeTextFile(
  out.replace(/\.[^/.]+$/, ".txt"),
  `colorX=${arg("--colorX", "#ff0000")}
colorY=${arg("--colorY", "#00ff00")}
colorZ=${arg("--colorZ", "#0000ff")}
noise=${noise}
gamma=${gamma}
seed=${seed}`,
);

function clamp(v: number) {
  return Math.max(0, Math.min(255, Math.floor(v)));
}

function mulberry32(a: number) {
  return () => {
    a |= 0;
    a = a + 0x6D2B79F5 | 0;
    let t = Math.imul(a ^ a >>> 15, 1 | a);
    t ^= t + Math.imul(t ^ t >>> 7, 61 | t);
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

Rust

use std::{env, fs, path::Path};

const W: u32 = 960;
const H: u32 = 480;
const MAX: u32 = 100;

struct Mulberry32 {
    state: u32,
}

impl Mulberry32 {
    fn new(seed: u32) -> Self {
        Self { state: seed }
    }

    fn next(&mut self) -> f64 {
        self.state = self.state.wrapping_add(0x6D2B79F5);
        let mut t = self.state;
        t = (t ^ (t >> 15)).wrapping_mul(1 | t);
        t ^= t.wrapping_add((t ^ (t >> 7)).wrapping_mul(61 | t));
        let result = t ^ (t >> 14);
        (result as f64) / 4294967296.0
    }
}

fn main() {
    let args: Vec<String> = env::args().collect();

    let out = arg(&args, "--out", "out.ppm");
    let seed: u64 = arg(&args, "--seed", "1").parse().unwrap();
    let mut rng = Mulberry32::new(seed as u32);

    let cx = parse_color(arg(&args, "--colorX", "#ff0000"));
    let cy = parse_color(arg(&args, "--colorY", "#00ff00"));
    let cz = parse_color(arg(&args, "--colorZ", "#0000ff"));

    let noise: f64 = arg(&args, "--noise", "0.1").parse().unwrap();
    let gamma: f64 = arg(&args, "--gamma", "1.0").parse().unwrap();

    let mut pixels = Vec::with_capacity((W * H * 3) as usize);

    for y in 0..H {
        for x in 0..W {
            let cxv = x as f64 / W as f64 * 3.5 - 2.5;
            let cyv = y as f64 / H as f64 * 2.0 - 1.0;

            let mut zx = 0.0_f64;
            let mut zy = 0.0_f64;
            let mut i = 0;

            while zx * zx + zy * zy < 4.0 && i < MAX {
                let tmp = zx * zx - zy * zy + cxv;
                zy = 2.0 * zx * zy + cyv;
                zx = tmp;
                i += 1;
            }

            let nx = x as f64 / W as f64;
            let ny = y as f64 / H as f64;
            let nz = (i as f64 / MAX as f64).powf(gamma);

            let t = nx + ny + nz + 1e-6;

            let r = (nx * cx[0] + ny * cy[0] + nz * cz[0]) / t;
            let g = (nx * cx[1] + ny * cy[1] + nz * cz[1]) / t;
            let b = (nx * cx[2] + ny * cy[2] + nz * cz[2]) / t;

            let n = (rng.next() - 0.5) * noise * 255.0;

            let cr = ((r + n).floor().max(0.0).min(255.0)) as u8;
            let cg = ((g + n).floor().max(0.0).min(255.0)) as u8;
            let cb = ((b + n).floor().max(0.0).min(255.0)) as u8;

            pixels.push(cr);
            pixels.push(cg);
            pixels.push(cb);
        }
    }

    let mut ppm = format!("P6\n{} {}\n255\n", W, H).into_bytes();
    ppm.extend_from_slice(&pixels);
    fs::write(&out, ppm).unwrap();

    let meta = format!(
        "colorX={}\ncolorY={}\ncolorZ={}\nnoise={}\ngamma={}\nseed={}",
        arg(&args, "--colorX", "#ff0000"),
        arg(&args, "--colorY", "#00ff00"),
        arg(&args, "--colorZ", "#0000ff"),
        noise,
        gamma,
        seed
    );

    fs::write(Path::new(&out).with_extension("txt"), meta).unwrap();
}

fn arg(args: &[String], key: &str, def: &str) -> String {
    args.iter()
        .position(|x| x == key)
        .and_then(|i| args.get(i + 1))
        .cloned()
        .unwrap_or(def.to_string())
}

fn parse_color(s: String) -> [f64; 3] {
    if s.starts_with('#') {
        return [
            u8::from_str_radix(&s[1..3], 16).unwrap() as f64,
            u8::from_str_radix(&s[3..5], 16).unwrap() as f64,
            u8::from_str_radix(&s[5..7], 16).unwrap() as f64,
        ];
    }
    let v: Vec<f64> = s.split(',').map(|x| x.parse().unwrap()).collect();
    [v[0], v[1], v[2]]
}

Accompanied by a Cargo.toml.

[package]
name = "fractal-banner"
version = "0.1.0"
edition = "2021"

So far, this is only one that wasn’t a single file.

Zig

const std = @import("std");

const W = 960;
const H = 480;
const MAX: u32 = 100;

pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    const outPath = getArg(args, "--out", "out.ppm");

    const seedStr = getArg(args, "--seed", "1");
    const seed: u32 = @intCast(std.fmt.parseInt(u64, seedStr, 10) catch 1);

    var rng = Mulberry32.init(seed);

    const cx = parseColor(getArg(args, "--colorX", "#ff0000"));
    const cy = parseColor(getArg(args, "--colorY", "#00ff00"));
    const cz = parseColor(getArg(args, "--colorZ", "#0000ff"));

    const noise = parseFloat(getArg(args, "--noise", "0.1"));
    const gamma = parseFloat(getArg(args, "--gamma", "1.0"));

    var file = try std.fs.cwd().createFile(outPath, .{});
    defer file.close();

    try file.writeAll("P3\n");
    try writeInt(file, W);
    try file.writeAll(" ");
    try writeInt(file, H);
    try file.writeAll("\n255\n");

    var y: u32 = 0;
    while (y < H) : (y += 1) {
        var x: u32 = 0;
        while (x < W) : (x += 1) {
            const fx = @as(f64, @floatFromInt(x)) / @as(f64, @floatFromInt(W));
            const fy = @as(f64, @floatFromInt(y)) / @as(f64, @floatFromInt(H));

            var zx: f64 = 0.0;
            var zy: f64 = 0.0;
            var i: u32 = 0;

            const cxv = fx * 3.5 - 2.5;
            const cyv = fy * 2.0 - 1.0;

            while (zx * zx + zy * zy < 4.0 and i < MAX) : (i += 1) {
                const tmp = zx * zx - zy * zy + cxv;
                zy = 2.0 * zx * zy + cyv;
                zx = tmp;
            }

            const nx = fx;
            const ny = fy;
            const nz = std.math.pow(
                f64,
                @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(MAX)),
                gamma,
            );
            const t = nx + ny + nz + 1e-6;
            const r = (nx * cx[0] + ny * cy[0] + nz * cz[0]) / t;
            const g = (nx * cx[1] + ny * cy[1] + nz * cz[1]) / t;
            const b = (nx * cx[2] + ny * cy[2] + nz * cz[2]) / t;
            const n = (rng.next() - 0.5) * noise * 255.0;

            try writeInt(file, clampFloor(r + n));
            try file.writeAll(" ");
            try writeInt(file, clampFloor(g + n));
            try file.writeAll(" ");
            try writeInt(file, clampFloor(b + n));
            try file.writeAll("\n");
        }
    }

    // Metadata
    const dot_index = std.mem.lastIndexOfScalar(u8, outPath, '.') orelse outPath.len;
    const metaPath = try std.fmt.allocPrint(allocator, "{s}.txt", .{outPath[0..dot_index]});
    defer allocator.free(metaPath);

    var metaFile = try std.fs.cwd().createFile(metaPath, .{});
    defer metaFile.close();

    var metaBuf: [1024]u8 = undefined;
    const metaStr = try std.fmt.bufPrint(
        &metaBuf,
        "colorX={s}\ncolorY={s}\ncolorZ={s}\nnoise={d}\ngamma={d}\nseed={d}",
        .{
            getArg(args, "--colorX", "#ff0000"),
            getArg(args, "--colorY", "#00ff00"),
            getArg(args, "--colorZ", "#0000ff"),
            noise,
            gamma,
            seed,
        },
    );
    try metaFile.writeAll(metaStr);
}

// ───────────────── RNG ─────────────────

const Mulberry32 = struct {
    state: u32,

    pub fn init(seed: u32) Mulberry32 {
        return .{ .state = seed };
    }

    pub fn next(self: *Mulberry32) f64 {
        self.state +%= 0x6D2B79F5;
        var t = self.state;
        t = (t ^ (t >> 15)) *% (1 | t);
        t ^= t +% ((t ^ (t >> 7)) *% (61 | t));
        const result = t ^ (t >> 14);
        return @as(f64, @floatFromInt(result)) / 4294967296.0;
    }
};

// ───────────────── helpers ─────────────────

fn clampFloor(v: f64) u32 {
    const clamped = @max(0.0, @min(255.0, v));
    return @intFromFloat(std.math.floor(clamped));
}

fn parseColor(s: []const u8) [3]f64 {
    if (s.len > 0 and s[0] == '#') {
        return .{
            @floatFromInt(hex(s[1..3])),
            @floatFromInt(hex(s[3..5])),
            @floatFromInt(hex(s[5..7])),
        };
    }

    var it = std.mem.splitScalar(u8, s, ',');
    return .{
        parseFloat(it.next().?),
        parseFloat(it.next().?),
        parseFloat(it.next().?),
    };
}

fn hex(s: []const u8) u8 {
    return std.fmt.parseInt(u8, s, 16) catch 0;
}

fn parseFloat(s: []const u8) f64 {
    return std.fmt.parseFloat(f64, s) catch 0.0;
}

fn getArg(args: []const []const u8, key: []const u8, def: []const u8) []const u8 {
    var i: usize = 0;
    while (i + 1 < args.len) : (i += 1) {
        if (std.mem.eql(u8, args[i], key)) {
            return args[i + 1];
        }
    }
    return def;
}

fn writeInt(file: std.fs.File, value: anytype) !void {
    var buf: [32]u8 = undefined;
    const s = try std.fmt.bufPrint(&buf, "{}", .{value});
    try file.writeAll(s);
}

And a bash script to drive them all.

#!/usr/bin/env bash

set -e

OUT_DIR="outputs"
mkdir -p "$OUT_DIR"

# ---- args ----
COLOR_X=${1:-#ff0000}
COLOR_Y=${2:-#00ff00}
COLOR_Z=${3:-#0000ff}
NOISE=${4:-1.0}
GAMMA=${5:-1.0}
SEED=${6:-7}
NAME=${7:-banner}

echo "Generating banners..."
echo "colors: $COLOR_X $COLOR_Y $COLOR_Z"
echo "noise: $NOISE | gamma: $GAMMA | seed: $SEED"

# ---- GO ----
go run ./go/main.go \
  --colorX "$COLOR_X" \
  --colorY "$COLOR_Y" \
  --colorZ "$COLOR_Z" \
  --noise "$NOISE" \
  --gamma "$GAMMA" \
  --seed "$SEED" \
  --out "$OUT_DIR/${NAME}-go.png"

# ---- RUST ----
(
  cd rust
  cargo run -- \
    --colorX "$COLOR_X" \
    --colorY "$COLOR_Y" \
    --colorZ "$COLOR_Z" \
    --noise "$NOISE" \
    --gamma "$GAMMA" \
    --seed "$SEED" \
    --out "../$OUT_DIR/${NAME}-rust.ppm"
)

# ---- DENO ----
deno run --allow-write ./deno/main.ts \
  --colorX "$COLOR_X" \
  --colorY "$COLOR_Y" \
  --colorZ "$COLOR_Z" \
  --noise "$NOISE" \
  --gamma "$GAMMA" \
  --seed "$SEED" \
  --out "$OUT_DIR/${NAME}-deno.ppm"

# ---- ZIG ----
zig run ./zig/main.zig -- \
  --colorX "$COLOR_X" \
  --colorY "$COLOR_Y" \
  --colorZ "$COLOR_Z" \
  --noise "$NOISE" \
  --gamma "$GAMMA" \
  --seed "$SEED" \
  --out "$OUT_DIR/${NAME}-zig.ppm"

echo "Done."

Here are the parameters I used. ./scripts/generate.sh "#F61C24" "#41D2EF" "#19181C" 0.7 3 7 blog-post-45

colorX=#F61C24
colorY=#41D2EF
colorZ=#19181C
noise=0.7
gamma=3
seed=7

Golang was my baseline. Unfortunately it’s not giving me any dopamine hits.

Why not TS Go? Deno’s been growing on me, but idiomatic JavaScript/TypeScript still doesn’t slot cleanly into my main() driven brain.

And out of all of them, I like how the Zig looks the most. It’s like a sexy Go. But it also took the most clanker wrangling to get into the specific implementation I wanted.

Why does the LLM matter? I don’t have an infinite amount of time and my goal is to get better with low level programming and math. There’s more training data available on Rust and it still looks similar enough to languages I recognize that I can work backwards.

So now I get to the point. Rust won. I don’t mind looking at it. It checks off enough boxes for my sidequest. And if I get stuck, I don’t need to make nearly as many threats at the chat.

Out of scope was minifying the output into a compressed jpeg. For now the SaaS wins.

“But any developer who uses a bot isn’t a real dev.” Okay I’m not a real dev.

Here’s the repo.

Where did this idea come from? I wanted to get back into music through my 30s. And didn’t feel like picking up tabla lessons again so I grabbed a 2nd hand Casio Privia PX575R with its highest F key broken. Figured it’d be a fun project to fix later. Kinda wild how Western theory just lays out all the notes in an iterable row. I’ve been putzing around trying to map taals I know over to the keyboard, trying to mess with the Sam and Tālī. Unfortunately for my coworkers, I’ve been tapping Dha Ti Na Na Dhi Na the last couple days because I can’t get this Raag my old music teacher used to play in our classes to have my practice listening for the start of each bar. But that then got me thinking of some other ways I can use math.