main.go

// Command drummachine is a 16-step drum sequencer that live-synthesizes its own
// kick/snare/clap/hi-hat voices (no audio assets) and mixes them through the
// pure-Go audio engine — CoreAudio/WASAPI/PulseAudio via sound/device. Tap the
// grid to program a beat, Space to start/stop, and adjust the tempo. It is the
// driver example for real-time audio output: a tight rhythmic clock scheduling
// polyphonic one-shots, versus the fire-and-forget sfx the games use.
//
//	go run ./examples/drummachine
package main

import (
	"fmt"
	"log"

	"golang.org/x/image/font/gofont/goregular"

	"github.com/doug/gophics/app"
	"github.com/doug/gophics/geom"
	"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/widget"
)

const (
	steps     = 16
	numVoices = 5
	minBPM    = 40
	maxBPM    = 240
)

var (
	bg       = paint.RGB(0.11, 0.12, 0.14)
	cellOff  = paint.RGB(0.19, 0.20, 0.24)
	cellBeat = paint.RGB(0.24, 0.25, 0.30) // downbeat columns, a touch lighter
	labelCol = paint.RGB(0.72, 0.75, 0.80)
	titleCol = paint.RGB(0.96, 0.97, 0.99)
	subCol   = paint.RGB(0.52, 0.55, 0.62)
	headCol  = paint.Color{R: 1, G: 1, B: 1, A: 0.10} // playhead column wash
	btnBg    = paint.RGB(0.22, 0.23, 0.28)
	btnFg    = paint.RGB(0.91, 0.93, 0.96)
	stopBg   = paint.RGB(0.86, 0.30, 0.34) // Play button while playing (a Stop)
	playBg   = paint.RGB(0.22, 0.62, 0.40) // Play button while stopped
)

// voiceCol tints each voice's active steps.
var voiceCol = [numVoices]paint.Color{
	paint.RGB(0.95, 0.36, 0.36), // kick
	paint.RGB(0.97, 0.62, 0.26), // snare
	paint.RGB(0.96, 0.82, 0.32), // clap
	paint.RGB(0.32, 0.80, 0.72), // closed hat
	paint.RGB(0.44, 0.66, 0.98), // open hat
}

type App struct{ Mixer *sound.Mixer }

func (App) CreateState() widget.State { return &drum{} }

type drum struct {
	widget.StateBase[App]
	mixer   *sound.Mixer
	voices  []voice
	grid    [numVoices][steps]bool
	bpm     float64
	playing bool
	step    int     // current playhead step
	acc     float64 // seconds accumulated toward the next step

	ctx     widget.Ctx
	cell    [numVoices][steps]geom.Rect
	playBtn geom.Rect
	tempoDn geom.Rect
	tempoUp geom.Rect
	clrBtn  geom.Rect
}

// stateHook, if set, receives the state on mount — for tests to drive input.
var stateHook func(*drum)

func (s *drum) Init(ctx widget.Ctx) {
	s.ctx = ctx
	s.mixer = s.W().Mixer
	s.voices = kit()
	s.bpm = 120
	s.loadDefaultPattern()
	s.start()
	ctx.AddTicker(s)
	if stateHook != nil {
		stateHook(s)
	}
}

func (s *drum) loadDefaultPattern() {
	s.grid = [numVoices][steps]bool{}
	set := func(v int, at ...int) {
		for _, st := range at {
			s.grid[v][st] = true
		}
	}
	set(0, 0, 8)                      // kick on the ones
	set(1, 4, 12)                     // snare backbeat
	set(2, 4, 12)                     // clap doubles the snare
	set(3, 0, 2, 4, 6, 8, 10, 12, 14) // closed hats on eighths
	set(4, 14)                        // open hat pickup
}

// start (re)starts playback from step 0 and sounds the downbeat.
func (s *drum) start() {
	s.playing = true
	s.step = 0
	s.acc = 0
	s.trigger(0)
}

func (s *drum) togglePlay() {
	if s.playing {
		s.playing = false
	} else {
		s.start()
	}
	s.ctx.Invalidate()
}

// trigger sounds every voice active on the given step.
func (s *drum) trigger(step int) {
	if s.mixer == nil {
		return
	}
	for v := range s.voices {
		if s.grid[v][step] {
			s.mixer.Play(s.voices[v].sample, sound.PlayOptions{Volume: s.voices[v].vol})
		}
	}
}

// stepDur is the seconds per 16th-note step at the current tempo.
func (s *drum) stepDur() float64 { return 60.0 / s.bpm / 4 }

// Tick advances the rhythmic clock, firing each step it crosses.
func (s *drum) Tick(dt float64) bool {
	if !s.playing {
		return false
	}
	dur := s.stepDur()
	s.acc += dt
	for s.acc >= dur {
		s.acc -= dur
		s.step = (s.step + 1) % steps
		s.trigger(s.step)
	}
	s.ctx.Invalidate()
	return true
}

