main.go
// Command luminaria is a generative musical instrument in the lineage of Toshio
// Iwai's Electroplankton and the Tenori-On: a 16×16 matrix that crawlers walk
// across, lighting nodes and playing them.
//
// Tap a cell to cycle it — a node, then a clockwise turn, then a
// counter-clockwise turn, then empty again — and drag to paint. Crawlers
// advance one cell per beat, wrap at the edges, and rotate when they hit a
// turn. A node they cross lights up, rings out, and pushes a note into the
// mixer: pitch from its row, stereo pan from its column.
//
// It is the driver example for *melodic* synthesis (examples/drummachine is the
// percussive one): synth.go builds Karplus-Strong, FM, and detuned-pad voices in
// Go at the mixer's sample rate, with no audio assets and nothing
// platform-specific, so the same notes sound on desktop, in the browser, and on
// a phone. It is also the example where the widget layer and the paint escape
// hatch share a screen — the control panel is ordinary themed widgets, the
// matrix is one widget.Canvas.
//
// go run ./examples/luminaria
package main
import (
"fmt"
"log"
"math/rand"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/shell"
"github.com/doug/gophics/sound"
"github.com/doug/gophics/sound/device"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
const (
cols = 16
rows = 16
minBPM = 60
maxBPM = 320
maxCrawlers = 6
panelW = 268
pagePad = 18
)
// cell is what a grid square holds. Tapping cycles through them in this order,
// which is also why empty is the zero value: a fresh board is silent.
type cell uint8
const (
empty cell = iota
node // lights, rings, and plays its row's pitch
turnCW // rotates a crawler a quarter turn clockwise
turnCCW // …and the other way
)
// next is the tap cycle.
func (c cell) next() cell {
if c == turnCCW {
return empty
}
return c + 1
}
// Directions, indexed by the crawler's dir. North is up the screen.
var delta = [4][2]int{{0, -1}, {1, 0}, {0, 1}, {-1, 0}}
type crawler struct {
x, y int // current cell
dir int // where it leaves this cell
from int // the direction it arrived by, for drawing the step it just took
col paint.Color
}
// ripple is the expanding ring a struck node throws off; t runs 0→1.
type ripple struct {
x, y float32 // cell centre, in cell units
t float32
col paint.Color
}
// pending is an echo repeat waiting for its turn — the delay effect is
// scheduled here rather than mixed as a feedback line, so it costs one struct
// per repeat and stays deterministic.
type pending struct {
in float64 // seconds until it sounds
freq float64
pan float64
vol float64
left int // repeats still to come after this one
gx, gy float32
col paint.Color
}
var (
bg = paint.RGB(0.055, 0.06, 0.085)
gridDot = paint.Color{R: 1, G: 1, B: 1, A: 0.07}
gridLine = paint.Color{R: 1, G: 1, B: 1, A: 0.035}
nodeCol = paint.RGB(0.42, 0.74, 0.98)
cwCol = paint.RGB(0.98, 0.68, 0.32)
ccwCol = paint.RGB(0.72, 0.52, 0.98)
)
// crawlerCols tints each crawler and everything it lights, so two crawlers
// crossing the same node read as two separate voices.
var crawlerCols = [maxCrawlers]paint.Color{
paint.RGB(0.42, 0.92, 0.76),
paint.RGB(0.98, 0.51, 0.62),
paint.RGB(0.98, 0.84, 0.40),
paint.RGB(0.55, 0.72, 0.99),
paint.RGB(0.78, 0.55, 0.98),
paint.RGB(0.46, 0.95, 0.46),
}
type App struct{ Mixer *sound.Mixer }
func (App) CreateState() widget.State { return &lum{} }
type lum struct {
widget.StateBase[App]
ctx widget.Ctx
mixer *sound.Mixer
rng *rand.Rand
grid [rows][cols]cell
flash [rows][cols]float32 // 0..1, decays; how lit a node is right now
crawlers []crawler
ripples []ripple
pending []pending
playing bool
bpm float64
acc float64 // seconds into the current step
scale int
voice Voice
octave int // index into octaveRoots
sustain float64 // seconds
echo float32 // 0 = dry
// Painting: a press cycles the cell it lands on and remembers the result,
// which a drag then paints onto every cell it crosses. Without the memory a
// drag would cycle each cell by however many move events it received.
painting bool
paintTo cell
lastCell [2]int
area geom.Rect // the matrix's rect inside the canvas, cached at paint
step float32 // one cell's side, cached with it
// The two rotation glyphs, authored once in a unit square (see drawTurn).
glyphArc [2]*paint.Path
glyphHead [2]*paint.Path
seq int64 // seeds the noise-excited voices, so playback is repeatable
}
var octaveNames = []string{"Low", "Mid", "High"}
var octaveRoots = []int{45, 57, 69} // MIDI A2 / A3 / A4
// stateHook, if set, receives the state on mount — for tests to drive input.
var stateHook func(*lum)
func (s *lum) Init(ctx widget.Ctx) {
s.ctx = ctx
s.mixer = s.W().Mixer
s.rng = rand.New(rand.NewSource(7))
s.bpm = 132
s.octave = 1
s.sustain = 1.4
s.echo = 0.35
s.playing = true
s.seed()
s.addCrawler()
s.addCrawler()
ctx.AddTicker(s)
if stateHook != nil {
stateHook(s)
}
}
// seed lays down an opening pattern: a scatter of nodes plus a ring of turns
// that catches a crawler into a loop, so the demo has something to say before
// anyone has touched it.
func (s *lum) seed() {
s.grid = [rows][cols]cell{}
for _, p := range [][2]int{{3, 3}, {12, 3}, {12, 12}, {3, 12}} {
s.grid[p[1]][p[0]] = turnCW
}
for range 22 {
x, y := s.rng.Intn(cols), s.rng.Intn(rows)
if s.grid[y][x] == empty {
s.grid[y][x] = node
}
}
}
func (s *lum) addCrawler() {
if len(s.crawlers) >= maxCrawlers {
return
}
i := len(s.crawlers)
s.crawlers = append(s.crawlers, crawler{
x: s.rng.Intn(cols),
y: s.rng.Intn(rows),
dir: i % 4,
col: crawlerCols[i],
})
}
func (s *lum) removeCrawler() {
if len(s.crawlers) > 0 {
s.crawlers = s.crawlers[:len(s.crawlers)-1]
}
}
func (s *lum) stepDur() float64 { return 60 / s.bpm / 2 } // eighth notes
// Tick runs the clock, the decay of everything that glows, and the echo queue.
// It reports true while it wants more frames, which is always: even stopped,
// ripples and flashes are still fading.
func (s *lum) Tick(dt float64) bool {
if dt > 0.1 { // a backgrounded tab shouldn't fire a burst of steps on return
dt = 0.1
}
if s.playing {
dur := s.stepDur()
for s.acc += dt; s.acc >= dur; s.acc -= dur {
s.advance()
}
}
s.decay(float32(dt))
s.drainEcho(dt)
s.ctx.Invalidate()
return true
}
// advance walks every crawler one cell and applies whatever it landed on.
func (s *lum) advance() {
for i := range s.crawlers {
c := &s.crawlers[i]
d := delta[c.dir]
c.x = (c.x + d[0] + cols) % cols
c.y = (c.y + d[1] + rows) % rows
c.from = c.dir
switch s.grid[c.y][c.x] {
case node:
s.strike(c.x, c.y, c.col)
case turnCW:
c.dir = (c.dir + 1) % 4
case turnCCW:
c.dir = (c.dir + 3) % 4
}
}
}
// strike lights a node and sounds it. Pitch climbs up the board (row 0 is the
// top and the highest step); pan follows the column, so a pattern that sweeps
// left to right sweeps across the stereo field with it.
func (s *lum) strike(x, y int, col paint.Color) {
s.flash[y][x] = 1
s.ripples = append(s.ripples, ripple{x: float32(x) + 0.5, y: float32(y) + 0.5, col: col})
freq := scales[s.scale].Freq(rows-1-y, octaveRoots[s.octave])
pan := (float64(x)/(cols-1))*1.6 - 0.8
s.play(freq, pan, 0.5)
if s.echo > 0.02 {
s.pending = append(s.pending, pending{
in: s.stepDur() * 1.5, freq: freq, pan: -pan * 0.7,
vol: 0.5 * float64(s.echo), left: 2,
gx: float32(x) + 0.5, gy: float32(y) + 0.5, col: col,
})
}
}
func (s *lum) play(freq, pan, vol float64) {
if s.mixer == nil {
return
}
s.seq++
s.mixer.PlaySource(Note(s.voice, freq, s.sustain, s.seq), sound.PlayOptions{Volume: vol, Pan: pan})
}
// drainEcho sounds any repeats that came due, re-queueing the ones that still
// have repeats left at a lower volume and on the opposite side.
func (s *lum) drainEcho(dt float64) {
out := s.pending[:0]
for _, p := range s.pending {
if p.in -= dt; p.in > 0 {
out = append(out, p)
continue
}
s.play(p.freq, p.pan, p.vol)
s.ripples = append(s.ripples, ripple{x: p.gx, y: p.gy, col: p.col.WithAlpha(0.5)})
if p.left > 0 {
p.left--
p.in = s.stepDur() * 1.5
p.vol *= 0.55
p.pan = -p.pan * 0.7
out = append(out, p)
}
}
s.pending = out
}
func (s *lum) decay(dt float32) {
for y := range s.flash {
for x := range s.flash[y] {
if s.flash[y][x] > 0 {
if s.flash[y][x] -= dt * 2.2; s.flash[y][x] < 0 {
s.flash[y][x] = 0
}
}
}
}
out := s.ripples[:0]
for _, r := range s.ripples {
if r.t += dt * 1.5; r.t < 1 {
out = append(out, r)
}
}
s.ripples = out
}
func (s *lum) clear() {
s.SetState(func() {
s.grid = [rows][cols]cell{}
s.ripples = s.ripples[:0]
s.pending = s.pending[:0]
})
}
// --- Input -------------------------------------------------------------------
// cellAt maps a canvas point to a grid cell.
func (s *lum) cellAt(p geom.Pt) (int, int, bool) {
if s.step <= 0 || !s.area.Contains(p) {
return 0, 0, false
}
x := int((p.X - s.area.Min.X) / s.step)
y := int((p.Y - s.area.Min.Y) / s.step)
if x < 0 || x >= cols || y < 0 || y >= rows {
return 0, 0, false
}
return x, y, true
}
func (s *lum) onPress(p geom.Pt) {
x, y, ok := s.cellAt(p)
if !ok {
return
}
s.paintTo = s.grid[y][x].next()
s.painting = true
s.lastCell = [2]int{x, y}
s.SetState(func() { s.set(x, y, s.paintTo) })
}
func (s *lum) onDrag(p geom.Pt, _ geom.Pt) {
if !s.painting {
return
}
x, y, ok := s.cellAt(p)
if !ok || (x == s.lastCell[0] && y == s.lastCell[1]) {
return
}
s.lastCell = [2]int{x, y}
s.SetState(func() { s.set(x, y, s.paintTo) })
}
// set writes a cell and previews it, so painting a node is audible immediately
// rather than only when a crawler eventually reaches it.
func (s *lum) set(x, y int, c cell) {
s.grid[y][x] = c
if c == node {
s.flash[y][x] = 1
s.play(scales[s.scale].Freq(rows-1-y, octaveRoots[s.octave]),
(float64(x)/(cols-1))*1.6-0.8, 0.34)
}
}
func (s *lum) onKey(k shell.Key) {
if k.Kind != shell.KeyPress {
return
}
switch k.Code {
case shell.KeySpace:
s.SetState(func() { s.playing = !s.playing })
case shell.KeyC:
s.clear()
case shell.KeyA:
s.SetState(s.addCrawler)
case shell.KeyR:
s.SetState(s.seed)
}
}
// --- Build -------------------------------------------------------------------
func (s *lum) Build(ctx widget.Ctx) widget.Widget {
// The instrument is light-on-dark by nature — the whole point is nodes
// glowing on an unlit field — so it pins the dark theme rather than
// following the platform scheme, and app.Config paints the same colour
// behind it in both.
th := theme.Dark()
return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: bg,
Child: widget.Padding{All: pagePad, Child: widget.LayoutBuilder{
Build: func(cs layout.Constraints) widget.Widget {
if cs.BoundedW() && cs.Max.W < panelW*2.4 {
return s.stacked(ctx, th)
}
return s.sideBySide(ctx, th)
},
}},
}}
}
// sideBySide is the desktop shape: the matrix takes everything the fixed-width
// panel doesn't.
func (s *lum) sideBySide(ctx widget.Ctx, th theme.Theme) widget.Widget {
return widget.Flex{
Axis: layout.Horizontal,
CrossAlign: layout.CrossStretch,
Children: []widget.Widget{
widget.Expand(s.matrix()),
widget.Sized{W: pagePad},
widget.Sized{W: panelW, Child: widget.Scroll{Child: s.panel(ctx, th)}},
},
}
}
// stacked is the phone shape: a square matrix across the top with the panel
// under it, the whole page scrolling. The matrix keeps its own drag — the
// gesture arena hands a drag to the deepest handler whose axis matches, and the
// Canvas is deeper than the Scroll — so painting a pattern doesn't scroll the
// page out from under the finger.
func (s *lum) stacked(ctx widget.Ctx, th theme.Theme) widget.Widget {
return widget.Scroll{Child: widget.Flex{
Axis: layout.Vertical,
CrossAlign: layout.CrossStretch,
Children: []widget.Widget{
widget.AspectRatio{Ratio: 1, Child: s.matrix()},
widget.Sized{H: pagePad},
s.panel(ctx, th),
},
}}
}
func (s *lum) matrix() widget.Widget {
return widget.Interactive{
Gestures: widget.Gestures{
OnKey: s.onKey,
OnPress: s.onPress,
OnDrag: s.onDrag,
DragAxis: widget.DragAny,
OnPressEnd: func() { s.painting = false },
},
Child: widget.Canvas{Clip: true, Draw: s.draw},
}
}
func (s *lum) panel(ctx widget.Ctx, th theme.Theme) widget.Widget {
playLabel := "Play"
if s.playing {
playLabel = "Pause"
}
return widget.Flex{
Axis: layout.Vertical,
CrossAlign: layout.CrossStretch,
Children: []widget.Widget{
widget.Text{Value: "Luminaria", Font: theme.FontBold, Size: th.Type.Title, Color: th.Text},
widget.Sized{H: 4},
widget.Text{Value: "Tap a cell to cycle it: node, turn right, turn left, empty. Drag to paint.",
Size: th.Type.Caption, Color: th.Muted, Wrap: true},
widget.Sized{H: 16},
theme.Button{Label: playLabel, Primary: true,
OnTap: func() { s.SetState(func() { s.playing = !s.playing }) }},
s.slider(th, "Tempo", fmt.Sprintf("%.0f BPM", s.bpm),
float32((s.bpm-minBPM)/(maxBPM-minBPM)),
func(v float32) { s.bpm = minBPM + float64(v)*(maxBPM-minBPM) }),
s.group(th, "Scale"),
theme.Dropdown{Options: scaleNames(), Selected: s.scale,
OnChange: func(i int) { s.SetState(func() { s.scale = i }) }},
s.group(th, "Voice"),
theme.Segmented{Options: voiceNames, Selected: int(s.voice),
OnChange: func(i int) { s.SetState(func() { s.voice = Voice(i) }) }},
s.group(th, "Register"),
theme.Segmented{Options: octaveNames, Selected: s.octave,
OnChange: func(i int) { s.SetState(func() { s.octave = i }) }},
s.slider(th, "Sustain", fmt.Sprintf("%.1fs", s.sustain), float32((s.sustain-0.2)/3.3),
func(v float32) { s.sustain = 0.2 + float64(v)*3.3 }),
s.slider(th, "Echo", fmt.Sprintf("%d%%", int(s.echo*100+0.5)), s.echo,
func(v float32) { s.echo = v }),
s.group(th, fmt.Sprintf("Crawlers — %d", len(s.crawlers))),
widget.Row(
widget.Expand(theme.Button{Label: "Remove", OnTap: func() { s.SetState(s.removeCrawler) }}),
widget.Sized{W: 8},
widget.Expand(theme.Button{Label: "Add", OnTap: func() { s.SetState(s.addCrawler) }}),
),
widget.Sized{H: 16},
widget.Row(
widget.Expand(theme.Button{Label: "Clear", OnTap: s.clear}),
widget.Sized{W: 8},
widget.Expand(theme.Button{Label: "Reseed", OnTap: func() { s.SetState(s.seed) }}),
),
widget.Sized{H: 12},
widget.Text{Value: "Space play · A crawler · C clear · R reseed",
Size: th.Type.Caption, Color: th.Muted, Wrap: true},
},
}
}
// group is a section label with the spacing above it that separates controls.
func (s *lum) group(th theme.Theme, label string) widget.Widget {
return widget.Padding{Insets: geom.Insets{Top: 18, Bottom: 6},
Child: widget.Align{X: 0, Y: 0.5,
Child: widget.Text{Value: label, Size: th.Type.Label, Color: th.Muted}}}
}
// slider is a labelled slider with its value echoed on the right — the shape
// every parameter in the panel takes.
func (s *lum) slider(th theme.Theme, label, value string, v float32, set func(float32)) widget.Widget {
return widget.Flex{
Axis: layout.Vertical,
CrossAlign: layout.CrossStretch,
Children: []widget.Widget{
widget.Padding{Insets: geom.Insets{Top: 18, Bottom: 6}, Child: widget.Row(
widget.Expand(widget.Text{Value: label, Size: th.Type.Label, Color: th.Muted}),
widget.Text{Value: value, Font: theme.FontBold, Size: th.Type.Label, Color: th.Primary},
)},
theme.Slider{Value: v, Label: label, OnChange: func(x float32) { s.SetState(func() { set(x) }) }},
},
}
}
func main() {
// Audio is best-effort: if no device opens, the matrix still runs, silent.
mixer := sound.NewMixer()
if closer, err := device.Open(mixer); err != nil {
log.Printf("audio disabled: %v", err)
} else {
defer closer.Close()
}
if err := app.Run(App{Mixer: mixer}, app.Config{
Title: "Luminaria",
AppID: "com.gophics.luminaria",
Size: geom.Size{W: 1040, H: 720},
Background: bg,
BackgroundDark: bg,
Font: goregular.TTF,
FontFamilies: map[string][]byte{theme.FontBold: gobold.TTF},
}); err != nil {
log.Fatal(err)
}
}
draw.go
package main
import (
"math"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/paint"
)
// Everything below draws into a single widget.Canvas — the escape hatch beside
// the widget-built panel. Nothing here is retained between frames except the
// simulation state itself; the matrix is re-recorded each tick, which at 256
// cells is a few hundred fills.
func (s *lum) draw(c paint.Canvas, sz geom.Size) {
// A square matrix, centred in whatever space the panel left over.
side := sz.W
if sz.H < side {
side = sz.H
}
step := side / cols
if step < 6 {
return
}
side = step * cols
s.step = step
s.area = geom.RectXYWH((sz.W-side)/2, (sz.H-side)/2, side, side)
s.drawField(c, step)
s.drawRipples(c, step)
s.drawCells(c, step)
s.drawCrawlers(c, step)
}
// drawField is the unlit board: a faint dot per cell and a lighter rule every
// four, which is the only thing giving the eye a beat to count against.
func (s *lum) drawField(c paint.Canvas, step float32) {
for i := 0; i <= cols; i += 4 {
x := s.area.Min.X + float32(i)*step
y := s.area.Min.Y + float32(i)*step
c.FillRect(geom.RectXYWH(x-0.5, s.area.Min.Y, 1, s.area.Dy()), gridLine)
c.FillRect(geom.RectXYWH(s.area.Min.X, y-0.5, s.area.Dx(), 1), gridLine)
}
r := step * 0.055
for y := range rows {
for x := range cols {
if s.grid[y][x] != empty {
continue
}
cx, cy := s.center(x, y, step)
c.FillRRect(geom.RectXYWH(cx-r, cy-r, 2*r, 2*r), r, gridDot)
}
}
}
func (s *lum) center(x, y int, step float32) (float32, float32) {
return s.area.Min.X + (float32(x)+0.5)*step, s.area.Min.Y + (float32(y)+0.5)*step
}
// drawRipples draws each strike's expanding ring. The ring thins as it grows
// and fades on a squared curve, so it reads as light spreading out rather than
// a circle being scaled up.
func (s *lum) drawRipples(c paint.Canvas, step float32) {
for _, rp := range s.ripples {
rad := step * (0.3 + rp.t*2.4)
a := (1 - rp.t) * (1 - rp.t) * 0.75 * rp.col.A
if a < 0.01 {
continue
}
w := step * 0.09 * (1 - rp.t*0.7)
box := geom.RectXYWH(s.area.Min.X+rp.x*step-rad, s.area.Min.Y+rp.y*step-rad, 2*rad, 2*rad)
c.StrokeRRect(box, rad, w, rp.col.WithAlpha(a))
}
}
func (s *lum) drawCells(c paint.Canvas, step float32) {
for y := range rows {
for x := range cols {
switch s.grid[y][x] {
case node:
s.drawNode(c, x, y, step)
case turnCW:
s.drawTurn(c, x, y, step, true)
case turnCCW:
s.drawTurn(c, x, y, step, false)
}
}
}
}
// drawNode draws a lamp: a dim disc at rest, and while lit a brighter core
// inside a halo, so a struck node blooms without changing size.
func (s *lum) drawNode(c paint.Canvas, x, y int, step float32) {
cx, cy := s.center(x, y, step)
f := s.flash[y][x]
if f > 0 {
hr := step * (0.32 + 0.22*f)
c.FillRRect(geom.RectXYWH(cx-hr, cy-hr, 2*hr, 2*hr), hr, nodeCol.WithAlpha(0.22*f))
}
r := step * 0.19
disc(c, cx, cy, r, mix(nodeCol.WithAlpha(0.55), paint.RGB(1, 1, 1), f*0.8))
}
// drawTurn stamps the rotation glyph: an open arc with a head on it, clockwise
// or not. It is handedness, not heading — a turn cell rotates whichever way a
// crawler happens to enter, which is what lets four of them close any
// rectangle into a loop.
//
// The glyph is authored once in a unit square and mapped onto each cell with a
// transform, rather than rebuilt per cell per frame. Paths are retained by the
// display list, so a single mutated path would leave every glyph drawing
// whatever shape was written last; caching two of them sidesteps that and the
// per-frame allocation at the same time.
func (s *lum) drawTurn(c paint.Canvas, x, y int, step float32, cw bool) {
s.buildGlyphs(step)
i := 0
col := cwCol
if !cw {
i, col = 1, ccwCol
}
cell := geom.RectXYWH(s.area.Min.X+float32(x)*step, s.area.Min.Y+float32(y)*step, step, step)
c.PushTransform(paint.MapRect(geom.RectXYWH(0, 0, 1, 1), cell))
c.StrokePath(s.glyphArc[i], glyphStroke, col)
c.FillPath(s.glyphHead[i], col)
c.PopTransform()
}
// glyphStroke is the arc's width in the unit square the glyph is authored in;
// the cell transform scales it up with everything else.
const glyphStroke = 0.058
// buildGlyphs (re)builds the two rotation glyphs. They are scale-free, so this
// runs once — the guard is only here because the state starts empty.
func (s *lum) buildGlyphs(float32) {
if s.glyphArc[0] != nil {
return
}
for i, dir := range []float64{1, -1} {
const (
segs = 22
radius = 0.24
sweep = 4.36 // 250°, leaving room for the head
headLen = 0.15
)
// The two glyphs are exact mirrors, which means the anticlockwise one
// starts from the mirrored angle (x → −x maps a → π − a) rather than
// from the same place; sweeping the other way from a shared start would
// leave the pair with their gaps in different corners.
start := -0.5 // radians; puts the gap across the top
if dir < 0 {
start = math.Pi + 0.5
}
arc := paint.NewPath()
var tip geom.Pt
for j := 0; j <= segs; j++ {
a := start + dir*sweep*float64(j)/segs
tip = geom.Pt{X: 0.5 + float32(radius*math.Cos(a)), Y: 0.5 + float32(radius*math.Sin(a))}
if j == 0 {
arc.MoveTo(tip)
} else {
arc.LineTo(tip)
}
}
s.glyphArc[i] = arc
// The head is a barb on the arc's end, pointing the way the arc travels.
head := start + dir*sweep + dir*math.Pi/2
p := paint.NewPath().MoveTo(geom.Pt{
X: tip.X + float32(headLen*math.Cos(head)),
Y: tip.Y + float32(headLen*math.Sin(head)),
})
for _, spread := range []float64{2.3, -2.3} {
a := head + spread
p.LineTo(geom.Pt{
X: tip.X + float32(headLen*0.95*math.Cos(a)),
Y: tip.Y + float32(headLen*0.95*math.Sin(a)),
})
}
s.glyphHead[i] = p.Close()
}
}
// drawCrawlers draws each agent partway between the cell it left and the cell
// it is on, so motion is continuous even though the simulation is a grid step.
// The interpolation runs backwards from the current cell along the direction it
// arrived by, which also makes an edge wrap enter from off-board instead of
// streaking across the width of the matrix.
func (s *lum) drawCrawlers(c paint.Canvas, step float32) {
frac := float32(1)
if d := s.stepDur(); s.playing && d > 0 {
frac = float32(s.acc / d)
if frac > 1 {
frac = 1
}
}
for _, cr := range s.crawlers {
d := delta[cr.from]
fx := float32(cr.x) + 0.5 - float32(d[0])*(1-frac)
fy := float32(cr.y) + 0.5 - float32(d[1])*(1-frac)
cx := s.area.Min.X + fx*step
cy := s.area.Min.Y + fy*step
// A short comet tail behind it, three stamps fading back along the step.
for i := 3; i >= 1; i-- {
t := float32(i) * 0.26
tx := cx - float32(d[0])*step*t
ty := cy - float32(d[1])*step*t
disc(c, tx, ty, step*(0.12-0.02*float32(i)), cr.col.WithAlpha(0.22-0.05*float32(i)))
}
disc(c, cx, cy, step*0.30, cr.col.WithAlpha(0.16)) // halo
disc(c, cx, cy, step*0.135, cr.col)
}
}
// disc fills a circle — a rounded rect whose radius is its half-width, which is
// how paint spells a circle.
func disc(c paint.Canvas, cx, cy, r float32, col paint.Color) {
c.FillRRect(geom.RectXYWH(cx-r, cy-r, 2*r, 2*r), r, col)
}
// mix blends two colours, keeping a's alpha channel.
func mix(a, b paint.Color, t float32) paint.Color {
return paint.Color{
R: a.R + (b.R-a.R)*t,
G: a.G + (b.G-a.G)*t,
B: a.B + (b.B-a.B)*t,
A: a.A + (b.A-a.A)*t,
}
}
synth.go
package main
import (
"math"
"math/rand"
"github.com/doug/gophics/sound"
)
// Every voice here is synthesized in Go at the mixer's rate — no audio assets,
// no platform synth. Each is a sound.Source the mixer pulls from, so a note is
// a few hundred bytes of state rather than a decoded buffer, and the whole file
// is deterministic (seeded noise only) and therefore unit-testable with no
// audio hardware.
const sr = float64(sound.SampleRate)
// Voice selects the timbre a lit node plays.
type Voice int
const (
VoicePluck Voice = iota // Karplus-Strong string
VoiceBell // two-operator FM with an inharmonic ratio
VoiceGlass // detuned triangle pad, slow attack
)
var voiceNames = []string{"Pluck", "Bell", "Glass"}
// Note builds a one-shot voice at freq Hz that decays over roughly sustain
// seconds. seed makes the noise-excited voices reproducible.
func Note(v Voice, freq, sustain float64, seed int64) sound.Source {
if sustain < 0.05 {
sustain = 0.05
}
switch v {
case VoiceBell:
return newBell(freq, sustain)
case VoiceGlass:
return newGlass(freq, sustain)
default:
return newPluck(freq, sustain, seed)
}
}
// --- Pluck -------------------------------------------------------------------
// pluck is Karplus-Strong: a delay line one period long, filled with noise and
// then repeatedly averaged with its neighbour. The averaging is a one-pole
// lowpass, so the high partials in the noise die first and what is left settles
// into a pitched, slowly darkening string.
type pluck struct {
buf []float32
idx int
gain float32 // per-period loop gain, tuned to reach silence at `sustain`
rem int // samples left before the voice retires
}
func newPluck(freq, sustain float64, seed int64) *pluck {
n := max(int(math.Round(sr/freq)), 2)
rng := rand.New(rand.NewSource(seed))
buf := make([]float32, n)
for i := range buf {
buf[i] = float32(rng.Float64()*2 - 1)
}
// Fade the excitation into the loop point so the first wrap doesn't click.
for i := 0; i < n/8; i++ {
buf[n-1-i] *= float32(i) / float32(n/8)
}
// Each slot in the delay line is rewritten once per trip around the loop,
// not once per sample — so the gain applied there is the *per-period* one.
// The loop runs freq times a second: solve g^(freq*sustain) = 1e-3.
return &pluck{
buf: buf,
gain: float32(math.Exp(math.Log(1e-3) / (freq * sustain))),
rem: int(sustain * sr),
}
}
func (p *pluck) Process(out []float32) bool {
for i := range out {
if p.rem <= 0 {
for j := i; j < len(out); j++ {
out[j] = 0
}
return false
}
cur := p.buf[p.idx]
next := p.buf[(p.idx+1)%len(p.buf)]
p.buf[p.idx] = (cur + next) * 0.5 * p.gain
out[i] = cur * 0.6
p.idx = (p.idx + 1) % len(p.buf)
p.rem--
}
return true
}
// --- Bell --------------------------------------------------------------------
// bell is a two-operator FM voice. The modulator sits at an inharmonic ratio
// (1.41 — near √2, so its partials never line up with the carrier's), which is
// what makes a struck-metal sound rather than a brass one. The modulation index
// decays faster than the amplitude, so the strike is bright and the tail is
// nearly a sine.
type bell struct {
pc, pm float64 // carrier and modulator phase
fc, fm float64
pos, total int
tau float64 // amplitude decay constant, in samples
}
func newBell(freq, sustain float64) *bell {
return &bell{
fc: freq,
fm: freq * 1.41,
total: int(sustain * sr),
tau: sustain * sr / 5, // ~e⁻⁵ by the end
}
}
func (b *bell) Process(out []float32) bool {
incC, incM := b.fc/sr, b.fm/sr
for i := range out {
if b.pos >= b.total {
for j := i; j < len(out); j++ {
out[j] = 0
}
return false
}
t := float64(b.pos) / b.tau
env := math.Exp(-t)
if b.pos < 200 { // 4.5 ms attack, enough to kill the click
env *= float64(b.pos) / 200
}
index := 3.2 * math.Exp(-t*2.5) // brightness dies before loudness
out[i] = float32(math.Sin(2*math.Pi*b.pc+index*math.Sin(2*math.Pi*b.pm)) * env * 0.42)
b.pc += incC
b.pm += incM
b.pos++
}
return true
}
// --- Glass -------------------------------------------------------------------
// glass is a pad: three triangle oscillators — the root, a slightly sharp
// detune, and the fifth above — under a slow attack and a long release. The
// detune beats against the root at a couple of hertz, which is what stops a
// sustained chord from sounding like a synthesizer test tone.
type glass struct {
p [3]float64
f [3]float64
pos, total int
attack int
}
func newGlass(freq, sustain float64) *glass {
total := int(sustain * 1.6 * sr) // pads outlive their nominal sustain
return &glass{
f: [3]float64{freq, freq * 1.004, freq * 1.5},
total: total,
attack: int(float64(total) * 0.18),
}
}
func (g *glass) Process(out []float32) bool {
amp := [3]float64{0.5, 0.4, 0.22}
for i := range out {
if g.pos >= g.total {
for j := i; j < len(out); j++ {
out[j] = 0
}
return false
}
var env float64
if g.pos < g.attack {
env = float64(g.pos) / float64(g.attack)
} else {
rem := float64(g.total-g.pos) / float64(g.total-g.attack)
env = rem * rem
}
var s float64
for k := range g.f {
s += triangle(g.p[k]) * amp[k]
g.p[k] += g.f[k] / sr
if g.p[k] >= 1 {
g.p[k] -= 1
}
}
out[i] = float32(s * env * 0.34)
g.pos++
}
return true
}
func triangle(phase float64) float64 { return 4*math.Abs(phase-0.5) - 1 }
// --- Scales ------------------------------------------------------------------
// Scale is a set of semitone offsets from the root, repeating every octave.
// Every scale here is gapped or symmetric — no minor seconds against the root —
// so the crawlers cannot land on a combination that sounds like a mistake. That
// is the whole trick behind this kind of toy: constrain the pitch set and any
// pattern the user draws is consonant.
type Scale struct {
Name string
Degrees []int
}
var scales = []Scale{
{"Pentatonic", []int{0, 2, 4, 7, 9}},
{"Minor pent.", []int{0, 3, 5, 7, 10}},
{"Insen", []int{0, 1, 5, 7, 10}}, // the Japanese scale Iwai's toys lean on
{"Dorian", []int{0, 2, 3, 5, 7, 9, 10}},
{"Whole tone", []int{0, 2, 4, 6, 8, 10}},
}
func scaleNames() []string {
out := make([]string, len(scales))
for i, s := range scales {
out[i] = s.Name
}
return out
}
// Freq maps a scale step (0 = the root) to hertz, walking up octaves as the
// step runs past the end of the scale. root is a MIDI note number.
func (s Scale) Freq(step, root int) float64 {
n := len(s.Degrees)
oct, deg := step/n, step%n
if deg < 0 {
deg += n
oct--
}
semis := root + 12*oct + s.Degrees[deg]
return 440 * math.Pow(2, float64(semis-69)/12)
}