concertina

Elecronic concertina
git clone https://git.woozle.org/neale/concertina.git

commit
a3d4bd2
parent
ddad765
author
Neale Pickett
date
2026-02-16 19:22:05 -0700 MST
gpio driver and layouts
12 files changed,  +372, -61
M go.mod
M go.sum
M cmd/concertina/concertina.go
+46, -16
  1@@ -8,6 +8,7 @@ import (
  2 	"tinygo.org/x/drivers"
  3 	"tinygo.org/x/drivers/ssd1306"
  4 	"git.woozle.org/neale/concertina/pkg/led"
  5+	"git.woozle.org/neale/concertina/pkg/pcf8575"
  6 )
  7 
  8 // For crying out loud.
  9@@ -18,10 +19,10 @@ var Black = color.RGBAModel.Convert(color.Black).(color.RGBA)
 10 type Concertina struct {
 11 	display *ssd1306.Device
 12 	tonegen MidiWriter
 13-	i2c drivers.I2C
 14+	expanders[4] pcf8575.Device
 15 }
 16 
 17-func NewConcertina() (*Concertina, error) {
 18+func NewConcertina(i2c drivers.I2C) (*Concertina, error) {
 19 	// Initialize tone generator
 20 	if err := machine.DefaultUART.Configure(machine.UARTConfig{
 21 		BaudRate: 31250,
 22@@ -41,13 +42,19 @@ func NewConcertina() (*Concertina, error) {
 23 
 24 	c := &Concertina {
 25 		tonegen: MidiWriter{Writer: machine.DefaultUART},
 26-		i2c: machine.I2C0,
 27+	}
 28+
 29+	for i := range(4) {
 30+		c.expanders[i] = pcf8575.Device{
 31+			Addr: 0x20,
 32+			I2C: i2c,
 33+		}
 34 	}
 35 
 36 	<-resetTimer // Wait until this timer has elapsed
 37 	VS1053_RESET.High()
 38 
 39-	c.display = ssd1306.NewI2C(c.i2c)
 40+	c.display = ssd1306.NewI2C(i2c)
 41 	c.display.Configure(
 42 		ssd1306.Config{
 43 			Width: 128,
 44@@ -55,6 +62,8 @@ func NewConcertina() (*Concertina, error) {
 45 			Address: 0x3C,
 46 		},
 47 	)
 48+	c.display.ClearDisplay()
 49+	c.display.Display()
 50 
 51 
 52 	c.tonegen.SetPatch(22)  // General MIDI harmonica
 53@@ -62,31 +71,52 @@ func NewConcertina() (*Concertina, error) {
 54 	return c, nil
 55 }
 56 
 57+var x int16
 58+func (c *Concertina) Loop() {
 59+	var buttons [4]uint16
 60+
 61+	led.Builtin.SetColor(color.RGBA{uint8(x % 2), 0, 0, 0})
 62+
 63+	c.display.ClearBuffer()
 64+	for i := range(4) {
 65+		buttons[i] = c.expanders[i].Get()
 66+		for bit := range(16) {
 67+			pc := White
 68+			if buttons[i] & (1<<bit)  != 0 {
 69+				pc = Black
 70+			}
 71+			c.display.SetPixel(int16(bit), int16(16+3*i), pc)
 72+		}
 73+	}
 74+	c.display.FillRectangle(x, 0, 5, 5, White)
 75+	x = (x + 1) % 120
 76+	c.display.Display()
 77+
 78+	//c.tonegen.NoteOn(byte(x))
 79+	time.Sleep(200 * time.Millisecond)
 80+	//c.tonegen.NoteOff(byte(x))
 81+}
 82+
 83 func main() {
 84 	// Blink the LED during startup.
 85 	blinkCtx, blinkCancel := context.WithCancel(context.Background())
 86 	go led.Flash(blinkCtx, led.Builtin, time.Second / 4, time.Second / 4)
 87 
 88-	c, err := NewConcertina()
 89+	c, err := NewConcertina(machine.I2C0)
 90 	if err != nil {
 91 		// let the little light blink forever and ever
 92 		for {
 93 			time.Sleep(1 * time.Hour)
 94 		}
 95 	}
 96-	blinkCancel()
 97-	
 98-	var x int
 99-	for {
100-		c.display.ClearBuffer()
101-		c.display.FillRectangle(int16(x), 28 - int16(x % 28), 4, 4, White)
102-		c.display.Display()
103-		x = (x + 1) % 120
104 
105-		c.tonegen.NoteOn(byte(x))
106+	// Okay, you can stop blinking now
107+	blinkCancel()
108+	//led.Builtin.Disable()
109 
110-		time.Sleep(200 * time.Millisecond)
111+	led.Builtin.SetColor(color.RGBA{0, 0, 10, 0})
112 
113-		c.tonegen.NoteOff(byte(x))
114+	for {
115+		c.Loop()
116 	}
117 }
A cmd/concertina/display.go
+12, -0
 1@@ -0,0 +1,12 @@
 2+package main
 3+
 4+import (
 5+	"tinygo.org/x/tinyfont"
 6+	"tinygo.org/x/tinyfont/gophers"
 7+)
 8+
 9+func (c *Concertina) Print(s string) {
10+	c.display.ClearDisplay()
11+	tinyfont.WriteLine(c.display, &gophers.Regular14pt, 18, 90, s, Black)
12+	c.display.Display()
13+}
M go.mod
+4, -1
 1@@ -2,6 +2,9 @@ module git.woozle.org/neale/concertina
 2 
 3 go 1.24.4
 4 
 5-require tinygo.org/x/drivers v0.34.0
 6+require (
 7+	tinygo.org/x/drivers v0.34.0
 8+	tinygo.org/x/tinyfont v0.3.0
 9+)
10 
11 require github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
M go.sum
+2, -0
1@@ -2,3 +2,5 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
2 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
3 tinygo.org/x/drivers v0.34.0 h1:lw8ePJeUSn9oICKBvQXHC9TIE+J00OfXfkGTrpXM9Iw=
4 tinygo.org/x/drivers v0.34.0/go.mod h1:ZdErNrApSABdVXjA1RejD67R8SNRI6RKVfYgQDZtKtk=
5+tinygo.org/x/tinyfont v0.3.0 h1:HIRLQoI3oc+2CMhPcfv+Ig88EcTImE/5npjqOnMD4lM=
6+tinygo.org/x/tinyfont v0.3.0/go.mod h1:+TV5q0KpwSGRWnN+ITijsIhrWYJkoUCp9MYELjKpAXk=
A pkg/layouts/abc.go
+100, -0
  1@@ -0,0 +1,100 @@
  2+package layouts
  3+
  4+var baseMidiNote = map[byte]int8{
  5+	'C': 60,
  6+	'D': 62,
  7+	'E': 64,
  8+	'F': 65,
  9+	'G': 67,
 10+	'A': 69,
 11+	'B': 71,
 12+	'c': 72,
 13+	'd': 74,
 14+	'e': 76,
 15+	'f': 77,
 16+	'g': 79,
 17+	'a': 81,
 18+	'b': 83,
 19+}
 20+
 21+func ABC2MIDI(note string) int8 {
 22+	if note == "" {
 23+		return -1
 24+	}
 25+
 26+	val, ok := baseMidiNote[note[0]]
 27+	if !ok {
 28+		return -1
 29+	}
 30+
 31+	for _, m := range note[1:] {
 32+		switch m {
 33+		case '♯':
 34+			fallthrough
 35+		case '^':
 36+			val += 1
 37+
 38+		case '♭':
 39+			fallthrough
 40+		case '_':
 41+			val -= 1
 42+
 43+		case '\'':
 44+			val += 12
 45+
 46+		case ',':
 47+			val -= 12
 48+
 49+		default:
 50+			return -1
 51+		}
 52+	}
 53+
 54+	return val
 55+}
 56+
 57+func MIDI2ABC(val int8) string {
 58+	octave := make([]byte, 0, 8)
 59+	for val < baseMidiNote['C'] {
 60+		octave = append(octave, ',')
 61+		val += 12
 62+	}
 63+	for val > baseMidiNote['b'] {
 64+		octave = append(octave, '\'')
 65+		val -= 12
 66+	}
 67+
 68+	var base string
 69+	switch val % 12 {
 70+	case 0:
 71+		base = "C"
 72+	case 1:
 73+		base = "C^"
 74+	case 2:
 75+		base = "D"
 76+	case 3:
 77+		base = "D^"
 78+	case 4:
 79+		base = "E"
 80+	case 5:
 81+		base = "F"
 82+	case 6:
 83+		base = "F^"
 84+	case 7:
 85+		base = "G"
 86+	case 8:
 87+		base = "G^"
 88+	case 9:
 89+		base = "A"
 90+	case 10:
 91+		base = "B_"
 92+	case 11:
 93+		base = "B"
 94+	}
 95+
 96+	if val >  baseMidiNote['B'] {
 97+		base = string(base[0] + 0x20) + base[1:]
 98+	}
 99+
100+	return base + string(octave)
101+}
A pkg/layouts/abc_test.go
+40, -0
 1@@ -0,0 +1,40 @@
 2+package layouts
 3+
 4+import "testing"
 5+
 6+func abcTest(t *testing.T, s string, n int8) {
 7+	if r := ABC2MIDI(s); r != n {
 8+		t.Errorf("ABC2MIDI(%#v): wanted %d, got %d", s, n, r)
 9+	}
10+}
11+
12+func midiTest(t *testing.T, n int8, s string) {
13+	if v := MIDI2ABC(n); v != s {
14+		t.Errorf("MIDI2ABC(%d); wanted %#v, got %#v", n, s, v)
15+	}
16+}
17+
18+func bothTest(t *testing.T, s string, n int8) {
19+	abcTest(t, s, n)
20+	midiTest(t, n, s)
21+}
22+
23+func TestABC(t *testing.T) {
24+	abcTest(t, "C", 60)
25+	abcTest(t, "D", 62)
26+	abcTest(t, "E", 64)
27+	abcTest(t, "F", 65)
28+	abcTest(t, "G", 67)
29+	abcTest(t, "A", 69)
30+	abcTest(t, "B", 71)
31+	abcTest(t, "c", 72)
32+	abcTest(t, "d", 74)
33+	abcTest(t, "e", 76)
34+	abcTest(t, "f", 77)
35+	abcTest(t, "g", 79)
36+	abcTest(t, "a", 81)
37+	abcTest(t, "b", 83)
38+	abcTest(t, "c^", 73)
39+	abcTest(t, "b_'", 94)
40+	abcTest(t, "G^,,", 44)
41+}
A pkg/layouts/layouts.go
+99, -0
  1@@ -0,0 +1,99 @@
  2+// Package layouts parses text layouts into []Layout.
  3+//
  4+// Because Go doesn't appear to have any built-in mechanism to output Go source code literals,
  5+// it's either this or some intermediate format like JSON or gobs.
  6+// This bespoke package is pretty small compared to the alternatives.
  7+package layouts
  8+
  9+import (
 10+	_ "embed"
 11+	"strconv"
 12+	"strings"
 13+)
 14+
 15+//go:embed layouts.txt
 16+var layouts_txt string
 17+
 18+const (
 19+	Sides   = 2
 20+	Rows    = 4
 21+	Columns = 5
 22+)
 23+
 24+type ParseError struct {
 25+	line int
 26+	desc string
 27+}
 28+
 29+func (e *ParseError) Error() string {
 30+	return "line " + strconv.Itoa(e.line) + ": " + e.desc
 31+}
 32+
 33+type Layout struct {
 34+	Name  string
 35+	Notes [Sides][Rows][Columns][2]int8
 36+}
 37+
 38+func ParseLayouts(s string) ([]*Layout, error) {
 39+	layouts := make([]*Layout, 0)
 40+	var current *Layout
 41+
 42+	row := 0
 43+	for lineno, line := range strings.Split(s, "\n") {
 44+		line = strings.TrimSpace(line)
 45+
 46+		// Skip blank lines and comments
 47+		if (line == "") || strings.HasPrefix(line, "#") {
 48+			continue
 49+		}
 50+
 51+		if row >= Rows {
 52+			return nil, &ParseError{lineno, "too many rows"}
 53+		}
 54+
 55+		// Split the line up into fields
 56+		fields := strings.Fields(line)
 57+
 58+		// New layouts are anything other than 11-field lines with | in the middle
 59+		if (len(fields) != 11) || (fields[5] != "|") {
 60+			current = &Layout{
 61+				Name: line,
 62+			}
 63+			layouts = append(layouts, current)
 64+			row = 0
 65+			continue
 66+		}
 67+
 68+		// We can skip the middle | now
 69+		fields = append(fields[:5], fields[6:]...)
 70+		for col, button := range fields {
 71+			side := col / Columns
 72+			col = col % Columns
 73+			notes := strings.Split(button, "/")
 74+
 75+			// If only one note is listed, push and draw are the same
 76+			if len(notes) == 1 {
 77+				notes = append(notes, notes[0])
 78+			}
 79+
 80+			if len(notes) != 2 {
 81+				return nil, &ParseError{lineno, "invalid button entry"}
 82+			}
 83+
 84+			for i, note := range notes {
 85+				midi := ABC2MIDI(note)
 86+				if (midi == -1) && (note != "-") {
 87+					return nil, &ParseError{lineno, "unrecognized note: " + note}
 88+				}
 89+				current.Notes[side][row][col][i] = midi
 90+			}
 91+		}
 92+		row += 1
 93+	}
 94+
 95+	return layouts, nil
 96+}
 97+
 98+func DefaultLayouts() ([]*Layout, error) {
 99+	return ParseLayouts(layouts_txt)
100+}
R src/layouts.txt => pkg/layouts/layouts.txt
+0, -0
A pkg/layouts/layouts_test.go
+42, -0
 1@@ -0,0 +1,42 @@
 2+package layouts
 3+
 4+import (
 5+	"encoding/json"
 6+	"testing"
 7+)
 8+
 9+func TestParser(t *testing.T) {
10+	txt := `Rochelle C/G
11+     	E,/F,	A,/B♭,	C♯/D♯	A/G	G♯/B♭	|	c♯/d♯	a/c♯	g♯/g	c♯'/b♭	a'/d'
12+	C,/G,	G,/B,	C/D	E/F	G/A	|	c/B	e/d	g/f	c'/a	e'/b
13+	B,/A,	D/F♯	G/A	B/c	d/e	|	g/f♯	b/a	d'/c'	g'/e'	b'/f♯'
14+	-	-	-	-	D,/D,	|	-	-	-	-	-
15+`
16+
17+	layouts, err := ParseLayouts(txt)
18+	if err != nil {
19+		t.Fatal(err)
20+	}
21+
22+	if l := len(layouts); l != 1 {
23+		t.Fatalf("wrong length: %d", l)
24+	}
25+
26+	layout := layouts[0]
27+	if layout.Name != "Rochelle C/G" {
28+		t.Error("wrong name")
29+	}
30+
31+	if b, err := json.Marshal(layout); err != nil {
32+		t.Error(err)
33+	} else {
34+		t.Log(string(b))
35+	}
36+
37+	if v := layout.Notes[0][0][0][0]; v != ABC2MIDI("E,") {
38+		t.Errorf("wrong note: %d", v)
39+	}
40+	if layout.Notes[0][0][0][1] != ABC2MIDI("F,") {
41+		t.Error("wrong note")
42+	}
43+}
A pkg/pcf8575/pcf8575.go
+27, -0
 1@@ -0,0 +1,27 @@
 2+package pcf8575
 3+
 4+import (
 5+	"encoding/binary"
 6+	"tinygo.org/x/drivers"
 7+)
 8+
 9+type Device struct {
10+	Addr uint16
11+	I2C  drivers.I2C
12+}
13+
14+// Read value of all pins
15+func (c *Device) Get() uint16 {
16+	rxbuf := make([]byte, 2)
17+	// If len(w) == 0, the low bit gets set
18+	// see /usr/local/lib/tinygo/src/machine/machine_atsamd21.go
19+	c.I2C.Tx(c.Addr, nil, rxbuf)
20+	return binary.LittleEndian.Uint16(rxbuf)
21+}
22+
23+// Put val onto all pins
24+func (c *Device) Put(val uint16) {
25+	txbuf := binary.LittleEndian.AppendUint16(nil, val)
26+	rxbuf := []byte{}
27+	c.I2C.Tx(c.Addr, txbuf, rxbuf)
28+}
D src/blink.c
+0, -30
 1@@ -1,30 +0,0 @@
 2-#include <stdbool.h>
 3-#include <stdint.h>
 4-#include <avr/io.h>
 5-#include <avr/interrupt.h>
 6-
 7-// CPU frequency in Hertz
 8-#define F_CPU (1 * 1000000UL)
 9-
10-void init(void) {
11-  DDRB = 0xff; // All port B pins are outputs
12-}
13-
14-void loop(void) {
15-  static uint32_t count = 0;
16-
17-  if (count++ == 20000) {
18-    PORTB ^= 0x01; // Toggle pin 0
19-    count = 0;
20-  }
21-}
22-
23-int main(void) {
24-	init();
25-
26-	for (;;) {
27-		loop();
28-	}
29-
30-	return 0;
31-}
D src/ruby.h
+0, -14
 1@@ -1,14 +0,0 @@
 2-// 'ruby', 30x32px
 3-const unsigned char ruby_xbm[] = {
 4-  0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x3f, 0x00, 0x00, 0x80, 0x3f,
 5-  0x00, 0x00, 0xc0, 0x3f, 0x00, 0x00, 0xe0, 0x3f, 0xc0, 0x01, 0xf0, 0x3f,
 6-  0xe0, 0x03, 0xf8, 0x3f, 0xff, 0x07, 0xf8, 0x3f, 0xff, 0x07, 0xf8, 0x1f,
 7-  0xff, 0xff, 0xff, 0x1f, 0xff, 0xff, 0xff, 0x1f, 0xff, 0xff, 0xff, 0x0f,
 8-  0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xfe, 0xff, 0xff, 0x07,
 9-  0xfc, 0xff, 0xff, 0x03, 0xf8, 0xff, 0xff, 0x01, 0xf0, 0xff, 0xff, 0x00,
10-  0xf0, 0xff, 0xff, 0x00, 0xf0, 0xff, 0xff, 0x00, 0xf0, 0xff, 0xff, 0x00,
11-  0xf0, 0xff, 0xff, 0x00, 0xe0, 0xff, 0xff, 0x00, 0xe0, 0xff, 0xff, 0x00,
12-  0xc0, 0xff, 0x7f, 0x00, 0x80, 0xff, 0x3f, 0x00, 0x00, 0xff, 0x1f, 0x00,
13-  0x00, 0xfe, 0x1f, 0x00, 0x00, 0xfe, 0x0f, 0x00, 0x00, 0xfc, 0x0f, 0x00,
14-  0x00, 0xf8, 0x07, 0x00, 0x00, 0xf0, 0x03, 0x00 
15-};