diff --git a/.gitignore b/.gitignore index 8196e5b..658eed1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib +__debug_bin # Test binary, built with `go test -c` *.test @@ -14,6 +18,9 @@ # Dependency directories (remove the comment below to include it) # vendor/ +# Go workspace file +go.work + # TinyGo *.elf *.uf2 diff --git a/README.md b/README.md index 2fb6e21..c6515e2 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,37 @@ Ctrl + Shift + P > Tasks: Run Build Task TODO -## Why should I use this? +## Debugging using a browser + +**NOTE**: This is a beta feature! Please do not expect it to function properly, if at all. + +If your EuroPi project is set up via the `europi.Bootstrap()` execution paradigm, you may operate and visualize it via a browser instead of having to flash it to a EuroPi hardware module or build it via TinyGo. + +There are some caveats that must be understood before using this functionality: +1. This builds via Pure Go and only simulates (to the best of ability) a RP2040 / Pico running the software. +1. The visualization web page is terrible. While some care was taken to make it minimally usable, it's not pretty and is rife with bugs. +1. The websocket interface presented can currently only bind to port 8080 on "all" network interfaces. +1. Projects which do not utilize the `europi.Bootstrap()` execution paradigm will not be able to partake in this functionality. +1. [pprof](https://github.com/google/pprof/blob/main/README.md) support is enabled while the websocket interface is available, but it does not represent how the code will run on a RP2040 / Pico or within a EuroPi module. It is specifically provided here for debugging multiprocessing functionality and gross abuses of memory and CPU. + +### How to get the browser support to work + +First, you need [Go installed](https://go.dev/doc/install). Preferably 1.18 or newer. + +Second, set up your module project to build with the `europi.Bootstrap()` execution paradigm and add the following line to the `europi.Bootstrap()` parameter list: + +```go +europi.AttachNonPicoWS(true), +``` + +Then, execute your module using Go (not TinyGo) and pass a buildflag tag parameter matching [which revision of hardware](hardware/README.md#revision-list) you want to run, like so: + +```bash +go run -tags=revision1 internal/projects/randomskips/randomskips.go +``` + +Finally, open up a browser to [localhost:8080](http://localhost:8080/) to see your module in action! + +## Why should I use the TinyGo version of the EuroPi firmware? You probably shouldn't. This is my passion project because I love Go. You should probably be using the official [EuroPi firmware](https://github.com/Allen-Synthesis/EuroPi). But if you are interested in writing a dedicated script for the EuroPi that requires concurrency and faster performance than MicroPython can offer, then maybe this is the right firmware for you! diff --git a/analog_reader.go b/analog_reader.go deleted file mode 100644 index d0d4167..0000000 --- a/analog_reader.go +++ /dev/null @@ -1,110 +0,0 @@ -package europi - -import ( - "machine" - "math" -) - -const ( - // Calibrated[Min|Max]AI was calculated using the EuroPi calibration program: - // https://github.com/Allen-Synthesis/EuroPi/blob/main/software/programming_instructions.md#calibrate-the-module - CalibratedMinAI = 300 - CalibratedMaxAI = 44009 - - DefaultSamples = 1000 -) - -func init() { - machine.InitADC() -} - -// AnalogReader is an interface for common analog read methods for knobs and cv input. -type AnalogReader interface { - Samples(samples uint16) - ReadVoltage() float32 - Percent() float32 - Range(steps uint16) uint16 -} - -// A struct for handling the reading of analogue control voltage. -// The analogue input allows you to 'read' CV from anywhere between 0 and 12V. -type AnalogInput struct { - machine.ADC - samples uint16 -} - -// NewAI creates a new AnalogInput. -func NewAI(pin machine.Pin) *AnalogInput { - adc := machine.ADC{Pin: pin} - adc.Configure(machine.ADCConfig{}) - return &AnalogInput{ADC: adc, samples: DefaultSamples} -} - -// Samples sets the number of reads for an more accurate average read. -func (a *AnalogInput) Samples(samples uint16) { - a.samples = samples -} - -// Percent return the percentage of the input's current relative range as a float between 0.0 and 1.0. -func (a *AnalogInput) Percent() float32 { - return float32(a.read()) / CalibratedMaxAI -} - -// ReadVoltage return the current read voltage between 0.0 and 10.0 volts. -func (a *AnalogInput) ReadVoltage() float32 { - return a.Percent() * MaxVoltage -} - -// Range return a value between 0 and the given steps (not inclusive) based on the range of the analog input. -func (a *AnalogInput) Range(steps uint16) uint16 { - return uint16(a.Percent() * float32(steps)) -} - -func (a *AnalogInput) read() uint16 { - var sum int - for i := 0; i < int(a.samples); i++ { - sum += Clamp(int(a.Get())-CalibratedMinAI, 0, CalibratedMaxAI) - } - return uint16(sum / int(a.samples)) -} - -// A struct for handling the reading of knob voltage and position. -type Knob struct { - machine.ADC - samples uint16 -} - -// NewKnob creates a new Knob struct. -func NewKnob(pin machine.Pin) *Knob { - adc := machine.ADC{Pin: pin} - adc.Configure(machine.ADCConfig{}) - return &Knob{ADC: adc, samples: DefaultSamples} -} - -// Samples sets the number of reads for an more accurate average read. -func (k *Knob) Samples(samples uint16) { - k.samples = samples -} - -// Percent return the percentage of the knob's current relative range as a float between 0.0 and 1.0. -func (k *Knob) Percent() float32 { - return 1 - float32(k.read())/math.MaxUint16 -} - -// ReadVoltage return the current read voltage between 0.0 and 10.0 volts. -func (k *Knob) ReadVoltage() float32 { - return k.Percent() * MaxVoltage -} - -// Range return a value between 0 and the given steps (not inclusive) based on the range of the knob's position. -func (k *Knob) Range(steps uint16) uint16 { - return uint16(k.Percent() * float32(steps)) -} - -func (k *Knob) read() uint16 { - var sum int - for i := 0; i < int(k.samples); i++ { - sum += int(k.Get()) - } - return uint16(sum / int(k.samples)) -} diff --git a/clamp/clamp.go b/clamp/clamp.go new file mode 100644 index 0000000..dab53a6 --- /dev/null +++ b/clamp/clamp.go @@ -0,0 +1,15 @@ +package clamp + +type Clampable interface { + ~int8 | ~int16 | ~int32 | ~int64 | ~int | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 +} + +func Clamp[T Clampable](v, min, max T) T { + if v > max { + v = max + } + if v < min { + v = min + } + return v +} diff --git a/clamp/clamp_test.go b/clamp/clamp_test.go new file mode 100644 index 0000000..1e4d9a5 --- /dev/null +++ b/clamp/clamp_test.go @@ -0,0 +1,41 @@ +package clamp_test + +import ( + "testing" + + "github.com/awonak/EuroPiGo/clamp" +) + +func TestClamp(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + if expected, actual := min, clamp.Clamp(min, min, max); actual != expected { + t.Fatalf("Clamp[%v, %v] Clamp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + if expected, actual := max, clamp.Clamp(max, min, max); actual != expected { + t.Fatalf("Clamp[%v, %v] Clamp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + if expected, actual := min, clamp.Clamp(min-2, min, max); actual != expected { + t.Fatalf("Clamp[%v, %v] Clamp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + if expected, actual := max, clamp.Clamp(max+2, min, max); actual != expected { + t.Fatalf("Clamp[%v, %v] Clamp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) +} diff --git a/debounce/debounce.go b/debounce/debounce.go new file mode 100644 index 0000000..46b74bf --- /dev/null +++ b/debounce/debounce.go @@ -0,0 +1,34 @@ +package debounce + +import "time" + +type Debouncer[T func(U, time.Duration), U any] interface { + Debounce(delay time.Duration) func(U) + LastChange() time.Time +} + +type debouncer[T func(U, time.Duration), U any] struct { + last time.Time + fn T +} + +func NewDebouncer[T func(U, time.Duration), U any](fn T) Debouncer[T, U] { + return &debouncer[T, U]{ + last: time.Now(), + fn: fn, + } +} + +func (d *debouncer[T, U]) Debounce(delay time.Duration) func(U) { + return func(u U) { + now := time.Now() + if deltaTime := now.Sub(d.last); deltaTime >= delay { + d.last = now + d.fn(u, deltaTime) + } + } +} + +func (d debouncer[T, U]) LastChange() time.Time { + return d.last +} diff --git a/debounce/debounce_test.go b/debounce/debounce_test.go new file mode 100644 index 0000000..38311ae --- /dev/null +++ b/debounce/debounce_test.go @@ -0,0 +1,89 @@ +package debounce_test + +import ( + "fmt" + "testing" + "time" + + "github.com/awonak/EuroPiGo/debounce" +) + +func TestDebouncer(t *testing.T) { + t.Run("New", func(t *testing.T) { + fn := func(any, time.Duration) {} + if actual := debounce.NewDebouncer(fn); actual == nil { + t.Fatalf("Debouncer[%p] NewDebouncer: expected[non-nil] actual[nil]", fn) + } + }) + + t.Run("Debounce", func(t *testing.T) { + t.Run("Delay0", func(t *testing.T) { + t.Parallel() + delay := time.Duration(0) + runs := 4 + runDebouncerTest(t, runs, delay, time.Duration(0), 4) + runDebouncerTest(t, runs, delay, time.Millisecond*1, 4) + runDebouncerTest(t, runs, delay, time.Second*1, 4) + }) + + t.Run("Delay100ms", func(t *testing.T) { + t.Parallel() + delay := time.Millisecond * 100 + runs := 4 + runDebouncerTest(t, runs, delay, time.Duration(0), 0) + runDebouncerTest(t, runs, delay, time.Millisecond*1, 0) + runDebouncerTest(t, runs, delay, time.Second*1, 2) + }) + + t.Run("Delay10s", func(t *testing.T) { + t.Parallel() + delay := time.Millisecond * 100 + runs := 4 + runDebouncerTest(t, runs, delay, time.Duration(0), 0) + runDebouncerTest(t, runs, delay, time.Millisecond*1, 0) + runDebouncerTest(t, runs, delay, time.Second*1, 0) + }) + }) + + t.Run("LastChange", func(t *testing.T) { + fn := func(any, time.Duration) {} + db := debounce.NewDebouncer(fn) + dbFunc := db.Debounce(0) + + beforeExpected := time.Now() + dbFunc(nil) + if actual := db.LastChange(); !actual.After(beforeExpected) { + t.Fatalf("Debouncer[%p] LastChange: expected(after)[%v] actual[%v]", fn, beforeExpected, actual) + } + }) +} + +func runDebouncerTest(t *testing.T, runs int, delay, interval time.Duration, minimumExpected int) { + t.Helper() + + var testName string + if interval == 0 { + testName = "Immediate" + } else { + testName = fmt.Sprintf("Interval%v", interval) + } + + var actual int + fn := func(any, time.Duration) { + actual++ + + } + + db := debounce.NewDebouncer(fn).Debounce(delay) + + t.Run(testName, func(t *testing.T) { + for i := 0; i < runs; i++ { + db(nil) + time.Sleep(interval) + } + // since these are timing-based, we have to be lenient + if actual < minimumExpected { + t.Fatalf("Debouncer[%p] Debounce(%v): expected[%v] actual[%v]", fn, delay, minimumExpected, actual) + } + }) +} diff --git a/helpers.go b/debug.go similarity index 65% rename from helpers.go rename to debug.go index 832f92a..18294e5 100644 --- a/helpers.go +++ b/debug.go @@ -1,22 +1,12 @@ package europi import ( + "context" "log" "runtime" "time" ) -// Clamp returns a value that is no lower than "low" and no higher than "high". -func Clamp[V uint8 | uint16 | int | float32](value, low, high V) V { - if value >= high { - return high - } - if value <= low { - return low - } - return value -} - func DebugMemoryUsage() { var mem runtime.MemStats runtime.ReadMemStats(&mem) @@ -40,3 +30,19 @@ func DebugMemoryUsedPerSecond() { time.Sleep(time.Second) } } + +// used for non-pico testing of europi apps +var ( + activateNonPicoWebSocket func(ctx context.Context, e Hardware) NonPicoWSActivation +) + +type NonPicoWSActivation interface { + Shutdown() error +} + +func ActivateNonPicoWS(ctx context.Context, e Hardware) NonPicoWSActivation { + if activateNonPicoWebSocket == nil { + return nil + } + return activateNonPicoWebSocket(ctx, e) +} diff --git a/debug_nonpico.go b/debug_nonpico.go new file mode 100644 index 0000000..d8baccf --- /dev/null +++ b/debug_nonpico.go @@ -0,0 +1,19 @@ +//go:build !pico +// +build !pico + +package europi + +import ( + "context" + + "github.com/awonak/EuroPiGo/internal/nonpico" +) + +func nonPicoActivateWebSocket(ctx context.Context, e Hardware) NonPicoWSActivation { + nonPicoWSApi := nonpico.ActivateWebSocket(ctx, e.Revision()) + return nonPicoWSApi +} + +func init() { + activateNonPicoWebSocket = nonPicoActivateWebSocket +} diff --git a/digital_reader.go b/digital_reader.go deleted file mode 100644 index eb8bbe5..0000000 --- a/digital_reader.go +++ /dev/null @@ -1,116 +0,0 @@ -package europi - -import ( - "machine" - "time" -) - -const DefaultDebounceDelay = time.Duration(50 * time.Millisecond) - -// DigitalReader is an interface for common digital inputs methods. -type DigitalReader interface { - Handler(func(machine.Pin)) - HandlerWithDebounce(func(machine.Pin), time.Duration) - LastInput() time.Time - Value() bool -} - -// DigitalInput is a struct for handling reading of the digital input. -type DigitalInput struct { - Pin machine.Pin - debounceDelay time.Duration - lastInput time.Time - callback func(p machine.Pin) -} - -// NewDI creates a new DigitalInput struct. -func NewDI(pin machine.Pin) *DigitalInput { - pin.Configure(machine.PinConfig{Mode: machine.PinInputPulldown}) - return &DigitalInput{ - Pin: pin, - lastInput: time.Now(), - debounceDelay: DefaultDebounceDelay, - } -} - -// LastInput return the time of the last high input (triggered at 0.8v). -func (d *DigitalInput) LastInput() time.Time { - return d.lastInput -} - -// Value returns true if the input is high (above 0.8v), else false. -func (d *DigitalInput) Value() bool { - // Invert signal to match expected behavior. - return !d.Pin.Get() -} - -// Handler sets the callback function to be call when a rising edge is detected. -func (d *DigitalInput) Handler(handler func(p machine.Pin)) { - d.HandlerWithDebounce(handler, 0) -} - -// Handler sets the callback function to be call when a rising edge is detected and debounce delay time has elapsed. -func (d *DigitalInput) HandlerWithDebounce(handler func(p machine.Pin), delay time.Duration) { - d.callback = handler - d.debounceDelay = delay - d.Pin.SetInterrupt(machine.PinFalling, d.debounceWrapper) -} - -func (d *DigitalInput) debounceWrapper(p machine.Pin) { - t := time.Now() - if t.Before(d.lastInput.Add(d.debounceDelay)) { - return - } - d.callback(p) - d.lastInput = t -} - -// Button is a struct for handling push button behavior. -type Button struct { - Pin machine.Pin - debounceDelay time.Duration - lastInput time.Time - callback func(p machine.Pin) -} - -// NewButton creates a new Button struct. -func NewButton(pin machine.Pin) *Button { - pin.Configure(machine.PinConfig{Mode: machine.PinInputPulldown}) - return &Button{ - Pin: pin, - lastInput: time.Now(), - debounceDelay: DefaultDebounceDelay, - } -} - -// Handler sets the callback function to be call when the button is pressed. -func (b *Button) Handler(handler func(p machine.Pin)) { - b.HandlerWithDebounce(handler, 0) -} - -// Handler sets the callback function to be call when the button is pressed and debounce delay time has elapsed. -func (b *Button) HandlerWithDebounce(handler func(p machine.Pin), delay time.Duration) { - b.callback = handler - b.debounceDelay = delay - b.Pin.SetInterrupt(machine.PinFalling, b.debounceWrapper) -} - -func (b *Button) debounceWrapper(p machine.Pin) { - t := time.Now() - if t.Before(b.lastInput.Add(b.debounceDelay)) { - return - } - b.callback(p) - b.lastInput = t -} - -// LastInput return the time of the last button press. -func (b *Button) LastInput() time.Time { - return b.lastInput -} - -// Value returns true if button is currently pressed, else false. -func (b *Button) Value() bool { - // Invert signal to match expected behavior. - return !b.Pin.Get() -} diff --git a/display.go b/display.go deleted file mode 100644 index 15fbad8..0000000 --- a/display.go +++ /dev/null @@ -1,56 +0,0 @@ -package europi - -import ( - "image/color" - "machine" - - "tinygo.org/x/drivers/ssd1306" - "tinygo.org/x/tinyfont" - "tinygo.org/x/tinyfont/proggy" -) - -const ( - OLEDFreq = machine.TWI_FREQ_400KHZ - OLEDAddr = 0x3C - OLEDWidth = 128 - OLEDHeight = 32 -) - -var ( - DefaultChannel = machine.I2C0 - DefaultFont = &proggy.TinySZ8pt7b - White = color.RGBA{255, 255, 255, 255} -) - -// Display is a wrapper around `ssd1306.Device` for drawing graphics and text to the OLED. -type Display struct { - ssd1306.Device - font *tinyfont.Font -} - -// NewDisplay returns a new Display struct. -func NewDisplay(channel *machine.I2C, sdaPin, sclPin machine.Pin) *Display { - channel.Configure(machine.I2CConfig{ - Frequency: OLEDFreq, - SDA: sdaPin, - SCL: sclPin, - }) - - display := ssd1306.NewI2C(DefaultChannel) - display.Configure(ssd1306.Config{ - Address: OLEDAddr, - Width: OLEDWidth, - Height: OLEDHeight, - }) - return &Display{Device: display, font: DefaultFont} -} - -// Font overrides the default font used by `WriteLine`. -func (d *Display) Font(font *tinyfont.Font) { - d.font = font -} - -// WriteLine writes the given text to the display where x, y is the bottom leftmost pixel of the text. -func (d *Display) WriteLine(text string, x, y int16) { - tinyfont.WriteLine(d, d.font, x, y, text, White) -} diff --git a/europi.go b/europi.go index db5a15e..cfadb84 100644 --- a/europi.go +++ b/europi.go @@ -1,64 +1,115 @@ -package europi // import europi "github.com/awonak/EuroPiGo" +package europi // import "github.com/awonak/EuroPiGo" import ( - "machine" + "github.com/awonak/EuroPiGo/hardware" + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/hardware/rev0" + "github.com/awonak/EuroPiGo/hardware/rev1" + _ "github.com/awonak/EuroPiGo/internal/nonpico" + _ "github.com/awonak/EuroPiGo/internal/pico" ) -const ( - MaxVoltage = 10.0 - MinVoltage = 0.0 +type ( + // Hardware is the collection of component wrappers used to interact with the module. + Hardware = hal.Hardware + + // EuroPiPrototype is the revision 0 hardware + EuroPiPrototype = rev0.EuroPiPrototype + // EuroPi is the revision 1 hardware + EuroPi = rev1.EuroPi + // TODO: add rev2 ) -// EuroPi is the collection of component wrappers used to interact with the module. -type EuroPi struct { - // Display is a wrapper around ssd1306.Device - Display *Display +// New will return a new EuroPi struct based on the detected hardware revision +func New() Hardware { + // ensure our hardware has been identified + ensureHardware() + + // blocks until revision has been identified + revision := hardware.GetRevision() + return NewFrom(revision) +} + +// NewFrom will return a new EuroPi struct based on a specific revision +func NewFrom(revision hal.Revision) Hardware { + if revision == hal.RevisionUnknown { + // unknown revision + return nil + } + + // ensure our hardware has been identified + ensureHardware() + + // this will block until the hardware components are initialized + hardware.WaitForReady() + + switch revision { + case hal.Revision0: + return rev0.Pi + case hal.Revision1: + return rev1.Pi + case hal.Revision2: + // TODO: add rev2 + return nil + default: + return nil + } +} - DI DigitalReader - AI AnalogReader +// Display returns the primary display from the hardware interface, if it has one +func Display(e Hardware) hal.DisplayOutput { + if e == nil { + return nil + } - B1 DigitalReader - B2 DigitalReader + switch e.Revision() { + case hal.Revision1: + return e.(*rev1.EuroPi).OLED + case hal.Revision2: + // TODO: add rev2 + //return e.(*rev2.EuroPiX).Display + } + return nil +} - K1 AnalogReader - K2 AnalogReader +// Button returns a button input at the specified index from the hardware interface, +// if it has one there +func Button(e Hardware, idx int) hal.ButtonInput { + if e == nil { + return nil + } - CV1 Outputer - CV2 Outputer - CV3 Outputer - CV4 Outputer - CV5 Outputer - CV6 Outputer - CV [6]Outputer + switch e.Revision() { + case hal.Revision0: + return e.(*rev0.EuroPiPrototype).Button(idx) + case hal.Revision1: + return e.(*rev1.EuroPi).Button(idx) + case hal.Revision2: + // TODO: add rev2 + //return e.(*rev2.EuroPiX).Button(idx) + } + return nil } -// New will return a new EuroPi struct. -func New() *EuroPi { - cv1 := NewOutput(machine.GPIO21, machine.PWM2) - cv2 := NewOutput(machine.GPIO20, machine.PWM2) - cv3 := NewOutput(machine.GPIO16, machine.PWM0) - cv4 := NewOutput(machine.GPIO17, machine.PWM0) - cv5 := NewOutput(machine.GPIO18, machine.PWM1) - cv6 := NewOutput(machine.GPIO19, machine.PWM1) - - return &EuroPi{ - Display: NewDisplay(machine.I2C0, machine.GPIO0, machine.GPIO1), - - DI: NewDI(machine.GPIO22), - AI: NewAI(machine.ADC0), - - B1: NewButton(machine.GPIO4), - B2: NewButton(machine.GPIO5), - - K1: NewKnob(machine.ADC1), - K2: NewKnob(machine.ADC2), - - CV1: cv1, - CV2: cv2, - CV3: cv3, - CV4: cv4, - CV5: cv5, - CV6: cv5, - CV: [6]Outputer{cv1, cv2, cv3, cv4, cv5, cv6}, +// Knob returns a knob input at the specified index from the hardware interface, +// if it has one there +func Knob(e Hardware, idx int) hal.KnobInput { + if e == nil { + return nil + } + + switch e.Revision() { + case hal.Revision0: + return e.(*rev0.EuroPiPrototype).Knob(idx) + case hal.Revision1: + return e.(*rev1.EuroPi).Knob(idx) + case hal.Revision2: + // TODO: add rev2 + //return e.(*rev2.EuroPiX).Knob(idx) } + return nil } + +// ensureHardware is part of the hardware setup system. +// It will be set to a function that can properly configure the hardware for use. +var ensureHardware func() diff --git a/europi_nonpico.go b/europi_nonpico.go new file mode 100644 index 0000000..ad3ca2e --- /dev/null +++ b/europi_nonpico.go @@ -0,0 +1,24 @@ +//go:build !pico +// +build !pico + +package europi + +import ( + "sync" + + "github.com/awonak/EuroPiGo/hardware" + "github.com/awonak/EuroPiGo/internal/nonpico" +) + +var nonPicoEnsureHardwareOnce sync.Once + +func nonPicoEnsureHardware() { + nonPicoEnsureHardwareOnce.Do(func() { + rev := nonpico.DetectRevision() + hardware.SetDetectedRevision(rev) + }) +} + +func init() { + ensureHardware = nonPicoEnsureHardware +} diff --git a/europi_pico.go b/europi_pico.go new file mode 100644 index 0000000..5076242 --- /dev/null +++ b/europi_pico.go @@ -0,0 +1,24 @@ +//go:build pico +// +build pico + +package europi + +import ( + "sync" + + "github.com/awonak/EuroPiGo/hardware" + "github.com/awonak/EuroPiGo/internal/pico" +) + +var picoEnsureHardwareOnce sync.Once + +func picoEnsureHardware() { + picoEnsureHardwareOnce.Do(func() { + rev := pico.DetectRevision() + hardware.SetDetectedRevision(rev) + }) +} + +func init() { + ensureHardware = picoEnsureHardware +} diff --git a/europi_test.go b/europi_test.go new file mode 100644 index 0000000..09685e7 --- /dev/null +++ b/europi_test.go @@ -0,0 +1,67 @@ +package europi_test + +import ( + "testing" + + europi "github.com/awonak/EuroPiGo" + "github.com/awonak/EuroPiGo/hardware" + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/hardware/rev0" + "github.com/awonak/EuroPiGo/hardware/rev1" +) + +func TestNew(t *testing.T) { + t.Run("Default", func(t *testing.T) { + // unless we're running on a pico, we should expect a nil back for the default revision + hardware.SetDetectedRevision(hal.RevisionUnknown) + if actual := europi.New(); actual != nil { + t.Fatalf("EuroPi New: expected[nil] actual[%T]", actual) + } + }) + + t.Run("Revision0", func(t *testing.T) { + hardware.SetDetectedRevision(hal.Revision0) + pi := europi.New() + switch actual := pi.(type) { + case *rev0.EuroPiPrototype: + if actual == nil { + t.Fatal("EuroPi New: expected[non-nil] actual[nil]") + } + case nil: + t.Fatal("EuroPi New: expected[non-nil] actual[nil]") + default: + t.Fatalf("EuroPi New: expected[EuroPi Prototype] actual[%v]", actual) + } + }) + + t.Run("Revision1", func(t *testing.T) { + hardware.SetDetectedRevision(hal.Revision1) + pi := europi.New() + switch actual := pi.(type) { + case *rev1.EuroPi: + if actual == nil { + t.Fatal("EuroPi New: expected[non-nil] actual[nil]") + } + case nil: + t.Fatal("EuroPi New: expected[non-nil] actual[nil]") + default: + t.Fatalf("EuroPi New: expected[EuroPi] actual[%v]", actual) + } + }) +} + +func TestNewFrom(t *testing.T) { + t.Run("Revision0", func(t *testing.T) { + hardware.SetDetectedRevision(hal.Revision0) + if actual, _ := europi.NewFrom(hal.Revision0).(*rev0.EuroPiPrototype); actual == nil { + t.Fatalf("EuroPi NewFrom: expected[EuroPiPrototype] actual[%T]", actual) + } + }) + + t.Run("Revision1", func(t *testing.T) { + hardware.SetDetectedRevision(hal.Revision1) + if actual, _ := europi.NewFrom(hal.Revision1).(*rev1.EuroPi); actual == nil { + t.Fatalf("EuroPi NewFrom: expected[EuroPi] actual[%T]", actual) + } + }) +} diff --git a/event/README.md b/event/README.md new file mode 100644 index 0000000..d708d49 --- /dev/null +++ b/event/README.md @@ -0,0 +1,15 @@ +# Event Bus + +A multi-provider, single-subscriber callback/events dispatcher. + +## What's It For? + +Under most circumstances, you wouldn't want or need this. It's not optimal - nor does it intend to be such - though it *is* thread-safe. + +## Why Not Use Channels? + +Channels are nice and all, but they're a bit heavy-weight for what this was intended to be used for. They also require more setup and management. For a full-featured and robust solution, a system based on them makes a lot more sense. This did not intend to be full-featured, nor robust. + +## What's This Package Good For, Then? + +Some testing apparati need a way to dictate asynchronous operations of simulated hardware. This just happened to be a simple solution for that. diff --git a/event/bus.go b/event/bus.go new file mode 100644 index 0000000..4ce7f51 --- /dev/null +++ b/event/bus.go @@ -0,0 +1,45 @@ +package event + +import "sync" + +type Bus interface { + Subscribe(subject string, callback func(msg any)) + Unsubscribe(subject string) + Post(subject string, msg any) +} + +func Subscribe[TMsg any](bus Bus, subject string, callback func(msg TMsg)) { + bus.Subscribe(subject, func(msg any) { + if tmsg, ok := msg.(TMsg); ok { + callback(tmsg) + } + }) +} + +func NewBus() Bus { + b := &bus{} + return b +} + +type bus struct { + chMap sync.Map +} + +func (b *bus) Subscribe(subject string, callback func(msg any)) { + b.chMap.Store(subject, callback) +} + +func (b *bus) Unsubscribe(subject string) { + b.chMap.Delete(subject) +} + +func (b *bus) Post(subject string, msg any) { + cb, found := b.chMap.Load(subject) + if !found { + return + } + + if callback, ok := cb.(func(any)); ok { + callback(msg) + } +} diff --git a/event/bus_test.go b/event/bus_test.go new file mode 100644 index 0000000..31a2219 --- /dev/null +++ b/event/bus_test.go @@ -0,0 +1,90 @@ +package event_test + +import ( + "fmt" + "sync" + "testing" + "time" + + "github.com/awonak/EuroPiGo/event" +) + +func TestBus(t *testing.T) { + t.Run("New", func(t *testing.T) { + if actual := event.NewBus(); actual == nil { + t.Fatal("Bus NewBus: expected[non-nil] actual[nil]") + } + }) + + t.Run("Post", func(t *testing.T) { + bus := event.NewBus() + + t.Run("Unsubscribed", func(t *testing.T) { + // test to see if we block on a post to an unsubscribed subject + subject := fmt.Sprintf("testing_%v", time.Now().UnixNano()) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + bus.Post(subject, "hello world") + }() + + // if we somehow block here, then the test deadline will eventually be reached + // and then it will be failed automatically + wg.Wait() + }) + }) + + t.Run("Subscribe", func(t *testing.T) { + bus := event.NewBus() + t.Run("Untyped", func(t *testing.T) { + var actual any + subject := "untyped" + bus.Subscribe(subject, func(msg any) { + actual = msg + }) + + expected := 5 + bus.Post(subject, expected) + if actual != expected { + t.Fatalf("Bus Subscribe(%v): expected[%v] actual[%v]", subject, expected, actual) + } + }) + + t.Run("Typed", func(t *testing.T) { + var actual string + subject := "typed" + event.Subscribe(bus, subject, func(msg string) { + actual = msg + }) + + expected := "hello world" + bus.Post(subject, expected) + if actual != expected { + t.Fatalf("Bus Subscribe(%v): expected[%v] actual[%v]", subject, expected, actual) + } + }) + }) + + t.Run("Unsubscribe", func(t *testing.T) { + bus := event.NewBus() + + t.Run("Subscribed", func(t *testing.T) { + subject := "unsub" + + var actual any + bus.Subscribe(subject, func(msg any) { + actual = msg + }) + + bus.Unsubscribe(subject) + + var expected any + bus.Post(subject, "hello world") + if actual != expected { + t.Fatalf("Bus Unsubscribe(%v): expected[%v] actual[%v]", subject, expected, actual) + } + }) + }) +} diff --git a/experimental/displaylogger/logger.go b/experimental/displaylogger/logger.go new file mode 100644 index 0000000..597a59a --- /dev/null +++ b/experimental/displaylogger/logger.go @@ -0,0 +1,74 @@ +package displaylogger + +import ( + "io" + "strings" + + "github.com/awonak/EuroPiGo/experimental/draw" + "github.com/awonak/EuroPiGo/experimental/fontwriter" + "github.com/awonak/EuroPiGo/hardware/hal" + "tinygo.org/x/tinyfont/proggy" +) + +var ( + DefaultFont = &proggy.TinySZ8pt7b +) + +type Logger interface { + io.Writer + Flush() +} + +type logger struct { + sb strings.Builder + display hal.DisplayOutput + writer fontwriter.Writer +} + +func NewLogger(display hal.DisplayOutput) Logger { + return &logger{ + sb: strings.Builder{}, + display: display, + writer: fontwriter.Writer{ + Display: display, + Font: DefaultFont, + }, + } +} + +func (w *logger) Write(p []byte) (n int, err error) { + n, err = w.sb.Write(p) + if err != nil { + return + } + + w.repaint() + return +} + +func (w *logger) Flush() { + w.repaint() +} + +func (w *logger) repaint() { + str := w.sb.String() + + w.display.ClearBuffer() + + lines := strings.Split(str, "\n") + w.sb.Reset() + _, maxY := w.display.Size() + yAdv := w.writer.Font.GetYAdvance() + maxLines := (maxY + int16(yAdv) - 1) / int16(yAdv) + for l := len(lines); l > int(maxLines); l-- { + lines = lines[1:] + } + w.sb.WriteString(strings.Join(lines, "\n")) + + liney := yAdv + for _, s := range lines { + w.writer.WriteLine(s, 0, int16(liney), draw.White) + liney += yAdv + } + _ = w.display.Display() +} diff --git a/experimental/draw/colors.go b/experimental/draw/colors.go new file mode 100644 index 0000000..e14e13a --- /dev/null +++ b/experimental/draw/colors.go @@ -0,0 +1,8 @@ +package draw + +import "image/color" + +var ( + White = color.RGBA{255, 255, 255, 255} + Black = color.RGBA{0, 0, 0, 255} +) diff --git a/experimental/fontwriter/alignment.go b/experimental/fontwriter/alignment.go new file mode 100644 index 0000000..b3ff377 --- /dev/null +++ b/experimental/fontwriter/alignment.go @@ -0,0 +1,17 @@ +package fontwriter + +type HorizontalAlignment int + +const ( + AlignLeft = HorizontalAlignment(iota) + AlignCenter + AlignRight +) + +type VerticalAlignment int + +const ( + AlignTop = VerticalAlignment(iota) + AlignMiddle + AlignBottom +) diff --git a/experimental/fontwriter/writer.go b/experimental/fontwriter/writer.go new file mode 100644 index 0000000..77eaf0d --- /dev/null +++ b/experimental/fontwriter/writer.go @@ -0,0 +1,112 @@ +package fontwriter + +import ( + "image/color" + + "github.com/awonak/EuroPiGo/hardware/hal" + "tinygo.org/x/tinydraw" + "tinygo.org/x/tinyfont" +) + +type Writer struct { + Display hal.DisplayOutput + Font tinyfont.Fonter +} + +// WriteLine writes the given text to the display where x, y is the bottom leftmost pixel of the text. +func (w *Writer) WriteLine(s string, x, y int16, c color.RGBA) { + tinyfont.WriteLine(w.Display, w.Font, x, y, s, c) +} + +// WriteLineAligned writes the given text to the display where: +// x, y is the bottom leftmost pixel of the text +// alignh is horizontal alignment +// alignv is vertical alignment +func (w *Writer) WriteLineAligned(text string, x, y int16, c color.RGBA, alignh HorizontalAlignment, alignv VerticalAlignment) { + w.writeLineAligned(text, x, y, c, alignh, alignv) +} + +func (w *Writer) writeLineAligned(text string, x, y int16, c color.RGBA, alignh HorizontalAlignment, alignv VerticalAlignment) { + x0, y0 := x, y + switch alignh { + case AlignLeft: + case AlignCenter: + dispWidth, _ := w.Display.Size() + _, outerWidth := tinyfont.LineWidth(w.Font, text) + x0 = (dispWidth-int16(outerWidth))/2 - x + case AlignRight: + dispWidth, _ := w.Display.Size() + _, outerWidth := tinyfont.LineWidth(w.Font, text) + x0 = dispWidth - int16(outerWidth) - x + default: + panic("invalid alignment") + } + tinyfont.WriteLine(w.Display, w.Font, x0, y0, text, c) +} + +// WriteLineInverse writes the given text to the display in an inverted way where x, y is the bottom leftmost pixel of the text +func (w *Writer) WriteLineInverse(text string, x, y int16, c color.RGBA) { + inverseC := color.RGBA{ + R: ^c.R, + G: ^c.G, + B: ^c.B, + A: c.A, + } + _, outerWidth := tinyfont.LineWidth(w.Font, text) + outerHeight := int16(w.Font.GetYAdvance()) + x0, y0 := x, y-outerHeight+2 + x1, y1 := x+1, y + _ = tinydraw.FilledRectangle(w.Display, x0, y0, int16(outerWidth+2), outerHeight, c) + tinyfont.WriteLine(w.Display, w.Font, x1, y1, text, inverseC) +} + +// WriteLineInverseAligned writes the given text to the display in an inverted way where: +// x, y is the bottom leftmost pixel of the text +// alignh is horizontal alignment +// alignv is vertical alignment +func (w *Writer) WriteLineInverseAligned(text string, x, y int16, c color.RGBA, alignh HorizontalAlignment, alignv VerticalAlignment) { + w.writeLineInverseAligned(text, w.Font, x, y, c, alignh, alignv) +} + +func (w *Writer) writeLineInverseAligned(text string, font tinyfont.Fonter, x, y int16, c color.RGBA, alignh HorizontalAlignment, alignv VerticalAlignment) { + _, outerWidth := tinyfont.LineWidth(font, text) + outerHeight := int16(font.GetYAdvance()) + x0, y0 := x, y-outerHeight+2 + x1, y1 := x+1, y + switch alignh { + case AlignLeft: + case AlignCenter: + dispWidth, _ := w.Display.Size() + x0 = (dispWidth-int16(outerWidth))/2 - x + x1 = x0 + 1 + case AlignRight: + dispWidth, _ := w.Display.Size() + x0 = dispWidth - int16(outerWidth) - x + x1 = x0 + 1 + default: + panic("invalid alignment") + } + switch alignv { + case AlignTop: + case AlignMiddle: + _, dispHeight := w.Display.Size() + midY := (dispHeight - outerHeight) / 2 + y0 += midY + y1 += midY + case AlignBottom: + _, dispHeight := w.Display.Size() + y1 = dispHeight - y1 + y0 = y1 - outerHeight + 2 + default: + panic("invalid alignment") + } + + inverseC := color.RGBA{ + R: ^c.R, + G: ^c.G, + B: ^c.B, + A: c.A, + } + _ = tinydraw.FilledRectangle(w.Display, x0, y0, int16(outerWidth+2), outerHeight, c) + tinyfont.WriteLine(w.Display, w.Font, x1, y1, text, inverseC) +} diff --git a/experimental/knobbank/knobbank.go b/experimental/knobbank/knobbank.go new file mode 100644 index 0000000..9f7a062 --- /dev/null +++ b/experimental/knobbank/knobbank.go @@ -0,0 +1,94 @@ +package knobbank + +import ( + "errors" + + "github.com/awonak/EuroPiGo/hardware/hal" +) + +type KnobBank struct { + hal.KnobInput + current int + lastValue float32 + bank []knobBankEntry +} + +func NewKnobBank(knob hal.KnobInput, opts ...KnobBankOption) (*KnobBank, error) { + if knob == nil { + return nil, errors.New("knob is nil") + } + + kb := &KnobBank{ + KnobInput: knob, + lastValue: knob.ReadVoltage(), + } + + for _, opt := range opts { + if err := opt(kb); err != nil { + return nil, err + } + } + + return kb, nil +} + +func (kb *KnobBank) Configure(config hal.AnalogInputConfig) error { + // Configure call on a KnobBank is not allowed + return errors.New("unsupported") + //return kb.knob.Configure(config) +} + +func (kb *KnobBank) CurrentName() string { + if len(kb.bank) == 0 { + return "" + } + return kb.bank[kb.current].name +} + +func (kb *KnobBank) CurrentIndex() int { + return kb.current +} + +func (kb *KnobBank) Current() hal.KnobInput { + return kb +} + +func (kb *KnobBank) ReadVoltage() float32 { + value := kb.KnobInput.ReadVoltage() + if len(kb.bank) == 0 { + return value + } + + cur := &kb.bank[kb.current] + percent := kb.Percent() + kb.lastValue = cur.update(percent, value, kb.lastValue) + return cur.Value() +} + +func (kb *KnobBank) Percent() float32 { + percent := kb.KnobInput.Percent() + if len(kb.bank) == 0 { + return percent + } + + cur := &kb.bank[kb.current] + value := kb.KnobInput.ReadVoltage() + kb.lastValue = cur.update(percent, value, kb.lastValue) + return cur.Percent() +} + +func (kb *KnobBank) Next() { + if len(kb.bank) == 0 { + kb.current = 0 + return + } + + cur := &kb.bank[kb.current] + cur.lock(kb.KnobInput, kb.lastValue) + + kb.current++ + if kb.current >= len(kb.bank) { + kb.current = 0 + } + kb.bank[kb.current].unlock() +} diff --git a/experimental/knobbank/knobbankentry.go b/experimental/knobbank/knobbankentry.go new file mode 100644 index 0000000..0fd7c3b --- /dev/null +++ b/experimental/knobbank/knobbankentry.go @@ -0,0 +1,62 @@ +package knobbank + +import ( + "math" + + "github.com/awonak/EuroPiGo/clamp" + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/lerp" +) + +type knobBankEntry struct { + name string + enabled bool + locked bool + value float32 + percent float32 + vlerp lerp.Lerper32[float32] + minVoltage float32 + maxVoltage float32 + scale float32 +} + +func (e *knobBankEntry) lock(knob hal.KnobInput, lastValue float32) float32 { + if e.locked { + return lastValue + } + + e.locked = true + value := knob.ReadVoltage() + percent := knob.Percent() + return e.update(percent, value, lastValue) +} + +func (e *knobBankEntry) unlock() { + if !e.enabled { + return + } + + e.locked = false +} + +func (e *knobBankEntry) Percent() float32 { + return clamp.Clamp(e.percent*e.scale, 0, 1) +} + +func (e *knobBankEntry) Value() float32 { + return clamp.Clamp(e.value*e.scale, e.minVoltage, e.maxVoltage) +} + +func (e *knobBankEntry) update(percent, value, lastValue float32) float32 { + if !e.enabled || e.locked { + return lastValue + } + + if math.Abs(float64(value-lastValue)) < 0.05 { + return lastValue + } + + e.percent = percent + e.value = value + return value +} diff --git a/experimental/knobbank/knobbankoptions.go b/experimental/knobbank/knobbankoptions.go new file mode 100644 index 0000000..b9ee72b --- /dev/null +++ b/experimental/knobbank/knobbankoptions.go @@ -0,0 +1,41 @@ +package knobbank + +import ( + "fmt" +) + +type KnobBankOption func(kb *KnobBank) error + +func WithDisabledKnob() KnobBankOption { + return func(kb *KnobBank) error { + kb.bank = append(kb.bank, knobBankEntry{}) + return nil + } +} + +const ( + defaultMinInputVoltage = 0.0 + defaultMaxInputVoltage = 10.0 +) + +func WithLockedKnob(name string, opts ...KnobOption) KnobBankOption { + return func(kb *KnobBank) error { + e := knobBankEntry{ + name: name, + enabled: true, + locked: true, + minVoltage: defaultMinInputVoltage, + maxVoltage: defaultMaxInputVoltage, + scale: 1, + } + + for _, opt := range opts { + if err := opt(&e); err != nil { + return fmt.Errorf("%s knob configuration error: %w", name, err) + } + } + + kb.bank = append(kb.bank, e) + return nil + } +} diff --git a/experimental/knobbank/knoboptions.go b/experimental/knobbank/knoboptions.go new file mode 100644 index 0000000..b5e6142 --- /dev/null +++ b/experimental/knobbank/knoboptions.go @@ -0,0 +1,40 @@ +package knobbank + +import ( + "fmt" + + "github.com/awonak/EuroPiGo/lerp" +) + +type KnobOption func(e *knobBankEntry) error + +func InitialPercentageValue(v float32) KnobOption { + return func(e *knobBankEntry) error { + if v < 0 || v > 1 { + return fmt.Errorf("initial percentage value of %f is outside the range [0..1]", v) + } + + e.percent = v + e.vlerp = lerp.NewLerp32[float32](defaultMinInputVoltage, defaultMaxInputVoltage) + e.value = e.vlerp.ClampedLerp(v) + return nil + } +} + +func MinInputVoltage(v float32) KnobOption { + return func(e *knobBankEntry) error { + e.minVoltage = v + e.vlerp = lerp.NewLerp32(e.minVoltage, e.maxVoltage) + e.value = e.vlerp.ClampedLerp(e.percent) + return nil + } +} + +func MaxInputVoltage(v float32) KnobOption { + return func(e *knobBankEntry) error { + e.maxVoltage = v + e.vlerp = lerp.NewLerp32(e.minVoltage, e.maxVoltage) + e.value = e.vlerp.ClampedLerp(e.percent) + return nil + } +} diff --git a/go.mod b/go.mod index e221ece..894749b 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,10 @@ module github.com/awonak/EuroPiGo -go 1.19 +go 1.18 require ( - tinygo.org/x/drivers v0.22.0 + github.com/gorilla/websocket v1.5.0 + tinygo.org/x/drivers v0.24.0 tinygo.org/x/tinydraw v0.3.0 tinygo.org/x/tinyfont v0.3.0 ) diff --git a/go.sum b/go.sum index ceddd07..700f6e8 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/bgould/http v0.0.0-20190627042742-d268792bdee7/go.mod h1:BTqvVegvwifo github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= github.com/frankban/quicktest v1.10.2/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hajimehoshi/go-jisx0208 v1.0.0/go.mod h1:yYxEStHL7lt9uL+AbdWgW9gBumwieDoZCiB1f/0X0as= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -20,8 +22,8 @@ tinygo.org/x/drivers v0.14.0/go.mod h1:uT2svMq3EpBZpKkGO+NQHjxjGf1f42ra4OnMMwQL2 tinygo.org/x/drivers v0.15.1/go.mod h1:uT2svMq3EpBZpKkGO+NQHjxjGf1f42ra4OnMMwQL2aI= tinygo.org/x/drivers v0.16.0/go.mod h1:uT2svMq3EpBZpKkGO+NQHjxjGf1f42ra4OnMMwQL2aI= tinygo.org/x/drivers v0.19.0/go.mod h1:uJD/l1qWzxzLx+vcxaW0eY464N5RAgFi1zTVzASFdqI= -tinygo.org/x/drivers v0.22.0 h1:s5c0hJY8pJYojSGS5AQgauwhTuH2bBZPDqdwkBDGW+o= -tinygo.org/x/drivers v0.22.0/go.mod h1:J4+51Li1kcfL5F93kmnDWEEzQF3bLGz0Am3Q7E2a8/E= +tinygo.org/x/drivers v0.24.0 h1:9yOV/GbdDXg+EKOWJl+FHLKMSfSI/skQ8Gmo8N/KzQo= +tinygo.org/x/drivers v0.24.0/go.mod h1:J4+51Li1kcfL5F93kmnDWEEzQF3bLGz0Am3Q7E2a8/E= tinygo.org/x/tinydraw v0.3.0 h1:OjsdMcES5+7IIs/4diFpq/pWFsa0VKtbi1mURuj2q64= tinygo.org/x/tinydraw v0.3.0/go.mod h1:Yz0vLSP2rHsIKpLYkEmLnE+2zyhhITu2LxiVtLRiW6I= tinygo.org/x/tinyfont v0.2.1/go.mod h1:eLqnYSrFRjt5STxWaMeOWJTzrKhXqpWw7nU3bPfKOAM= diff --git a/hardware/README.md b/hardware/README.md new file mode 100644 index 0000000..db1517c --- /dev/null +++ b/hardware/README.md @@ -0,0 +1,40 @@ +# hardware + +This package is used for obtaining singleton objects for particular hardware, identified by `Revision` and `HardwareId`. + +## Revision List + +**NOTE**: The full revision list may be found under [hal/revision.go](hal/revision.go). + +| Identifier | Alias | (non-pico) Build Flags | Notes | +|----|----|----|----| +| `Revision0` | `EuroPiProto` | | EuroPi prototype developed before revision 1 design was solidified. Due to lack of examples of this hardware, support is limited or non-existent. | +| `Revision1` | `EuroPi` | `revision1` or `europi` | EuroPi 'production' release, revision 1. | +| `Revision2` | `EuroPiX` | `revision2` or `europix` | EuroPi X - an improved hardware revision of the EuroPi. Currently in pre-production development hell. | + +## Hardware List + +**NOTE**: The full hardware list may be found under [hal/hardware.go](hal/hardware.go). + +Calling `GetHardware()` with a `Revision` (see above) and `HardwareId` (see below) may return a singleton hardware interface. Check to see if the returned value is `nil` before using it; `nil` is considered to be either a revision detection failure, missing hardware, or some other error. + +| HardwareId | HardwareId Alias | Interface | EuroPi Prototype | EuroPi | EuroPi-X | Notes | +|----|----|----|----|----|----|----| +| `HardwareIdInvalid` | | N/A | | | | Always returns a `nil` interface/object | +| `HardwareIdRevisionMarker` | | `hal.RevisionMarker` | | | | Provides an interface to obtain the `Revision` identifier of the currently detected (or compiled-for) hardware. | +| `HardwareIdDigital1Input` | | `hal.DigitalInput` | | `InputDigital1` | | The Digital Input of the EuroPi. | +| `HardwareIdAnalog1Input` | `HardwareIdAnalogue1Input` | `hal.AnalogInput` | | `InputAnalog1` | | The Analogue Input of the EuroPi. | +| `HardwareIdDisplay1Output` | | `hal.DisplayOutput` | | `OutputDisplay1` | | The Display (OLED) of the EuroPi. Provides an interface for determining display resolution, as it might be different between revisions of the EuroPi hardware. | +| `HardwareIdButton1Input` | | `hal.ButtonInput` | `InputButton1` | `InputButton1` | | The Button 1 gate input of the EuroPi. | +| `HardwareIdButton2Input` | | `hal.ButtonInput` | `InputButton2` | `InputButton2` | | The Button 2 gate input of the EuroPi. | +| `HardwareIdKnob1Input` | | `hal.KnobInput` | `InputKnob1` | `InputKnob1` | | The Knob 1 potentiometer input of the EuroPi. | +| `HardwareIdKnob2Input` | | `hal.KnobInput` | `InputKnob2` | `InputKnob2` | | The Knob 2 potentiometer input of the EuroPi. | +| `HardwareIdVoltage1Output` | | `hal.VoltageOutput` | `OutputAnalog1` | `OutputVoltage1` | | The #1 `CV` / `V/Octave` output of the EuroPi. While the EuroPi supports a 0.0 to 10.0 Volts output necessary for `V/Octave` (see `units.VOct`), it can be carefully used with `units.CV` to output a specific range of 0.0 to 5.0 Volts, instead. For the EuroPi Prototype, the range is 0.0 to 3.3 Volts. | +| `HardwareIdVoltage2Output` | | `hal.VoltageOutput` | `OutputAnalog2` | `OutputVoltage2` | | The #2 `CV` / `V/Octave` output of the EuroPi. See `HardwareIdVoltage1Output` for more details. | +| `HardwareIdVoltage3Output` | | `hal.VoltageOutput` | `OutputAnalog3` | `OutputVoltage3` | | The #3 `CV` / `V/Octave` output of the EuroPi. See `HardwareIdVoltage1Output` for more details. | +| `HardwareIdVoltage4Output` | | `hal.VoltageOutput` | `OutputAnalog4` | `OutputVoltage4` | | The #4 `CV` / `V/Octave` output of the EuroPi. See `HardwareIdVoltage1Output` for more details. | +| `HardwareIdVoltage5Output` | | `hal.VoltageOutput` | `OutputDigital1` | `OutputVoltage5` | | The #5 `CV` / `V/Octave` output of the EuroPi. See `HardwareIdVoltage1Output` for more details. | +| `HardwareIdVoltage6Output` | | `hal.VoltageOutput` | `OutputDigital2` | `OutputVoltage6` | | The #6 `CV` / `V/Octave` output of the EuroPi. See `HardwareIdVoltage1Output` for more details. | +| `HardwareIdRandom1Generator` | | `hal.RandomGenerator` | `DeviceRandomGenerator1` | `DeviceRandomGenerator1` | | Provides an interface to calibrate or seed the random number generator of the hardware. | +| `HardwareIdVoltage7Output` | | `hal.VoltageOutput` | `OutputDigital3` | | | The #7 `CV` / `V/Octave` output of the EuroPi Prototype. See `HardwareIdVoltage1Output` for more details. | +| `HardwareIdVoltage8Output` | | `hal.VoltageOutput` | `OutputDigital4` | | | The #8 `CV` / `V/Octave` output of the EuroPi Prototype. See `HardwareIdVoltage1Output` for more details. | diff --git a/hardware/common/analoginput.go b/hardware/common/analoginput.go new file mode 100644 index 0000000..0ceb501 --- /dev/null +++ b/hardware/common/analoginput.go @@ -0,0 +1,103 @@ +package common + +import ( + "errors" + + "github.com/awonak/EuroPiGo/clamp" + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/lerp" + "github.com/awonak/EuroPiGo/units" +) + +// Analog is a struct for handling the reading of analogue control voltage. +// The analogue input allows you to 'read' CV from anywhere between 0 and 12V. +type Analoginput struct { + adc ADCProvider + samples int + cal lerp.Remapper32[uint16, float32] +} + +var ( + // static check + _ hal.AnalogInput = (*Analoginput)(nil) + // silence linter + _ = NewAnalogInput +) + +type ADCProvider interface { + Get(samples int) uint16 +} + +// NewAnalogInput creates a new Analog Input +func NewAnalogInput(adc ADCProvider, initialConfig hal.AnalogInputConfig) *Analoginput { + if adc == nil { + return nil + } + return &Analoginput{ + adc: adc, + samples: initialConfig.Samples, + cal: initialConfig.Calibration, + } +} + +// Configure updates the device with various configuration parameters +func (a *Analoginput) Configure(config hal.AnalogInputConfig) error { + if config.Samples == 0 { + return errors.New("samples must be non-zero") + } + + if config.Calibration != nil { + a.cal = config.Calibration + } + + a.samples = config.Samples + return nil +} + +// ReadRawVoltage returns the current smoothed value from the analog input device. +func (a *Analoginput) ReadRawVoltage() uint16 { + return a.adc.Get(a.samples) +} + +// ReadVoltage returns the current percentage read between 0.0 and 1.0. +func (a *Analoginput) Percent() float32 { + return a.ReadVoltage() / a.cal.OutputMaximum() +} + +// ReadVoltage returns the current read voltage between 0.0 and 10.0 volts. +func (a *Analoginput) ReadVoltage() float32 { + rawVoltage := a.ReadRawVoltage() + return a.cal.Remap(rawVoltage) +} + +// ReadCV returns the current read voltage as a CV value. +func (a *Analoginput) ReadCV() units.CV { + v := a.ReadVoltage() + // CV is ranged over 0.0V .. +5.0V and stores the values as a normalized + // version (0.0 .. +1.0), so to convert our input voltage to that, we just + // normalize the voltage (divide it by 5) and clamp the result. + return clamp.Clamp(units.CV(v/5.0), 0.0, 1.0) +} + +func (a *Analoginput) ReadBipolarCV() units.BipolarCV { + v := a.ReadVoltage() + // BipolarCV is ranged over -5.0V .. +5.0V and stores the values as a normalized + // version (-1.0 .. +1.0), so to convert our input voltage to that, we just + // normalize the voltage (divide it by 5) and clamp the result. + return clamp.Clamp(units.BipolarCV(v/5.0), -1.0, 1.0) +} + +// ReadCV returns the current read voltage as a V/Octave value. +func (a *Analoginput) ReadVOct() units.VOct { + return units.VOct(a.ReadVoltage()) +} + +// MinVoltage returns the minimum voltage that that input can ever read by this device +func (a *Analoginput) MinVoltage() float32 { + return a.cal.OutputMinimum() +} + +// MaxVoltage returns the maximum voltage that the input can ever read by this device +func (a *Analoginput) MaxVoltage() float32 { + return a.cal.OutputMaximum() +} diff --git a/hardware/common/contextpi.go b/hardware/common/contextpi.go new file mode 100644 index 0000000..b5bf084 --- /dev/null +++ b/hardware/common/contextpi.go @@ -0,0 +1,141 @@ +package common + +import ( + "context" + "fmt" + "sync" + "sync/atomic" +) + +// ContextPi gives the EuroPi hardware the components necessary +// to perform rudimentary context operations +type ContextPi struct { + context.Context + mu sync.Mutex // protects following fields + done atomic.Value // of chan struct{}, created lazily, closed by first cancel call + children map[canceler]struct{} // set to nil by the first cancel call + err error // set to non-nil by the first cancel call +} + +func (c *ContextPi) Done() <-chan struct{} { + d := c.done.Load() + if d != nil { + return d.(chan struct{}) + } + c.mu.Lock() + defer c.mu.Unlock() + d = c.done.Load() + if d == nil { + d = make(chan struct{}) + c.done.Store(d) + } + return d.(chan struct{}) +} + +func (c *ContextPi) Err() error { + c.mu.Lock() + err := c.err + c.mu.Unlock() + return err +} + +func (c *ContextPi) Value(key any) any { + if key == &cancelCtxKey { + return c + } + return c.Context.Value(key) +} + +// cancel closes c.done, cancels each of c's children, and, if +// removeFromParent is true, removes c from its parent's children. +func (c *ContextPi) cancel(removeFromParent bool, err error) { + if err == nil { + panic("context: internal error: missing cancel error") + } + c.mu.Lock() + if c.err != nil { + c.mu.Unlock() + return // already canceled + } + c.err = err + d, _ := c.done.Load().(chan struct{}) + if d == nil { + c.done.Store(closedchan) + } else { + close(d) + } + for child := range c.children { + // NOTE: acquiring the child's lock while holding parent's lock. + child.cancel(false, err) + } + c.children = nil + c.mu.Unlock() + + if removeFromParent { + removeChild(c.Context, c) + } +} + +// &cancelCtxKey is the key that a cancelCtx returns itself for. +var cancelCtxKey int + +// parentCancelCtx returns the underlying *cancelCtx for parent. +// It does this by looking up parent.Value(&cancelCtxKey) to find +// the innermost enclosing *cancelCtx and then checking whether +// parent.Done() matches that *cancelCtx. (If not, the *cancelCtx +// has been wrapped in a custom implementation providing a +// different done channel, in which case we should not bypass it.) +func parentCancelCtx(parent context.Context) (*ContextPi, bool) { + if parent == nil { + return nil, false + } + done := parent.Done() + if done == closedchan || done == nil { + return nil, false + } + p, ok := parent.Value(&cancelCtxKey).(*ContextPi) + if !ok { + return nil, false + } + pdone, _ := p.done.Load().(chan struct{}) + if pdone != done { + return nil, false + } + return p, true +} + +// removeChild removes a context from its parent. +func removeChild(parent context.Context, child canceler) { + p, ok := parentCancelCtx(parent) + if !ok { + return + } + p.mu.Lock() + if p.children != nil { + delete(p.children, child) + } + p.mu.Unlock() +} + +func (c *ContextPi) Shutdown(reason any) error { + var err error + if reason != nil { + err = fmt.Errorf("%v", reason) + } + c.cancel(true, err) + return nil +} + +// A canceler is a context type that can be canceled directly. The +// implementation is *ContextPi +type canceler interface { + cancel(removeFromParent bool, err error) + Done() <-chan struct{} +} + +// closedchan is a reusable closed channel. +var closedchan = make(chan struct{}) + +func init() { + close(closedchan) +} diff --git a/hardware/common/digitalinput.go b/hardware/common/digitalinput.go new file mode 100644 index 0000000..c030ac2 --- /dev/null +++ b/hardware/common/digitalinput.go @@ -0,0 +1,72 @@ +package common + +import ( + "time" + + "github.com/awonak/EuroPiGo/debounce" + "github.com/awonak/EuroPiGo/hardware/hal" +) + +// Digitalinput is a struct for handling reading of the digital input. +type Digitalinput struct { + dr DigitalReaderProvider + lastChange time.Time +} + +var ( + // static check + _ hal.DigitalInput = (*Digitalinput)(nil) + // silence linter + _ = NewDigitalInput +) + +type DigitalReaderProvider interface { + Get() bool + SetHandler(changes hal.ChangeFlags, handler func()) +} + +// NewDigitalInput creates a new digital input struct. +func NewDigitalInput(dr DigitalReaderProvider) *Digitalinput { + if dr == nil { + return nil + } + return &Digitalinput{ + dr: dr, + lastChange: time.Now(), + } +} + +// Configure updates the device with various configuration parameters +func (d *Digitalinput) Configure(config hal.DigitalInputConfig) error { + return nil +} + +// Value returns true if the input is high (above 0.8v), else false. +func (d *Digitalinput) Value() bool { + return d.dr.Get() +} + +// Handler sets the callback function to be call when the incoming signal going high event is detected. +func (d *Digitalinput) Handler(handler func(value bool, deltaTime time.Duration)) { + d.HandlerEx(hal.ChangeRising, handler) +} + +// HandlerEx sets the callback function to be call when the input changes in a specified way. +func (d *Digitalinput) HandlerEx(changes hal.ChangeFlags, handler func(value bool, deltaTime time.Duration)) { + d.dr.SetHandler(changes, func() { + now := time.Now() + timeDelta := now.Sub(d.lastChange) + handler(d.Value(), timeDelta) + d.lastChange = now + }) +} + +// HandlerWithDebounce sets the callback function to be call when the incoming signal going high event is detected and debounce delay time has elapsed. +func (d *Digitalinput) HandlerWithDebounce(handler func(value bool, deltaTime time.Duration), delay time.Duration) { + db := debounce.NewDebouncer(handler).Debounce(delay) + d.Handler(func(value bool, _ time.Duration) { + // throw away the deltaTime coming in on the handler + // we want to use what's on the debouncer, instead + db(value) + }) +} diff --git a/hardware/common/displayoutput.go b/hardware/common/displayoutput.go new file mode 100644 index 0000000..dc32455 --- /dev/null +++ b/hardware/common/displayoutput.go @@ -0,0 +1,62 @@ +package common + +import ( + "image/color" + + "github.com/awonak/EuroPiGo/hardware/hal" +) + +// DisplayOutput is a wrapper around `ssd1306.Device` for drawing graphics and text to the OLED. +type DisplayOutput struct { + dp DisplayProvider +} + +var ( + // static check + _ hal.DisplayOutput = (*DisplayOutput)(nil) + // silence linter + _ = NewDisplayOutput +) + +type DisplayProvider interface { + ClearBuffer() + Size() (x, y int16) + SetPixel(x, y int16, c color.RGBA) + Display() error +} + +// NewDisplayOutput returns a new Display struct. +func NewDisplayOutput(dp DisplayProvider) *DisplayOutput { + if dp == nil { + return nil + } + return &DisplayOutput{ + dp: dp, + } +} + +// Configure updates the device with various configuration parameters +func (d *DisplayOutput) Configure(config hal.DisplayOutputConfig) error { + return nil +} + +// ClearBuffer clears the internal display buffer for the device +func (d *DisplayOutput) ClearBuffer() { + d.dp.ClearBuffer() +} + +// Size returns the display resolution for the device +func (d *DisplayOutput) Size() (x, y int16) { + return d.dp.Size() +} + +// SetPixel sets a specific pixel at coordinates (`x`,`y`) to color `c`. +func (d *DisplayOutput) SetPixel(x, y int16, c color.RGBA) { + d.dp.SetPixel(x, y, c) +} + +// Display commits the internal buffer to the display device. +// This will update the physical content displayed on the device. +func (d *DisplayOutput) Display() error { + return d.dp.Display() +} diff --git a/hardware/common/randomgenerator.go b/hardware/common/randomgenerator.go new file mode 100644 index 0000000..d452f4b --- /dev/null +++ b/hardware/common/randomgenerator.go @@ -0,0 +1,37 @@ +package common + +import ( + "github.com/awonak/EuroPiGo/hardware/hal" +) + +type RandomGenerator struct { + rnd RNDProvider +} + +var ( + // static check + _ hal.RandomGenerator = (*RandomGenerator)(nil) + // silence linter + _ = NewRandomGenerator +) + +func NewRandomGenerator(rnd RNDProvider) *RandomGenerator { + return &RandomGenerator{ + rnd: rnd, + } +} + +type RNDProvider interface { + Configure(config hal.RandomGeneratorConfig) error +} + +// Configure updates the device with various configuration parameters +func (r *RandomGenerator) Configure(config hal.RandomGeneratorConfig) error { + if r.rnd != nil { + if err := r.rnd.Configure(config); err != nil { + return err + } + } + + return nil +} diff --git a/hardware/common/voltageoutput.go b/hardware/common/voltageoutput.go new file mode 100644 index 0000000..3be76ef --- /dev/null +++ b/hardware/common/voltageoutput.go @@ -0,0 +1,85 @@ +package common + +import ( + "fmt" + + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/units" +) + +// VoltageOutput is struct for interacting with the CV/VOct voltage output jacks. +type VoltageOutput struct { + pwm PWMProvider +} + +var ( + // static check + _ hal.VoltageOutput = (*VoltageOutput)(nil) + // silence linter + _ = NewVoltageOuput +) + +type PWMProvider interface { + Configure(config hal.VoltageOutputConfig) error + Set(v float32) + Get() float32 + MinVoltage() float32 + MaxVoltage() float32 +} + +// NewOutput returns a new Output interface. +func NewVoltageOuput(pwm PWMProvider, initialConfig hal.VoltageOutputConfig) *VoltageOutput { + o := &VoltageOutput{ + pwm: pwm, + } + err := o.Configure(initialConfig) + if err != nil { + panic(fmt.Errorf("configuration error: %v", err.Error())) + } + + return o +} + +// Configure updates the device with various configuration parameters +func (o *VoltageOutput) Configure(config hal.VoltageOutputConfig) error { + if err := o.pwm.Configure(config); err != nil { + return err + } + + return nil +} + +// SetVoltage sets the current output voltage within a range of 0.0 to 10.0. +func (o *VoltageOutput) SetVoltage(v float32) { + o.pwm.Set(v) +} + +// SetCV sets the current output voltage based on a CV value +func (o *VoltageOutput) SetCV(cv units.CV) { + o.SetVoltage(cv.ToVolts()) +} + +// SetBipolarCV sets the current output voltage based on a BipolarCV value +func (o *VoltageOutput) SetBipolarCV(cv units.BipolarCV) { + o.SetVoltage(cv.ToVolts()) +} + +// SetCV sets the current output voltage based on a V/Octave value +func (o *VoltageOutput) SetVOct(voct units.VOct) { + o.SetVoltage(voct.ToVolts()) +} + +// Voltage returns the current voltage +func (o *VoltageOutput) Voltage() float32 { + return o.pwm.Get() +} + +// MinVoltage returns the minimum voltage this device will output +func (o *VoltageOutput) MinVoltage() float32 { + return o.pwm.MinVoltage() +} + +// MaxVoltage returns the maximum voltage this device will output +func (o *VoltageOutput) MaxVoltage() float32 { + return o.pwm.MaxVoltage() +} diff --git a/hardware/hal/analoginput.go b/hardware/hal/analoginput.go new file mode 100644 index 0000000..26b3fcf --- /dev/null +++ b/hardware/hal/analoginput.go @@ -0,0 +1,23 @@ +package hal + +import ( + "github.com/awonak/EuroPiGo/lerp" + "github.com/awonak/EuroPiGo/units" +) + +type AnalogInput interface { + Configure(config AnalogInputConfig) error + Percent() float32 + ReadRawVoltage() uint16 + ReadVoltage() float32 + ReadCV() units.CV + ReadBipolarCV() units.BipolarCV + ReadVOct() units.VOct + MinVoltage() float32 + MaxVoltage() float32 +} + +type AnalogInputConfig struct { + Samples int + Calibration lerp.Remapper32[uint16, float32] +} diff --git a/hardware/hal/buttoninput.go b/hardware/hal/buttoninput.go new file mode 100644 index 0000000..ca67a05 --- /dev/null +++ b/hardware/hal/buttoninput.go @@ -0,0 +1,3 @@ +package hal + +type ButtonInput = DigitalInput diff --git a/hardware/hal/changes.go b/hardware/hal/changes.go new file mode 100644 index 0000000..8c300c5 --- /dev/null +++ b/hardware/hal/changes.go @@ -0,0 +1,13 @@ +package hal + +type ChangeFlags int + +const ( + ChangeRising = ChangeFlags(1 << iota) + ChangeFalling +) + +const ( + ChangeNone = ChangeFlags(0) + ChangeAny = ChangeRising | ChangeFalling +) diff --git a/hardware/hal/digitalinput.go b/hardware/hal/digitalinput.go new file mode 100644 index 0000000..dd208c0 --- /dev/null +++ b/hardware/hal/digitalinput.go @@ -0,0 +1,17 @@ +package hal + +import "time" + +type DigitalInput interface { + Configure(config DigitalInputConfig) error + Value() bool + Handler(handler func(value bool, deltaTime time.Duration)) + HandlerEx(changes ChangeFlags, handler func(value bool, deltaTime time.Duration)) + HandlerWithDebounce(handler func(value bool, deltaTime time.Duration), delay time.Duration) +} + +type DigitalInputConfig struct { + Samples uint16 + CalibratedMinAI uint16 + CalibratedMaxAI uint16 +} diff --git a/hardware/hal/displayoutput.go b/hardware/hal/displayoutput.go new file mode 100644 index 0000000..8d396af --- /dev/null +++ b/hardware/hal/displayoutput.go @@ -0,0 +1,13 @@ +package hal + +import "image/color" + +type DisplayOutput interface { + ClearBuffer() + Size() (x, y int16) + SetPixel(x, y int16, c color.RGBA) + Display() error +} + +type DisplayOutputConfig struct { +} diff --git a/hardware/hal/hardware.go b/hardware/hal/hardware.go new file mode 100644 index 0000000..09bfb8b --- /dev/null +++ b/hardware/hal/hardware.go @@ -0,0 +1,43 @@ +package hal + +import "context" + +// HardwareId defines an identifier for specific hardware. See the README.md in the hardware directory for more details. +type HardwareId int + +const ( + HardwareIdInvalid = HardwareId(iota) + HardwareIdRevisionMarker + HardwareIdDigital1Input + HardwareIdAnalog1Input + HardwareIdDisplay1Output + HardwareIdButton1Input + HardwareIdButton2Input + HardwareIdKnob1Input + HardwareIdKnob2Input + HardwareIdVoltage1Output + HardwareIdVoltage2Output + HardwareIdVoltage3Output + HardwareIdVoltage4Output + HardwareIdVoltage5Output + HardwareIdVoltage6Output + HardwareIdRandom1Generator + HardwareIdVoltage7Output + HardwareIdVoltage8Output + // NOTE: always ONLY append to this list, NEVER remove, rename, or reorder +) + +// aliases for friendly internationali(s|z)ation, colloquialisms, and naming conventions +const ( + HardwareIdAnalogue1Input = HardwareIdAnalog1Input +) + +// Hardware is the collection of component wrappers used to interact with the module. +type Hardware interface { + Context() context.Context + Shutdown(reason any) error + Revision() Revision + Random() RandomGenerator + Button(idx int) ButtonInput + Knob(idx int) KnobInput +} diff --git a/hardware/hal/knobinput.go b/hardware/hal/knobinput.go new file mode 100644 index 0000000..888a8ec --- /dev/null +++ b/hardware/hal/knobinput.go @@ -0,0 +1,3 @@ +package hal + +type KnobInput = AnalogInput diff --git a/hardware/hal/randomgenerator.go b/hardware/hal/randomgenerator.go new file mode 100644 index 0000000..93a194e --- /dev/null +++ b/hardware/hal/randomgenerator.go @@ -0,0 +1,7 @@ +package hal + +type RandomGenerator interface { + Configure(config RandomGeneratorConfig) error +} + +type RandomGeneratorConfig struct{} diff --git a/hardware/hal/revision.go b/hardware/hal/revision.go new file mode 100644 index 0000000..af90822 --- /dev/null +++ b/hardware/hal/revision.go @@ -0,0 +1,19 @@ +package hal + +// Revision defines an identifier for hardware platforms. See the README.md in the hardware directory for more details. +type Revision int + +const ( + RevisionUnknown = Revision(iota) + Revision0 + Revision1 + Revision2 + // NOTE: always ONLY append to this list, NEVER remove, rename, or reorder +) + +// aliases +const ( + EuroPiProto = Revision0 + EuroPi = Revision1 + EuroPiX = Revision2 +) diff --git a/hardware/hal/revisionmarker.go b/hardware/hal/revisionmarker.go new file mode 100644 index 0000000..68049eb --- /dev/null +++ b/hardware/hal/revisionmarker.go @@ -0,0 +1,31 @@ +package hal + +type RevisionMarker interface { + Revision() Revision +} + +type revisionMarker struct { + detectedRevision Revision +} + +var ( + // static check + _ RevisionMarker = &revisionMarker{} + + RevisionMark RevisionMarker +) + +func NewRevisionMark(opts ...Revision) RevisionMarker { + r := &revisionMarker{ + detectedRevision: RevisionUnknown, + } + if len(opts) > 0 { + r.detectedRevision = opts[0] + } + return r +} + +// Revision returns the detected revision of the current hardware +func (r revisionMarker) Revision() Revision { + return r.detectedRevision +} diff --git a/hardware/hal/voltageoutput.go b/hardware/hal/voltageoutput.go new file mode 100644 index 0000000..834ba1d --- /dev/null +++ b/hardware/hal/voltageoutput.go @@ -0,0 +1,24 @@ +package hal + +import ( + "time" + + "github.com/awonak/EuroPiGo/lerp" + "github.com/awonak/EuroPiGo/units" +) + +type VoltageOutput interface { + SetVoltage(v float32) + SetCV(cv units.CV) + SetBipolarCV(cv units.BipolarCV) + SetVOct(voct units.VOct) + Voltage() float32 + MinVoltage() float32 + MaxVoltage() float32 +} + +type VoltageOutputConfig struct { + Period time.Duration + Monopolar bool + Calibration lerp.Remapper32[float32, uint16] +} diff --git a/hardware/platform.go b/hardware/platform.go new file mode 100644 index 0000000..515a5cf --- /dev/null +++ b/hardware/platform.go @@ -0,0 +1,67 @@ +package hardware + +import ( + "sync" + "sync/atomic" + + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/hardware/rev0" + "github.com/awonak/EuroPiGo/hardware/rev1" +) + +// GetHardware returns a hardware device based on EuroPi `revision` and hardware `id`. +// a `nil` result means that the hardware was not found or some sort of error occurred. +func GetHardware[T any](revision hal.Revision, id hal.HardwareId) T { + switch revision { + case hal.Revision0: + return rev0.GetHardware[T](id) + + case hal.Revision1: + return rev1.GetHardware[T](id) + + case hal.Revision2: + // TODO: implement hardware design of rev2 + //return rev2.GetHardware[T](id) + fallthrough + + default: + var none T + return none + } +} + +// WaitForReady awaits the readiness of the hardware initialization. +// This will block until every aspect of hardware initialization has completed. +func WaitForReady() { + ensureHardwareReady() + hardwareReadyCond.L.Lock() + for { + ready := hardwareReady.Load() + if v, ok := ready.(bool); v && ok { + break + } + hardwareReadyCond.Wait() + } + hardwareReadyCond.L.Unlock() +} + +var ( + hardwareReady atomic.Value + hardwareReadyMu sync.Mutex + hardwareReadyOnce sync.Once + hardwareReadyCond *sync.Cond +) + +func ensureHardwareReady() { + hardwareReadyOnce.Do(func() { + hardwareReadyCond = sync.NewCond(&hardwareReadyMu) + }) +} + +// SetReady is used by the hardware initialization code. +// Do not call this function directly. +func SetReady() { + ensureHardwareReady() + hardwareReady.Store(true) + hardwareReadyCond.Broadcast() +} diff --git a/hardware/rev0/README.md b/hardware/rev0/README.md new file mode 100644 index 0000000..b5d8147 --- /dev/null +++ b/hardware/rev0/README.md @@ -0,0 +1,21 @@ +# hardware/rev1 + +This package is used for the [Original EuroPi hardware](https://github.com/Allen-Synthesis/EuroPi/tree/main/hardware). + +## Hardware Mapping + +| Name | Interface | HardwareId | HardwareId Alias | +|----|----|----|----| +| `InputButton1` | `hal.ButtonInput` | `HardwareIdButton1Input` | | +| `InputButton2` | `hal.ButtonInput` | `HardwareIdButton2Input` | | +| `InputKnob1` | `hal.KnobInput` | `HardwareIdKnob1Input` | | +| `InputKnob2` | `hal.KnobInput` | `HardwareIdKnob2Input` | | +| `OutputAnalog1` | `hal.VoltageOutput` | `HardwareIdVoltage1Output` | `HardwareIdAnalog1Output` | +| `OutputAnalog2` | `hal.VoltageOutput` | `HardwareIdVoltage2Output` | `HardwareIdAnalog2Output` | +| `OutputAnalog3` | `hal.VoltageOutput` | `HardwareIdVoltage3Output` | `HardwareIdAnalog3Output` | +| `OutputAnalog4` | `hal.VoltageOutput` | `HardwareIdVoltage4Output` | `HardwareIdAnalog4Output` | +| `OutputDigital1` | `hal.VoltageOutput` | `HardwareIdVoltage5Output` | `HardwareIdDigital1Output` | +| `OutputDigital2` | `hal.VoltageOutput` | `HardwareIdVoltage6Output` | `HardwareIdDigital2Output` | +| `OutputDigital3` | `hal.VoltageOutput` | `HardwareIdVoltage7Output` | `HardwareIdDigital3Output` | +| `OutputDigital4` | `hal.VoltageOutput` | `HardwareIdVoltage8Output` | `HardwareIdDigital4Output` | +| `DeviceRandomGenerator1` | `hal.RandomGenerator` | `HardwareIdRandom1Generator` | | diff --git a/hardware/rev0/analoginput.go b/hardware/rev0/analoginput.go new file mode 100644 index 0000000..e0eb273 --- /dev/null +++ b/hardware/rev0/analoginput.go @@ -0,0 +1,27 @@ +package rev0 + +import ( + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/lerp" +) + +const ( + // DefaultCalibrated[Min|Max]AI was calculated using the EuroPi calibration program: + // https://github.com/Allen-Synthesis/EuroPi/blob/main/software/programming_instructions.md#calibrate-the-module + DefaultCalibratedMinAI = 300 + DefaultCalibratedMaxAI = 44009 + + DefaultSamples = 1000 + + MaxInputVoltage = 3.3 + MinInputVoltage = 0.0 +) + +var ( + DefaultAICalibration = lerp.NewRemap32[uint16, float32](DefaultCalibratedMinAI, DefaultCalibratedMaxAI, MinInputVoltage, MaxInputVoltage) + + aiInitialConfig = hal.AnalogInputConfig{ + Samples: DefaultSamples, + Calibration: DefaultAICalibration, + } +) diff --git a/hardware/rev0/hardware.go b/hardware/rev0/hardware.go new file mode 100644 index 0000000..eca1a81 --- /dev/null +++ b/hardware/rev0/hardware.go @@ -0,0 +1,41 @@ +package rev0 + +import "github.com/awonak/EuroPiGo/hardware/hal" + +// aliases for module revision specific referencing +const ( + // K1 + HardwareIdKnob1Input = hal.HardwareIdKnob1Input + // K2 + HardwareIdKnob2Input = hal.HardwareIdKnob2Input + // B1 + HardwareIdButton1Input = hal.HardwareIdButton1Input + // B2 + HardwareIdButton2Input = hal.HardwareIdButton2Input + // AJ1 + HardwareIdAnalog1Output = hal.HardwareIdVoltage1Output + // AJ2 + HardwareIdAnalog2Output = hal.HardwareIdVoltage2Output + // AJ3 + HardwareIdAnalog3Output = hal.HardwareIdVoltage3Output + // AJ4 + HardwareIdAnalog4Output = hal.HardwareIdVoltage4Output + // DJ1 + HardwareIdDigital1Output = hal.HardwareIdVoltage5Output + // DJ2 + HardwareIdDigital2Output = hal.HardwareIdVoltage6Output + // DJ3 + HardwareIdDigital3Output = hal.HardwareIdVoltage7Output + // DJ4 + HardwareIdDigital4Output = hal.HardwareIdVoltage8Output + // RNG + HardwareIdRandom1Generator = hal.HardwareIdRandom1Generator +) + +// aliases for friendly internationali(s|z)ation, colloquialisms, and naming conventions +const ( + HardwareIdAnalogue1Output = HardwareIdAnalog1Output + HardwareIdAnalogue2Output = HardwareIdAnalog2Output + HardwareIdAnalogue3Output = HardwareIdAnalog3Output + HardwareIdAnalogue4Output = HardwareIdAnalog4Output +) diff --git a/hardware/rev0/platform.go b/hardware/rev0/platform.go new file mode 100644 index 0000000..fed2d71 --- /dev/null +++ b/hardware/rev0/platform.go @@ -0,0 +1,177 @@ +package rev0 + +import ( + "context" + + "github.com/awonak/EuroPiGo/hardware/common" + "github.com/awonak/EuroPiGo/hardware/hal" +) + +// Pi will be configured during `init()` from platform-specific files. +// See `hardware/pico/pico.go` and `hardware/nonpico/nonpico.go` for more information. +var Pi *EuroPiPrototype + +var ( + // static check + _ hal.Hardware = (*EuroPiPrototype)(nil) +) + +type EuroPiPrototype struct { + common.ContextPi + + // B1 is the Button 1 input on a EuroPi Prototype + B1 hal.ButtonInput + // B2 is the Button 2 input on a EuroPi Prototype + B2 hal.ButtonInput + // K1 is the Knob 1 input on a EuroPi Prototype + K1 hal.KnobInput + // K2 is the Knob 2 input on a EuroPi Prototype + K2 hal.KnobInput + // AJ1 is the analog voltage output 1 jack on a EuroPi Prototype. It supports a range of output voltages between 0.0 and 3.3 V. + AJ1 hal.VoltageOutput + // AJ2 is the analog voltage output 2 jack on a EuroPi Prototype. It supports a range of output voltages between 0.0 and 3.3 V. + AJ2 hal.VoltageOutput + // AJ3 is the analog voltage output 3 jack on a EuroPi Prototype. It supports a range of output voltages between 0.0 and 3.3 V. + AJ3 hal.VoltageOutput + // AJ4 is the analog voltage output 4 jack on a EuroPi Prototype. It supports a range of output voltages between 0.0 and 3.3 V. + AJ4 hal.VoltageOutput + // DJ1 is the digital voltage output 1 jack on a EuroPi Prototype. It supports output voltages of 0.0 and 3.3 V. + DJ1 hal.VoltageOutput + // DJ2 is the digital voltage output 2 jack on a EuroPi Prototype. It supports output voltages of 0.0 and 3.3 V. + DJ2 hal.VoltageOutput + // DJ3 is the digital voltage output 3 jack on a EuroPi Prototype. It supports output voltages of 0.0 and 3.3 V. + DJ3 hal.VoltageOutput + // DJ4 is the digital voltage output 4 jack on a EuroPi Prototype. It supports output voltages of 0.0 and 3.3 V. + DJ4 hal.VoltageOutput + // RND is the random number generator within the EuroPi Prototype. + RND hal.RandomGenerator +} + +func (e *EuroPiPrototype) Context() context.Context { + return e +} + +func (e *EuroPiPrototype) Revision() hal.Revision { + return hal.Revision0 +} + +func (e *EuroPiPrototype) Random() hal.RandomGenerator { + return e.RND +} + +func (e *EuroPiPrototype) String() string { + return "EuroPi Prototype" +} + +func (e *EuroPiPrototype) AJ() [4]hal.VoltageOutput { + return [4]hal.VoltageOutput{e.AJ1, e.AJ2, e.AJ3, e.AJ4} +} + +func (e *EuroPiPrototype) DJ() [4]hal.VoltageOutput { + return [4]hal.VoltageOutput{e.DJ1, e.DJ2, e.DJ3, e.DJ4} +} + +func (e *EuroPiPrototype) Button(idx int) hal.ButtonInput { + switch idx { + case 0: + return e.B1 + case 1: + return e.B2 + default: + return nil + } +} + +func (e *EuroPiPrototype) Knob(idx int) hal.KnobInput { + switch idx { + case 0: + return e.K1 + case 1: + return e.K2 + default: + return nil + } +} + +// GetHardware returns a EuroPi hardware device based on hardware `id`. +// a `nil` result means that the hardware was not found or some sort of error occurred. +func GetHardware[T any](hw hal.HardwareId) T { + var t T + if Pi == nil { + return t + } + + switch hw { + case HardwareIdButton1Input: + t, _ = Pi.B1.(T) + case HardwareIdButton2Input: + t, _ = Pi.B2.(T) + case HardwareIdKnob1Input: + t, _ = Pi.K1.(T) + case HardwareIdKnob2Input: + t, _ = Pi.K2.(T) + case HardwareIdAnalog1Output: + t, _ = Pi.AJ1.(T) + case HardwareIdAnalog2Output: + t, _ = Pi.AJ2.(T) + case HardwareIdAnalog3Output: + t, _ = Pi.AJ3.(T) + case HardwareIdAnalog4Output: + t, _ = Pi.AJ4.(T) + case HardwareIdDigital1Output: + t, _ = Pi.DJ1.(T) + case HardwareIdDigital2Output: + t, _ = Pi.DJ2.(T) + case HardwareIdDigital3Output: + t, _ = Pi.DJ3.(T) + case HardwareIdDigital4Output: + t, _ = Pi.DJ4.(T) + case HardwareIdRandom1Generator: + t, _ = Pi.RND.(T) + default: + } + return t +} + +// Initialize sets up the hardware +// +// This is only to be called by the automatic platform initialization functions +func Initialize(params InitializationParameters) { + Pi = &EuroPiPrototype{ + ContextPi: common.ContextPi{ + Context: context.Background(), + }, + B1: common.NewDigitalInput(params.InputButton1), + B2: common.NewDigitalInput(params.InputButton2), + K1: common.NewAnalogInput(params.InputKnob1, aiInitialConfig), + K2: common.NewAnalogInput(params.InputKnob2, aiInitialConfig), + AJ1: common.NewVoltageOuput(params.OutputAnalog1, cvInitialConfig), + AJ2: common.NewVoltageOuput(params.OutputAnalog2, cvInitialConfig), + AJ3: common.NewVoltageOuput(params.OutputAnalog3, cvInitialConfig), + AJ4: common.NewVoltageOuput(params.OutputAnalog4, cvInitialConfig), + DJ1: common.NewVoltageOuput(params.OutputDigital1, cvInitialConfig), + DJ2: common.NewVoltageOuput(params.OutputDigital2, cvInitialConfig), + DJ3: common.NewVoltageOuput(params.OutputDigital3, cvInitialConfig), + DJ4: common.NewVoltageOuput(params.OutputDigital4, cvInitialConfig), + RND: common.NewRandomGenerator(params.DeviceRandomGenerator1), + } +} + +// InitializationParameters is a ferry for hardware functions to the interface layer found here +// +// This is only to be used by the automatic platform initialization functions +type InitializationParameters struct { + InputButton1 common.DigitalReaderProvider + InputButton2 common.DigitalReaderProvider + InputKnob1 common.ADCProvider + InputKnob2 common.ADCProvider + OutputAnalog1 common.PWMProvider + OutputAnalog2 common.PWMProvider + OutputAnalog3 common.PWMProvider + OutputAnalog4 common.PWMProvider + OutputDigital1 common.PWMProvider + OutputDigital2 common.PWMProvider + OutputDigital3 common.PWMProvider + OutputDigital4 common.PWMProvider + DeviceRandomGenerator1 common.RNDProvider +} diff --git a/hardware/rev0/voltageoutput.go b/hardware/rev0/voltageoutput.go new file mode 100644 index 0000000..b3ff4c8 --- /dev/null +++ b/hardware/rev0/voltageoutput.go @@ -0,0 +1,33 @@ +package rev0 + +import ( + "time" + + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/lerp" +) + +const ( + // Manually calibrated to best match expected voltages. Additional info: + // https://github.com/Allen-Synthesis/EuroPi/blob/main/software/programming_instructions.md#calibrate-the-module + CalibratedOffset = 0 + // The default pwmGroup Top of MaxUint16 caused noisy output. Dropping this down to a 8bit value resulted in much smoother cv output. + CalibratedTop = 0xff - CalibratedOffset + + MaxOutputVoltage = 3.3 + MinOutputVoltage = 0.0 + + // We need a rather high frequency to achieve a stable cv ouput, which means we need a rather low duty cycle period. + // Set a period of 500ns. + DefaultPWMPeriod time.Duration = time.Nanosecond * 500 +) + +var ( + DefaultVoltageOutputCalibration = lerp.NewRemap32[float32, uint16](MinOutputVoltage, MaxOutputVoltage, CalibratedOffset, CalibratedTop) + + cvInitialConfig = hal.VoltageOutputConfig{ + Period: DefaultPWMPeriod, + Monopolar: true, + Calibration: DefaultVoltageOutputCalibration, + } +) diff --git a/hardware/rev1/README.md b/hardware/rev1/README.md new file mode 100644 index 0000000..44a8a55 --- /dev/null +++ b/hardware/rev1/README.md @@ -0,0 +1,22 @@ +# hardware/rev1 + +This package is used for the [Original EuroPi hardware](https://github.com/Allen-Synthesis/EuroPi/tree/main/hardware). + +## Hardware Mapping + +| Name | Interface | HardwareId | HardwareId Alias | +|----|----|----|----| +| `InputDigital1` | `hal.DigitalInput` | `HardwareIdDigital1Input` | | +| `InputAnalog1` | `hal.AnalogInput` | `HardwareIdAnalog1Input` | `HardwareIdAnalogue1Input` | +| `OutputDisplay1` | `hal.DisplayOutput` | `HardwareIdDisplay1Output` | | +| `InputButton1` | `hal.ButtonInput` | `HardwareIdButton1Input` | | +| `InputButton2` | `hal.ButtonInput` | `HardwareIdButton2Input` | | +| `InputKnob1` | `hal.KnobInput` | `HardwareIdKnob1Input` | | +| `InputKnob2` | `hal.KnobInput` | `HardwareIdKnob2Input` | | +| `OutputVoltage1` | `hal.VoltageOutput` | `HardwareIdVoltage1Output` | `HardwareIdCV1Output` | +| `OutputVoltage2` | `hal.VoltageOutput` | `HardwareIdVoltage2Output` | `HardwareIdCV2Output` | +| `OutputVoltage3` | `hal.VoltageOutput` | `HardwareIdVoltage3Output` | `HardwareIdCV3Output` | +| `OutputVoltage4` | `hal.VoltageOutput` | `HardwareIdVoltage4Output` | `HardwareIdCV4Output` | +| `OutputVoltage5` | `hal.VoltageOutput` | `HardwareIdVoltage5Output` | `HardwareIdCV5Output` | +| `OutputVoltage6` | `hal.VoltageOutput` | `HardwareIdVoltage6Output` | `HardwareIdCV6Output` | +| `DeviceRandomGenerator1` | `hal.RandomGenerator` | `HardwareIdRandom1Generator` | | diff --git a/hardware/rev1/analoginput.go b/hardware/rev1/analoginput.go new file mode 100644 index 0000000..f78ccf0 --- /dev/null +++ b/hardware/rev1/analoginput.go @@ -0,0 +1,27 @@ +package rev1 + +import ( + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/lerp" +) + +const ( + // DefaultCalibrated[Min|Max]AI was calculated using the EuroPi calibration program: + // https://github.com/Allen-Synthesis/EuroPi/blob/main/software/programming_instructions.md#calibrate-the-module + DefaultCalibratedMinAI = 300 + DefaultCalibratedMaxAI = 44009 + + DefaultSamples = 1000 + + MaxInputVoltage = 10.0 + MinInputVoltage = 0.0 +) + +var ( + DefaultAICalibration = lerp.NewRemap32[uint16, float32](DefaultCalibratedMinAI, DefaultCalibratedMaxAI, MinInputVoltage, MaxInputVoltage) + + aiInitialConfig = hal.AnalogInputConfig{ + Samples: DefaultSamples, + Calibration: DefaultAICalibration, + } +) diff --git a/hardware/rev1/hardware.go b/hardware/rev1/hardware.go new file mode 100644 index 0000000..a82c057 --- /dev/null +++ b/hardware/rev1/hardware.go @@ -0,0 +1,41 @@ +package rev1 + +import "github.com/awonak/EuroPiGo/hardware/hal" + +// aliases for module revision specific referencing +const ( + // DI: Digital Input + HardwareIdDigital1Input = hal.HardwareIdDigital1Input + // AI: Analog(ue) Input + HardwareIdAnalog1Input = hal.HardwareIdAnalog1Input + // Display + HardwareIdDisplay1Output = hal.HardwareIdDisplay1Output + // K1 + HardwareIdKnob1Input = hal.HardwareIdKnob1Input + // K2 + HardwareIdKnob2Input = hal.HardwareIdKnob2Input + // B1 + HardwareIdButton1Input = hal.HardwareIdButton1Input + // B2 + HardwareIdButton2Input = hal.HardwareIdButton2Input + // CV1 + HardwareIdCV1Output = hal.HardwareIdVoltage1Output + // CV2 + HardwareIdCV2Output = hal.HardwareIdVoltage2Output + // CV3 + HardwareIdCV3Output = hal.HardwareIdVoltage3Output + // CV4 + HardwareIdCV4Output = hal.HardwareIdVoltage4Output + // CV5 + HardwareIdCV5Output = hal.HardwareIdVoltage5Output + // CV6 + HardwareIdCV6Output = hal.HardwareIdVoltage6Output + // RNG + HardwareIdRandom1Generator = hal.HardwareIdRandom1Generator +) + +// aliases for friendly internationali(s|z)ation, colloquialisms, and naming conventions +const ( + HardwareIdAnalogue1Input = HardwareIdAnalog1Input + HardwareIdOLED1Output = HardwareIdDisplay1Output +) diff --git a/hardware/rev1/platform.go b/hardware/rev1/platform.go new file mode 100644 index 0000000..0b679c7 --- /dev/null +++ b/hardware/rev1/platform.go @@ -0,0 +1,174 @@ +package rev1 + +import ( + "context" + + "github.com/awonak/EuroPiGo/hardware/common" + "github.com/awonak/EuroPiGo/hardware/hal" +) + +// Pi will be configured during `init()` from platform-specific files. +// See `hardware/pico/pico.go` and `hardware/nonpico/nonpico.go` for more information. +var Pi *EuroPi + +type EuroPi struct { + common.ContextPi + + // DI is the Digital Input on a EuroPi + DI hal.DigitalInput + // AI is the Analogue Input on a EuroPi + AI hal.AnalogInput + // OLED is the display output on a EuroPi + OLED hal.DisplayOutput + // B1 is the Button 1 input on a EuroPi + B1 hal.ButtonInput + // B2 is the Button 2 input on a EuroPi + B2 hal.ButtonInput + // K1 is the Knob 1 input on a EuroPi + K1 hal.KnobInput + // K2 is the Knob 2 input on a EuroPi + K2 hal.KnobInput + // CV1 is the voltage output 1 jack on a EuroPi. It supports a range of output voltages between 0.0 and 10.0 V. + CV1 hal.VoltageOutput + // CV2 is the voltage output 2 jack on a EuroPi. It supports a range of output voltages between 0.0 and 10.0 V. + CV2 hal.VoltageOutput + // CV3 is the voltage output 3 jack on a EuroPi. It supports a range of output voltages between 0.0 and 10.0 V. + CV3 hal.VoltageOutput + // CV4 is the voltage output 4 jack on a EuroPi. It supports a range of output voltages between 0.0 and 10.0 V. + CV4 hal.VoltageOutput + // CV5 is the voltage output 5 jack on a EuroPi. It supports a range of output voltages between 0.0 and 10.0 V. + CV5 hal.VoltageOutput + // CV6 is the voltage output 6 jack on a EuroPi. It supports a range of output voltages between 0.0 and 10.0 V. + CV6 hal.VoltageOutput + // RND is the random number generator within the EuroPi + RND hal.RandomGenerator +} + +func (e *EuroPi) Context() context.Context { + return e +} + +func (e *EuroPi) Revision() hal.Revision { + return hal.Revision1 +} + +func (e *EuroPi) Random() hal.RandomGenerator { + return e.RND +} + +func (e *EuroPi) String() string { + return "EuroPi" +} + +func (e *EuroPi) CV() [6]hal.VoltageOutput { + return [6]hal.VoltageOutput{e.CV1, e.CV2, e.CV3, e.CV4, e.CV5, e.CV6} +} + +func (e *EuroPi) Button(idx int) hal.ButtonInput { + switch idx { + case 0: + return e.B1 + case 1: + return e.B2 + default: + return nil + } +} + +func (e *EuroPi) Knob(idx int) hal.KnobInput { + switch idx { + case 0: + return e.K1 + case 1: + return e.K2 + default: + return nil + } +} + +// GetHardware returns a EuroPi hardware device based on hardware `id`. +// a `nil` result means that the hardware was not found or some sort of error occurred. +func GetHardware[T any](hw hal.HardwareId) T { + var t T + if Pi == nil { + return t + } + + switch hw { + case HardwareIdDigital1Input: + t, _ = Pi.DI.(T) + case HardwareIdAnalog1Input: + t, _ = Pi.AI.(T) + case HardwareIdDisplay1Output: + t, _ = Pi.OLED.(T) + case HardwareIdButton1Input: + t, _ = Pi.B1.(T) + case HardwareIdButton2Input: + t, _ = Pi.B2.(T) + case HardwareIdKnob1Input: + t, _ = Pi.K1.(T) + case HardwareIdKnob2Input: + t, _ = Pi.K2.(T) + case HardwareIdCV1Output: + t, _ = Pi.CV1.(T) + case HardwareIdCV2Output: + t, _ = Pi.CV2.(T) + case HardwareIdCV3Output: + t, _ = Pi.CV3.(T) + case HardwareIdCV4Output: + t, _ = Pi.CV4.(T) + case HardwareIdCV5Output: + t, _ = Pi.CV5.(T) + case HardwareIdCV6Output: + t, _ = Pi.CV6.(T) + case HardwareIdRandom1Generator: + t, _ = Pi.RND.(T) + default: + } + return t +} + +// Initialize sets up the hardware +// +// This is only to be called by the automatic platform initialization functions +func Initialize(params InitializationParameters) { + Pi = &EuroPi{ + ContextPi: common.ContextPi{ + Context: context.Background(), + }, + DI: common.NewDigitalInput(params.InputDigital1), + AI: common.NewAnalogInput(params.InputAnalog1, aiInitialConfig), + OLED: common.NewDisplayOutput(params.OutputDisplay1), + B1: common.NewDigitalInput(params.InputButton1), + B2: common.NewDigitalInput(params.InputButton2), + K1: common.NewAnalogInput(params.InputKnob1, aiInitialConfig), + K2: common.NewAnalogInput(params.InputKnob2, aiInitialConfig), + CV1: common.NewVoltageOuput(params.OutputVoltage1, cvInitialConfig), + CV2: common.NewVoltageOuput(params.OutputVoltage2, cvInitialConfig), + CV3: common.NewVoltageOuput(params.OutputVoltage3, cvInitialConfig), + CV4: common.NewVoltageOuput(params.OutputVoltage4, cvInitialConfig), + CV5: common.NewVoltageOuput(params.OutputVoltage5, cvInitialConfig), + CV6: common.NewVoltageOuput(params.OutputVoltage6, cvInitialConfig), + RND: common.NewRandomGenerator(params.DeviceRandomGenerator1), + } +} + +// InitializationParameters is a ferry for hardware functions to the interface layer found here +// +// This is only to be used by the automatic platform initialization functions +type InitializationParameters struct { + InputDigital1 common.DigitalReaderProvider + InputAnalog1 common.ADCProvider + OutputDisplay1 common.DisplayProvider + InputButton1 common.DigitalReaderProvider + InputButton2 common.DigitalReaderProvider + InputKnob1 common.ADCProvider + InputKnob2 common.ADCProvider + OutputVoltage1 common.PWMProvider + OutputVoltage2 common.PWMProvider + OutputVoltage3 common.PWMProvider + OutputVoltage4 common.PWMProvider + OutputVoltage5 common.PWMProvider + OutputVoltage6 common.PWMProvider + DeviceRandomGenerator1 common.RNDProvider +} diff --git a/hardware/rev1/voltageoutput.go b/hardware/rev1/voltageoutput.go new file mode 100644 index 0000000..5b2c14f --- /dev/null +++ b/hardware/rev1/voltageoutput.go @@ -0,0 +1,33 @@ +package rev1 + +import ( + "time" + + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/lerp" +) + +const ( + // Manually calibrated to best match expected voltages. Additional info: + // https://github.com/Allen-Synthesis/EuroPi/blob/main/software/programming_instructions.md#calibrate-the-module + CalibratedOffset = 0 + // The default pwmGroup Top of MaxUint16 caused noisy output. Dropping this down to a 8bit value resulted in much smoother cv output. + CalibratedTop = 0xff - CalibratedOffset + + MaxOutputVoltage = 10.0 + MinOutputVoltage = 0.0 + + // We need a rather high frequency to achieve a stable cv ouput, which means we need a rather low duty cycle period. + // Set a period of 500ns. + DefaultPWMPeriod time.Duration = time.Nanosecond * 500 +) + +var ( + DefaultVoltageOutputCalibration = lerp.NewRemap32[float32, uint16](MinOutputVoltage, MaxOutputVoltage, CalibratedOffset, CalibratedTop) + + cvInitialConfig = hal.VoltageOutputConfig{ + Period: DefaultPWMPeriod, + Monopolar: true, + Calibration: DefaultVoltageOutputCalibration, + } +) diff --git a/hardware/revisiondetection.go b/hardware/revisiondetection.go new file mode 100644 index 0000000..6d55ffd --- /dev/null +++ b/hardware/revisiondetection.go @@ -0,0 +1,59 @@ +package hardware + +import ( + "sync" + + "github.com/awonak/EuroPiGo/hardware/hal" +) + +// GetRevision returns the currently detected hardware revision. +// If the current revision hasn't been detected, yet, then this call +// will block until it is. +func GetRevision() hal.Revision { + var waitForDetect sync.WaitGroup + waitForDetect.Add(1) + var detectedRevision hal.Revision + OnRevisionDetected(func(revision hal.Revision) { + detectedRevision = revision + waitForDetect.Done() + }) + waitForDetect.Wait() + return detectedRevision +} + +var ( + onRevisionDetected chan func(revision hal.Revision) + revisionChannelInit sync.Once + revisionWgDone sync.Once +) + +func ensureOnRevisionDetection() { + revisionChannelInit.Do(func() { + onRevisionDetected = make(chan func(revision hal.Revision), 10) + }) +} + +func OnRevisionDetected(fn func(revision hal.Revision)) { + if fn == nil { + return + } + ensureOnRevisionDetection() + onRevisionDetected <- fn +} + +// SetDetectedRevision sets the currently detected hardware revision. +// This should not be called directly. +func SetDetectedRevision(opts ...hal.Revision) { + ensureOnRevisionDetection() + // need to be sure it's ready before we can done() it + hal.RevisionMark = hal.NewRevisionMark(opts...) + revisionWgDone.Do(func() { + go func() { + for fn := range onRevisionDetected { + if fn != nil { + fn(hal.RevisionMark.Revision()) + } + } + }() + }) +} diff --git a/internal/nonpico/common/adc.go b/internal/nonpico/common/adc.go new file mode 100644 index 0000000..d409347 --- /dev/null +++ b/internal/nonpico/common/adc.go @@ -0,0 +1,40 @@ +//go:build !pico +// +build !pico + +package common + +import ( + "fmt" + + "github.com/awonak/EuroPiGo/event" + "github.com/awonak/EuroPiGo/hardware/common" + "github.com/awonak/EuroPiGo/hardware/hal" +) + +type nonPicoAdc struct { + id hal.HardwareId + value uint16 +} + +var ( + // static check + _ common.ADCProvider = (*nonPicoAdc)(nil) +) + +func NewNonPicoAdc(id hal.HardwareId) *nonPicoAdc { + adc := &nonPicoAdc{ + id: id, + } + event.Subscribe(bus, fmt.Sprintf("hw_value_%d", id), func(msg HwMessageADCValue) { + adc.value = msg.Value + }) + return adc +} + +func (a *nonPicoAdc) Get(samples int) uint16 { + var sum int + for i := 0; i < samples; i++ { + sum += int(a.value) + } + return uint16(sum / samples) +} diff --git a/internal/nonpico/common/bus.go b/internal/nonpico/common/bus.go new file mode 100644 index 0000000..3b59270 --- /dev/null +++ b/internal/nonpico/common/bus.go @@ -0,0 +1,45 @@ +//go:build !pico +// +build !pico + +package common + +import ( + "fmt" + + "github.com/awonak/EuroPiGo/event" + "github.com/awonak/EuroPiGo/hardware/hal" +) + +var ( + bus = event.NewBus() +) + +func SetDigitalValue(hid hal.HardwareId, value bool) { + bus.Post(fmt.Sprintf("hw_value_%d", hid), HwMessageDigitalValue{ + Value: value, + }) +} + +func TriggerInterrupt(hid hal.HardwareId, change hal.ChangeFlags) { + bus.Post(fmt.Sprintf("hw_interrupt_%d", hid), HwMessageInterrupt{ + Change: change, + }) +} + +func SetADCValue(hid hal.HardwareId, value uint16) { + bus.Post(fmt.Sprintf("hw_value_%d", hid), HwMessageADCValue{ + Value: value, + }) +} + +func OnPWMValue(hid hal.HardwareId, fn func(hid hal.HardwareId, value uint16, voltage float32)) { + event.Subscribe(bus, fmt.Sprintf("hw_pwm_%d", hid), func(msg HwMessagePwmValue) { + fn(hid, msg.Value, msg.Voltage) + }) +} + +func OnDisplayOutput(hid hal.HardwareId, fn func(hid hal.HardwareId, op HwDisplayOp, params []int16)) { + event.Subscribe(bus, fmt.Sprintf("hw_display_%d", hid), func(msg HwMessageDisplay) { + fn(hid, msg.Op, msg.Operands) + }) +} diff --git a/internal/nonpico/common/digitalreader.go b/internal/nonpico/common/digitalreader.go new file mode 100644 index 0000000..6892807 --- /dev/null +++ b/internal/nonpico/common/digitalreader.go @@ -0,0 +1,46 @@ +//go:build !pico +// +build !pico + +package common + +import ( + "fmt" + + "github.com/awonak/EuroPiGo/event" + "github.com/awonak/EuroPiGo/hardware/common" + "github.com/awonak/EuroPiGo/hardware/hal" +) + +type nonPicoDigitalReader struct { + id hal.HardwareId + value bool +} + +var ( + // static check + _ common.DigitalReaderProvider = (*nonPicoDigitalReader)(nil) +) + +func NewNonPicoDigitalReader(id hal.HardwareId) *nonPicoDigitalReader { + dr := &nonPicoDigitalReader{ + id: id, + value: true, // start off in high, as that's actually read as low + } + event.Subscribe(bus, fmt.Sprintf("hw_value_%d", id), func(msg HwMessageDigitalValue) { + dr.value = !msg.Value + }) + return dr +} + +func (d *nonPicoDigitalReader) Get() bool { + // Invert signal to match expected behavior. + return !d.value +} + +func (d *nonPicoDigitalReader) SetHandler(changes hal.ChangeFlags, handler func()) { + event.Subscribe(bus, fmt.Sprintf("hw_interrupt_%d", d.id), func(msg HwMessageInterrupt) { + if (msg.Change & changes) != 0 { + handler() + } + }) +} diff --git a/internal/nonpico/common/displaymode.go b/internal/nonpico/common/displaymode.go new file mode 100644 index 0000000..13ab116 --- /dev/null +++ b/internal/nonpico/common/displaymode.go @@ -0,0 +1,11 @@ +//go:build !pico +// +build !pico + +package common + +type DisplayMode int + +const ( + DisplayModeSeparate = DisplayMode(iota) + DisplayModeCombined +) diff --git a/internal/nonpico/common/displayoutput.go b/internal/nonpico/common/displayoutput.go new file mode 100644 index 0000000..a55a418 --- /dev/null +++ b/internal/nonpico/common/displayoutput.go @@ -0,0 +1,61 @@ +//go:build !pico +// +build !pico + +package common + +import ( + "fmt" + "image/color" + + "github.com/awonak/EuroPiGo/hardware/common" + "github.com/awonak/EuroPiGo/hardware/hal" +) + +const ( + oledWidth = 128 + oledHeight = 32 +) + +type nonPicoDisplayOutput struct { + id hal.HardwareId + width int16 + height int16 +} + +var ( + // static check + _ common.DisplayProvider = (*nonPicoDisplayOutput)(nil) +) + +func NewNonPicoDisplayOutput(id hal.HardwareId) *nonPicoDisplayOutput { + dp := &nonPicoDisplayOutput{ + id: id, + width: oledWidth, + height: oledHeight, + } + + return dp +} + +func (d *nonPicoDisplayOutput) ClearBuffer() { + bus.Post(fmt.Sprintf("hw_display_%d", d.id), HwMessageDisplay{ + Op: HwDisplayOpClearBuffer, + }) +} + +func (d *nonPicoDisplayOutput) Size() (x, y int16) { + return d.width, d.height +} +func (d *nonPicoDisplayOutput) SetPixel(x, y int16, c color.RGBA) { + bus.Post(fmt.Sprintf("hw_display_%d", d.id), HwMessageDisplay{ + Op: HwDisplayOpSetPixel, + Operands: []int16{x, y, int16(c.R), int16(c.B), int16(c.G), int16(c.A)}, + }) +} + +func (d *nonPicoDisplayOutput) Display() error { + bus.Post(fmt.Sprintf("hw_display_%d", d.id), HwMessageDisplay{ + Op: HwDisplayOpDisplay, + }) + return nil +} diff --git a/internal/nonpico/common/messages.go b/internal/nonpico/common/messages.go new file mode 100644 index 0000000..5780de4 --- /dev/null +++ b/internal/nonpico/common/messages.go @@ -0,0 +1,39 @@ +package common + +import "github.com/awonak/EuroPiGo/hardware/hal" + +// HwMessageDigitalValue represents a digital value update +type HwMessageDigitalValue struct { + Value bool +} + +// HwMessageADCValue represents an ADC value update +type HwMessageADCValue struct { + Value uint16 +} + +// HwMessageInterrupt represents an interrupt +type HwMessageInterrupt struct { + Change hal.ChangeFlags +} + +// HwMessagePwmValue represents a pulse width modulator value update +type HwMessagePwmValue struct { + Value uint16 + Voltage float32 +} + +// HwMessageDisplay represents a display update. +type HwMessageDisplay struct { + Op HwDisplayOp + Operands []int16 +} + +// HwDisplayOp is the operation for a display update. +type HwDisplayOp int + +const ( + HwDisplayOpClearBuffer = HwDisplayOp(iota) + HwDisplayOpSetPixel + HwDisplayOpDisplay +) diff --git a/internal/nonpico/common/pwm.go b/internal/nonpico/common/pwm.go new file mode 100644 index 0000000..073b704 --- /dev/null +++ b/internal/nonpico/common/pwm.go @@ -0,0 +1,56 @@ +//go:build !pico +// +build !pico + +package common + +import ( + "fmt" + + "github.com/awonak/EuroPiGo/hardware/common" + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/lerp" +) + +type nonPicoPwm struct { + id hal.HardwareId + cal lerp.Remapper32[float32, uint16] + v float32 +} + +var ( + // static check + _ common.PWMProvider = (*nonPicoPwm)(nil) +) + +func NewNonPicoPwm(id hal.HardwareId, cal lerp.Remapper32[float32, uint16]) *nonPicoPwm { + p := &nonPicoPwm{ + id: id, + cal: cal, + } + return p +} + +func (p *nonPicoPwm) Configure(config hal.VoltageOutputConfig) error { + return nil +} + +func (p *nonPicoPwm) Set(v float32) { + pulseWidth := p.cal.Remap(v) + p.v = p.cal.Unmap(pulseWidth) + bus.Post(fmt.Sprintf("hw_pwm_%d", p.id), HwMessagePwmValue{ + Value: pulseWidth, + Voltage: p.v, + }) +} + +func (p *nonPicoPwm) Get() float32 { + return p.v +} + +func (p *nonPicoPwm) MinVoltage() float32 { + return p.cal.InputMinimum() +} + +func (p *nonPicoPwm) MaxVoltage() float32 { + return p.cal.InputMaximum() +} diff --git a/internal/nonpico/nonpico.go b/internal/nonpico/nonpico.go new file mode 100644 index 0000000..87e6ad2 --- /dev/null +++ b/internal/nonpico/nonpico.go @@ -0,0 +1,7 @@ +package nonpico + +// This file is required for compilation to occur. Do not remove it +// or the empty init function at the bottom of the file. + +func init() { +} diff --git a/internal/nonpico/platform.go b/internal/nonpico/platform.go new file mode 100644 index 0000000..b01d3b5 --- /dev/null +++ b/internal/nonpico/platform.go @@ -0,0 +1,38 @@ +//go:build !pico +// +build !pico + +package nonpico + +import ( + "github.com/awonak/EuroPiGo/hardware" + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/internal/nonpico/rev0" + "github.com/awonak/EuroPiGo/internal/nonpico/rev1" +) + +func initRevision0() { + rev0.DoInit() +} + +func initRevision1() { + rev1.DoInit() +} + +func initRevision2() { + //TODO: rev2.DoInit() +} + +func init() { + hardware.OnRevisionDetected(func(revision hal.Revision) { + switch revision { + case hal.Revision0: + initRevision0() + case hal.Revision1: + initRevision1() + case hal.Revision2: + initRevision2() + default: + } + hardware.SetReady() + }) +} diff --git a/internal/nonpico/rev0/api.go b/internal/nonpico/rev0/api.go new file mode 100644 index 0000000..5d6a38e --- /dev/null +++ b/internal/nonpico/rev0/api.go @@ -0,0 +1,26 @@ +//go:build !pico +// +build !pico + +package rev0 + +import ( + "github.com/awonak/EuroPiGo/hardware/hal" +) + +type voltageOutputMsg struct { + Kind string `json:"kind"` + HardwareId hal.HardwareId `json:"hardwareId"` + Voltage float32 `json:"voltage"` +} + +type setDigitalInputMsg struct { + Kind string `json:"kind"` + HardwareId hal.HardwareId `json:"hardwareId"` + Value bool `json:"value"` +} + +type setAnalogInputMsg struct { + Kind string `json:"kind"` + HardwareId hal.HardwareId `json:"hardwareId"` + Voltage float32 `json:"voltage"` +} diff --git a/internal/nonpico/rev0/listeners.go b/internal/nonpico/rev0/listeners.go new file mode 100644 index 0000000..88a6516 --- /dev/null +++ b/internal/nonpico/rev0/listeners.go @@ -0,0 +1,68 @@ +//go:build !pico +// +build !pico + +package rev0 + +import ( + "sync" + + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/hardware/rev0" + "github.com/awonak/EuroPiGo/internal/nonpico/common" + "github.com/awonak/EuroPiGo/lerp" +) + +func setupDefaultState() { + common.SetDigitalValue(rev0.HardwareIdButton1Input, false) + common.SetDigitalValue(rev0.HardwareIdButton2Input, false) + + common.SetADCValue(rev0.HardwareIdKnob1Input, aiLerp.Lerp(0.5)) + common.SetADCValue(rev0.HardwareIdKnob2Input, aiLerp.Lerp(0.5)) +} + +func setupVoltageOutputListeners(cb func(id hal.HardwareId, voltage float32)) { + ids := []hal.HardwareId{ + rev0.HardwareIdAnalog1Output, + rev0.HardwareIdAnalog2Output, + rev0.HardwareIdAnalog3Output, + rev0.HardwareIdAnalog4Output, + rev0.HardwareIdDigital1Output, + rev0.HardwareIdDigital2Output, + rev0.HardwareIdDigital3Output, + rev0.HardwareIdDigital4Output, + } + for _, id := range ids { + common.OnPWMValue(id, func(hid hal.HardwareId, value uint16, voltage float32) { + cb(hid, voltage) + }) + } +} + +var ( + states sync.Map +) + +func setDigitalInput(id hal.HardwareId, value bool) { + prevState, _ := states.Load(id) + + states.Store(id, value) + common.SetDigitalValue(id, value) + + if prevState != value { + if value { + // rising + common.TriggerInterrupt(id, hal.ChangeRising) + } else { + // falling + common.TriggerInterrupt(id, hal.ChangeFalling) + } + } +} + +var ( + aiLerp = lerp.NewLerp32[uint16](rev0.DefaultCalibratedMinAI, rev0.DefaultCalibratedMaxAI) +) + +func setAnalogInput(id hal.HardwareId, voltage float32) { + common.SetADCValue(id, aiLerp.Lerp(voltage)) +} diff --git a/internal/nonpico/rev0/platform.go b/internal/nonpico/rev0/platform.go new file mode 100644 index 0000000..128d060 --- /dev/null +++ b/internal/nonpico/rev0/platform.go @@ -0,0 +1,24 @@ +package rev0 + +import ( + "github.com/awonak/EuroPiGo/hardware/rev0" + "github.com/awonak/EuroPiGo/internal/nonpico/common" +) + +func DoInit() { + rev0.Initialize(rev0.InitializationParameters{ + InputButton1: common.NewNonPicoDigitalReader(rev0.HardwareIdButton1Input), + InputButton2: common.NewNonPicoDigitalReader(rev0.HardwareIdButton2Input), + InputKnob1: common.NewNonPicoAdc(rev0.HardwareIdKnob1Input), + InputKnob2: common.NewNonPicoAdc(rev0.HardwareIdKnob2Input), + OutputAnalog1: common.NewNonPicoPwm(rev0.HardwareIdAnalog1Output, rev0.DefaultVoltageOutputCalibration), + OutputAnalog2: common.NewNonPicoPwm(rev0.HardwareIdAnalog2Output, rev0.DefaultVoltageOutputCalibration), + OutputAnalog3: common.NewNonPicoPwm(rev0.HardwareIdAnalog3Output, rev0.DefaultVoltageOutputCalibration), + OutputAnalog4: common.NewNonPicoPwm(rev0.HardwareIdAnalog4Output, rev0.DefaultVoltageOutputCalibration), + OutputDigital1: common.NewNonPicoPwm(rev0.HardwareIdDigital1Output, rev0.DefaultVoltageOutputCalibration), + OutputDigital2: common.NewNonPicoPwm(rev0.HardwareIdDigital2Output, rev0.DefaultVoltageOutputCalibration), + OutputDigital3: common.NewNonPicoPwm(rev0.HardwareIdDigital3Output, rev0.DefaultVoltageOutputCalibration), + OutputDigital4: common.NewNonPicoPwm(rev0.HardwareIdDigital4Output, rev0.DefaultVoltageOutputCalibration), + DeviceRandomGenerator1: nil, + }) +} diff --git a/internal/nonpico/rev0/site/index.html b/internal/nonpico/rev0/site/index.html new file mode 100644 index 0000000..e343a54 --- /dev/null +++ b/internal/nonpico/rev0/site/index.html @@ -0,0 +1,173 @@ + + + + + + EuroPi Tester + + + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + \ No newline at end of file diff --git a/internal/nonpico/rev0/wsactivation.go b/internal/nonpico/rev0/wsactivation.go new file mode 100644 index 0000000..27ec08e --- /dev/null +++ b/internal/nonpico/rev0/wsactivation.go @@ -0,0 +1,129 @@ +//go:build !pico +// +build !pico + +package rev0 + +import ( + "context" + "embed" + "encoding/json" + "io/fs" + "log" + "net/http" + _ "net/http/pprof" + + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/internal/nonpico/ws" +) + +type WSActivation struct { + ctx context.Context + cancel context.CancelFunc +} + +func ActivateWebSocket(ctx context.Context) *WSActivation { + a := &WSActivation{} + + a.Start(ctx) + + return a +} + +func (a *WSActivation) Shutdown() error { + if a.cancel != nil { + a.cancel() + } + return nil +} + +//go:embed site +var nonPicoSiteContent embed.FS + +func (a *WSActivation) Start(ctx context.Context) { + a.ctx, a.cancel = context.WithCancel(ctx) + + // initialize default state + setupDefaultState() + + go func() { + defer a.cancel() + + subFS, _ := fs.Sub(nonPicoSiteContent, "site") + http.Handle("/", http.FileServer(http.FS(subFS))) + http.HandleFunc("/ws", a.apiHandler) + if err := http.ListenAndServe(":8080", nil); err != nil { + panic(err) + } + }() +} + +func (a *WSActivation) apiHandler(w http.ResponseWriter, r *http.Request) { + log.Println(r.URL, "rev0.apiHandler") + + if r.Body != nil { + // just in case someone sent us a body + defer r.Body.Close() + } + + sock, err := ws.Upgrade(w, r) + if err != nil { + log.Println("failed to upgrade websocket connection:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + defer sock.Close() + + setupVoltageOutputListeners(func(id hal.HardwareId, voltage float32) { + _ = sock.WriteJSON(voltageOutputMsg{ + Kind: "voltageOutput", + HardwareId: id, + Voltage: voltage, + }) + }) + + type kind struct { + Kind string `json:"kind"` + } + + for { + // test for doneness + select { + case <-sock.Done(): + break + default: + } + + blob, err := sock.ReadMessage() + if err != nil { + break + } + + var k kind + if err := json.Unmarshal(blob, &k); err != nil { + sock.SetError(err) + break + } + + switch k.Kind { + case "setDigitalInput": + var di setDigitalInputMsg + if err := json.Unmarshal(blob, &di); err != nil { + sock.SetError(err) + break + } + setDigitalInput(di.HardwareId, di.Value) + + case "setAnalogInput": + var ai setAnalogInputMsg + if err := json.Unmarshal(blob, &ai); err != nil { + sock.SetError(err) + break + } + setAnalogInput(ai.HardwareId, ai.Voltage) + + default: + // ignore + } + } +} diff --git a/internal/nonpico/rev1/api.go b/internal/nonpico/rev1/api.go new file mode 100644 index 0000000..e652fce --- /dev/null +++ b/internal/nonpico/rev1/api.go @@ -0,0 +1,43 @@ +//go:build !pico +// +build !pico + +package rev1 + +import ( + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/internal/nonpico/common" +) + +type voltageOutputMsg struct { + Kind string `json:"kind"` + HardwareId hal.HardwareId `json:"hardwareId"` + Voltage float32 `json:"voltage"` +} + +// displayMode = displayModeSeparate (0) +type displayOutputMsg struct { + Kind string `json:"kind"` + HardwareId hal.HardwareId `json:"hardwareId"` + Op common.HwDisplayOp `json:"op"` + Params []int16 `json:"params"` +} + +// displayMode = displayModeCombined (1) +type displayScreenOuptutMsg struct { + Kind string `json:"kind"` + Width int `json:"width"` + Height int `json:"height"` + Data []byte `json:"data"` +} + +type setDigitalInputMsg struct { + Kind string `json:"kind"` + HardwareId hal.HardwareId `json:"hardwareId"` + Value bool `json:"value"` +} + +type setAnalogInputMsg struct { + Kind string `json:"kind"` + HardwareId hal.HardwareId `json:"hardwareId"` + Voltage float32 `json:"voltage"` +} diff --git a/internal/nonpico/rev1/listeners.go b/internal/nonpico/rev1/listeners.go new file mode 100644 index 0000000..cd8a5a7 --- /dev/null +++ b/internal/nonpico/rev1/listeners.go @@ -0,0 +1,65 @@ +//go:build !pico +// +build !pico + +package rev1 + +import ( + "sync" + + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/hardware/rev1" + "github.com/awonak/EuroPiGo/internal/nonpico/common" + "github.com/awonak/EuroPiGo/lerp" +) + +func setupDefaultState() { + common.SetDigitalValue(rev1.HardwareIdDigital1Input, false) + common.SetADCValue(rev1.HardwareIdAnalog1Input, rev1.DefaultCalibratedMaxAI) + + common.SetDigitalValue(rev1.HardwareIdButton1Input, false) + common.SetDigitalValue(rev1.HardwareIdButton2Input, false) + + common.SetADCValue(rev1.HardwareIdKnob1Input, aiLerp.Lerp(0.5)) + common.SetADCValue(rev1.HardwareIdKnob2Input, aiLerp.Lerp(0.5)) +} + +func setupVoltageOutputListeners(cb func(id hal.HardwareId, voltage float32)) { + for id := hal.HardwareIdVoltage1Output; id <= hal.HardwareIdVoltage6Output; id++ { + common.OnPWMValue(id, func(hid hal.HardwareId, value uint16, voltage float32) { + cb(hid, voltage) + }) + } +} + +func setupDisplayOutputListener(cb func(id hal.HardwareId, op common.HwDisplayOp, params []int16)) { + common.OnDisplayOutput(hal.HardwareIdDisplay1Output, cb) +} + +var ( + states sync.Map +) + +func setDigitalInput(id hal.HardwareId, value bool) { + prevState, _ := states.Load(id) + + states.Store(id, value) + common.SetDigitalValue(id, value) + + if prevState != value { + if value { + // rising + common.TriggerInterrupt(id, hal.ChangeRising) + } else { + // falling + common.TriggerInterrupt(id, hal.ChangeFalling) + } + } +} + +var ( + aiLerp = lerp.NewLerp32[uint16](rev1.DefaultCalibratedMinAI, rev1.DefaultCalibratedMaxAI) +) + +func setAnalogInput(id hal.HardwareId, voltage float32) { + common.SetADCValue(id, aiLerp.Lerp(voltage)) +} diff --git a/internal/nonpico/rev1/platform.go b/internal/nonpico/rev1/platform.go new file mode 100644 index 0000000..169d266 --- /dev/null +++ b/internal/nonpico/rev1/platform.go @@ -0,0 +1,25 @@ +package rev1 + +import ( + "github.com/awonak/EuroPiGo/hardware/rev1" + "github.com/awonak/EuroPiGo/internal/nonpico/common" +) + +func DoInit() { + rev1.Initialize(rev1.InitializationParameters{ + InputDigital1: common.NewNonPicoDigitalReader(rev1.HardwareIdDigital1Input), + InputAnalog1: common.NewNonPicoAdc(rev1.HardwareIdAnalog1Input), + OutputDisplay1: common.NewNonPicoDisplayOutput(rev1.HardwareIdDisplay1Output), + InputButton1: common.NewNonPicoDigitalReader(rev1.HardwareIdButton1Input), + InputButton2: common.NewNonPicoDigitalReader(rev1.HardwareIdButton2Input), + InputKnob1: common.NewNonPicoAdc(rev1.HardwareIdKnob1Input), + InputKnob2: common.NewNonPicoAdc(rev1.HardwareIdKnob2Input), + OutputVoltage1: common.NewNonPicoPwm(rev1.HardwareIdCV1Output, rev1.DefaultVoltageOutputCalibration), + OutputVoltage2: common.NewNonPicoPwm(rev1.HardwareIdCV2Output, rev1.DefaultVoltageOutputCalibration), + OutputVoltage3: common.NewNonPicoPwm(rev1.HardwareIdCV3Output, rev1.DefaultVoltageOutputCalibration), + OutputVoltage4: common.NewNonPicoPwm(rev1.HardwareIdCV4Output, rev1.DefaultVoltageOutputCalibration), + OutputVoltage5: common.NewNonPicoPwm(rev1.HardwareIdCV5Output, rev1.DefaultVoltageOutputCalibration), + OutputVoltage6: common.NewNonPicoPwm(rev1.HardwareIdCV6Output, rev1.DefaultVoltageOutputCalibration), + DeviceRandomGenerator1: nil, + }) +} diff --git a/internal/nonpico/rev1/site/index.html b/internal/nonpico/rev1/site/index.html new file mode 100644 index 0000000..35d420f --- /dev/null +++ b/internal/nonpico/rev1/site/index.html @@ -0,0 +1,268 @@ + + + + + + EuroPi Tester + + + + + +
+ + +
+
+ + +