func (s *drum) setTempo(delta float64) {
	s.bpm = clampf(s.bpm+delta, minBPM, maxBPM)
	s.ctx.Invalidate()
}

func (s *drum) clear() {
	s.grid = [numVoices][steps]bool{}
	s.ctx.Invalidate()
}

// toggleCell flips a step and, when turning it on, previews the voice.
func (s *drum) toggleCell(v, st int) {
	s.grid[v][st] = !s.grid[v][st]
	if s.grid[v][st] && s.mixer != nil {
		s.mixer.Play(s.voices[v].sample, sound.PlayOptions{Volume: s.voices[v].vol})
	}
	s.ctx.Invalidate()
}

func (s *drum) onPress(p geom.Pt) {
	for v := range numVoices {
		for st := range steps {
			if s.cell[v][st].Contains(p) {
				s.toggleCell(v, st)
				return
			}
		}
	}
	switch {
	case s.playBtn.Contains(p):
		s.togglePlay()
	case s.tempoDn.Contains(p):
		s.setTempo(-5)
	case s.tempoUp.Contains(p):
		s.setTempo(5)
	case s.clrBtn.Contains(p):
		s.clear()
	}
}

func (s *drum) Build(_ widget.Ctx) widget.Widget {
	return widget.Interactive{
		Gestures: widget.Gestures{
			OnKey: func(k shell.Key) {
				if k.Kind != shell.KeyPress {
					return
				}
				switch k.Code {
				case shell.KeySpace:
					s.togglePlay()
				case shell.KeyUp:
					s.setTempo(5)
				case shell.KeyDown:
					s.setTempo(-5)
				}
			},
			OnPress: func(p geom.Pt) { s.onPress(p) },
		},
		Child: widget.Canvas{Clip: true, Draw: s.draw},
	}
}

func (s *drum) draw(c paint.Canvas, sz geom.Size) {
	c.Clear(bg)
	const pad = 22
	c.TextIn("", "Drum Machine", geom.Pt{X: pad, Y: 44}, 26, titleCol)
	c.TextIn("", fmt.Sprintf("%.0f BPM", s.bpm), geom.Pt{X: sz.W - pad - 74, Y: 44}, 18, subCol)

	const (
		labelW  = 84
		cellGap = 5
		beatGap = 10 // extra space between groups of four
		rowH    = 46
		bh      = 40 // transport button height
	)
	// Center the grid + transport block vertically in the space under the title,
	// so it sits well both in the app window and the (taller) gallery card.
	blockH := float32(numVoices*rowH + 10 + bh)
	gridTop := 64 + (sz.H-64-blockH)/2
	if gridTop < 64 {
		gridTop = 64
	}
	gridLeft := float32(pad + labelW)
	gridRight := sz.W - pad
	availW := gridRight - gridLeft
	cellW := (availW - 15*cellGap - 3*beatGap) / 16
	if cellW < 12 {
		return
	}
	cellH := float32(rowH - 12)
	xOf := func(st int) float32 {
		return gridLeft + float32(st)*(cellW+cellGap) + float32(st/4)*beatGap
	}
	yOf := func(v int) float32 { return gridTop + float32(v)*rowH }

	// Playhead column wash behind the active step.
	if s.playing {
		x := xOf(s.step)
		c.FillRRect(geom.RectXYWH(x-2, gridTop-4, cellW+4, numVoices*rowH-2), 5, headCol)
	}

	for v := range numVoices {
		c.TextIn("", s.voices[v].name, geom.Pt{X: pad, Y: yOf(v) + cellH*0.72}, 14, labelCol)
		for st := range steps {
			r := geom.RectXYWH(xOf(st), yOf(v), cellW, cellH)
			s.cell[v][st] = r
			switch {
			case s.grid[v][st]:
				col := voiceCol[v]
				if s.playing && st == s.step {
					col = lighten(col, 0.25) // flash the playing step
				}
				c.FillRRect(r, 5, col)
			case st%4 == 0:
				c.FillRRect(r, 5, cellBeat)
			default:
				c.FillRRect(r, 5, cellOff)
			}
		}
	}

	// Transport row.
	ty := gridTop + float32(numVoices*rowH+10)
	s.playBtn = geom.RectXYWH(gridLeft, ty, 96, bh)
	pbg, plabel := playBg, "Play"
	if s.playing {
		pbg, plabel = stopBg, "Stop"
	}
	s.button(c, s.playBtn, plabel, pbg, paint.RGB(1, 1, 1))

	s.tempoDn = geom.RectXYWH(gridLeft+110, ty, 40, bh)
	s.tempoUp = geom.RectXYWH(gridLeft+214, ty, 40, bh)
	s.button(c, s.tempoDn, "–", btnBg, btnFg)
	s.button(c, s.tempoUp, "+", btnBg, btnFg)
	c.TextIn("", "tempo", geom.Pt{X: gridLeft + 160, Y: ty + bh/2 + 5}, 14, subCol)

	s.clrBtn = geom.RectXYWH(gridRight-96, ty, 96, bh)
	s.button(c, s.clrBtn, "Clear", btnBg, btnFg)
}

