concertina

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

commit
ba0b731
parent
f751184
author
Neale Pickett
date
2026-02-17 21:22:12 -0700 MST
bellows + unit tests
9 files changed,  +251, -65
A cmd/concertina/bellows.go
+33, -0
 1@@ -0,0 +1,33 @@
 2+package main
 3+
 4+import (
 5+	"tinygo.org/x/drivers"
 6+	"tinygo.org/x/drivers/vl6180x"
 7+)
 8+
 9+// BellowsSlop defines the coarseness of the bellows action.
10+// Set this lower to make the bellows more "twitchy"
11+const BellowsSlop = 2
12+
13+type Bellows struct {
14+	vl6180x.Device
15+	prev uint16
16+}
17+
18+func NewBellows(i2c drivers.I2C) *Bellows {
19+	b := &Bellows{
20+		Device: vl6180x.New(i2c),
21+	}
22+	b.Device.Configure(false) // this argument is ignored!
23+	return b
24+}
25+
26+// Read the direction of the bellows.
27+//
28+// Negative means draw, because that makes more sense to me.
29+func (b *Bellows) Direction() int16 {
30+	distance := b.Read()
31+	dir := int16(b.prev -distance) / BellowsSlop
32+	b.prev = distance
33+	return dir
34+}
A cmd/concertina/buttons.go
+36, -0
 1@@ -0,0 +1,36 @@
 2+package main
 3+
 4+import "git.woozle.org/neale/concertina/pkg/layouts"
 5+import "git.woozle.org/neale/concertina/internal/midi"
 6+
 7+// XXX: figure out where to put this documentation.
 8+//
 9+// Buttons are read from four (4) 16-bit GPIO expanders, two per side.
