diff --git a/.gitignore b/.gitignore index 45fc17f..d5387a0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ erl_crash.dump # Ignore package tarball (built via "mix hex.build"). circuits_uart_midi_framing-*.tar +.elixir_ls diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..a36e540 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,56 @@ +image: elixir:latest + +cache: + key: "$CI_JOB_NAME" + paths: + - deps + - _build + - /root/.mix + +variables: + MIX_ENV: "test" + +before_script: + - mix local.hex --force + - mix local.rebar --force + - mix deps.get --only test + +test: + stage: test + script: + - mix test + +credo: + stage: test + script: + - mix credo + +audit: + stage: test + script: + - mix hex.audit + +format: + stage: test + script: + - mix format --check-formatted + +pages: + stage: deploy + script: + - mix docs -o public + artifacts: + paths: + - public + only: + - master + +package: + stage: deploy + only: + - /^v[0-9]+\.[0-9]+\.[0-9]+$/ + script: + - mix hex.build + artifacts: + paths: + - "circuits_uart_midi_framing-*.tar" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0589941 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +Copyright 2020 James Harton + +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. + +* No Harm: The software may not be used by anyone for systems or activities that actively and knowingly endanger, harm, or otherwise threaten the physical, mental, economic, or general well-being of other individuals or groups, in violation of the United Nations Universal Declaration of Human Rights (https://www.un.org/en/universal-declaration-human-rights/). + +* Services: If the Software is used to provide a service to others, the licensee shall, as a condition of use, require those others not to use the service in any way that violates the No Harm clause above. + +* Enforceability: If any portion or provision of this License shall to any extent be declared illegal or unenforceable by a court of competent jurisdiction, then the remainder of this License, or the application of such portion or provision in circumstances other than those as to which it is so declared illegal or unenforceable, shall not be affected thereby, and each portion and provision of this Agreement shall be valid and enforceable to the fullest extent permitted by law. + +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. + +This Hippocratic License is an Ethical Source license (https://ethicalsource.dev) derived from the MIT License, amended to limit the impact of the unethical use of open source software. + diff --git a/README.md b/README.md index 88db3a0..ce28cd3 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # Circuits.UART.Framing.MIDI -**TODO: Add description** +Implements a simple framing that splits incoming serial data into individual +messages. ## Installation If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `circuits_uart_midi_framing` to your list of dependencies in `mix.exs`: +by adding `circuits_uart_midi_framing` to your list of dependencies in +`mix.exs`: ```elixir def deps do diff --git a/lib/circuits/uart/framing/midi.ex b/lib/circuits/uart/framing/midi.ex index 7418d89..32b98de 100644 --- a/lib/circuits/uart/framing/midi.ex +++ b/lib/circuits/uart/framing/midi.ex @@ -1,18 +1,46 @@ defmodule Circuits.UART.Framing.MIDI do + @behaviour Circuits.UART.Framing + alias __MODULE__.Buffer + @moduledoc """ - Documentation for `Circuits.UART.Framing.MIDI`. + Implements framing for the MIDI protocol. + + It doesn't decode any messages, it simply splits incoming serial frames apart + into individual MIDI messages as per the specification. """ + @impl true + def init(_args), do: {:ok, Buffer.init()} + @doc """ - Hello world. - - ## Examples - - iex> Circuits.UART.Framing.MIDI.hello() - :world - + Basically a no-op from a framing point of view. There's no intra-message + delimeter required, so we simply return the data unchanged. """ - def hello do - :world + @impl true + def add_framing(data, state) do + {:ok, data, state} end + + @doc """ + Process incoming data and break it apart into separate MIDI packets. + """ + @impl true + def remove_framing(data, %Buffer{} = buffer) do + buffer = Buffer.append(buffer, data) + + case Buffer.get_packets(buffer) do + {:ok, messages, %Buffer{buffer: <<>>} = buffer} -> {:ok, messages, buffer} + {:ok, messages, %Buffer{} = buffer} -> {:in_frame, messages, buffer} + {:error, reason} -> {:error, reason} + end + end + + @impl true + def frame_timeout(buffer), do: buffer + + @impl true + def flush(direction, _buffer) when direction == :receive or direction == :both, + do: Buffer.init() + + def flush(:transmit, buffer), do: buffer end diff --git a/lib/circuits/uart/framing/midi/buffer.ex b/lib/circuits/uart/framing/midi/buffer.ex new file mode 100644 index 0000000..05d03a3 --- /dev/null +++ b/lib/circuits/uart/framing/midi/buffer.ex @@ -0,0 +1,100 @@ +defmodule Circuits.UART.Framing.MIDI.Buffer do + alias __MODULE__ + defstruct buffer: <<>> + + @type t :: %Buffer{buffer: binary} + + @moduledoc """ + Implements a buffer for incoming serial data. + """ + + @doc """ + Initialise a new empty buffer. + """ + @spec init :: Buffer.t() + def init, do: %Buffer{} + + @doc """ + Consume as much of the buffer as possible, splitting it into messages. + """ + def get_packets(%Buffer{buffer: buffer}) do + with {:ok, buffer} <- drop_leading_data_bytes(buffer), + {:ok, messages, buffer} <- consume_messages([], buffer) do + {:ok, messages, %Buffer{buffer: buffer}} + else + :error -> {:ok, [], %Buffer{buffer: buffer}} + end + end + + @doc """ + Apend new data onto the end of the buffer. + """ + @spec append(Buffer.t(), binary) :: Buffer.t() + def append(%Buffer{buffer: buffer}, data) when is_binary(data), + do: %Buffer{buffer: buffer <> data} + + @doc """ + Indicates whether the buffer is empty or not. + """ + @spec empty?(Buffer.t()) :: boolean + def empty?(%Buffer{buffer: <<>>}), do: true + def empty?(%Buffer{}), do: false + + defp consume_status_byte(<<1::integer-size(1), byte::bitstring-size(7), rest::binary>>), + do: {:ok, <<1::integer-size(1), byte::bitstring-size(7)>>, rest} + + defp consume_status_byte(data), do: {:error, data} + + defp consume_data_byte(<<0::integer-size(1), byte::bitstring-size(7), rest::binary>>), + do: {:ok, <<0::integer-size(1), byte::bitstring-size(7)>>, rest} + + defp consume_data_byte(data), do: {:error, data} + + defp drop_leading_data_bytes(data) when is_binary(data) do + data + |> consume_data_byte() + |> drop_leading_data_bytes() + end + + defp drop_leading_data_bytes({:ok, _byte, <<>>}), do: :error + + defp drop_leading_data_bytes({:ok, _byte, data}) do + data + |> consume_data_byte() + |> drop_leading_data_bytes() + end + + defp drop_leading_data_bytes({:error, data}), do: {:ok, data} + + defp consume_data_bytes(bytes, <<>>), do: {:ok, bytes, <<>>} + + defp consume_data_bytes(bytes, data) do + case consume_data_byte(data) do + {:ok, byte, rest} -> consume_data_bytes(bytes <> byte, rest) + {:error, rest} -> {:ok, bytes, rest} + end + end + + defp consume_messages(messages, <<>>), do: {:ok, Enum.reverse(messages), <<>>} + + defp consume_messages(messages, data) when is_binary(data) do + case consume_message(data) do + {:ok, message, rest} -> consume_messages([message | messages], rest) + {:error, rest} -> {:ok, messages, rest} + end + end + + defp consume_message(data) when is_binary(data) do + case consume_status_byte(data) do + {:ok, <<0xF0>>, rest} -> consume_sysex_payload(<<0xF0>>, rest) + {:ok, status_byte, rest} -> consume_data_bytes(status_byte, rest) + {:error, rest} -> {:error, rest} + end + end + + defp consume_sysex_payload(payload, <<0xF7, rest::binary>>), do: {:ok, payload, rest} + defp consume_sysex_payload(payload, <<>>), do: {:ok, <<>>, payload} + + defp consume_sysex_payload(payload, <>), + do: consume_sysex_payload(payload <> byte, rest) +end diff --git a/mix.exs b/mix.exs index ff9d118..008d168 100644 --- a/mix.exs +++ b/mix.exs @@ -1,16 +1,34 @@ defmodule Circuits.UART.Framing.MIDI.MixProject do use Mix.Project + @version "0.1.0" + + @description """ + Implements MIDI framing for Serial ports connected via Circuits.UART. + """ + def project do [ app: :circuits_uart_midi_framing, - version: "0.1.0", + version: @version, elixir: "~> 1.10", + package: package(), + description: @description, start_permanent: Mix.env() == :prod, deps: deps() ] end + def package do + [ + maintainers: ["James Harton "], + licenses: ["Hippocratic"], + links: %{ + "Source" => "https://gitlab.com/jimsy/circuits_uart_midi_framing" + } + ] + end + # Run "mix help compile.app" to learn about applications. def application do [ @@ -21,8 +39,10 @@ defmodule Circuits.UART.Framing.MIDI.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - # {:dep_from_hexpm, "~> 0.3.0"}, - # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + {:circuits_uart, "~> 1.4"}, + {:credo, "~> 1.1", only: [:dev, :test], runtime: false}, + {:earmark, ">= 0.0.0", only: [:dev, :test]}, + {:ex_doc, ">= 0.0.0", only: [:dev, :test]} ] end end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..499871a --- /dev/null +++ b/mix.lock @@ -0,0 +1,12 @@ +%{ + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "circuits_uart": {:hex, :circuits_uart, "1.4.1", "f8151bfb5ac29fe2e791eec00bc6ec298fdb8fa81ddd80bdb0f9f2828f20d7b8", [:mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "8cc3065de6d9b859f76d10fa438e243103eea1ba53b9749e4c63005c6d89780b"}, + "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"}, + "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, + "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, + "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, + "jason": {:hex, :jason, "1.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"}, + "makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49736fe5b66a08d8575bf5321d716bac5da20c8e6b97714fec2bcd6febcfa1f8"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, +} diff --git a/test/circuits/uart/framing/midi/buffer_test.exs b/test/circuits/uart/framing/midi/buffer_test.exs new file mode 100644 index 0000000..e22f8c4 --- /dev/null +++ b/test/circuits/uart/framing/midi/buffer_test.exs @@ -0,0 +1,95 @@ +defmodule Circuits.UART.Framing.MIDI.BufferTest do + use ExUnit.Case, async: true + alias Circuits.UART.Framing.MIDI.Buffer + + @note_off <<0x80, 0x3C, 0x40>> + @note_on <<0x90, 0x3C, 0x40>> + + describe "init/0" do + test "it creates an empty buffer" do + assert %Buffer{buffer: <<>>} = Buffer.init() + end + end + + describe "append/2" do + test "it appends data to the buffer" do + buffer = %Buffer{buffer: <<0, 1>>} + buffer = Buffer.append(buffer, <<2, 3>>) + assert %Buffer{buffer: <<0, 1, 2, 3>>} = buffer + end + end + + describe "get_packets/1" do + test "it can decode a single note-on" do + {:ok, messages, buffer} = + Buffer.init() + |> Buffer.append(@note_on) + |> Buffer.get_packets() + + assert [@note_on] = messages + assert Buffer.empty?(buffer) + end + + test "it can decode a single note-off" do + {:ok, messages, buffer} = + Buffer.init() + |> Buffer.append(@note_off) + |> Buffer.get_packets() + + assert [@note_off] = messages + assert Buffer.empty?(buffer) + end + + test "it drops leading data bytes" do + {:ok, messages, buffer} = + Buffer.init() + |> Buffer.append(<<0, 0, 0>> <> @note_off) + |> Buffer.get_packets() + + assert [@note_off] = messages + assert Buffer.empty?(buffer) + end + + test "it splits multiple messages" do + {:ok, messages, buffer} = + Buffer.init() + |> Buffer.append(@note_on <> @note_off) + |> Buffer.get_packets() + + assert [@note_on, @note_off] = messages + assert Buffer.empty?(buffer) + end + + test "it handles running status" do + {:ok, messages, buffer} = + Buffer.init() + |> Buffer.append(<<0x90, 0x3C, 0x40, 0x3D, 0x40, 0x3E, 0x40>>) + |> Buffer.get_packets() + + assert [<<0x90, 0x3C, 0x40, 0x3D, 0x40, 0x3E, 0x40>>] = messages + assert Buffer.empty?(buffer) + end + + test "it handles multiple messages without data bytes" do + {:ok, messages, buffer} = + Buffer.init() + |> Buffer.append(<<0xFF, 0xFE, 0xF8>>) + |> Buffer.get_packets() + + assert [<<0xFF>>, <<0xFE>>, <<0xF8>>] = messages + assert Buffer.empty?(buffer) + end + + test "it handles arbitrary-length sysex messages" do + message = <<0xF0, 0x7D>> <> Base.encode64("Marty McFly") <> <<0xF7>> + + {:ok, messages, buffer} = + Buffer.init() + |> Buffer.append(message) + |> Buffer.get_packets() + + assert [message] = messages + assert Buffer.empty?(buffer) + end + end +end diff --git a/test/circuits/uart/framing/midi_test.exs b/test/circuits/uart/framing/midi_test.exs index 44d7ec4..396e0fb 100644 --- a/test/circuits/uart/framing/midi_test.exs +++ b/test/circuits/uart/framing/midi_test.exs @@ -1,8 +1,4 @@ defmodule Circuits.UART.Framing.MIDITest do use ExUnit.Case doctest Circuits.UART.Framing.MIDI - - test "greets the world" do - assert Circuits.UART.Framing.MIDI.hello() == :world - end end