func (s *drum) button(c paint.Canvas, r geom.Rect, label string, bgc, fg paint.Color) {
	c.FillRRect(r, 8, bgc)
	w := s.ctx.Painter().MeasureWidthIn("", label, 15)
	c.TextIn("", label, geom.Pt{X: r.Min.X + (r.Dx()-w)/2, Y: r.Min.Y + r.Dy()/2 + 5}, 15, fg)
}

func lighten(c paint.Color, amt float32) paint.Color {
	return paint.Color{
		R: c.R + (1-c.R)*amt,
		G: c.G + (1-c.G)*amt,
		B: c.B + (1-c.B)*amt,
		A: c.A,
	}
}

func clampf(v, lo, hi float64) float64 {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}

func main() {
	// Audio is best-effort: if the device won't open, the sequencer 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: "Drum Machine",
		// The pad grid is a fixed layout, not a reflowing one, so on a narrow
		// screen it is scaled down whole rather than cropped.
		Size:       geom.Size{W: 760, H: 424},
		ScaleToFit: true,
		Background: bg,
		Font:       goregular.TTF,
	}); err != nil {
		log.Fatal(err)
	}
}

synth.go

package main

import (
	"math"
	"math/rand"

	"github.com/doug/gophics/sound"
)

// Drum voices are synthesized from scratch into mono float32 PCM at the mixer's
// SampleRate — no audio assets. Each is a short one-shot the sequencer triggers
// on the beat. The math is deterministic (a seeded PRNG for the noise voices),
// so the samples are unit-testable.

const sr = float64(sound.SampleRate)

// kick: a sine whose pitch drops fast from ~120 Hz to ~48 Hz under a quick
// amplitude decay — the classic synthesized bass drum.
func kick() *sound.Sample {
	n := int(0.42 * sr)
	buf := make([]float32, n)
	phase := 0.0
	for i := range buf {
		t := float64(i) / sr
		f := 48 + (120-48)*math.Exp(-t*34) // pitch envelope
		phase += f / sr
		amp := math.Exp(-t * 9)
		buf[i] = float32(math.Sin(2*math.Pi*phase) * amp * 0.95)
	}
	return sound.NewSample(buf)
}

// snare: a short tonal body (~180 Hz) mixed with white noise, both decaying
// fast — body for the "thock", noise for the "sh".
func snare(rng *rand.Rand) *sound.Sample {
	n := int(0.2 * sr)
	buf := make([]float32, n)
	phase := 0.0
	for i := range buf {
		t := float64(i) / sr
		amp := math.Exp(-t * 24)
		phase += 180.0 / sr
		body := math.Sin(2*math.Pi*phase) * 0.5
		noise := rng.Float64()*2 - 1
		buf[i] = float32((body + noise*0.85) * amp * 0.7)
	}
	return sound.NewSample(buf)
}

// hat: high-passed white noise with an exponential decay. A short decay is a
// closed hi-hat, a long one an open hi-hat.
func hat(rng *rand.Rand, decay, amp float64) *sound.Sample {
	n := int((decay*4 + 0.01) * sr)
	buf := make([]float32, n)
	prev := 0.0
	for i := range buf {
		t := float64(i) / sr
		white := rng.Float64()*2 - 1
		hp := white - prev // crude one-pole high-pass (differencing)
		prev = white
		buf[i] = float32(hp * math.Exp(-t/decay) * amp)
	}
	return sound.NewSample(buf)
}

// clap: three quick high-passed noise bursts a few ms apart, then a short tail —
// the stacked transients that read as a hand clap.
func clap(rng *rand.Rand) *sound.Sample {
	n := int(0.24 * sr)
	buf := make([]float32, n)
	prev := 0.0
	for i := range buf {
		t := float64(i) / sr
		white := rng.Float64()*2 - 1
		hp := white - prev*0.6
		prev = white
		var env float64
		switch {
		case t < 0.009:
			env = math.Exp(-t / 0.003)
		case t < 0.018:
			env = math.Exp(-(t - 0.009) / 0.003)
		case t < 0.027:
			env = math.Exp(-(t - 0.018) / 0.004)
		default:
			env = math.Exp(-(t - 0.027) / 0.045)
		}
		buf[i] = float32(hp * env * 0.55)
	}
	return sound.NewSample(buf)
}

// voice is one row of the sequencer: a named drum sound at a mixing volume.
type voice struct {
	name   string
	sample *sound.Sample
	vol    float64
}

// kit builds the fixed five-voice drum kit, top row to bottom.
func kit() []voice {
	rng := rand.New(rand.NewSource(1)) // fixed seed → identical kit every run
	return []voice{
		{"Kick", kick(), 0.95},
		{"Snare", snare(rng), 0.8},
		{"Clap", clap(rng), 0.7},
		{"CH Hat", hat(rng, 0.03, 0.45), 0.6},
		{"OH Hat", hat(rng, 0.14, 0.4), 0.5},
	}
}