10+// Each row of buttons goes to one byte in the expander,
11+// and there are four rows (three finger rows and one thumb row).
12+//
13+// I spent a long time wringing my hands about wasting all those GPIO ports,
14+// but the GPIO expander is $2.50, so I'm going to try to not worry about it.
15+// Anyway, nobody makes a 17-port GPIO expander.
16+
17+// ButtonMap maps buttons to MIDI notes.
18+type ButtonMap struct {
19+	Push [4][16]midi.Note
20+	Draw [4][16]midi.Note
21+}
22+
23+// MapLayout generates a ButtonMapping from a layouts.Layout.
24+func MapLayout(l *layouts.Layout) *ButtonMap {
25+	m := new(ButtonMap)
26+	for side := range layouts.Sides {
27+		for row := range layouts.Rows {
28+			for col := range layouts.Columns {
29+				expander := (side*2) + row/2
30+				bit := ((row % 2) * 8) + col
31+				m.Push[expander][bit] = midi.Note(l.Notes[side][row][col][0])
32+				m.Draw[expander][bit] = midi.Note(l.Notes[side][row][col][1])
33+			}
34+		}
35+	}
36+	return m
37+}
M cmd/concertina/concertina.go
+39, -16
  1@@ -6,6 +6,7 @@ import (
  2 	"machine"
  3 	"time"
  4 
  5+	"git.woozle.org/neale/concertina/internal/midi"
  6 	"git.woozle.org/neale/concertina/pkg/layouts"
  7 	"git.woozle.org/neale/concertina/pkg/led"
  8 	"git.woozle.org/neale/concertina/pkg/pcf8575"
  9@@ -22,8 +23,10 @@ var Black = color.RGBAModel.Convert(color.Black).(color.RGBA)
 10 
 11 type Concertina struct {
 12 	display       *ssd1306.Device
 13-	tonegen       MidiWriter
 14+	tonegen       midi.Writer
 15+	bellows       *Bellows
 16 	expanders     [4]pcf8575.Device
 17+	buttonMap     *ButtonMap
 18 	layouts       []*layouts.Layout
 19 	currentLayout int
 20 }
 21@@ -47,7 +50,7 @@ func NewConcertina(i2c drivers.I2C) (*Concertina, error) {
 22 	}
 23 
 24 	c := &Concertina{
 25-		tonegen: MidiWriter{Writer: machine.DefaultUART},
 26+		tonegen: midi.Writer{Writer: machine.DefaultUART},
 27 	}
 28 
 29 	for i := range c.expanders {
 30@@ -57,13 +60,12 @@ func NewConcertina(i2c drivers.I2C) (*Concertina, error) {
 31 		}
 32 	}
 33 
 34-	if false {
 35-		if l, err := layouts.DefaultLayouts(); err != nil {
 36-			return nil, err
 37-		} else {
 38-			c.layouts = l
 39-		}
 40+	if l, err := layouts.DefaultLayouts(); err != nil {
 41+		return nil, err
 42+	} else {
 43+		c.layouts = l
 44 	}
 45+	c.loadLayout(0)
 46 
 47 	c.display = ssd1306.NewI2C(i2c)
 48 	c.display.Configure(
 49@@ -74,9 +76,11 @@ func NewConcertina(i2c drivers.I2C) (*Concertina, error) {
 50 		},
 51 	)
 52 	c.display.ClearDisplay()
 53-	tinyfont.WriteLine(c.display, &proggy.TinySZ8pt7b, 0, 20, "Ruby", White)
 54+	tinyfont.WriteLine(c.display, &proggy.TinySZ8pt7b, 0, 10, "Ruby", White)
 55 	c.display.Display()
 56 
 57+	c.bellows = NewBellows(i2c)
 58+
 59 	<-resetTimer // Wait until this timer has elapsed
 60 	VS1053_RESET.High()
 61 
 62@@ -85,24 +89,43 @@ func NewConcertina(i2c drivers.I2C) (*Concertina, error) {
 63 	return c, nil
 64 }
 65 
 66+func (c *Concertina) loadLayout(n int) {
 67+	c.buttonMap = MapLayout(c.layouts[n])
 68+	c.currentLayout = n
 69+}
 70+
 71 func (c *Concertina) Loop() {
 72 	var buttons [4]uint16
 73+	//toPlay := new(Polyphony)
 74+
 75+	// get the bellows direction
 76+	c.display.FillRectangle(0, 16, 100, 4, Black)
 77+	dir := c.bellows.Direction()
 78+	if (dir < 0) {
 79+		c.display.FillRectangle(50+dir, 16, -dir, 4, White)
 80+	} else if (dir > 0) {
 81+		c.display.FillRectangle(50, 16, dir, 4, White)
 82+	}
 83 
 84-	c.display.FillRectangle(124, 0, 4, 16, Black)
 85-	for x := range 4 {
 86+	// Read Everybody
 87+	for x := range buttons {
 88 		buttons[x] = c.expanders[x].Get()
 89+	}
 90+
 91+	// Draw what was pressed
 92+	c.display.FillRectangle(124, 0, 4, 16, Black)
 93+	for x, bits := range buttons {
 94 		for bit := range 16 {
 95-			pc := White
 96-			if buttons[x]&(1<<bit) != 0 {
 97-				pc = Black
 98+			if bits&(1<<bit) != 0 {
 99+				c.display.SetPixel(int16(124+x), int16(bit), White)
100 			}
101-			c.display.SetPixel(int16(124+x), int16(bit), pc)
102 		}
103 	}
104+
105 	c.display.Display()
106 
107 	//c.tonegen.NoteOn(byte(x))
108-	time.Sleep(200 * time.Millisecond)
109+	//time.Sleep(200 * time.Millisecond)
110 	//c.tonegen.NoteOff(byte(x))
111 }
112 
D cmd/concertina/midi.go
+0, -45
 1@@ -1,45 +0,0 @@
 2-package main
 3-
 4-import "io"
 5-
 6-type MidiWriter struct {
 7-	io.Writer
 8-	Channel byte
 9-	Playing *MidiNotes
10-}
11-
12-func (w MidiWriter) NoteOnVelocity(note byte, velocity byte) {
13-	w.Write([]byte{0x90 + w.Channel, note, velocity})
14-	w.Playing.On(note)
15-}
16-
17-func (w MidiWriter) NoteOn(note byte) {
18-	w.NoteOnVelocity(note, 127)
19-}
20-
21-func (w MidiWriter) NoteOffVelocity(note byte, velocity byte) {
22-	w.Write([]byte{0x80 + w.Channel, note, velocity})
23-	w.Playing.Off(note)
24-}
25-
26-func (w MidiWriter) NoteOff(note byte) {
27-	w.NoteOffVelocity(note, 127)
28-}
29-
30-func (w MidiWriter) SetPatch(patch byte) {
31-	w.Write([]byte{0xC0 + w.Channel, patch})
32-}
33-
34-type MidiNotes [2]uint64
35-
36-func (n *MidiNotes) Equal(a MidiNotes) bool {
37-	return (n[0] == a[0]) && (n[1] == a[1])
38-}
39-
40-func (n *MidiNotes) On(note byte) {
41-	n[note/64] |= 1 << (note % 64)
42-}
43-
44-func (n *MidiNotes) Off(note byte) {
45-	n[note/64] &= ^(1 << (note % 64))
46-}
A internal/README.md
+2, -0
1@@ -0,0 +1,2 @@
2+I can't figure out how to make `tinygo test` work.
3+By putting things in here, I can use plain ol' `go test`.
A internal/midi/midi.go
+62, -0
 1@@ -0,0 +1,62 @@
 2+package midi
 3+
 4+import "io"
 5+
 6+// Note is a MIDI note, between 0 and 127.
 7+// Any value <0 means no note.
 8+type Note int8
 9+
10+// Writer sends note on/off events to a MIDI device,
11+// and keeps track of what's currently playing.
12+type Writer struct {
13+	io.Writer
14+	Channel byte
15+	Playing *Polyphony
16+}
17+
18+func NewWriter(w io.Writer, channel byte) (*Writer) {
19+	return &Writer{
20+		w,
21+		channel,
22+		&Polyphony{},
23+	}
24+}
25+
26+func (w *Writer) NoteOnVelocity(note byte, velocity byte) {
27+	w.Write([]byte{0x90 + w.Channel, note, velocity})
28+	w.Playing.On(note)
29+}
30+
31+func (w *Writer) NoteOn(note byte) {
32+	w.NoteOnVelocity(note, 127)
33+}
34+
35+func (w *Writer) NoteOffVelocity(note byte, velocity byte) {
36+	w.Write([]byte{0x80 + w.Channel, note, velocity})
37+	w.Playing.Off(note)
38+}
39+
40+func (w *Writer) NoteOff(note byte) {
41+	w.NoteOffVelocity(note, 127)
42+}
43+
44+func (w *Writer) SetPatch(patch byte) {
45+	w.Write([]byte{0xC0 + w.Channel, patch})
46+}
47+
48+// Polyphony tracks what notes are currently playing.
49+type Polyphony struct {
50+	playing [2]uint64
51+}
52+
53+func (n *Polyphony) Equal(a Polyphony) bool {
54+	return (n.playing[0] == a.playing[0]) && (n.playing[1] == a.playing[1])
55+}
56+
57+func (n *Polyphony) On(note byte) {
58+	n.playing[note/64] |= 1 << (note % 64)
59+}
60+
61+func (n *Polyphony) Off(note byte) {
62+	n.playing[note/64] &= ^(1 << (note % 64))
63+}
A internal/midi/midi_test.go
+63, -0
 1@@ -0,0 +1,63 @@
 2+package midi
 3+
 4+import (
 5+	"math/bits"
 6+	"testing"
 7+)
 8+
 9+// WriteCounter just counts how many bytes were written
10+type WriteCounter struct {
11+	len int
12+}
13+func (w *WriteCounter) Write(p []byte) (int, error) {
14+	w.len += len(p)
15+	return len(p), nil
16+}
17+
18+func (n *Polyphony) OnesCount() int {
19+	return bits.OnesCount64(n.playing[0]) + bits.OnesCount64(n.playing[1])
20+}
21+
22+func TestWriter(t *testing.T) {
23+	counter := new(WriteCounter)
24+	w := NewWriter(counter, 0)
25+
26+	if w.Playing.OnesCount() != 0 {
27+		t.Error("polyphony tracking is busted")
28+	}
29+
30+	w.NoteOn(12)
31+	if w.Playing.OnesCount() != 1 {
32+		t.Error("polyphony tracking is busted")
33+	}
34+
35+	w.NoteOn(12)
36+	if w.Playing.OnesCount() != 1 {
37+		t.Error("polyphony tracking is busted")
38+	}
39+
40+	w.NoteOn(20)
41+	if w.Playing.OnesCount() != 2 {
42+		t.Error("polyphony tracking is busted")
43+	}
44+
45+	w.NoteOn(20)
46+	if w.Playing.OnesCount() != 2 {
47+		t.Error("polyphony tracking is busted")
48+	}
49+
50+	w.NoteOff(12)
51+	if w.Playing.OnesCount() != 1 {
52+		t.Error("polyphony tracking is busted")
53+	}
54+
55+	w.NoteOff(12)
56+	if w.Playing.OnesCount() != 1 {
57+		t.Error("polyphony tracking is busted")
58+	}
59+
60+	w.NoteOff(20)
61+	if w.Playing.OnesCount() != 0 {
62+		t.Error("polyphony tracking is busted")
63+	}
64+}
M pkg/layouts/layouts.go
+4, -4
 1@@ -47,10 +47,6 @@ func ParseLayouts(s string) ([]*Layout, error) {
 2 			continue
 3 		}
 4 
 5-		if row >= Rows {
 6-			return nil, &ParseError{lineno, "too many rows"}
 7-		}
 8-
 9 		// Split the line up into fields
10 		fields := strings.Fields(line)
11 
12@@ -64,6 +60,10 @@ func ParseLayouts(s string) ([]*Layout, error) {
13 			continue
14 		}
15 
16+		if row >= Rows {
17+			return nil, &ParseError{lineno, "too many rows: " + line}
18+		}
19+
20 		// We can skip the middle | now
21 		fields = append(fields[:5], fields[6:]...)
22 		for col, button := range fields {
M pkg/layouts/layouts_test.go
+12, -0
 1@@ -40,3 +40,15 @@ func TestParser(t *testing.T) {
 2 		t.Error("wrong note")
 3 	}
 4 }
 5+
 6+func TestDefaultParser(t *testing.T) {
 7+	layouts, err := DefaultLayouts()
 8+	if err != nil {
 9+		t.Fatal(err)
10+	}
11+
12+	// If you add or remove a layout, you'll have to change this constant :)
13+	if l := len(layouts); l != 4 {
14+		t.Errorf("wrong number of layouts: %d", l)
15+	}
16+}