diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..598e9f8 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[run] +branch = True + +[report] +exclude_lines = + pragma: no cover \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ea39e8..a448369 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ bundles dist **/*.egg-info .vscode -.nox \ No newline at end of file +.nox +.coverage diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..119d5ab --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/README.md b/README.md index 7a29261..8d17941 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/winterbloom_smolmidi b/lib/winterbloom_smolmidi new file mode 160000 index 0000000..e4f55ac --- /dev/null +++ b/lib/winterbloom_smolmidi @@ -0,0 +1 @@ +Subproject commit e4f55acf93fdb43e291ff89c96b06bf1b96125a9 diff --git a/lib/winterbloom_voltageio b/lib/winterbloom_voltageio new file mode 160000 index 0000000..aa82fac --- /dev/null +++ b/lib/winterbloom_voltageio @@ -0,0 +1 @@ +Subproject commit aa82fac05055101d407b8ec4f76dfb3f11320f14 diff --git a/noxfile.py b/noxfile.py index 1ef30c3..9b3fd8b 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,3 +1,5 @@ +import glob + import nox @@ -5,7 +7,7 @@ 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) @@ -13,12 +15,15 @@ def blacken(session): @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") diff --git a/tests/conftest.py b/tests/conftest.py index d517f0f..e3f1001 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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)) diff --git a/tests/test__midi_ext.py b/tests/test__midi_ext.py new file mode 100644 index 0000000..f4d1e40 --- /dev/null +++ b/tests/test__midi_ext.py @@ -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] diff --git a/tests/test_trigger.py b/tests/test_trigger.py new file mode 100644 index 0000000..b3643d8 --- /dev/null +++ b/tests/test_trigger.py @@ -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 diff --git a/winterbloom_sol.py b/winterbloom_sol.py deleted file mode 100644 index be97582..0000000 --- a/winterbloom_sol.py +++ /dev/null @@ -1,27 +0,0 @@ -# The MIT License (MIT) -# -# Copyright (c) 2019 Alethea Flowers for Winterbloom -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -__version__ = "0.0.0-auto.0" -__repo__ = "https://github.com/theacodes/Winterbloom_Sol.git" - -""" -""" diff --git a/winterbloom_sol/__init__.py b/winterbloom_sol/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/winterbloom_sol/_midi_ext.py b/winterbloom_sol/_midi_ext.py new file mode 100644 index 0000000..d89d2d5 --- /dev/null +++ b/winterbloom_sol/_midi_ext.py @@ -0,0 +1,85 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Alethea Flowers for Winterbloom +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE.# The MIT License (MIT) + +import winterbloom_smolmidi as smolmidi + +_DEBUG = False +_DEDUPLICATE_MESSAGES = set([ + smolmidi.CHANNEL_PRESSURE, + smolmidi.AFTERTOUCH, + smolmidi.CC, + smolmidi.PITCH_BEND, + smolmidi.SONG_POSITION, +]) + + +class DeduplicatingMidiIn: + """Like MidiIn, but can de-duplicate messages. + + For example, if the buffer is filled with a lot of Channel Pressure + messages and we only care about the most recent one, this can ignore + all but the latest automatically. + """ + def __init__(self, midi_in): + self._midi_in = midi_in + self._peeked = None + + # TODO: Mark this with @micropython.native + def receive(self): + if self._peeked is not None: + message = self._peeked + self._peeked = None + else: + message = self._midi_in.receive() + + if message is None: + return None + + if message.type not in _DEDUPLICATE_MESSAGES: + return message + + # Peek ahead and see if there's another message of the same type. + count = 0 + while True: + self._peeked = self._midi_in.receive() + + # If not, break. + if self._peeked is None: + break + + if self._peeked.type != message.type: + break + + message = self._peeked + count += 1 + + if _DEBUG and count: # pragma: no cover + print( + "Skipped {} messages, error count: {}".format( + count, self._midi_in.error_count + ) + ) + + return message + + def receive_sysex(self, *args, **kwargs): + return self._midi_in.receive_sysex(*args, **kwargs) diff --git a/winterbloom_sol/trigger.py b/winterbloom_sol/trigger.py new file mode 100644 index 0000000..dd85dc9 --- /dev/null +++ b/winterbloom_sol/trigger.py @@ -0,0 +1,72 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Alethea Flowers for Winterbloom +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE.# The MIT License (MIT) + +import time + + +class Trigger: + """A trigger/retrigger helper. + + This handles "triggering" an output for a short duration. This is + similar to gate, but gate is a continous on/off, whereas trigger + is a short pulse of on or off. For example:: + + Trigger: ____-____ + Gate: ____----- + + Example usage:: + + trigger = Trigger(DigitalInOut(board.D3)) + trigger() + + while True: + trigger.step() + """ + def __init__(self, output, duration=15): + self._output = output + self._duration = duration + self._start_time = None + + def trigger(self, value=True, duration=None): + # TODO: Figure out what to do if the trigger + # is still on-going. + if self._start_time is not None: + return False + + if duration: + self._duration = duration + self._output.value = value + self._start_time = time.monotonic() + + return True + + __call__ = trigger + + def step(self): + if self._start_time is None: + return + + now = time.monotonic() + elapsed_ms = (now - self._start_time) * 1000 + if elapsed_ms > self._duration: + self._output.value = not self._output.value + self._start_time = None