Skip to content

Commit

Permalink
Add _midi_ext and trigger
Browse files Browse the repository at this point in the history
  • Loading branch information
theacodes committed Oct 28, 2019
1 parent 82184ab commit d85f81a
Show file tree
Hide file tree
Showing 14 changed files with 349 additions and 135 deletions.
6 changes: 6 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[run]
branch = True

[report]
exclude_lines =
pragma: no cover
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ bundles
dist
**/*.egg-info
.vscode
.nox
.nox
.coverage
6 changes: 6 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[submodule "lib/winterbloom_smolmidi"]
path = lib/winterbloom_smolmidi
url = https://github.com/theacodes/Winterbloom_SmolMIDI
[submodule "lib/winterbloom_voltageio"]
path = lib/winterbloom_voltageio
url = https://github.com/theacodes/Winterbloom_VoltageIO
104 changes: 2 additions & 102 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,106 +1,6 @@
# Winterbloom VoltageIO
# Winterbloom Sol

This is a [CircuitPython](https://circuitpython.org) helper library for setting a digital-to-analog Converter (DAC) to a direct voltage value. That is, instead of setting a 16-bit integer value you can set the DAC to a floating-point voltage value. It also provides similar helpers for reading voltage values from analog-to-digital converters (ADCs).

For example you can replace this code:

```python
import board
from analogio import AnalogOut

analog_out = AnalogOut(board.A0)

# 0-3.3v range, so this should be ~0.25 volts.
analog_out.value = round(65535 * 0.25 / 3.3)
```

with this code:

```python
import board
from winterbloom_voltageio import VoltageOut

voltage_out = VoltageOut.from_pin(board.A0)

# 3.3v range.
voltage_out.linear_calibration(3.3)

# And 0.25v out.
voltage_out.voltage = 0.25
```

While this is a useful convenience for when you want to skip doing the math to set the DAC to a specific voltage, it's also incredibly useful for working around any non-linearity in your DAC. Real-world DACs have some degree of imperfection. You can measure the DAC's output voltage at various output values and then use `VoltageIO.direct_calibration` to get more accurate voltage output:

```python
voltage_out.direct_calibration({
# Voltage: DAC value
0: 0,
0.825: 16000,
1.65: 32723,
2.475: 49230,
3.3, 65535,
})
```

This library is also extremely useful if your DAC's output is scaled. For example, if you have an op amp after your DAC that's scaling its output to 0v-10v. You can use `VoltageIO` to set the output based on the final, scaled voltage:


```python
# While the DAC itself only ouputs up to 3.3v,
# it's scaled up to 10v by an op amp.
voltage_out.linear_calibration(10)

# 5.5v output.
voltage_out.voltage = 5.5
```

Very similarly and for similar reasons, you can use `VoltageIn` to read voltage values from ADCs. For example, you might replace this code:

```python
import board
from analogio import AnalogIn

analog_in = AnalogIn(board.A1)

# 3.3v range
voltage = analog_in.value * 3.3 / 65536

print(voltage)
```

with:

```python

import board
from winterbloom_voltageio import VoltageIn

voltage_in = VoltageIn.from_pin(board.A0)

# 3.3v range.
voltage_in.linear_calibration(3.3)

print(voltage_in)
```

Just like with `VoltageOut`, you can directly specify the calibration values. This allows you to counteract any non-linearity:

```python
voltage_in.direct_calibration({
# ADC value: voltage
0: 0,
16000: 0.825,
32723: 1.65,
49230: 2.475,
65535: 3.3,
})
```

And again, just like with `VoltageOut`, this class is useful for dealing with cases where your input voltage is scaled up or down for your ADC.

## Installation

Install this library by copying [winterbloom_voltageio.py](winterbloom_voltageio.py) to your device's `lib` folder.
TODO. :)

## License and contributing

Expand Down
1 change: 1 addition & 0 deletions lib/winterbloom_smolmidi
Submodule winterbloom_smolmidi added at e4f55a
1 change: 1 addition & 0 deletions lib/winterbloom_voltageio
Submodule winterbloom_voltageio added at aa82fa
13 changes: 9 additions & 4 deletions noxfile.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import glob

import nox


@nox.session(python="3")
def blacken(session):
"""Run black code formater."""
session.install("black==19.3b0", "isort==4.3.21")
files = ["noxfile.py", "winterbloom_sol.py"]
files = ["noxfile.py"] + glob.glob("winterbloom_sol/*.py")
session.run("black", *files)
session.run("isort", "--recursive", *files)


@nox.session(python="3")
def lint(session):
session.install("flake8==3.7.8", "black==19.3b0")
files = ["noxfile.py", "winterbloom_sol.py"]
files = ["noxfile.py"] + glob.glob("winterbloom_sol/*.py")
session.run("black", "--check", *files)
session.run("flake8", *files)


@nox.session(python="3")
def test(session):
session.install("pytest")
session.run("python", "-m", "pytest", "tests")
session.install("pytest", "pytest-cov")
session.run("python", "-m", "pytest",
"--cov=winterbloom_sol",
"--cov-report=term-missing",
"tests")
11 changes: 10 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import os
import sys

HERE = os.path.abspath(os.path.dirname(__file__))
ROOT = os.path.abspath(os.path.join(HERE, '..'))
STUBS = os.path.join(HERE, 'stubs')
LIB = os.path.join(ROOT, 'lib')

# Insert import stubs directory into sys.path.
sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), 'stubs')))
sys.path.insert(1, STUBS)

# Insert libs into sys.path
for path in os.listdir(LIB):
sys.path.insert(1, os.path.join(LIB, path))
91 changes: 91 additions & 0 deletions tests/test__midi_ext.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Copyright (c) 2019 Alethea Flowers for Winterbloom
# Licensed under the MIT License

import pytest

import winterbloom_smolmidi as smolmidi
from winterbloom_sol import _midi_ext


def make_message(type, *data):
msg = smolmidi.Message()
msg.type = type
msg.data = data
return msg


class MidiInStub:
def __init__(self, messages):
self._messages = iter(messages)

def receive(self):
return next(self._messages)

def receive_sysex(self):
return [0x01, 0x02]


def test_normal_stream():
midi_in = _midi_ext.DeduplicatingMidiIn(MidiInStub([
make_message(smolmidi.NOTE_ON, 0x64, 0x65),
make_message(smolmidi.NOTE_OFF, 0x64, 0x70),
None
]))

assert midi_in.receive().type == smolmidi.NOTE_ON
assert midi_in.receive().type == smolmidi.NOTE_OFF
assert midi_in.receive() is None


def test_stream_with_duplicates():
midi_in = _midi_ext.DeduplicatingMidiIn(MidiInStub([
make_message(smolmidi.NOTE_ON, 0x64, 0x65),
make_message(smolmidi.CHANNEL_PRESSURE, 0x01),
make_message(smolmidi.CHANNEL_PRESSURE, 0x02),
make_message(smolmidi.CHANNEL_PRESSURE, 0x03),
make_message(smolmidi.CHANNEL_PRESSURE, 0x04),
make_message(smolmidi.CHANNEL_PRESSURE, 0x05),
make_message(smolmidi.CHANNEL_PRESSURE, 0x06),
make_message(smolmidi.NOTE_OFF, 0x64, 0x70),
None
]))

assert midi_in.receive().type == smolmidi.NOTE_ON
msg = midi_in.receive()
assert msg.type == smolmidi.CHANNEL_PRESSURE
assert msg.data[0] == 0x06
assert midi_in.receive().type == smolmidi.NOTE_OFF
assert midi_in.receive() is None


def test_stream_with_discontinous_duplicates():
midi_in = _midi_ext.DeduplicatingMidiIn(MidiInStub([
make_message(smolmidi.NOTE_ON, 0x64, 0x65),
make_message(smolmidi.CHANNEL_PRESSURE, 0x01),
make_message(smolmidi.CHANNEL_PRESSURE, 0x02),
make_message(smolmidi.CHANNEL_PRESSURE, 0x03),
None,
make_message(smolmidi.CHANNEL_PRESSURE, 0x04),
make_message(smolmidi.CHANNEL_PRESSURE, 0x05),
make_message(smolmidi.CHANNEL_PRESSURE, 0x06),
make_message(smolmidi.NOTE_OFF, 0x64, 0x70),
None
]))

assert midi_in.receive().type == smolmidi.NOTE_ON
msg = midi_in.receive()
assert msg.type == smolmidi.CHANNEL_PRESSURE
assert msg.data[0] == 0x03
msg = midi_in.receive()
# It does *not* return the "None" in the middle of the stream,
# instead, it just returns the next valid message.
assert msg.type == smolmidi.CHANNEL_PRESSURE
assert msg.data[0] == 0x06
assert midi_in.receive().type == smolmidi.NOTE_OFF
assert midi_in.receive() is None


def test_receive_sysex():
midi_in = _midi_ext.DeduplicatingMidiIn(MidiInStub([]))

assert midi_in.receive_sysex() == [0x01, 0x02]
64 changes: 64 additions & 0 deletions tests/test_trigger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright (c) 2019 Alethea Flowers for Winterbloom
# Licensed under the MIT License

from unittest import mock

from winterbloom_sol import trigger


class DigitalInOutStub:
def __init__(self):
self.value = False


@mock.patch("time.monotonic", autospec=True)
def test_trigger_basic(time_monotonic):
output = DigitalInOutStub()
trig = trigger.Trigger(output)

time_monotonic.return_value = 0
trig()

assert output.value is True

time_monotonic.return_value = 0.014
trig.step()
assert output.value is True

time_monotonic.return_value = 0.016
trig.step()
assert output.value is False


def test_retrigger():
output = DigitalInOutStub()
trig = trigger.Trigger(output)

assert trig(True)
assert not trig(True)


@mock.patch("time.monotonic", autospec=True)
def test_trigger_custom_duration(time_monotonic):
output = DigitalInOutStub()
trig = trigger.Trigger(output)

time_monotonic.return_value = 0
trig(duration=50)

time_monotonic.return_value = 0.049
trig.step()
assert output.value is True

time_monotonic.return_value = 0.051
trig.step()
assert output.value is False


def test_empty_step():
output = DigitalInOutStub()
trig = trigger.Trigger(output)

trig.step()

assert output.value is False
27 changes: 0 additions & 27 deletions winterbloom_sol.py

This file was deleted.

Empty file added winterbloom_sol/__init__.py
Empty file.
Loading

0 comments on commit d85f81a

Please sign in to comment.