5.00 Volts

+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + \ No newline at end of file diff --git a/internal/nonpico/rev1/wsactivation.go b/internal/nonpico/rev1/wsactivation.go new file mode 100644 index 0000000..57cff7f --- /dev/null +++ b/internal/nonpico/rev1/wsactivation.go @@ -0,0 +1,175 @@ +//go:build !pico +// +build !pico + +package rev1 + +import ( + "context" + "embed" + "encoding/json" + "io/fs" + "log" + "net/http" + _ "net/http/pprof" + "strconv" + + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/internal/nonpico/common" + "github.com/awonak/EuroPiGo/internal/nonpico/ws" +) + +type WSActivation struct { + ctx context.Context + cancel context.CancelFunc + displayMode common.DisplayMode +} + +func ActivateWebSocket(ctx context.Context) *WSActivation { + a := &WSActivation{} + + a.Start(ctx) + + return a +} + +func (a *WSActivation) Shutdown() error { + if a.cancel != nil { + a.cancel() + } + return nil +} + +//go:embed site +var nonPicoSiteContent embed.FS + +func (a *WSActivation) Start(ctx context.Context) { + a.ctx, a.cancel = context.WithCancel(ctx) + + setupDefaultState() + + go func() { + defer a.cancel() + + subFS, _ := fs.Sub(nonPicoSiteContent, "site") + http.Handle("/", http.FileServer(http.FS(subFS))) + http.HandleFunc("/ws", a.apiHandler) + if err := http.ListenAndServe(":8080", nil); err != nil { + panic(err) + } + }() +} + +func (a *WSActivation) apiHandler(w http.ResponseWriter, r *http.Request) { + log.Println(r.URL, "rev1.apiHandler") + + if r.Body != nil { + // just in case someone sent us a body + defer r.Body.Close() + } + + q := r.URL.Query() + dm, _ := strconv.Atoi(q.Get("displayMode")) + a.displayMode = common.DisplayMode(dm) + + sock, err := ws.Upgrade(w, r) + if err != nil { + log.Println("failed to upgrade websocket connection:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + defer sock.Close() + + setupVoltageOutputListeners(func(id hal.HardwareId, voltage float32) { + _ = sock.WriteJSON(voltageOutputMsg{ + Kind: "voltageOutput", + HardwareId: id, + Voltage: voltage, + }) + }) + + displayWidth, displayHeight := 128, 32 + displayScreenOutputMsg := displayScreenOuptutMsg{ + Kind: "displayScreenOutput", + Width: displayWidth, + Height: displayHeight, + Data: make([]byte, displayWidth*displayHeight*4), + } + setupDisplayOutputListener(func(id hal.HardwareId, op common.HwDisplayOp, params []int16) { + switch a.displayMode { + case common.DisplayModeCombined: + switch op { + case common.HwDisplayOpClearBuffer: + for i := range displayScreenOutputMsg.Data { + displayScreenOutputMsg.Data[i] = 0 + } + case common.HwDisplayOpSetPixel: + y, x := int(params[1]), int(params[0]) + if y < 0 || y >= displayHeight || x < 0 || x >= displayWidth { + break + } + pos := (int(params[1])*displayWidth + int(params[0])) * 4 + displayScreenOutputMsg.Data[pos] = byte(params[2]) + displayScreenOutputMsg.Data[pos+1] = byte(params[3]) + displayScreenOutputMsg.Data[pos+2] = byte(params[4]) + displayScreenOutputMsg.Data[pos+3] = byte(params[5]) + case common.HwDisplayOpDisplay: + _ = sock.WriteJSON(displayScreenOutputMsg) + default: + } + + default: + _ = sock.WriteJSON(displayOutputMsg{ + Kind: "displayOutput", + HardwareId: id, + Op: op, + Params: params, + }) + } + }) + + type kind struct { + Kind string `json:"kind"` + } + + for { + // test for doneness + select { + case <-sock.Done(): + break + default: + } + + blob, err := sock.ReadMessage() + if err != nil { + break + } + + var k kind + if err := json.Unmarshal(blob, &k); err != nil { + sock.SetError(err) + break + } + + switch k.Kind { + case "setDigitalInput": + var di setDigitalInputMsg + if err := json.Unmarshal(blob, &di); err != nil { + sock.SetError(err) + break + } + setDigitalInput(di.HardwareId, di.Value) + + case "setAnalogInput": + var ai setAnalogInputMsg + if err := json.Unmarshal(blob, &ai); err != nil { + sock.SetError(err) + break + } + setAnalogInput(ai.HardwareId, ai.Voltage) + + default: + // ignore + } + } +} diff --git a/internal/nonpico/revisiondetection.go b/internal/nonpico/revisiondetection.go new file mode 100644 index 0000000..19833fd --- /dev/null +++ b/internal/nonpico/revisiondetection.go @@ -0,0 +1,12 @@ +//go:build !pico +// +build !pico + +package nonpico + +import "github.com/awonak/EuroPiGo/hardware/hal" + +var detectedRevision hal.Revision + +func DetectRevision() hal.Revision { + return detectedRevision +} diff --git a/internal/nonpico/revisiondetection_rev0.go b/internal/nonpico/revisiondetection_rev0.go new file mode 100644 index 0000000..aac032e --- /dev/null +++ b/internal/nonpico/revisiondetection_rev0.go @@ -0,0 +1,13 @@ +//go:build !pico && (revision0 || europiproto || europiprototype) +// +build !pico +// +build revision0 europiproto europiprototype + +package nonpico + +import ( + "github.com/awonak/EuroPiGo/hardware/hal" +) + +func init() { + detectedRevision = hal.Revision0 +} diff --git a/internal/nonpico/revisiondetection_rev1.go b/internal/nonpico/revisiondetection_rev1.go new file mode 100644 index 0000000..9aaeeb2 --- /dev/null +++ b/internal/nonpico/revisiondetection_rev1.go @@ -0,0 +1,13 @@ +//go:build !pico && (revision1 || europi) +// +build !pico +// +build revision1 europi + +package nonpico + +import ( + "github.com/awonak/EuroPiGo/hardware/hal" +) + +func init() { + detectedRevision = hal.Revision1 +} diff --git a/internal/nonpico/revisiondetection_rev2.go b/internal/nonpico/revisiondetection_rev2.go new file mode 100644 index 0000000..64d4f2e --- /dev/null +++ b/internal/nonpico/revisiondetection_rev2.go @@ -0,0 +1,13 @@ +//go:build !pico && (revision2 || europix) +// +build !pico +// +build revision2 europix + +package nonpico + +import ( + "github.com/awonak/EuroPiGo/hardware/hal" +) + +func init() { + detectedRevision = hal.Revision2 +} diff --git a/internal/nonpico/ws/websocket.go b/internal/nonpico/ws/websocket.go new file mode 100644 index 0000000..69105df --- /dev/null +++ b/internal/nonpico/ws/websocket.go @@ -0,0 +1,101 @@ +//go:build !pico +// +build !pico + +package ws + +import ( + "context" + "errors" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + pongWait = time.Second * 60 +) + +type WebSocket struct { + conn *websocket.Conn + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + err error +} + +func (w *WebSocket) Done() <-chan struct{} { + if w.ctx == nil { + return nil + } + + return w.ctx.Done() +} + +func (w *WebSocket) WriteJSON(msg any) error { + if w.conn == nil { + return errors.New("connection not established") + } + + w.mu.Lock() + defer w.mu.Unlock() + + err := w.conn.WriteJSON(msg) + if err != nil { + w.SetError(err) + } + return err +} + +func (w *WebSocket) ReadMessage() ([]byte, error) { + _, blob, err := w.conn.ReadMessage() + if err != nil && websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + w.SetError(err) + } + return blob, err +} + +func (w *WebSocket) SetError(err error) { + w.err = err + if w.cancel != nil { + w.cancel() + } +} + +func (w *WebSocket) Close() error { + if w.cancel != nil { + w.cancel() + } + + if w.conn == nil { + return nil + } + + return w.conn.Close() +} + +var upgrader = websocket.Upgrader{} + +func Upgrade(w http.ResponseWriter, r *http.Request) (*WebSocket, error) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(r.Context()) + + conn.SetReadLimit(2048) + _ = conn.SetWriteDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(appData string) error { + return conn.SetWriteDeadline(time.Now().Add(pongWait)) + }) + + ws := &WebSocket{ + conn: conn, + ctx: ctx, + cancel: cancel, + } + + return ws, nil +} diff --git a/internal/nonpico/wsactivator.go b/internal/nonpico/wsactivator.go new file mode 100644 index 0000000..a4309df --- /dev/null +++ b/internal/nonpico/wsactivator.go @@ -0,0 +1,28 @@ +//go:build !pico +// +build !pico + +package nonpico + +import ( + "context" + + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/internal/nonpico/rev0" + "github.com/awonak/EuroPiGo/internal/nonpico/rev1" +) + +type WSActivation interface { + Shutdown() error +} + +func ActivateWebSocket(ctx context.Context, revision hal.Revision) WSActivation { + switch revision { + case hal.Revision0: + return rev0.ActivateWebSocket(ctx) + case hal.Revision1: + return rev1.ActivateWebSocket(ctx) + // TODO: add rev2 + default: + return nil + } +} diff --git a/internal/pico/adc.go b/internal/pico/adc.go new file mode 100644 index 0000000..2ad5a17 --- /dev/null +++ b/internal/pico/adc.go @@ -0,0 +1,42 @@ +//go:build pico +// +build pico + +package pico + +import ( + "machine" + "runtime/interrupt" + "sync" +) + +var ( + adcOnce sync.Once +) + +type picoAdc struct { + adc machine.ADC +} + +func newPicoAdc(pin machine.Pin) *picoAdc { + adcOnce.Do(machine.InitADC) + + adc := &picoAdc{ + adc: machine.ADC{Pin: pin}, + } + adc.adc.Configure(machine.ADCConfig{}) + return adc +} + +func (a *picoAdc) Get(samples int) uint16 { + if samples == 0 { + return 0 + } + + var sum int + state := interrupt.Disable() + for i := 0; i < samples; i++ { + sum += int(a.adc.Get()) + } + interrupt.Restore(state) + return uint16(sum / samples) +} diff --git a/internal/pico/digitalreader.go b/internal/pico/digitalreader.go new file mode 100644 index 0000000..db4442e --- /dev/null +++ b/internal/pico/digitalreader.go @@ -0,0 +1,52 @@ +//go:build pico +// +build pico + +package pico + +import ( + "machine" + "runtime/interrupt" + + "github.com/awonak/EuroPiGo/hardware/hal" +) + +type picoDigitalReader struct { + pin machine.Pin +} + +func newPicoDigitalReader(pin machine.Pin) *picoDigitalReader { + dr := &picoDigitalReader{ + pin: pin, + } + dr.pin.Configure(machine.PinConfig{Mode: machine.PinInputPulldown}) + return dr +} + +func (d *picoDigitalReader) Get() bool { + state := interrupt.Disable() + // Invert signal to match expected behavior. + v := !d.pin.Get() + interrupt.Restore(state) + return v +} + +func (d *picoDigitalReader) SetHandler(changes hal.ChangeFlags, handler func()) { + pinChange := d.convertChangeFlags(changes) + + state := interrupt.Disable() + d.pin.SetInterrupt(pinChange, func(machine.Pin) { + handler() + }) + interrupt.Restore(state) +} + +func (d *picoDigitalReader) convertChangeFlags(changes hal.ChangeFlags) machine.PinChange { + var pinChange machine.PinChange + if (changes & hal.ChangeRising) != 0 { + pinChange |= machine.PinFalling + } + if (changes & hal.ChangeFalling) != 0 { + pinChange |= machine.PinRising + } + return pinChange +} diff --git a/internal/pico/display.go b/internal/pico/display.go new file mode 100644 index 0000000..617e701 --- /dev/null +++ b/internal/pico/display.go @@ -0,0 +1,58 @@ +//go:build pico +// +build pico + +package pico + +import ( + "image/color" + "machine" + + "tinygo.org/x/drivers/ssd1306" +) + +const ( + oledFreq = machine.KHz * 400 + oledAddr = ssd1306.Address_128_32 + oledWidth = 128 + oledHeight = 32 +) + +type picoDisplayOutput struct { + dev ssd1306.Device +} + +func newPicoDisplayOutput(channel *machine.I2C, sdaPin, sclPin machine.Pin) *picoDisplayOutput { + channel.Configure(machine.I2CConfig{ + Frequency: oledFreq, + SDA: sdaPin, + SCL: sclPin, + }) + + display := ssd1306.NewI2C(channel) + display.Configure(ssd1306.Config{ + Address: oledAddr, + Width: oledWidth, + Height: oledHeight, + }) + + dp := &picoDisplayOutput{ + dev: display, + } + + return dp +} + +func (d *picoDisplayOutput) ClearBuffer() { + d.dev.ClearBuffer() +} + +func (d *picoDisplayOutput) Size() (x, y int16) { + return d.dev.Size() +} +func (d *picoDisplayOutput) SetPixel(x, y int16, c color.RGBA) { + d.dev.SetPixel(x, y, c) +} + +func (d *picoDisplayOutput) Display() error { + return d.dev.Display() +} diff --git a/internal/pico/pico.go b/internal/pico/pico.go new file mode 100644 index 0000000..4d10075 --- /dev/null +++ b/internal/pico/pico.go @@ -0,0 +1,7 @@ +package pico + +// This file is required for compilation to occur. Do not remove it +// or the empty init function at the bottom of the file. + +func init() { +} diff --git a/internal/pico/platform.go b/internal/pico/platform.go new file mode 100644 index 0000000..f7cb297 --- /dev/null +++ b/internal/pico/platform.go @@ -0,0 +1,72 @@ +//go:build pico +// +build pico + +package pico + +import ( + "machine" + + "github.com/awonak/EuroPiGo/hardware" + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/hardware/rev0" + "github.com/awonak/EuroPiGo/hardware/rev1" +) + +// EuroPi Prototype +func initRevision0() { + rev0.Initialize(rev0.InitializationParameters{ + InputButton1: newPicoDigitalReader(machine.GPIO15), + InputButton2: newPicoDigitalReader(machine.GPIO18), + InputKnob1: newPicoAdc(machine.ADC2), // machine.GPIO28 + InputKnob2: newPicoAdc(machine.ADC1), // machine.GPIO27 + OutputAnalog1: newPicoPwm(machine.PWM2, machine.GPIO21), + OutputAnalog2: newPicoPwm(machine.PWM3, machine.GPIO22), + OutputAnalog3: newPicoPwm(machine.PWM1, machine.GPIO19), + OutputAnalog4: newPicoPwm(machine.PWM2, machine.GPIO20), + OutputDigital1: newPicoPwm(machine.PWM7, machine.GPIO14), + OutputDigital2: newPicoPwm(machine.PWM5, machine.GPIO11), + OutputDigital3: newPicoPwm(machine.PWM5, machine.GPIO10), + OutputDigital4: newPicoPwm(machine.PWM3, machine.GPIO7), + DeviceRandomGenerator1: &picoRnd{}, + }) +} + +// EuroPi (original) +func initRevision1() { + rev1.Initialize(rev1.InitializationParameters{ + InputDigital1: newPicoDigitalReader(machine.GPIO22), + InputAnalog1: newPicoAdc(machine.ADC0), // machine.GPIO26 + OutputDisplay1: newPicoDisplayOutput(machine.I2C0, machine.GPIO0, machine.GPIO1), + InputButton1: newPicoDigitalReader(machine.GPIO4), + InputButton2: newPicoDigitalReader(machine.GPIO5), + InputKnob1: newPicoAdc(machine.ADC1), // machine.GPIO27 + InputKnob2: newPicoAdc(machine.ADC2), // machine.GPIO28 + OutputVoltage1: newPicoPwm(machine.PWM2, machine.GPIO21), + OutputVoltage2: newPicoPwm(machine.PWM2, machine.GPIO20), + OutputVoltage3: newPicoPwm(machine.PWM0, machine.GPIO16), + OutputVoltage4: newPicoPwm(machine.PWM0, machine.GPIO17), + OutputVoltage5: newPicoPwm(machine.PWM1, machine.GPIO18), + OutputVoltage6: newPicoPwm(machine.PWM1, machine.GPIO19), + DeviceRandomGenerator1: &picoRnd{}, + }) +} + +// EuroPi-X +func initRevision2() { + // TODO: initialize hardware +} + +func init() { + hardware.OnRevisionDetected(func(revision hal.Revision) { + switch revision { + case hal.Revision0: + initRevision0() + case hal.Revision1: + initRevision1() + case hal.Revision2: + initRevision2() + default: + } + hardware.SetReady() + }) +} diff --git a/internal/pico/pwm.go b/internal/pico/pwm.go new file mode 100644 index 0000000..ce5259f --- /dev/null +++ b/internal/pico/pwm.go @@ -0,0 +1,110 @@ +//go:build pico +// +build pico + +package pico + +import ( + "fmt" + "machine" + "math" + "runtime/interrupt" + "sync/atomic" + "time" + + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/hardware/rev0" + "github.com/awonak/EuroPiGo/lerp" +) + +type picoPwm struct { + pwm pwmGroup + pin machine.Pin + ch uint8 + v uint32 + period time.Duration + monopolar bool + cal lerp.Remapper32[float32, uint16] +} + +// pwmGroup is an interface for interacting with a machine.pwmGroup +type pwmGroup interface { + Configure(config machine.PWMConfig) error + Channel(pin machine.Pin) (channel uint8, err error) + Top() uint32 + SetTop(top uint32) + Get(channel uint8) uint32 + Set(channel uint8, value uint32) + SetPeriod(period uint64) error +} + +type picoPwmMode int + +func newPicoPwm(pwm pwmGroup, pin machine.Pin) *picoPwm { + p := &picoPwm{ + pwm: pwm, + pin: pin, + period: rev0.DefaultPWMPeriod, + // NOTE: cal must be set non-nil by Configure() at least 1 time + } + return p +} + +func (p *picoPwm) Configure(config hal.VoltageOutputConfig) error { + state := interrupt.Disable() + defer interrupt.Restore(state) + + if config.Period != 0 { + p.period = config.Period + } + + err := p.pwm.Configure(machine.PWMConfig{ + Period: uint64(p.period.Nanoseconds()), + }) + if err != nil { + return fmt.Errorf("pwm Configure error: %w", err) + } + + if config.Calibration != nil { + p.cal = config.Calibration + } + + if any(p.cal) == nil { + return fmt.Errorf("pwm Configure error: Calibration must be non-nil") + } + + p.pwm.SetTop(uint32(p.cal.OutputMaximum())) + ch, err := p.pwm.Channel(p.pin) + if err != nil { + return fmt.Errorf("pwm Channel error: %w", err) + } + p.ch = ch + + p.monopolar = config.Monopolar + + return nil +} + +func (p *picoPwm) Set(v float32) { + if p.monopolar { + if v < 0.0 { + v = -v + } + } + volts := p.cal.Remap(v) + state := interrupt.Disable() + p.pwm.Set(p.ch, uint32(volts)) + interrupt.Restore(state) + atomic.StoreUint32(&p.v, math.Float32bits(v)) +} + +func (p *picoPwm) Get() float32 { + return math.Float32frombits(atomic.LoadUint32(&p.v)) +} + +func (p *picoPwm) MinVoltage() float32 { + return p.cal.InputMinimum() +} + +func (p *picoPwm) MaxVoltage() float32 { + return p.cal.InputMaximum() +} diff --git a/internal/pico/revisiondetection.go b/internal/pico/revisiondetection.go new file mode 100644 index 0000000..f5741e4 --- /dev/null +++ b/internal/pico/revisiondetection.go @@ -0,0 +1,55 @@ +//go:build pico +// +build pico + +package pico + +import ( + "machine" + "runtime/interrupt" + + "github.com/awonak/EuroPiGo/hardware/hal" +) + +func configureAndGetPinLevel(pin machine.Pin) bool { + pin.Configure(machine.PinConfig{ + Mode: machine.PinInputPulldown, + }) + state := interrupt.Disable() + level := pin.Get() + interrupt.Restore(state) + return level +} + +func GetDetectedRevisionBits() int { + var revision int + if gp6 := configureAndGetPinLevel(machine.GPIO6); gp6 { + revision |= 0b0001 + } + if gp7 := configureAndGetPinLevel(machine.GPIO7); gp7 { + revision |= 0b0010 + } + if gp8 := configureAndGetPinLevel(machine.GPIO8); gp8 { + revision |= 0b0100 + } + if gp9 := configureAndGetPinLevel(machine.GPIO9); gp9 { + revision |= 0b1000 + } + return revision +} + +func DetectRevision() hal.Revision { + revBits := GetDetectedRevisionBits() + switch revBits { + case 0: // 0000 + if rev1AsRev0 { + return hal.Revision0 + } + return hal.Revision1 + case 1: // 0001 + return hal.Revision2 + default: // not yet known or maybe Revision0 / EuroPi-Proto? + return hal.RevisionUnknown + } +} + +var rev1AsRev0 bool diff --git a/internal/pico/revisiondetection_rev0.go b/internal/pico/revisiondetection_rev0.go new file mode 100644 index 0000000..9a91603 --- /dev/null +++ b/internal/pico/revisiondetection_rev0.go @@ -0,0 +1,9 @@ +//go:build pico && (revision0 || europiproto || europiprototype) +// +build pico +// +build revision0 europiproto europiprototype + +package pico + +func init() { + rev1AsRev0 = true +} diff --git a/internal/pico/rnd.go b/internal/pico/rnd.go new file mode 100644 index 0000000..0289324 --- /dev/null +++ b/internal/pico/rnd.go @@ -0,0 +1,21 @@ +//go:build pico +// +build pico + +package pico + +import ( + "machine" + "math/rand" + + "github.com/awonak/EuroPiGo/hardware/hal" +) + +type picoRnd struct{} + +func (r *picoRnd) Configure(config hal.RandomGeneratorConfig) error { + xl, _ := machine.GetRNG() + xh, _ := machine.GetRNG() + x := int64(xh)<<32 | int64(xl) + rand.Seed(x) + return nil +} diff --git a/scripts/clockwerk/clockwerk.go b/internal/projects/clockwerk/clockwerk.go similarity index 65% rename from scripts/clockwerk/clockwerk.go rename to internal/projects/clockwerk/clockwerk.go index 217158c..3d151d1 100644 --- a/scripts/clockwerk/clockwerk.go +++ b/internal/projects/clockwerk/clockwerk.go @@ -28,13 +28,18 @@ package main import ( - "machine" "strconv" "time" "tinygo.org/x/tinydraw" + "tinygo.org/x/tinyfont/proggy" europi "github.com/awonak/EuroPiGo" + "github.com/awonak/EuroPiGo/clamp" + "github.com/awonak/EuroPiGo/experimental/draw" + "github.com/awonak/EuroPiGo/experimental/fontwriter" + "github.com/awonak/EuroPiGo/hardware/hal" + "github.com/awonak/EuroPiGo/lerp" ) const ( @@ -49,6 +54,7 @@ var ( // Positive values are multiplications and negative values are divisions. DefaultFactor = [6]int{1, 2, 4, -2, -4, -8} FactorChoices []int + DefaultFont = &proggy.TinySZ8pt7b ) func init() { @@ -74,8 +80,11 @@ type Clockwerk struct { period time.Duration clocks [6]int resets [6]chan uint8 + writer fontwriter.Writer *europi.EuroPi + bpmLerp lerp.Lerper32[uint16] + factorLerp lerp.Lerper32[int] } func (c *Clockwerk) editParams() { @@ -95,7 +104,7 @@ func (c *Clockwerk) editParams() { func (c *Clockwerk) readBPM() uint16 { // Provide a range of 59 - 240 bpm. bpm < 60 will switch to external clock. - _bpm := c.K1.Range((MaxBPM+1)-(MinBPM-2)) + MinBPM - 1 + _bpm := c.bpmLerp.ClampedLerpRound(c.K1.Percent()) if _bpm < MinBPM { c.external = true _bpm = 0 @@ -110,7 +119,8 @@ func (c *Clockwerk) readBPM() uint16 { } func (c *Clockwerk) readFactor() int { - return FactorChoices[c.K2.Range(uint16(len(FactorChoices)))] + idx := c.factorLerp.ClampedLerpRound(c.K2.Percent()) + return FactorChoices[idx] } func (c *Clockwerk) startClocks() { @@ -152,13 +162,13 @@ func (c *Clockwerk) clock(i uint8, reset chan uint8) { high, low := c.clockPulseWidth(c.clocks[i]) - c.CV[i].On() + c.CV()[i].SetCV(1.0) t = t.Add(high) - time.Sleep(t.Sub(time.Now())) + time.Sleep(time.Since(t)) - c.CV[i].Off() + c.CV()[i].SetCV(0.0) t = t.Add(low) - time.Sleep(t.Sub(time.Now())) + time.Sleep(time.Since(t)) } } @@ -185,16 +195,18 @@ func (c *Clockwerk) updateDisplay() { return } c.displayShouldUpdate = false - c.Display.ClearBuffer() + c.OLED.ClearBuffer() // Master clock and pulse width. var external string if c.external { external = "^" } - c.Display.WriteLine(external+"BPM: "+strconv.Itoa(int(c.bpm)), 2, 8) + c.writer.WriteLine(external+"BPM: "+strconv.Itoa(int(c.bpm)), 2, 8, draw.White) // Display each clock multiplication or division setting. + dispWidth, _ := c.OLED.Size() + divWidth := int(dispWidth) / len(c.clocks) for i, factor := range c.clocks { text := " 1" switch { @@ -203,66 +215,98 @@ func (c *Clockwerk) updateDisplay() { case factor > 1: text = "x" + strconv.Itoa(factor) } - c.Display.WriteLine(text, int16(i*europi.OLEDWidth/len(c.clocks))+2, 26) + c.writer.WriteLine(text, int16(i*divWidth)+2, 26, draw.White) } - xWidth := int16(europi.OLEDWidth / len(c.clocks)) + xWidth := int16(divWidth) xOffset := int16(c.selected) * xWidth // TODO: replace box with chevron. - tinydraw.Rectangle(c.Display, xOffset, 16, xWidth, 16, europi.White) + _ = tinydraw.Rectangle(c.OLED, xOffset, 16, xWidth, 16, draw.White) - c.Display.Display() + _ = c.OLED.Display() } -func main() { - c := Clockwerk{ - EuroPi: europi.New(), - clocks: DefaultFactor, - displayShouldUpdate: true, +var app Clockwerk + +func appStart(e *europi.EuroPi) { + app.EuroPi = e + app.clocks = DefaultFactor + app.displayShouldUpdate = true + app.writer = fontwriter.Writer{ + Display: e.OLED, + Font: DefaultFont, } + app.bpmLerp = lerp.NewLerp32[uint16](MinBPM-1, MaxBPM) + app.factorLerp = lerp.NewLerp32(0, len(FactorChoices)-1) // Lower range value can have lower sample size - c.K1.Samples(500) - c.K2.Samples(20) + _ = app.K1.Configure(hal.AnalogInputConfig{ + Samples: 500, + }) + _ = app.K2.Configure(hal.AnalogInputConfig{ + Samples: 20, + }) - c.DI.Handler(func(pin machine.Pin) { + app.DI.Handler(func(_ bool, deltaTime time.Duration) { // Measure current period between clock pulses. - c.period = time.Now().Sub(c.DI.LastInput()) + app.period = deltaTime }) // Move clock config option to the left. - c.B1.Handler(func(p machine.Pin) { - if c.B2.Value() { - c.doClockReset = true + app.B1.Handler(func(_ bool, deltaTime time.Duration) { + if app.B2.Value() { + app.doClockReset = true return } - c.selected = uint8(europi.Clamp(int(c.selected)-1, 0, len(c.clocks))) - c.displayShouldUpdate = true + app.selected = uint8(clamp.Clamp(int(app.selected)-1, 0, len(app.clocks))) + app.displayShouldUpdate = true }) // Move clock config option to the right. - c.B2.Handler(func(p machine.Pin) { - if c.B1.Value() { - c.doClockReset = true + app.B2.Handler(func(_ bool, deltaTime time.Duration) { + if app.B1.Value() { + app.doClockReset = true return } - c.selected = uint8(europi.Clamp(int(c.selected)+1, 0, len(c.clocks)-1)) - c.displayShouldUpdate = true + app.selected = uint8(clamp.Clamp(int(app.selected)+1, 0, len(app.clocks)-1)) + app.displayShouldUpdate = true }) // Init parameter configs based on current knob positions. - c.bpm = c.readBPM() - c.prevk2 = c.readFactor() + app.bpm = app.readBPM() + app.prevk2 = app.readFactor() - c.startClocks() + app.startClocks() +} + +func mainLoop() { + if app.doClockReset { + app.doClockReset = false + app.resetClocks() + app.displayShouldUpdate = true + } + europi.DebugMemoryUsage() +} + +func main() { + e, _ := europi.New().(*europi.EuroPi) + if e == nil { + panic("europi not detected") + } + // since we're not using a full bootstrap, manually activate the webservice (this is a no-op on pico) + if ws := europi.ActivateNonPicoWS(e.Context(), e); ws != nil { + defer func() { + _ = ws.Shutdown() + }() + } + + appStart(e) + + // Check for clock updates every 2 seconds. + ticker := time.NewTicker(ResetDelay) + defer ticker.Stop() for { - // Check for clock updates every 2 seconds. - time.Sleep(ResetDelay) - if c.doClockReset { - c.doClockReset = false - c.resetClocks() - c.displayShouldUpdate = true - } - europi.DebugMemoryUsage() + <-ticker.C + mainLoop() } } diff --git a/internal/projects/diagnostics/diagnostics.go b/internal/projects/diagnostics/diagnostics.go new file mode 100644 index 0000000..0bdf517 --- /dev/null +++ b/internal/projects/diagnostics/diagnostics.go @@ -0,0 +1,108 @@ +// Diagnostics is a script for demonstrating all main interactions with the europi-go firmware. +package main + +import ( + "fmt" + "time" + + "tinygo.org/x/tinydraw" + "tinygo.org/x/tinyfont/proggy" + + europi "github.com/awonak/EuroPiGo" + "github.com/awonak/EuroPiGo/experimental/draw" + "github.com/awonak/EuroPiGo/experimental/fontwriter" +) + +type MyApp struct { + knobsDisplayPercent bool + prevK1 uint16 + prevK2 uint16 + staticCv int + prevStaticCv int +} + +var myApp MyApp + +func appStart(e *europi.EuroPi) { + myApp.staticCv = 5 + + // Demonstrate adding a IRQ handler to B1 and B2. + e.B1.Handler(func(_ bool, _ time.Duration) { + myApp.knobsDisplayPercent = !myApp.knobsDisplayPercent + }) + + e.B2.Handler(func(_ bool, _ time.Duration) { + myApp.staticCv = (myApp.staticCv + 1) % int(e.K1.MaxVoltage()) + }) +} + +var ( + DefaultFont = &proggy.TinySZ8pt7b +) + +func mainLoop(e *europi.EuroPi) { + e.OLED.ClearBuffer() + + // Highlight the border of the oled display. + _ = tinydraw.Rectangle(e.OLED, 0, 0, 128, 32, draw.White) + + writer := fontwriter.Writer{ + Display: e.OLED, + Font: DefaultFont, + } + + // Display analog and digital input values. + inputText := fmt.Sprintf("din: %5v ain: %2.2f ", e.DI.Value(), e.AI.Percent()) + writer.WriteLine(inputText, 3, 8, draw.White) + + // Display knob values based on app state. + var knobText string + if myApp.knobsDisplayPercent { + knobText = fmt.Sprintf("K1: %0.2f K2: %0.2f", e.K1.Percent(), e.K2.Percent()) + } else { + knobText = fmt.Sprintf("K1: %3d K2: %3d", int(e.K1.Percent()*100), int(e.K2.Percent()*100)) + } + writer.WriteLine(knobText, 3, 18, draw.White) + + // Show current button press state. + writer.WriteLine(fmt.Sprintf("B1: %5v B2: %5v", e.B1.Value(), e.B2.Value()), 3, 28, draw.White) + + _ = e.OLED.Display() + + // Set voltage values for the 6 CV outputs. + if kv := uint16(e.K1.Percent() * float32(1<<12)); kv != myApp.prevK1 { + e.CV1.SetVoltage(e.K1.ReadVoltage()) + e.CV4.SetVoltage(e.CV4.MaxVoltage() - e.K1.ReadVoltage()) + myApp.prevK1 = kv + } + if kv := uint16(e.K2.Percent() * float32(1<<12)); kv != myApp.prevK2 { + e.CV2.SetVoltage(e.K2.ReadVoltage()) + e.CV5.SetVoltage(e.CV5.MaxVoltage() - e.K2.ReadVoltage()) + myApp.prevK2 = kv + } + e.CV3.SetCV(1.0) + if myApp.staticCv != myApp.prevStaticCv { + e.CV6.SetVoltage(float32(myApp.staticCv)) + myApp.prevStaticCv = myApp.staticCv + } +} + +func main() { + e, _ := europi.New().(*europi.EuroPi) + if e == nil { + panic("europi not detected") + } + + // since we're not using a full bootstrap, manually activate the webservice (this is a no-op on pico) + if ws := europi.ActivateNonPicoWS(e.Context(), e); ws != nil { + defer func() { + _ = ws.Shutdown() + }() + } + + appStart(e) + for { + mainLoop(e) + time.Sleep(time.Millisecond) + } +} diff --git a/lerp/lerp.go b/lerp/lerp.go new file mode 100644 index 0000000..bd6e9d2 --- /dev/null +++ b/lerp/lerp.go @@ -0,0 +1,24 @@ +package lerp + +type Lerpable interface { + ~int8 | ~int16 | ~int32 | ~int64 | ~int | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 +} + +type Float interface { + ~float32 | ~float64 +} + +type Lerper[T Lerpable, F Float] interface { + Lerp(t F) T + ClampedLerp(t F) T + LerpRound(t F) T + ClampedLerpRound(t F) T + InverseLerp(v T) F + ClampedInverseLerp(v T) F + OutputMinimum() T + OutputMaximum() T +} + +type Lerper32[T Lerpable] Lerper[T, float32] + +type Lerper64[T Lerpable] Lerper[T, float64] diff --git a/lerp/lerp32.go b/lerp/lerp32.go new file mode 100644 index 0000000..ba1a9f1 --- /dev/null +++ b/lerp/lerp32.go @@ -0,0 +1,53 @@ +package lerp + +import "github.com/awonak/EuroPiGo/clamp" + +type lerp32[T Lerpable] struct { + b T + r float32 +} + +func NewLerp32[T Lerpable](min, max T) Lerper32[T] { + return lerp32[T]{ + b: min, + r: float32(max - min), + } +} + +func (l lerp32[T]) Lerp(t float32) T { + return T(t*l.r) + l.b +} + +func (l lerp32[T]) ClampedLerp(t float32) T { + return clamp.Clamp(T(t*l.r)+l.b, l.b, T(l.r)+l.b) +} + +func (l lerp32[T]) LerpRound(t float32) T { + return T(t*l.r+0.5) + l.b +} + +func (l lerp32[T]) ClampedLerpRound(t float32) T { + return clamp.Clamp(T(t*l.r+0.5)+l.b, l.b, T(l.r)+l.b) +} + +func (l lerp32[T]) InverseLerp(v T) float32 { + if l.r != 0.0 { + return (float32(v) - float32(l.b)) / l.r + } + return 0.0 +} + +func (l lerp32[T]) ClampedInverseLerp(v T) float32 { + if l.r != 0.0 { + return clamp.Clamp((float32(v)-float32(l.b))/l.r, 0.0, 1.0) + } + return 0.0 +} + +func (l lerp32[T]) OutputMinimum() T { + return l.b +} + +func (l lerp32[T]) OutputMaximum() T { + return T(l.r) + l.b +} diff --git a/lerp/lerp32_test.go b/lerp/lerp32_test.go new file mode 100644 index 0000000..e4e76e3 --- /dev/null +++ b/lerp/lerp32_test.go @@ -0,0 +1,280 @@ +package lerp_test + +import ( + "testing" + + "github.com/awonak/EuroPiGo/lerp" +) + +func TestLerp32(t *testing.T) { + t.Run("New", func(t *testing.T) { + min, max := 0, 10 + if actual := lerp.NewLerp32(min, max); actual == nil { + t.Fatalf("Lerp32[%v, %v] NewLerp32: expected[non-nil] actual[nil]", min, max) + } + }) + + t.Run("Lerp", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := min, l.Lerp(0.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] Lerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := max, l.Lerp(1.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] Lerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // Lerp() will work as a linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := -2*max, l.Lerp(-2.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] Lerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := 2*max, l.Lerp(2.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] Lerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("ClampedLerp", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := min, l.ClampedLerp(0.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := max, l.ClampedLerp(1.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := min, l.ClampedLerp(-2.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := max, l.ClampedLerp(2.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("LerpRound", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := min, l.LerpRound(0.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] LerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := max, l.LerpRound(1.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] LerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // LerpRound() will work as a linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := -2*max+1, l.LerpRound(-2.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] LerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := 2*max, l.LerpRound(2.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] LerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("ClampedLerpRound", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := min, l.ClampedLerpRound(0.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedLerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := max, l.ClampedLerpRound(1.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedLerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // ClampedLerpRound() will work as a linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := min, l.ClampedLerpRound(-2.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedLerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := max, l.ClampedLerpRound(2.0); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedLerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("InverseLerp", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := float32(0.0), l.InverseLerp(min); actual != expected { + t.Fatalf("Lerp32[%v, %v] InverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Zero", func(t *testing.T) { + min, max := 5, 5 + l := lerp.NewLerp32(min, max) + if expected, actual := float32(0.0), l.InverseLerp(max); actual != expected { + t.Fatalf("Lerp32[%v, %v] InverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := float32(1.0), l.InverseLerp(max); actual != expected { + t.Fatalf("Lerp32[%v, %v] InverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // InverseLerp() will work as an inverse linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := float32(-0.2), l.InverseLerp(-2); actual != expected { + t.Fatalf("Lerp32[%v, %v] InverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := float32(1.2), l.InverseLerp(12); actual != expected { + t.Fatalf("Lerp32[%v, %v] InverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("ClampedInverseLerp", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := float32(0.0), l.ClampedInverseLerp(min); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedInverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Zero", func(t *testing.T) { + min, max := 5, 5 + l := lerp.NewLerp32(min, max) + if expected, actual := float32(0.0), l.ClampedInverseLerp(max); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedInverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := float32(1.0), l.ClampedInverseLerp(max); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedInverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := float32(0.0), l.ClampedInverseLerp(-2); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedInverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := float32(1.0), l.ClampedInverseLerp(12); actual != expected { + t.Fatalf("Lerp32[%v, %v] ClampedInverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("OutputMinimum", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := min, l.OutputMinimum(); actual != expected { + t.Fatalf("Lerp32[%v, %v] OutputMinimum: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("OutputMaximum", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp32(min, max) + if expected, actual := max, l.OutputMaximum(); actual != expected { + t.Fatalf("Lerp32[%v, %v] OutputMaximum: expected[%v] actual[%v]", min, max, expected, actual) + } + }) +} diff --git a/lerp/lerp64.go b/lerp/lerp64.go new file mode 100644 index 0000000..9c0ba9b --- /dev/null +++ b/lerp/lerp64.go @@ -0,0 +1,53 @@ +package lerp + +import "github.com/awonak/EuroPiGo/clamp" + +type lerp64[T Lerpable] struct { + b T + r float64 +} + +func NewLerp64[T Lerpable](min, max T) Lerper64[T] { + return lerp64[T]{ + b: min, + r: float64(max - min), + } +} + +func (l lerp64[T]) Lerp(t float64) T { + return T(t*l.r) + l.b +} + +func (l lerp64[T]) ClampedLerp(t float64) T { + return clamp.Clamp(T(t*l.r)+l.b, l.b, T(l.r)+l.b) +} + +func (l lerp64[T]) LerpRound(t float64) T { + return T(t*l.r+0.5) + l.b +} + +func (l lerp64[T]) ClampedLerpRound(t float64) T { + return clamp.Clamp(T(t*l.r+0.5)+l.b, l.b, T(l.r)+l.b) +} + +func (l lerp64[T]) InverseLerp(v T) float64 { + if l.r != 0.0 { + return (float64(v) - float64(l.b)) / l.r + } + return 0.0 +} + +func (l lerp64[T]) ClampedInverseLerp(v T) float64 { + if l.r != 0.0 { + return clamp.Clamp((float64(v)-float64(l.b))/l.r, 0.0, 1.0) + } + return 0.0 +} + +func (l lerp64[T]) OutputMinimum() T { + return l.b +} + +func (l lerp64[T]) OutputMaximum() T { + return T(l.r) + l.b +} diff --git a/lerp/lerp64_test.go b/lerp/lerp64_test.go new file mode 100644 index 0000000..43392ce --- /dev/null +++ b/lerp/lerp64_test.go @@ -0,0 +1,280 @@ +package lerp_test + +import ( + "testing" + + "github.com/awonak/EuroPiGo/lerp" +) + +func TestLerp64(t *testing.T) { + t.Run("New", func(t *testing.T) { + min, max := 0, 10 + if actual := lerp.NewLerp64(min, max); actual == nil { + t.Fatalf("Lerp64[%v, %v] NewLerp64: expected[non-nil] actual[nil]", min, max) + } + }) + + t.Run("Lerp", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := min, l.Lerp(0.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] Lerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := max, l.Lerp(1.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] Lerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // Lerp() will work as a linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := -2*max, l.Lerp(-2.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] Lerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := 2*max, l.Lerp(2.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] Lerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("ClampedLerp", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := min, l.ClampedLerp(0.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := max, l.ClampedLerp(1.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := min, l.ClampedLerp(-2.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := max, l.ClampedLerp(2.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("LerpRound", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := min, l.LerpRound(0.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] LerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := max, l.LerpRound(1.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] LerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // LerpRound() will work as a linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := -2*max+1, l.LerpRound(-2.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] LerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := 2*max, l.LerpRound(2.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] LerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("ClampedLerpRound", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := min, l.ClampedLerpRound(0.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedLerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := max, l.ClampedLerpRound(1.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedLerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // ClampedLerpRound() will work as a linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := min, l.ClampedLerpRound(-2.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedLerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := max, l.ClampedLerpRound(2.0); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedLerpRound: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("InverseLerp", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := float64(0.0), l.InverseLerp(min); actual != expected { + t.Fatalf("Lerp64[%v, %v] InverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Zero", func(t *testing.T) { + min, max := 5, 5 + l := lerp.NewLerp64(min, max) + if expected, actual := float64(0.0), l.InverseLerp(max); actual != expected { + t.Fatalf("Lerp64[%v, %v] InverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := float64(1.0), l.InverseLerp(max); actual != expected { + t.Fatalf("Lerp64[%v, %v] InverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // InverseLerp() will work as an inverse linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := float64(-0.2), l.InverseLerp(-2); actual != expected { + t.Fatalf("Lerp64[%v, %v] InverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := float64(1.2), l.InverseLerp(12); actual != expected { + t.Fatalf("Lerp64[%v, %v] InverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("ClampedInverseLerp", func(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := float64(0.0), l.ClampedInverseLerp(min); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedInverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Zero", func(t *testing.T) { + min, max := 5, 5 + l := lerp.NewLerp64(min, max) + if expected, actual := float64(0.0), l.ClampedInverseLerp(max); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedInverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := float64(1.0), l.ClampedInverseLerp(max); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedInverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := float64(0.0), l.ClampedInverseLerp(-2); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedInverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := float64(1.0), l.ClampedInverseLerp(12); actual != expected { + t.Fatalf("Lerp64[%v, %v] ClampedInverseLerp: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + }) + }) + + t.Run("OutputMinimum", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := min, l.OutputMinimum(); actual != expected { + t.Fatalf("Lerp64[%v, %v] OutputMinimum: expected[%v] actual[%v]", min, max, expected, actual) + } + }) + + t.Run("OutputMaximum", func(t *testing.T) { + min, max := 0, 10 + l := lerp.NewLerp64(min, max) + if expected, actual := max, l.OutputMaximum(); actual != expected { + t.Fatalf("Lerp64[%v, %v] OutputMaximum: expected[%v] actual[%v]", min, max, expected, actual) + } + }) +} diff --git a/lerp/remap.go b/lerp/remap.go new file mode 100644 index 0000000..047c6e5 --- /dev/null +++ b/lerp/remap.go @@ -0,0 +1,13 @@ +package lerp + +type Remapper[TIn, TOut Lerpable, TFloat Float] interface { + Remap(in TIn) TOut + Unmap(out TOut) TIn + InputMinimum() TIn + InputMaximum() TIn + OutputMinimum() TOut + OutputMaximum() TOut +} + +type Remapper32[TIn, TOut Lerpable] Remapper[TIn, TOut, float32] +type Remapper64[TIn, TOut Lerpable] Remapper[TIn, TOut, float64] diff --git a/lerp/remap32.go b/lerp/remap32.go new file mode 100644 index 0000000..1f91515 --- /dev/null +++ b/lerp/remap32.go @@ -0,0 +1,39 @@ +package lerp + +type remap32[TIn, TOut Lerpable] struct { + inLerp Lerper32[TIn] + outLerp Lerper32[TOut] +} + +func NewRemap32[TIn, TOut Lerpable](inMin, inMax TIn, outMin, outMax TOut) Remapper32[TIn, TOut] { + return remap32[TIn, TOut]{ + inLerp: NewLerp32(inMin, inMax), + outLerp: NewLerp32(outMin, outMax), + } +} + +func (r remap32[TIn, TOut]) Remap(value TIn) TOut { + t := r.inLerp.InverseLerp(value) + return r.outLerp.Lerp(t) +} + +func (r remap32[TIn, TOut]) Unmap(value TOut) TIn { + t := r.outLerp.InverseLerp(value) + return r.inLerp.Lerp(t) +} + +func (r remap32[TIn, TOut]) InputMinimum() TIn { + return r.inLerp.OutputMinimum() +} + +func (r remap32[TIn, TOut]) InputMaximum() TIn { + return r.inLerp.OutputMaximum() +} + +func (r remap32[TIn, TOut]) OutputMinimum() TOut { + return r.outLerp.OutputMinimum() +} + +func (r remap32[TIn, TOut]) OutputMaximum() TOut { + return r.outLerp.OutputMaximum() +} diff --git a/lerp/remap32_test.go b/lerp/remap32_test.go new file mode 100644 index 0000000..18246c8 --- /dev/null +++ b/lerp/remap32_test.go @@ -0,0 +1,160 @@ +package lerp_test + +import ( + "math" + "testing" + + "github.com/awonak/EuroPiGo/lerp" +) + +func TestRemap32(t *testing.T) { + t.Run("New", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + if actual := lerp.NewRemap32(inMin, inMax, outMin, outMax); actual == nil { + t.Fatalf("Remap32[%v, %v, %v, %v] NewRemap32: expected[non-nil] actual[nil]", inMin, inMax, outMin, outMax) + } + }) + + t.Run("Remap", func(t *testing.T) { + t.Run("ZeroRange", func(t *testing.T) { + inMin, inMax := 10, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := outMin, l.Remap(inMin); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] Remap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := outMin, l.Remap(inMin); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] Remap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := outMax, l.Remap(inMax); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] Remap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // Remap() will work as a linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := float32(-4.39822971502571), l.Remap(-2); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] Remap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + // we get a slightly different number than expectation due to floating-point accuracy. + if expected, actual := float32(4.3982306), l.Remap(12); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] Remap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + }) + }) + + t.Run("Unmap", func(t *testing.T) { + t.Run("ZeroRange", func(t *testing.T) { + inMin, inMax := 10, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := inMin, l.Unmap(outMin); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] Unmap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := inMin, l.Unmap(outMin); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] Unmap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := inMax, l.Unmap(outMax); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] Unmap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // Unmap() will work as a linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + // one would correctly assume that the expected value would be -2, + // but due to floating point error, it's really -1.9999996, which + // truncates down to -1 + if expected, actual := -1, l.Unmap(float32(-4.39822971502571)); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] Unmap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := 12, l.Unmap(float32(4.39822971502571)); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] Unmap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + }) + }) + + t.Run("InputMinimum", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := inMin, l.InputMinimum(); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] InputMinimum: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("InputMaximum", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := inMax, l.InputMaximum(); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] InputMaximum: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("OutputMinimum", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := outMin, l.OutputMinimum(); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] OutputMinimum: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("OutputMaximum", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float32(-math.Pi), float32(math.Pi) + l := lerp.NewRemap32(inMin, inMax, outMin, outMax) + if expected, actual := outMax, l.OutputMaximum(); actual != expected { + t.Fatalf("Remap32[%v, %v, %v, %v] OutputMaximum: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) +} diff --git a/lerp/remap64.go b/lerp/remap64.go new file mode 100644 index 0000000..69b6907 --- /dev/null +++ b/lerp/remap64.go @@ -0,0 +1,39 @@ +package lerp + +type remap64[TIn, TOut Lerpable] struct { + inLerp Lerper64[TIn] + outLerp Lerper64[TOut] +} + +func NewRemap64[TIn, TOut Lerpable](inMin, inMax TIn, outMin, outMax TOut) Remapper64[TIn, TOut] { + return remap64[TIn, TOut]{ + inLerp: NewLerp64(inMin, inMax), + outLerp: NewLerp64(outMin, outMax), + } +} + +func (r remap64[TIn, TOut]) Remap(value TIn) TOut { + t := r.inLerp.InverseLerp(value) + return r.outLerp.Lerp(t) +} + +func (r remap64[TIn, TOut]) Unmap(value TOut) TIn { + t := r.outLerp.InverseLerp(value) + return r.inLerp.Lerp(t) +} + +func (r remap64[TIn, TOut]) InputMinimum() TIn { + return r.inLerp.OutputMinimum() +} + +func (r remap64[TIn, TOut]) InputMaximum() TIn { + return r.inLerp.OutputMaximum() +} + +func (r remap64[TIn, TOut]) OutputMinimum() TOut { + return r.outLerp.OutputMinimum() +} + +func (r remap64[TIn, TOut]) OutputMaximum() TOut { + return r.outLerp.OutputMaximum() +} diff --git a/lerp/remap64_test.go b/lerp/remap64_test.go new file mode 100644 index 0000000..f23a3db --- /dev/null +++ b/lerp/remap64_test.go @@ -0,0 +1,156 @@ +package lerp_test + +import ( + "math" + "testing" + + "github.com/awonak/EuroPiGo/lerp" +) + +func TestRemap64(t *testing.T) { + t.Run("New", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + if actual := lerp.NewRemap64(inMin, inMax, outMin, outMax); actual == nil { + t.Fatalf("Remap64[%v, %v, %v, %v] NewRemap64: expected[non-nil] actual[nil]", inMin, inMax, outMin, outMax) + } + }) + + t.Run("Remap", func(t *testing.T) { + t.Run("ZeroRange", func(t *testing.T) { + inMin, inMax := 10, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := outMin, l.Remap(inMin); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] Remap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := outMin, l.Remap(inMin); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] Remap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := outMax, l.Remap(inMax); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] Remap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // Remap() will work as a linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := float64(-4.39822971502571), l.Remap(-2); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] Remap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := float64(4.39822971502571), l.Remap(12); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] Remap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + }) + }) + + t.Run("Unmap", func(t *testing.T) { + t.Run("ZeroRange", func(t *testing.T) { + inMin, inMax := 10, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := inMin, l.Unmap(outMin); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] Unmap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := inMin, l.Unmap(outMin); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] Unmap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := inMax, l.Unmap(outMax); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] Unmap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + // Unmap() will work as a linear extrapolator when operating out of range + t.Run("BelowMin", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := -2, l.Unmap(float64(-4.39822971502571)); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] Unmap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := 12, l.Unmap(float64(4.39822971502571)); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] Unmap: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + }) + }) + + t.Run("InputMinimum", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := inMin, l.InputMinimum(); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] InputMinimum: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("InputMaximum", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := inMax, l.InputMaximum(); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] InputMaximum: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("OutputMinimum", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := outMin, l.OutputMinimum(); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] OutputMinimum: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) + + t.Run("OutputMaximum", func(t *testing.T) { + inMin, inMax := 0, 10 + outMin, outMax := float64(-math.Pi), float64(math.Pi) + l := lerp.NewRemap64(inMin, inMax, outMin, outMax) + if expected, actual := outMax, l.OutputMaximum(); actual != expected { + t.Fatalf("Remap64[%v, %v, %v, %v] OutputMaximum: expected[%v] actual[%v]", inMin, inMax, outMin, outMax, expected, actual) + } + }) +} diff --git a/outputer.go b/outputer.go deleted file mode 100644 index 4512bad..0000000 --- a/outputer.go +++ /dev/null @@ -1,87 +0,0 @@ -package europi - -import ( - "log" - "machine" -) - -const ( - // Manually calibrated to best match expected voltages. Additional info: - // https://github.com/Allen-Synthesis/EuroPi/blob/main/software/programming_instructions.md#calibrate-the-module - CalibratedOffset = 0 - // The default PWM Top of MaxUint16 caused noisy output. Dropping this down to a 8bit value resulted in much smoother cv output. - CalibratedTop = 0xff - CalibratedOffset -) - -// We need a rather high frequency to achieve a stable cv ouput, which means we need a rather low duty cycle period. -// Set a period of 500ns. -var defaultPeriod uint64 = 500 - -// PWMer is an interface for interacting with a machine.pwmGroup -type PWMer interface { - Configure(config machine.PWMConfig) error - Channel(pin machine.Pin) (channel uint8, err error) - Top() uint32 - SetTop(top uint32) - Get(channel uint8) (value uint32) - Set(channel uint8, value uint32) - SetPeriod(period uint64) error -} - -// Outputer is an interface for interacting with the cv output jacks. -type Outputer interface { - Get() (value uint32) - Voltage(v float32) - On() - Off() -} - -// Outputer is struct for interacting with the cv output jacks. -type Output struct { - pwm PWMer - pin machine.Pin - ch uint8 -} - -// NewOutput returns a new Output struct. -func NewOutput(pin machine.Pin, pwm PWMer) *Output { - err := pwm.Configure(machine.PWMConfig{ - Period: defaultPeriod, - }) - if err != nil { - log.Fatal("pwm Configure error: ", err.Error()) - } - - pwm.SetTop(CalibratedTop) - - ch, err := pwm.Channel(pin) - if err != nil { - log.Fatal("pwm Channel error: ", err.Error()) - } - - return &Output{pwm, pin, ch} -} - -// Get returns the current set voltage in the range of 0 to pwm.Top(). -func (o *Output) Get() uint32 { - return o.pwm.Get(o.ch) -} - -// Voltage sets the current output voltage within a range of 0.0 to 10.0. -func (o *Output) Voltage(v float32) { - v = Clamp(v, MinVoltage, MaxVoltage) - invertedCv := (v / MaxVoltage) * float32(o.pwm.Top()) - // cv := (float32(o.pwm.Top()) - invertedCv) - CalibratedOffset - cv := float32(invertedCv) - CalibratedOffset - o.pwm.Set(o.ch, uint32(cv)) -} - -// On sets the current voltage high at 10.0v. -func (o *Output) On() { - o.pwm.Set(o.ch, o.pwm.Top()) -} - -// Off sets the current voltage low at 0.0v. -func (o *Output) Off() { - o.pwm.Set(o.ch, 0) -} diff --git a/quantizer/mode.go b/quantizer/mode.go new file mode 100644 index 0000000..a66f0a7 --- /dev/null +++ b/quantizer/mode.go @@ -0,0 +1,9 @@ +package quantizer + +// Mode specifies the kind of Quantizer function to be used. +type Mode int + +const ( + ModeRound = Mode(iota) + ModeTrunc +) diff --git a/quantizer/quantizer.go b/quantizer/quantizer.go new file mode 100644 index 0000000..8fae0f5 --- /dev/null +++ b/quantizer/quantizer.go @@ -0,0 +1,20 @@ +package quantizer + +// Quantizer specifies a quantization interface. +type Quantizer[T any] interface { + QuantizeToIndex(in float32, length int) int + QuantizeToValue(in float32, list []T) T +} + +// New creates a quantizer of the specified `mode`. +func New[T any](mode Mode) Quantizer[T] { + switch mode { + case ModeRound: + return &Round[T]{} + case ModeTrunc: + return &Trunc[T]{} + default: + // unsupported mode + return nil + } +} diff --git a/quantizer/quantizer_round.go b/quantizer/quantizer_round.go new file mode 100644 index 0000000..36c902f --- /dev/null +++ b/quantizer/quantizer_round.go @@ -0,0 +1,35 @@ +package quantizer + +import ( + "github.com/awonak/EuroPiGo/lerp" +) + +// Round is a rounding-style quantizer +type Round[T any] struct{} + +// QuantizeToIndex takes a normalized input value and a length value, then provides +// a return value between 0 and (length - 1), inclusive. +// +// A return value of -1 means that the length parameter was 0 (a value that cannot be quantized over successfully). +func (Round[T]) QuantizeToIndex(in float32, length int) int { + if length == 0 { + return -1 + } + + return lerp.NewLerp32(0, length-1).ClampedLerpRound(in) +} + +// QuantizeToValue takes a normalized input value and a list of values, then provides +// a return value chosen from the provided list of values. +// +// A return value of the zeroish equivalent of the value means that the list parameter +// was empty (this situation does not lend well to quantization). +func (q Round[T]) QuantizeToValue(in float32, list []T) T { + idx := q.QuantizeToIndex(in, len(list)) + if idx == -1 { + var empty T + return empty + } + + return list[idx] +} diff --git a/quantizer/quantizer_round_test.go b/quantizer/quantizer_round_test.go new file mode 100644 index 0000000..1c29c2b --- /dev/null +++ b/quantizer/quantizer_round_test.go @@ -0,0 +1,111 @@ +package quantizer_test + +import ( + "testing" + + "github.com/awonak/EuroPiGo/quantizer" +) + +func TestRound(t *testing.T) { + t.Run("New", func(t *testing.T) { + if actual := quantizer.New[int](quantizer.ModeRound); actual == nil { + t.Fatalf("Quantizer[%v] New: expected[non-nil] actual[nil]", quantizer.ModeRound) + } + }) + + t.Run("QuantizeToIndex", func(t *testing.T) { + q := quantizer.New[int](quantizer.ModeRound) + + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := float32(0) + length := 10 + if expected, actual := 0, q.QuantizeToIndex(min, length); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToIndex(%v, %v): expected[%v] actual[%v]", quantizer.ModeRound, min, length, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max := float32(1) + length := 10 + if expected, actual := length-1, q.QuantizeToIndex(max, length); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToIndex(%v, %v): expected[%v] actual[%v]", quantizer.ModeRound, max, length, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := float32(-1) + length := 10 + if expected, actual := 0, q.QuantizeToIndex(belowMin, length); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToIndex(%v, %v): expected[%v] actual[%v]", quantizer.ModeRound, belowMin, length, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax := float32(2) + length := 10 + if expected, actual := length-1, q.QuantizeToIndex(aboveMax, length); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToIndex(%v, %v): expected[%v] actual[%v]", quantizer.ModeRound, aboveMax, length, expected, actual) + } + }) + + t.Run("EmptySet", func(t *testing.T) { + emptySet := float32(0.5) + length := 0 + if expected, actual := -1, q.QuantizeToIndex(emptySet, length); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToIndex(%v, %v): expected[%v] actual[%v]", quantizer.ModeRound, emptySet, length, expected, actual) + } + }) + }) + }) + + t.Run("QuantizeToValue", func(t *testing.T) { + q := quantizer.New[int](quantizer.ModeRound) + + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := float32(0) + list := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0} + if expected, actual := list[0], q.QuantizeToValue(min, list); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToValue(%v, %T): expected[%v] actual[%v]", quantizer.ModeRound, min, list, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max := float32(1) + list := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0} + if expected, actual := list[len(list)-1], q.QuantizeToValue(max, list); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToValue(%v, %T): expected[%v] actual[%v]", quantizer.ModeRound, max, list, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := float32(-1) + list := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0} + if expected, actual := list[0], q.QuantizeToValue(belowMin, list); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToValue(%v, %T): expected[%v] actual[%v]", quantizer.ModeRound, belowMin, list, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax := float32(2) + list := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0} + if expected, actual := list[len(list)-1], q.QuantizeToValue(aboveMax, list); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToValue(%v, %T): expected[%v] actual[%v]", quantizer.ModeRound, aboveMax, list, expected, actual) + } + }) + + t.Run("EmptySet", func(t *testing.T) { + emptySet := float32(0.5) + var list []int + if expected, actual := 0, q.QuantizeToValue(emptySet, list); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToValue(%v, %T): expected[%v] actual[%v]", quantizer.ModeRound, emptySet, list, expected, actual) + } + }) + }) + }) +} diff --git a/quantizer/quantizer_test.go b/quantizer/quantizer_test.go new file mode 100644 index 0000000..4e76433 --- /dev/null +++ b/quantizer/quantizer_test.go @@ -0,0 +1,17 @@ +package quantizer_test + +import ( + "math" + "testing" + + "github.com/awonak/EuroPiGo/quantizer" +) + +func TestNew(t *testing.T) { + t.Run("Invalid", func(t *testing.T) { + mode := quantizer.Mode(math.MinInt) + if actual := quantizer.New[int](mode); actual != nil { + t.Fatalf("Quantizer[%v] New: expected[nil] actual[non-nil]", mode) + } + }) +} diff --git a/quantizer/quantizer_trunc.go b/quantizer/quantizer_trunc.go new file mode 100644 index 0000000..539a095 --- /dev/null +++ b/quantizer/quantizer_trunc.go @@ -0,0 +1,36 @@ +package quantizer + +import ( + "github.com/awonak/EuroPiGo/lerp" +) + +// Trunc is a truncating-style quantizer. +// That is, it performs a mathematical `floor` if positive and `ceiling` if negative. +type Trunc[T any] struct{} + +// QuantizeToIndex takes a normalized input value and a length value, then provides +// a return value between 0 and (length - 1), inclusive. +// +// A return value of -1 means that the length parameter was 0 (a value that cannot be quantized over successfully). +func (Trunc[T]) QuantizeToIndex(in float32, length int) int { + if length == 0 { + return -1 + } + + return lerp.NewLerp32(0, length-1).ClampedLerp(in) +} + +// QuantizeToValue takes a normalized input value and a list of values, then provides +// a return value chosen from the provided list of values. +// +// A return value of the zeroish equivalent of the value means that the list parameter +// was empty (this situation does not lend well to quantization). +func (q Trunc[T]) QuantizeToValue(in float32, list []T) T { + idx := q.QuantizeToIndex(in, len(list)) + if idx == -1 { + var empty T + return empty + } + + return list[idx] +} diff --git a/quantizer/quantizer_trunc_test.go b/quantizer/quantizer_trunc_test.go new file mode 100644 index 0000000..05af357 --- /dev/null +++ b/quantizer/quantizer_trunc_test.go @@ -0,0 +1,111 @@ +package quantizer_test + +import ( + "testing" + + "github.com/awonak/EuroPiGo/quantizer" +) + +func TestTrunc(t *testing.T) { + t.Run("New", func(t *testing.T) { + if actual := quantizer.New[int](quantizer.ModeTrunc); actual == nil { + t.Fatalf("Quantizer[%v] New: expected[non-nil] actual[nil]", quantizer.ModeTrunc) + } + }) + + t.Run("QuantizeToIndex", func(t *testing.T) { + q := quantizer.New[int](quantizer.ModeTrunc) + + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := float32(0) + length := 10 + if expected, actual := 0, q.QuantizeToIndex(min, length); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToIndex(%v, %v): expected[%v] actual[%v]", quantizer.ModeTrunc, min, length, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max := float32(1) + length := 10 + if expected, actual := length-1, q.QuantizeToIndex(max, length); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToIndex(%v, %v): expected[%v] actual[%v]", quantizer.ModeTrunc, max, length, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := float32(-1) + length := 10 + if expected, actual := 0, q.QuantizeToIndex(belowMin, length); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToIndex(%v, %v): expected[%v] actual[%v]", quantizer.ModeTrunc, belowMin, length, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax := float32(2) + length := 10 + if expected, actual := length-1, q.QuantizeToIndex(aboveMax, length); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToIndex(%v, %v): expected[%v] actual[%v]", quantizer.ModeTrunc, aboveMax, length, expected, actual) + } + }) + + t.Run("EmptySet", func(t *testing.T) { + emptySet := float32(0.5) + length := 0 + if expected, actual := -1, q.QuantizeToIndex(emptySet, length); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToIndex(%v, %v): expected[%v] actual[%v]", quantizer.ModeTrunc, emptySet, length, expected, actual) + } + }) + }) + }) + + t.Run("QuantizeToValue", func(t *testing.T) { + q := quantizer.New[int](quantizer.ModeTrunc) + + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := float32(0) + list := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0} + if expected, actual := list[0], q.QuantizeToValue(min, list); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToValue(%v, %T): expected[%v] actual[%v]", quantizer.ModeTrunc, min, list, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max := float32(1) + list := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0} + if expected, actual := list[len(list)-1], q.QuantizeToValue(max, list); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToValue(%v, %T): expected[%v] actual[%v]", quantizer.ModeTrunc, max, list, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := float32(-1) + list := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0} + if expected, actual := list[0], q.QuantizeToValue(belowMin, list); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToValue(%v, %T): expected[%v] actual[%v]", quantizer.ModeTrunc, belowMin, list, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax := float32(2) + list := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0} + if expected, actual := list[len(list)-1], q.QuantizeToValue(aboveMax, list); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToValue(%v, %T): expected[%v] actual[%v]", quantizer.ModeTrunc, aboveMax, list, expected, actual) + } + }) + + t.Run("EmptySet", func(t *testing.T) { + emptySet := float32(0.5) + var list []int + if expected, actual := 0, q.QuantizeToValue(emptySet, list); actual != expected { + t.Fatalf("Quantizer[%v] QuantizeToValue(%v, %T): expected[%v] actual[%v]", quantizer.ModeTrunc, emptySet, list, expected, actual) + } + }) + }) + }) +} diff --git a/scripts/diagnostics/diagnostics.go b/scripts/diagnostics/diagnostics.go deleted file mode 100644 index f6c1043..0000000 --- a/scripts/diagnostics/diagnostics.go +++ /dev/null @@ -1,79 +0,0 @@ -// Diagnostics is a script for demonstrating all main interactions with the EuroPiGo firmware. -package main - -import ( - "fmt" - "machine" - - "tinygo.org/x/tinydraw" - - europi "github.com/awonak/EuroPiGo" -) - -type MyApp struct { - knobsDisplayPercent bool - prevK1 uint16 - prevK2 uint16 - staticCv int - prevStaticCv int -} - -func main() { - - myApp := MyApp{ - staticCv: 5, - } - - e := europi.New() - - // Demonstrate adding a IRQ handler to B1 and B2. - e.B1.Handler(func(p machine.Pin) { - myApp.knobsDisplayPercent = !myApp.knobsDisplayPercent - }) - - e.B2.Handler(func(p machine.Pin) { - myApp.staticCv = (myApp.staticCv + 1) % europi.MaxVoltage - }) - - for { - e.Display.ClearBuffer() - - // Highlight the border of the oled display. - tinydraw.Rectangle(e.Display, 0, 0, 128, 32, europi.White) - - // Display analog and digital input values. - inputText := fmt.Sprintf("din: %5v ain: %2.2f ", e.DI.Value(), e.AI.Percent()) - e.Display.WriteLine(inputText, 3, 8) - - // Display knob values based on app state. - var knobText string - if myApp.knobsDisplayPercent { - knobText = fmt.Sprintf("K1: %0.2f K2: %0.2f", e.K1.Percent(), e.K2.Percent()) - } else { - knobText = fmt.Sprintf("K1: %2d K2: %2d", e.K1.Range(100), e.K2.Range(100)) - } - e.Display.WriteLine(knobText, 3, 18) - - // Show current button press state. - e.Display.WriteLine(fmt.Sprintf("B1: %5v B2: %5v", e.B1.Value(), e.B2.Value()), 3, 28) - - e.Display.Display() - - // Set voltage values for the 6 CV outputs. - if e.K1.Range(1<<12) != myApp.prevK1 { - e.CV1.Voltage(e.K1.ReadVoltage()) - e.CV4.Voltage(europi.MaxVoltage - e.K1.ReadVoltage()) - myApp.prevK1 = e.K1.Range(1 << 12) - } - if e.K2.Range(1<<12) != myApp.prevK2 { - e.CV2.Voltage(e.K2.ReadVoltage()) - e.CV5.Voltage(europi.MaxVoltage - e.K2.ReadVoltage()) - myApp.prevK2 = e.K2.Range(1 << 12) - } - e.CV3.On() - if myApp.staticCv != myApp.prevStaticCv { - e.CV6.Voltage(float32(myApp.staticCv)) - myApp.prevStaticCv = myApp.staticCv - } - } -} diff --git a/units/bipolarcv.go b/units/bipolarcv.go new file mode 100644 index 0000000..8cde411 --- /dev/null +++ b/units/bipolarcv.go @@ -0,0 +1,24 @@ +package units + +import "github.com/awonak/EuroPiGo/clamp" + +// BipolarCV is a normalized representation [-1.0 .. 1.0] of a Control Voltage [-5.0 .. 5.0] value. +type BipolarCV float32 + +// ToVolts converts a (normalized) BipolarCV value to a value between -5.0 and 5.0 volts +func (c BipolarCV) ToVolts() float32 { + return c.ToFloat32() * 5.0 +} + +// ToCV converts a (normalized) BipolarCV value to a (normalized) CV value and a sign bit +func (c BipolarCV) ToCV() (cv CV, sign int) { + if c < 0.0 { + return CV(-c.ToFloat32()), -1 + } + return CV(c.ToFloat32()), 1 +} + +// ToFloat32 returns a (normalized) BipolarCV value to its floating point representation [-1.0 .. 1.0] +func (c BipolarCV) ToFloat32() float32 { + return clamp.Clamp(float32(c), -1.0, 1.0) +} diff --git a/units/bipolarcv_test.go b/units/bipolarcv_test.go new file mode 100644 index 0000000..67813c8 --- /dev/null +++ b/units/bipolarcv_test.go @@ -0,0 +1,205 @@ +package units_test + +import ( + "math" + "testing" + + "github.com/awonak/EuroPiGo/units" +) + +func TestBipolarCVToVolts(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := units.BipolarCV(-1.0) + if expected, actual := float32(-5.0), min.ToVolts(); actual != expected { + t.Fatalf("BipolarCV[%v] ToVolts: expected[%f] actual[%f]", min, expected, actual) + } + }) + + t.Run("Zero", func(t *testing.T) { + zero := units.BipolarCV(0.0) + if expected, actual := float32(0.0), zero.ToVolts(); actual != expected { + t.Fatalf("BipolarCV[%v] ToVolts: expected[%f] actual[%f]", zero, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max := units.BipolarCV(1.0) + if expected, actual := float32(5.0), max.ToVolts(); actual != expected { + t.Fatalf("BipolarCV[%v] ToVolts: expected[%f] actual[%f]", max, expected, actual) + } + }) + }) + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := units.BipolarCV(-2.0) + if expected, actual := float32(-5.0), belowMin.ToVolts(); actual != expected { + t.Fatalf("BipolarCV[%v] ToVolts: expected[%f] actual[%f]", belowMin, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax := units.BipolarCV(2.0) + if expected, actual := float32(5.0), aboveMax.ToVolts(); actual != expected { + t.Fatalf("BipolarCV[%v] ToVolts: expected[%f] actual[%f]", aboveMax, expected, actual) + } + }) + }) + + t.Run("NaN", func(t *testing.T) { + nan := units.BipolarCV(math.NaN()) + if actual := nan.ToVolts(); !math.IsNaN(float64(actual)) { + t.Fatalf("BipolarCV[%v] ToVolts: expected[%f] actual[%f]", nan, math.NaN(), actual) + } + }) + + t.Run("Inf", func(t *testing.T) { + t.Run("Neg", func(t *testing.T) { + negInf := units.BipolarCV(math.Inf(-1)) + if expected, actual := float32(-5.0), negInf.ToVolts(); actual != expected { + t.Fatalf("BipolarCV[%v] ToVolts: expected[%f] actual[%f]", negInf, expected, actual) + } + }) + + t.Run("Pos", func(t *testing.T) { + posInf := units.BipolarCV(math.Inf(1)) + if expected, actual := float32(5.0), posInf.ToVolts(); actual != expected { + t.Fatalf("BipolarCV[%v] ToVolts: expected[%f] actual[%f]", posInf, expected, actual) + } + }) + }) +} + +func TestBipolarCVToCV(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := units.BipolarCV(-1.0) + expectedMin, expectedMinSign := units.CV(1.0), -1 + if actual, actualSign := min.ToCV(); actual != expectedMin || actualSign != expectedMinSign { + t.Fatalf("BipolarCV[%v] ToCV: expected[%f sign(%d)] actual[%f sign(%d)]", min, expectedMin, expectedMinSign, actual, actualSign) + } + }) + + t.Run("Zero", func(t *testing.T) { + zero := units.BipolarCV(0.0) + expectedZero, expectedZeroSign := units.CV(0.0), 1 + if actual, actualSign := zero.ToCV(); actual != expectedZero || actualSign != expectedZeroSign { + t.Fatalf("BipolarCV[%v] ToCV: expected[%f sign(%d)] actual[%f sign(%d)]", zero, expectedZero, expectedZeroSign, actual, actualSign) + } + }) + + t.Run("Max", func(t *testing.T) { + max := units.BipolarCV(1.0) + expectedMax, expectedMaxSign := units.CV(1.0), 1 + if actual, actualSign := max.ToCV(); actual != expectedMax || actualSign != expectedMaxSign { + t.Fatalf("BipolarCV[%v] ToCV: expected[%f sign(%d)] actual[%f sign(%d)]", max, expectedMax, expectedMaxSign, actual, actualSign) + } + }) + }) + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := units.BipolarCV(-2.0) + expectedBelowMin, expectedBelowMinSign := units.CV(1.0), -1 + if actual, actualSign := belowMin.ToCV(); actual != expectedBelowMin || actualSign != expectedBelowMinSign { + t.Fatalf("BipolarCV[%v] ToCV: expected[%f sign(%d)] actual[%f sign(%d)]", belowMin, expectedBelowMin, expectedBelowMinSign, actual, actualSign) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax := units.BipolarCV(2.0) + expectedAboveMax, expectedAboveMaxSign := units.CV(1.0), 1 + if actual, actualSign := aboveMax.ToCV(); actual != expectedAboveMax || actualSign != expectedAboveMaxSign { + t.Fatalf("BipolarCV[%v] ToCV: expected[%f sign(%d)] actual[%f sign(%d)]", aboveMax, expectedAboveMax, expectedAboveMaxSign, actual, actualSign) + } + }) + }) + + t.Run("NaN", func(t *testing.T) { + nan := units.BipolarCV(math.NaN()) + expectedNanSign := 1 + if actual, actualSign := nan.ToCV(); !math.IsNaN(float64(actual)) || actualSign != expectedNanSign { + t.Fatalf("BipolarCV[%v] ToCV: expected[%f sign(%d)] actual[%f sign(%d)]", nan, math.NaN(), expectedNanSign, actual, actualSign) + } + }) + + t.Run("Inf", func(t *testing.T) { + t.Run("Neg", func(t *testing.T) { + negInf := units.BipolarCV(math.Inf(-1)) + expectedNegInf, expectedNegInfSign := units.CV(1.0), -1 + if actual, actualSign := negInf.ToCV(); actual != expectedNegInf || actualSign != expectedNegInfSign { + t.Fatalf("BipolarCV[%v] ToCV: expected[%f sign(%d)] actual[%f sign(%d)]", negInf, expectedNegInf, expectedNegInfSign, actual, actualSign) + } + }) + + t.Run("Pos", func(t *testing.T) { + posInf := units.BipolarCV(math.Inf(1)) + expectedPosInf, expectedPosInfSign := units.CV(1.0), 1 + if actual, actualSign := posInf.ToCV(); actual != expectedPosInf || actualSign != expectedPosInfSign { + t.Fatalf("BipolarCV[%v] ToCV: expected[%f sign(%d)] actual[%f sign(%d)]", posInf, expectedPosInf, expectedPosInfSign, actual, actualSign) + } + }) + }) +} + +func TestBipolarCVToFloat32(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := units.BipolarCV(-1.0) + if expected, actual := float32(-1.0), min.ToFloat32(); actual != expected { + t.Fatalf("BipolarCV[%v] ToFloat32: expected[%f] actual[%f]", min, expected, actual) + } + }) + + t.Run("Zero", func(t *testing.T) { + zero := units.BipolarCV(0.0) + if expected, actual := float32(0.0), zero.ToFloat32(); actual != expected { + t.Fatalf("BipolarCV[%v] ToFloat32: expected[%f] actual[%f]", zero, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max := units.BipolarCV(1.0) + if expected, actual := float32(1.0), max.ToFloat32(); actual != expected { + t.Fatalf("BipolarCV[%v] ToFloat32: expected[%f] actual[%f]", max, expected, actual) + } + }) + }) + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := units.BipolarCV(-2.0) + if expected, actual := float32(-1.0), belowMin.ToFloat32(); actual != expected { + t.Fatalf("BipolarCV[%v] ToFloat32: expected[%f] actual[%f]", belowMin, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax := units.BipolarCV(2.0) + if expected, actual := float32(1.0), aboveMax.ToFloat32(); actual != expected { + t.Fatalf("BipolarCV[%v] ToFloat32: expected[%f] actual[%f]", aboveMax, expected, actual) + } + }) + }) + + t.Run("NaN", func(t *testing.T) { + nan := units.BipolarCV(math.NaN()) + if actual := nan.ToFloat32(); !math.IsNaN(float64(actual)) { + t.Fatalf("BipolarCV[%v] ToFloat32: expected[%f] actual[%f]", nan, math.NaN(), actual) + } + }) + + t.Run("Inf", func(t *testing.T) { + t.Run("Neg", func(t *testing.T) { + negInf := units.BipolarCV(math.Inf(-1)) + if expected, actual := float32(-1.0), negInf.ToFloat32(); actual != expected { + t.Fatalf("BipolarCV[%v] ToFloat32: expected[%f] actual[%f]", negInf, expected, actual) + } + }) + + t.Run("Pos", func(t *testing.T) { + posInf := units.BipolarCV(math.Inf(1)) + if expected, actual := float32(1.0), posInf.ToFloat32(); actual != expected { + t.Fatalf("BipolarCV[%v] ToFloat32: expected[%f] actual[%f]", posInf, expected, actual) + } + }) + }) +} diff --git a/units/cv.go b/units/cv.go new file mode 100644 index 0000000..b23c687 --- /dev/null +++ b/units/cv.go @@ -0,0 +1,25 @@ +package units + +import "github.com/awonak/EuroPiGo/clamp" + +// CV is a normalized representation [0.0 .. 1.0] of a Control Voltage [0.0 .. 5.0] value. +type CV float32 + +// ToVolts converts a (normalized) CV value to a value between 0.0 and 5.0 volts +func (c CV) ToVolts() float32 { + return c.ToFloat32() * 5.0 +} + +// ToBipolarCV converts a (normalized) CV value to a (normalized) BipolarCV value +func (c CV) ToBipolarCV(sign int) BipolarCV { + bc := BipolarCV(c.ToFloat32()) + if sign < 0 { + return -bc + } + return bc +} + +// ToFloat32 returns a (normalized) CV value to its floating point representation [0.0 .. 1.0] +func (c CV) ToFloat32() float32 { + return clamp.Clamp(float32(c), 0.0, 1.0) +} diff --git a/units/cv_test.go b/units/cv_test.go new file mode 100644 index 0000000..6ed525e --- /dev/null +++ b/units/cv_test.go @@ -0,0 +1,185 @@ +package units_test + +import ( + "math" + "testing" + + "github.com/awonak/EuroPiGo/units" +) + +func TestCVToVolts(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := units.CV(-1.0) + if expected, actual := float32(0.0), min.ToVolts(); actual != expected { + t.Fatalf("CV[%v] ToVolts: expected[%f] actual[%f]", min, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max := units.CV(1.0) + if expected, actual := float32(5.0), max.ToVolts(); actual != expected { + t.Fatalf("CV[%v] ToVolts: expected[%f] actual[%f]", max, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := units.CV(-2.0) + if expected, actual := float32(0.0), belowMin.ToVolts(); actual != expected { + t.Fatalf("CV[%v] ToVolts: expected[%f] actual[%f]", belowMin, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax := units.CV(2.0) + if expected, actual := float32(5.0), aboveMax.ToVolts(); actual != expected { + t.Fatalf("CV[%v] ToVolts: expected[%f] actual[%f]", aboveMax, expected, actual) + } + }) + }) + + t.Run("NaN", func(t *testing.T) { + nan := units.CV(math.NaN()) + if actual := nan.ToVolts(); !math.IsNaN(float64(actual)) { + t.Fatalf("CV[%v] ToVolts: expected[%f] actual[%f]", nan, math.NaN(), actual) + } + }) + + t.Run("Inf", func(t *testing.T) { + t.Run("Neg", func(t *testing.T) { + negInf := units.CV(math.Inf(-1)) + if expected, actual := float32(0.0), negInf.ToVolts(); actual != expected { + t.Fatalf("CV[%v] ToVolts: expected[%f] actual[%f]", negInf, expected, actual) + } + }) + + t.Run("Pos", func(t *testing.T) { + posInf := units.CV(math.Inf(1)) + if expected, actual := float32(5.0), posInf.ToVolts(); actual != expected { + t.Fatalf("CV[%v] ToVolts: expected[%f] actual[%f]", posInf, expected, actual) + } + }) + }) +} + +func TestCVToBipolarCV(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min, minSign := units.CV(1.0), -1 + if expected, actual := units.BipolarCV(-1.0), min.ToBipolarCV(minSign); actual != expected { + t.Fatalf("CV[%v sign(%d)] ToBipolarCV: expected[%v] actual[%v]", min, minSign, expected, actual) + } + }) + + t.Run("Zero", func(t *testing.T) { + zero, zeroSign := units.CV(0.0), 1 + if expected, actual := units.BipolarCV(0.0), zero.ToBipolarCV(zeroSign); actual != expected { + t.Fatalf("CV[%v sign(%d)] ToBipolarCV: expected[%v] actual[%v]", zero, zeroSign, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max, maxSign := units.CV(1.0), 1 + if expected, actual := units.BipolarCV(1.0), max.ToBipolarCV(maxSign); actual != expected { + t.Fatalf("CV[%v sign(%d)] ToBipolarCV: expected[%v] actual[%v]", max, maxSign, expected, actual) + } + }) + }) + + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin, belowMinSign := units.CV(2.0), -1 + if expected, actual := units.BipolarCV(-1.0), belowMin.ToBipolarCV(belowMinSign); actual != expected { + t.Fatalf("CV[%v sign(%d)] ToBipolarCV: expected[%v] actual[%v]", belowMin, belowMinSign, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax, aboveMaxSign := units.CV(2.0), 1 + if expected, actual := units.BipolarCV(1.0), aboveMax.ToBipolarCV(aboveMaxSign); actual != expected { + t.Fatalf("CV[%v sign(%d)] ToBipolarCV: expected[%v] actual[%v]", aboveMax, aboveMaxSign, expected, actual) + } + }) + }) + + t.Run("NaN", func(t *testing.T) { + nan, nanSign := units.CV(math.NaN()), 1 + if actual := nan.ToBipolarCV(nanSign); !math.IsNaN(float64(actual)) { + t.Fatalf("CV[%v sign(%d)] ToBipolarCV: expected[%v] actual[%v]", nan, nanSign, nan, actual) + } + }) + + t.Run("Inf", func(t *testing.T) { + t.Run("Neg", func(t *testing.T) { + negInf, negInfSign := units.CV(math.Inf(1)), -1 + if expected, actual := units.BipolarCV(-1.0), negInf.ToBipolarCV(negInfSign); actual != expected { + t.Fatalf("CV[%v sign(%d)] ToBipolarCV: expected[%v] actual[%v]", negInf, negInfSign, expected, actual) + } + }) + + t.Run("Pos", func(t *testing.T) { + posInf, posInfSign := units.CV(math.Inf(1)), 1 + if expected, actual := units.BipolarCV(1.0), posInf.ToBipolarCV(posInfSign); actual != expected { + t.Fatalf("CV[%v sign(%d)] ToBipolarCV: expected[%v] actual[%v]", posInf, posInfSign, expected, actual) + } + }) + }) +} + +func TestCVToFloat32(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := units.CV(0.0) + if expected, actual := float32(0.0), min.ToFloat32(); actual != expected { + t.Fatalf("CV[%v] ToFloat32: expected[%f] actual[%f]", min, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max := units.CV(1.0) + if expected, actual := float32(1.0), max.ToFloat32(); actual != expected { + t.Fatalf("CV[%v] ToFloat32: expected[%f] actual[%f]", max, expected, actual) + } + }) + }) + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := units.CV(-2.0) + if expected, actual := float32(0.0), belowMin.ToFloat32(); actual != expected { + t.Fatalf("CV[%v] ToFloat32: expected[%f] actual[%f]", belowMin, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax := units.CV(2.0) + if expected, actual := float32(1.0), aboveMax.ToFloat32(); actual != expected { + t.Fatalf("CV[%v] ToFloat32: expected[%f] actual[%f]", aboveMax, expected, actual) + } + }) + }) + + t.Run("NaN", func(t *testing.T) { + nan := units.CV(math.NaN()) + if actual := nan.ToFloat32(); !math.IsNaN(float64(actual)) { + t.Fatalf("CV[%v] ToFloat32: expected[%f] actual[%f]", nan, math.NaN(), actual) + } + }) + + t.Run("Inf", func(t *testing.T) { + t.Run("Neg", func(t *testing.T) { + negInf := units.CV(math.Inf(-1)) + if expected, actual := float32(0.0), negInf.ToFloat32(); actual != expected { + t.Fatalf("CV[%v] ToFloat32: expected[%f] actual[%f]", negInf, expected, actual) + } + }) + + t.Run("Pos", func(t *testing.T) { + posInf := units.CV(math.Inf(1)) + if expected, actual := float32(1.0), posInf.ToFloat32(); actual != expected { + t.Fatalf("CV[%v] ToFloat32: expected[%f] actual[%f]", posInf, expected, actual) + } + }) + }) +} diff --git a/units/duration.go b/units/duration.go new file mode 100644 index 0000000..8520690 --- /dev/null +++ b/units/duration.go @@ -0,0 +1,19 @@ +package units + +import ( + "fmt" + "time" +) + +func DurationString(dur time.Duration) string { + switch { + case dur < time.Microsecond: + return fmt.Sprint(dur) + case dur < time.Millisecond: + return fmt.Sprintf("%3.1fus", dur.Seconds()*1_000_000.0) + case dur < time.Second: + return fmt.Sprintf("%3.1fms", dur.Seconds()*1_000.0) + default: + return fmt.Sprintf("%3.1fs", dur.Seconds()) + } +} diff --git a/units/duration_test.go b/units/duration_test.go new file mode 100644 index 0000000..b77a49e --- /dev/null +++ b/units/duration_test.go @@ -0,0 +1,52 @@ +package units_test + +import ( + "testing" + "time" + + "github.com/awonak/EuroPiGo/units" +) + +func TestDurationString(t *testing.T) { + t.Run("Zero", func(t *testing.T) { + zero := time.Duration(0) + if expected, actual := "0s", units.DurationString(zero); actual != expected { + t.Fatalf("Duration[%v] DurationString: expected[%s] actual[%s]", zero, expected, actual) + } + }) + + t.Run("Nanoseconds", func(t *testing.T) { + ns := time.Duration(time.Nanosecond * 123) + if expected, actual := "123ns", units.DurationString(ns); actual != expected { + t.Fatalf("Duration[%v] DurationString: expected[%s] actual[%s]", ns, expected, actual) + } + }) + + t.Run("Microseconds", func(t *testing.T) { + us := time.Duration(time.Nanosecond * 12_345) + if expected, actual := "12.3us", units.DurationString(us); actual != expected { + t.Fatalf("Duration[%v] DurationString: expected[%s] actual[%s]", us, expected, actual) + } + }) + + t.Run("Milliseconds", func(t *testing.T) { + ms := time.Duration(time.Microsecond * 12_345) + if expected, actual := "12.3ms", units.DurationString(ms); actual != expected { + t.Fatalf("Duration[%v] DurationString: expected[%s] actual[%s]", ms, expected, actual) + } + }) + + t.Run("Seconds", func(t *testing.T) { + s := time.Duration(time.Millisecond * 12_345) + if expected, actual := "12.3s", units.DurationString(s); actual != expected { + t.Fatalf("Duration[%v] DurationString: expected[%s] actual[%s]", s, expected, actual) + } + }) + + t.Run("Minutes", func(t *testing.T) { + m := time.Duration(time.Millisecond * 754567) + if expected, actual := "754.6s", units.DurationString(m); actual != expected { + t.Fatalf("Duration[%v] DurationString: expected[%s] actual[%s]", m, expected, actual) + } + }) +} diff --git a/units/hertz.go b/units/hertz.go new file mode 100644 index 0000000..182ac39 --- /dev/null +++ b/units/hertz.go @@ -0,0 +1,37 @@ +package units + +import ( + "fmt" + "time" +) + +type Hertz float32 + +func (h Hertz) ToPeriod() time.Duration { + if h == 0 { + return 0 + } + return time.Duration(float32(time.Second) / float32(h)) +} + +func (h Hertz) String() string { + switch { + case h < 0.000_001: + return fmt.Sprintf("%3.1fnHz", h*1_000_000_000.0) + case h < 0.001: + return fmt.Sprintf("%3.1fuHz", h*1_000_000.0) + case h < 1.0: + return fmt.Sprintf("%3.1fmHz", h*1_000.0) + case h < 1_000.0: + return fmt.Sprintf("%3.1fHz", h) + case h < 1_000_000.0: + return fmt.Sprintf("%3.1fkHz", h/1_000.0) + case h < 1_000_000_000.0: + return fmt.Sprintf("%3.1fMHz", h/1_000_000.0) + case h < 1_000_000_000_000.0: + return fmt.Sprintf("%3.1fGHz", h/1_000_000_000.0) + default: + // use scientific notation + return fmt.Sprintf("%3.1gHz", h) + } +} diff --git a/units/hertz_test.go b/units/hertz_test.go new file mode 100644 index 0000000..7ef5554 --- /dev/null +++ b/units/hertz_test.go @@ -0,0 +1,103 @@ +package units_test + +import ( + "testing" + "time" + + "github.com/awonak/EuroPiGo/units" +) + +func TestHertzToPeriod(t *testing.T) { + t.Run("Zero", func(t *testing.T) { + zero := units.Hertz(0) + if expected, actual := time.Duration(0), zero.ToPeriod(); actual != expected { + t.Fatalf("Hertz[%v] ToPeriod: expected[%s] actual[%s]", zero, expected, actual) + } + }) + + t.Run("Seconds", func(t *testing.T) { + s := units.Hertz(0.1) + if expected, actual := time.Second*10, s.ToPeriod(); actual != expected { + t.Fatalf("Hertz[%v] ToPeriod: expected[%s] actual[%s]", s, expected, actual) + } + }) + + t.Run("Milliseconds", func(t *testing.T) { + ms := units.Hertz(100.0) + if expected, actual := time.Millisecond*10, ms.ToPeriod(); actual != expected { + t.Fatalf("Hertz[%v] ToPeriod: expected[%s] actual[%s]", ms, expected, actual) + } + }) + + t.Run("Microseconds", func(t *testing.T) { + us := units.Hertz(100_000.0) + if expected, actual := time.Microsecond*10, us.ToPeriod(); actual != expected { + t.Fatalf("Hertz[%v] ToPeriod: expected[%s] actual[%s]", us, expected, actual) + } + }) + + t.Run("Nanoseconds", func(t *testing.T) { + ns := units.Hertz(100_000_000.0) + if expected, actual := time.Nanosecond*10, ns.ToPeriod(); actual != expected { + t.Fatalf("Hertz[%v] ToPeriod: expected[%s] actual[%s]", ns, expected, actual) + } + }) +} + +func TestHertzString(t *testing.T) { + t.Run("NanoHertz", func(t *testing.T) { + nhz := units.Hertz(0.000_000_1) + if expected, actual := "100.0nHz", nhz.String(); actual != expected { + t.Fatalf("Hertz[%v] String: expected[%s] actual[%s]", nhz, expected, actual) + } + }) + + t.Run("MicroHertz", func(t *testing.T) { + uhz := units.Hertz(0.000_1) + if expected, actual := "100.0uHz", uhz.String(); actual != expected { + t.Fatalf("Hertz[%v] String: expected[%s] actual[%s]", uhz, expected, actual) + } + }) + + t.Run("MilliHertz", func(t *testing.T) { + mhz := units.Hertz(0.1) + if expected, actual := "100.0mHz", mhz.String(); actual != expected { + t.Fatalf("Hertz[%v] String: expected[%s] actual[%s]", mhz, expected, actual) + } + }) + + t.Run("Hertz", func(t *testing.T) { + hz := units.Hertz(100.0) + if expected, actual := "100.0Hz", hz.String(); actual != expected { + t.Fatalf("Hertz[%v] String: expected[%s] actual[%s]", hz, expected, actual) + } + }) + + t.Run("KiloHertz", func(t *testing.T) { + khz := units.Hertz(100_000.0) + if expected, actual := "100.0kHz", khz.String(); actual != expected { + t.Fatalf("Hertz[%v] String: expected[%s] actual[%s]", khz, expected, actual) + } + }) + + t.Run("MegaHertz", func(t *testing.T) { + mhz := units.Hertz(100_000_000.0) + if expected, actual := "100.0MHz", mhz.String(); actual != expected { + t.Fatalf("Hertz[%v] String: expected[%s] actual[%s]", mhz, expected, actual) + } + }) + + t.Run("GigaHertz", func(t *testing.T) { + ghz := units.Hertz(100_000_000_000.0) + if expected, actual := "100.0GHz", ghz.String(); actual != expected { + t.Fatalf("Hertz[%v] String: expected[%s] actual[%s]", ghz, expected, actual) + } + }) + + t.Run("Other", func(t *testing.T) { + hz := units.Hertz(100_000_000_000_000.0) + if expected, actual := "1e+14Hz", hz.String(); actual != expected { + t.Fatalf("Hertz[%v] String: expected[%s] actual[%s]", hz, expected, actual) + } + }) +} diff --git a/units/voct.go b/units/voct.go new file mode 100644 index 0000000..c6a1a6b --- /dev/null +++ b/units/voct.go @@ -0,0 +1,21 @@ +package units + +import "github.com/awonak/EuroPiGo/clamp" + +const ( + MinVOct VOct = 0.0 + MaxVOct VOct = 10.0 +) + +// VOct is a representation of a Volt-per-Octave value +type VOct float32 + +// ToVolts converts a V/Octave value to a value between 0.0 and 10.0 volts +func (v VOct) ToVolts() float32 { + return v.ToFloat32() +} + +// ToFloat32 returns a V/Octave value to its floating point representation [0.0 .. 10.0] +func (v VOct) ToFloat32() float32 { + return float32(clamp.Clamp(v, MinVOct, MaxVOct)) +} diff --git a/units/voct_test.go b/units/voct_test.go new file mode 100644 index 0000000..35f474d --- /dev/null +++ b/units/voct_test.go @@ -0,0 +1,120 @@ +package units_test + +import ( + "math" + "testing" + + "github.com/awonak/EuroPiGo/units" +) + +func TestVOctToVolts(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := units.VOct(0.0) + if expected, actual := float32(0.0), min.ToVolts(); actual != expected { + t.Fatalf("VOct[%v] ToVolts: expected[%f] actual[%f]", min, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max := units.VOct(10.0) + if expected, actual := float32(10.0), max.ToVolts(); actual != expected { + t.Fatalf("VOct[%v] ToVolts: expected[%f] actual[%f]", max, expected, actual) + } + }) + }) + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := units.VOct(-2.0) + if expected, actual := float32(0.0), belowMin.ToVolts(); actual != expected { + t.Fatalf("VOct[%v] ToVolts: expected[%f] actual[%f]", belowMin, expected, actual) + } + }) + + t.Run("AboveMax", func(t *testing.T) { + aboveMax := units.VOct(12.0) + if expected, actual := float32(10.0), aboveMax.ToVolts(); actual != expected { + t.Fatalf("VOct[%v] ToVolts: expected[%f] actual[%f]", aboveMax, expected, actual) + } + }) + }) + + t.Run("NaN", func(t *testing.T) { + nan := units.VOct(math.NaN()) + if actual := nan.ToVolts(); !math.IsNaN(float64(actual)) { + t.Fatalf("VOct[%v] ToVolts: expected[%f] actual[%f]", nan, math.NaN(), actual) + } + }) + + t.Run("Inf", func(t *testing.T) { + t.Run("Neg", func(t *testing.T) { + negInf := units.VOct(math.Inf(-1)) + if expected, actual := float32(0.0), negInf.ToVolts(); actual != expected { + t.Fatalf("VOct[%v] ToVolts: expected[%f] actual[%f]", negInf, expected, actual) + } + }) + + t.Run("Pos", func(t *testing.T) { + posInf := units.VOct(math.Inf(1)) + if expected, actual := float32(10.0), posInf.ToVolts(); actual != expected { + t.Fatalf("VOct[%v] ToVolts: expected[%f] actual[%f]", posInf, expected, actual) + } + }) + }) +} + +func TestVOctToFloat32(t *testing.T) { + t.Run("InRange", func(t *testing.T) { + t.Run("Min", func(t *testing.T) { + min := units.VOct(0.0) + if expected, actual := float32(0.0), min.ToFloat32(); actual != expected { + t.Fatalf("VOct[%v] ToFloat32: expected[%f] actual[%f]", min, expected, actual) + } + }) + + t.Run("Max", func(t *testing.T) { + max := units.VOct(10.0) + if expected, actual := float32(10.0), max.ToFloat32(); actual != expected { + t.Fatalf("VOct[%v] ToFloat32: expected[%f] actual[%f]", max, expected, actual) + } + }) + }) + t.Run("OutOfRange", func(t *testing.T) { + t.Run("BelowMin", func(t *testing.T) { + belowMin := units.VOct(-2.0) + if expected, actual := float32(0.0), belowMin.ToFloat32(); actual != expected { + t.Fatalf("VOct[%v] ToFloat32: expected[%f] actual[%f]", belowMin, expected, actual) + } + }) + + t.Run("BelowMax", func(t *testing.T) { + aboveMax := units.VOct(122.0) + if expected, actual := float32(10.0), aboveMax.ToFloat32(); actual != expected { + t.Fatalf("VOct[%v] ToFloat32: expected[%f] actual[%f]", aboveMax, expected, actual) + } + }) + }) + + t.Run("NaN", func(t *testing.T) { + nan := units.VOct(math.NaN()) + if actual := nan.ToFloat32(); !math.IsNaN(float64(actual)) { + t.Fatalf("VOct[%v] ToFloat32: expected[%f] actual[%f]", nan, math.NaN(), actual) + } + }) + + t.Run("Inf", func(t *testing.T) { + t.Run("Neg", func(t *testing.T) { + negInf := units.VOct(math.Inf(-1)) + if expected, actual := float32(0.0), negInf.ToFloat32(); actual != expected { + t.Fatalf("VOct[%v] ToFloat32: expected[%f] actual[%f]", negInf, expected, actual) + } + }) + + t.Run("Pos", func(t *testing.T) { + posInf := units.VOct(math.Inf(1)) + if expected, actual := float32(10.0), posInf.ToFloat32(); actual != expected { + t.Fatalf("VOct[%v] ToFloat32: expected[%f] actual[%f]", posInf, expected, actual) + } + }) + }) +}