diff --git a/.gitignore b/.gitignore index 0adff93..528af8c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,5 @@ erl_crash.dump *.ez # Ignore package tarball (built via "mix hex.build"). -mp3_frame_parser-*.tar +mpeg_audio_frame_parser-*.tar diff --git a/README.md b/README.md index d25a47b..d54f9e3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,27 @@ -# Mp3FrameParser +# MPEGAudioFrameParser -Given a series of binary packets, extract any complete MP3 frames that can be found and return them to clients. +Given a series of binary packets, extract any complete MPEG audio frames that can be found and return them to clients. -Currently WIP. +In theory supports all of MPEG layer 1, 2 and 3 and versions 1, 2 and 2.5, but is most useful for (and has been most thoroughly tested with) layer 3 (MP3) files. + +## Usage + +Currently WIP. See the moduledoc for usage examples. + +## Contributing + +Contributions welcome! Please ensure that appropriate automated tests are included with any pull requests. + +## License + +Copyright 2018 Rob Watson. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/config/config.exs b/config/config.exs index 7e5e625..87b6664 100644 --- a/config/config.exs +++ b/config/config.exs @@ -10,11 +10,11 @@ use Mix.Config # You can configure your application as: # -# config :mp3_frame_parser, key: :value +# config :mpeg_audio_frame_parser, key: :value # # and access this configuration in your application as: # -# Application.get_env(:mp3_frame_parser, :key) +# Application.get_env(:mpeg_audio_frame_parser, :key) # # You can also configure a 3rd-party app: # @@ -28,3 +28,5 @@ use Mix.Config # here (which is why it is important to import them last). # # import_config "#{Mix.env}.exs" + +config :logger, discard_threshold: 20_000, level: :info diff --git a/lib/mp3_frame_parser.ex b/lib/mp3_frame_parser.ex deleted file mode 100644 index a43c105..0000000 --- a/lib/mp3_frame_parser.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule Mp3FrameParser do - @moduledoc """ - Documentation for Mp3FrameParser. - """ - - @doc """ - Hello world. - - ## Examples - - iex> Mp3FrameParser.hello - :world - - """ - def hello do - :world - end -end diff --git a/lib/mpeg_audio_frame_parser.ex b/lib/mpeg_audio_frame_parser.ex new file mode 100644 index 0000000..5f09b0d --- /dev/null +++ b/lib/mpeg_audio_frame_parser.ex @@ -0,0 +1,104 @@ +defmodule MPEGAudioFrameParser do + @moduledoc """ + This is the public API for MPEGAudioFrameParser application. + + MPEGAudioFrameParser is implemented as a GenServer that, when fed consecutive + packets of binary data (for example, from a file or network source), will + parse individual MPEG audio frames from the incoming data. + + No decoding is performed on the audio data. Instead, the resultant frames + are ready to be fed into a separate decoder, or retransmitted over the + network. + """ + + @server MPEGAudioFrameParser.Server + + @doc """ + Start the MPEG audio parser server. This must be done before calling the other + API functions. + + iex> {:ok, pid} = MPEGAudioFrameParser.start_link() + ...> is_pid(pid) + true + """ + def start_link(name \\ @server) do + GenServer.start_link(@server, nil, name: name) + end + + @doc """ + Add raw binary data to the current stream. + + Returns: A list of zero or more structs, each representing a complete MPEG + audio frame. Note that because frames may be split across multiple packets, + this list may be empty, or contain more than one frame on each call. Any + leftover bytes will be stored by the server, and prepended to subsequent + packets. + + ## Example + + Using a faked 128kbps 44.1k stereo MP3 frame. Note at least two headers must + be processed to return a single MP3 frame - otherwise frame completeness + couldn't be assured: + + iex> packet = <<0b11111111111_11_01_0_1001_00_0_0_00_00_0_0_00::size(32), 1::size(3304)>> + ...> {:ok, _pid} = MPEGAudioFrameParser.start_link() + ...> MPEGAudioFrameParser.add_packet(packet) + ...> MPEGAudioFrameParser.add_packet(packet) + ...> |> length + 1 + + """ + def add_packet(packet, name \\ @server) do + GenServer.call(name, {:add_packet, packet}) + end + + @doc """ + Add raw binary data to the current stream. + + Similar to `MPEGAudioFrameParser.add_packet/1`, but does not return the + frames. Instead, they can be retrieved at a later point, or by another + process. + + See `MPEGAudioFrameParser.pop_frame/0`. + """ + def cast_packet(packet, name \\ @server) do + GenServer.cast(name, {:add_packet, packet}) + end + + @doc """ + Pop a single completed frame. + Useful in combination with `MPEGAudioFrameParser.cast_packet/2`. + + Returns a struct representing an individual MPEG audio frame, or `nil` if no + frame is available. + + ## Example + + Using a faked 128kbps 44.1k stereo MP3 frame. Note at least two headers must + be processed to return a single MP3 frame - otherwise frame completeness + couldn't be assured: + + iex> packet = <<0b11111111111_11_01_0_1001_00_0_0_00_00_0_0_00::size(32), 1::size(3304)>> + ...> {:ok, _pid} = MPEGAudioFrameParser.start_link() + ...> MPEGAudioFrameParser.cast_packet(packet) + :ok + ...> MPEGAudioFrameParser.cast_packet(packet) + :ok + ...> frame = MPEGAudioFrameParser.pop_frame() + ...> frame.__struct__ + MPEGAudioFrameParser.Frame + """ + def pop_frame(name \\ @server) do + GenServer.call(name, :pop_frame) + end + + @doc """ + Reset the server's state, returning any available complete frames. Any + additional bytes that are not part of a completed frame are discarded. + + Returns a list containing any available complete audio frames. + """ + def flush(name \\ @server) do + GenServer.call(name, :flush) + end +end diff --git a/lib/mpeg_audio_frame_parser/example.ex b/lib/mpeg_audio_frame_parser/example.ex new file mode 100644 index 0000000..c062790 --- /dev/null +++ b/lib/mpeg_audio_frame_parser/example.ex @@ -0,0 +1,70 @@ +defmodule MPEGAudioFrameParser.Example do + require Logger + + def run(path_to_mp3, block_size \\ 2048) do + # start our test process: + MPEGAudioFrameParser.start_link() + + # open the provided file: + {:ok, file} = File.open(path_to_mp3, [:read, :binary]) + + # and start to read blocks of data: + read_bytes(file, block_size, 0, 0, 0) + end + + defp read_bytes(file, block_size, total_packets, total_frames, total_bytes_processed) do + IO.binread(file, block_size) + |> handle_read(file, block_size, total_packets + 1, total_frames, total_bytes_processed) + end + + defp handle_read(:eof, _file, _block_size, total_packets, total_frames, total_bytes_processed) do + Logger.info( + "End of file detected. Parsed #{total_frames} MP3 frames from #{total_packets} packets of data, total bytes processed #{ + total_bytes_processed + }" + ) + end + + defp handle_read( + {:error, reason}, + _file, + _block_size, + _total_packets, + _total_frames, + _total_bytes_processed + ) do + Logger.error("Error reading file: #{reason}") + exit(:shutdown) + end + + defp handle_read(data, file, block_size, total_packets, total_frames, total_bytes_processed) do + frames = MPEGAudioFrameParser.add_packet(data) + check_frames(frames) + + read_bytes( + file, + block_size, + total_packets, + total_frames + length(frames), + total_byte_size(frames, total_bytes_processed) + ) + end + + defp check_frames([]), do: nil + + defp check_frames(frames) do + [head | tail] = frames + print_frame(head) + check_frames(tail) + end + + defp print_frame(_frame) do + end + + defp total_byte_size([], total), do: total + + defp total_byte_size(list, total) do + [head | tail] = list + total_byte_size(tail, total + byte_size(head.data)) + end +end diff --git a/lib/mpeg_audio_frame_parser/frame.ex b/lib/mpeg_audio_frame_parser/frame.ex new file mode 100644 index 0000000..e274181 --- /dev/null +++ b/lib/mpeg_audio_frame_parser/frame.ex @@ -0,0 +1,208 @@ +defmodule MPEGAudioFrameParser.Frame do + defstruct version_id: nil, + crc_protection: false, + bitrate: nil, + layer: nil, + sample_rate: nil, + padding: 0, + channel_mode: nil, + data: <<>>, + valid: false, + complete: false + + alias MPEGAudioFrameParser.Frame + require Logger + + @sync_word 0b11111111111 + @header_length 32 + + def from_header(header) + when bit_size(header) == @header_length + do + frame = %Frame{data: header} + |> Map.put(:version_id, parse_version(header)) + |> Map.put(:layer, parse_layer(header)) + |> Map.put(:crc_protection, parse_crc_protection(header)) + |> Map.put(:bitrate, parse_bitrate(header)) + |> Map.put(:sample_rate, parse_sample_rate(header)) + |> Map.put(:padding, parse_padding(header)) + + %{frame | valid: header_valid?(frame)} + end + + def header_valid?(%Frame{version_id: version_id, layer: layer, bitrate: bitrate, sample_rate: sample_rate}) + when version_id != :reserved + and layer != :reserved + and bitrate != :bad + and sample_rate != :bad + do + true + end + + def header_valid?(%Frame{}), do: false + + def frame_length(%Frame{bitrate: bitrate, sample_rate: sample_rate} = frame) + when is_integer(bitrate) + and is_integer(sample_rate) + do + bits_per_frame = samples_per_frame(frame) / 8 + (bits_per_frame * (frame.bitrate * 1000) / frame.sample_rate + frame.padding) + |> trunc + end + + def frame_length(%Frame{}), do: 0 + + def add_bytes(frame, packet) do + limit = bytes_missing(frame) + {:ok, bytes, rest, complete} = split_packet(packet, limit) + {:ok, %{frame | data: frame.data <> bytes, complete: complete}, rest} + end + + def bytes_missing(frame) do + (frame_length(frame) - byte_size(frame.data)) + |> max(0) + end + + # Private Functions + + defp split_packet(packet, limit) do + bytes_available = byte_size(packet) + bytes_to_take = min(bytes_available, limit) + bytes_to_leave = bytes_available - bytes_to_take + + part1 = :binary.part(packet, {0, bytes_to_take}) + part2 = :binary.part(packet, {bytes_available, -bytes_to_leave}) + + {:ok, part1, part2, bytes_to_take == limit} + end + + defp parse_version(<<@sync_word::size(11), bits::size(2), _::bits>>), do: version_atom(bits) + + defp version_atom(0b11), do: :version1 + defp version_atom(0b10), do: :version2 + defp version_atom(0b00), do: :"version2.5" + defp version_atom(0b01), do: :reserved + + defp parse_layer(<<@sync_word::size(11), _::size(2), bits::size(2), _::bits>>), do: layer_atom(bits) + + defp layer_atom(0b11), do: :layer1 + defp layer_atom(0b10), do: :layer2 + defp layer_atom(0b01), do: :layer3 + defp layer_atom(0b00), do: :reserved + + defp parse_crc_protection(<<@sync_word::size(11), _::size(4), 0b0::size(1), _::bits>>), do: false + defp parse_crc_protection(<<@sync_word::size(11), _::size(4), 0b1::size(1), _::bits>>), do: true + + defp parse_bitrate(<<@sync_word::size(11), version_bits::size(2), layer_bits::size(2), _::size(1), bitrate_bits::size(4), _::bits>>) do + version_atom = version_atom(version_bits) + layer_atom = layer_atom(layer_bits) + + case {version_atom, layer_atom, bitrate_bits} do + # V1, L1 + {:version1, :layer1, 0b0001} -> 32 + {:version1, :layer1, 0b0010} -> 64 + {:version1, :layer1, 0b0011} -> 96 + {:version1, :layer1, 0b0100} -> 128 + {:version1, :layer1, 0b0101} -> 160 + {:version1, :layer1, 0b0110} -> 192 + {:version1, :layer1, 0b0111} -> 224 + {:version1, :layer1, 0b1000} -> 256 + {:version1, :layer1, 0b1001} -> 288 + {:version1, :layer1, 0b1010} -> 320 + {:version1, :layer1, 0b1011} -> 352 + {:version1, :layer1, 0b1100} -> 384 + {:version1, :layer1, 0b1101} -> 416 + {:version1, :layer1, 0b1110} -> 448 + + # V1, L2 + {:version1, :layer2, 0b0001} -> 32 + {:version1, :layer2, 0b0010} -> 48 + {:version1, :layer2, 0b0011} -> 56 + {:version1, :layer2, 0b0100} -> 64 + {:version1, :layer2, 0b0101} -> 80 + {:version1, :layer2, 0b0110} -> 96 + {:version1, :layer2, 0b0111} -> 112 + {:version1, :layer2, 0b1000} -> 128 + {:version1, :layer2, 0b1001} -> 160 + {:version1, :layer2, 0b1010} -> 192 + {:version1, :layer2, 0b1011} -> 224 + {:version1, :layer2, 0b1100} -> 256 + {:version1, :layer2, 0b1101} -> 320 + {:version1, :layer2, 0b1110} -> 384 + + # V1, L3 + {:version1, :layer3, 0b0001} -> 32 + {:version1, :layer3, 0b0010} -> 40 + {:version1, :layer3, 0b0011} -> 48 + {:version1, :layer3, 0b0100} -> 56 + {:version1, :layer3, 0b0101} -> 64 + {:version1, :layer3, 0b0110} -> 80 + {:version1, :layer3, 0b0111} -> 96 + {:version1, :layer3, 0b1000} -> 112 + {:version1, :layer3, 0b1001} -> 128 + {:version1, :layer3, 0b1010} -> 160 + {:version1, :layer3, 0b1011} -> 192 + {:version1, :layer3, 0b1100} -> 224 + {:version1, :layer3, 0b1101} -> 256 + {:version1, :layer3, 0b1110} -> 320 + + # V2, L1 + {version, :layer1, 0b0001} when version in [:version2, :"version2.5"] -> 32 + {version, :layer1, 0b0010} when version in [:version2, :"version2.5"] -> 48 + {version, :layer1, 0b0011} when version in [:version2, :"version2.5"] -> 56 + {version, :layer1, 0b0100} when version in [:version2, :"version2.5"] -> 64 + {version, :layer1, 0b0101} when version in [:version2, :"version2.5"] -> 80 + {version, :layer1, 0b0110} when version in [:version2, :"version2.5"] -> 96 + {version, :layer1, 0b0111} when version in [:version2, :"version2.5"] -> 112 + {version, :layer1, 0b1000} when version in [:version2, :"version2.5"] -> 128 + {version, :layer1, 0b1001} when version in [:version2, :"version2.5"] -> 144 + {version, :layer1, 0b1010} when version in [:version2, :"version2.5"] -> 160 + {version, :layer1, 0b1011} when version in [:version2, :"version2.5"] -> 176 + {version, :layer1, 0b1100} when version in [:version2, :"version2.5"] -> 192 + {version, :layer1, 0b1101} when version in [:version2, :"version2.5"] -> 224 + {version, :layer1, 0b1110} when version in [:version2, :"version2.5"] -> 256 + + # V2, L2/L3 + {version, _, 0b0001} when version in [:version2, :"version2.5"] -> 8 + {version, _, 0b0010} when version in [:version2, :"version2.5"] -> 16 + {version, _, 0b0011} when version in [:version2, :"version2.5"] -> 24 + {version, _, 0b0100} when version in [:version2, :"version2.5"] -> 32 + {version, _, 0b0101} when version in [:version2, :"version2.5"] -> 40 + {version, _, 0b0110} when version in [:version2, :"version2.5"] -> 48 + {version, _, 0b0111} when version in [:version2, :"version2.5"] -> 56 + {version, _, 0b1000} when version in [:version2, :"version2.5"] -> 64 + {version, _, 0b1001} when version in [:version2, :"version2.5"] -> 80 + {version, _, 0b1010} when version in [:version2, :"version2.5"] -> 96 + {version, _, 0b1011} when version in [:version2, :"version2.5"] -> 112 + {version, _, 0b1100} when version in [:version2, :"version2.5"] -> 128 + {version, _, 0b1101} when version in [:version2, :"version2.5"] -> 144 + {version, _, 0b1110} when version in [:version2, :"version2.5"] -> 160 + + _ -> :bad + end + end + + defp parse_sample_rate(<<@sync_word::size(11), version_bits::size(2), _::size(7), sample_rate_bits::size(2), _::bits>>) do + case {version_bits, sample_rate_bits} do + {0b11, 0b00} -> 44100 + {0b11, 0b01} -> 48000 + {0b11, 0b10} -> 32000 + {0b10, 0b00} -> 22050 + {0b10, 0b01} -> 24000 + {0b10, 0b10} -> 16000 + {0b00, 0b00} -> 11025 + {0b00, 0b01} -> 12000 + {0b00, 0b10} -> 8000 + _ -> :bad + end + end + + defp parse_padding(<<@sync_word::size(11), _::size(11), 0b0::size(1), _::bits>>), do: 0 + defp parse_padding(<<@sync_word::size(11), _::size(11), 0b1::size(1), _::bits>>), do: 1 + + defp samples_per_frame(%Frame{layer: :layer1}), do: 384 + defp samples_per_frame(%Frame{layer: :layer2}), do: 1152 + defp samples_per_frame(%Frame{layer: :layer3, version_id: :version1}), do: 1152 + defp samples_per_frame(%Frame{layer: :layer3, version_id: _}), do: 576 + defp samples_per_frame(%Frame{}), do: 0 +end diff --git a/lib/mpeg_audio_frame_parser/impl.ex b/lib/mpeg_audio_frame_parser/impl.ex new file mode 100644 index 0000000..7e9272b --- /dev/null +++ b/lib/mpeg_audio_frame_parser/impl.ex @@ -0,0 +1,85 @@ +defmodule MPEGAudioFrameParser.Impl do + alias MPEGAudioFrameParser.Frame + require Logger + + @sync_word 0b11111111111 + @initial_state %{leftover: <<>>, current_frame: nil, frames: []} + + def init() do + {:ok, @initial_state} + end + + def add_packet(state, packet) do + process_bytes(state, packet) + end + + def pop_frame(%{frames: []} = state) do + {:ok, nil, state} + end + + def pop_frame(state) do + {frame, rest} = List.pop_at(state.frames, -1) + {:ok, frame, %{state | frames: rest}} + end + + def flush(state) do + {:ok, state.frames, @initial_state} + end + + # Private Functions + + # No data left, or not enough to be able to validate next frame. Return: + defp process_bytes(state, packet) + when bit_size(packet) < 32 + do + {:ok, %{state | leftover: packet}} + end + + # Leftover from previous call available. Prepend to this packet: + defp process_bytes(%{leftover: leftover} = state, packet) + when bit_size(leftover) > 0 + do + process_bytes(%{state | leftover: <<>>}, <>) + end + + # Not synced, found a sync word. Create a new frame struct: + defp process_bytes(%{current_frame: nil} = state, <<@sync_word::size(11), header::size(21), rest::bits>>) do + header = <<@sync_word::size(11), header::size(21)>> + frame = Frame.from_header(header) + process_bytes(%{state | current_frame: frame}, rest) + end + + # Not synced, no sync word found. Discard a byte: + defp process_bytes(%{current_frame: nil} = state, packet) do + <<_byte, rest::bits>> = packet + process_bytes(state, rest) + end + + # Synced, but with an invalid header. Discard a byte: + defp process_bytes(%{current_frame: %Frame{valid: false}} = state, packet) do + data = <> + <<_byte, rest::bits>> = data + process_bytes(%{state | current_frame: nil}, rest) + end + + # Synced, frame complete, and looks like another sync word. Create a new frame struct: + defp process_bytes(%{current_frame: %Frame{complete: true}} = state, <<@sync_word::size(11), header::size(21), rest::bits>>) do + header = <<@sync_word::size(11), header::size(21)>> + frames = [state.current_frame | state.frames] + new_state = %{state | current_frame: Frame.from_header(header), frames: frames} + process_bytes(new_state, rest) + end + + # Synced, frame complete, but next frame looks bad. Prepend, discard a byte and unsync: + defp process_bytes(%{current_frame: %Frame{complete: true}} = state, packet) do + Logger.warn "Lost sync. Discarding a byte and searching again for a sync word." + <<_byte, rest>> = state.current_frame.data + process_bytes(%{state | current_frame: nil}, <>) + end + + # Synced, current frame not complete and we have bytes available. Add bytes to frame: + defp process_bytes(%{current_frame: %Frame{complete: false}} = state, packet) do + {:ok, frame, rest} = Frame.add_bytes(state.current_frame, packet) + process_bytes(%{state | current_frame: frame}, rest) + end +end diff --git a/lib/mpeg_audio_frame_parser/server.ex b/lib/mpeg_audio_frame_parser/server.ex new file mode 100644 index 0000000..1915a96 --- /dev/null +++ b/lib/mpeg_audio_frame_parser/server.ex @@ -0,0 +1,27 @@ +defmodule MPEGAudioFrameParser.Server do + use GenServer + alias MPEGAudioFrameParser.Impl + require IEx + + def init(_args), do: Impl.init() + + def handle_call({:add_packet, packet}, _from, state) do + {:ok, state} = Impl.add_packet(state, packet) + {:reply, state.frames, %{state | frames: []}} + end + + def handle_call(:pop_frame, _from, state) do + {:ok, frame, new_state} = Impl.pop_frame(state) + {:reply, frame, new_state} + end + + def handle_call(:flush, _from, state) do + {:ok, frames, new_state} = Impl.flush(state) + {:reply, frames, new_state} + end + + def handle_cast({:add_packet, packet}, state) do + {:ok, state} = Impl.add_packet(state, packet) + {:noreply, state} + end +end diff --git a/mix.exs b/mix.exs index 7ff8d5c..ca1457b 100644 --- a/mix.exs +++ b/mix.exs @@ -1,9 +1,9 @@ -defmodule Mp3FrameParser.MixProject do +defmodule MPEGAudioFrameParser.MixProject do use Mix.Project def project do [ - app: :mp3_frame_parser, + app: :mpeg_audio_frame_parser, version: "0.1.0", elixir: "~> 1.6", start_permanent: Mix.env() == :prod, diff --git a/test/fixtures/test_128_44100.mp3 b/test/fixtures/test_128_44100.mp3 new file mode 100644 index 0000000..672d0a1 Binary files /dev/null and b/test/fixtures/test_128_44100.mp3 differ diff --git a/test/fixtures/test_128_44100_crc_protection.mp3 b/test/fixtures/test_128_44100_crc_protection.mp3 new file mode 100644 index 0000000..9db9001 Binary files /dev/null and b/test/fixtures/test_128_44100_crc_protection.mp3 differ diff --git a/test/fixtures/test_160_24000.mp3 b/test/fixtures/test_160_24000.mp3 new file mode 100644 index 0000000..6449424 Binary files /dev/null and b/test/fixtures/test_160_24000.mp3 differ diff --git a/test/fixtures/test_64_12000.mp3 b/test/fixtures/test_64_12000.mp3 new file mode 100644 index 0000000..7bcdcc5 Binary files /dev/null and b/test/fixtures/test_64_12000.mp3 differ diff --git a/test/fixtures/test_64_22050.mp2 b/test/fixtures/test_64_22050.mp2 new file mode 100644 index 0000000..d2596f5 Binary files /dev/null and b/test/fixtures/test_64_22050.mp2 differ diff --git a/test/integration_test.exs b/test/integration_test.exs new file mode 100644 index 0000000..9d66bb5 --- /dev/null +++ b/test/integration_test.exs @@ -0,0 +1,49 @@ +defmodule MPEGAudioFrameParserIntegrationTest do + use ExUnit.Case + + test "128kbps 44100hz MP3" do + MPEGAudioFrameParser.start_link() + assert count_frames("test/fixtures/test_128_44100.mp3") == 253 + end + + test "64kbps 12000hz MP3" do + MPEGAudioFrameParser.start_link() + assert count_frames("test/fixtures/test_64_12000.mp3") == 139 + end + + test "160kbps 24000hz MP3" do + MPEGAudioFrameParser.start_link() + assert count_frames("test/fixtures/test_160_24000.mp3") == 276 + end + + test "128kbps 44100hz MP3 with CRC protection" do + MPEGAudioFrameParser.start_link() + assert count_frames("test/fixtures/test_128_44100_crc_protection.mp3") == 253 + end + + test "64kbps 22050hz MP2" do + MPEGAudioFrameParser.start_link() + assert count_frames("test/fixtures/test_64_22050.mp2") == 125 + end + + defp count_frames(path) do + File.cwd!() + |> Path.join(path) + |> File.open!() + |> read_bytes(4096, 0) + end + + defp read_bytes(file, block_size, total_frames) do + IO.binread(file, block_size) + |> handle_read(file, block_size, total_frames) + end + + defp handle_read(:eof, _file, _block_size, total_frames) do + total_frames + end + + defp handle_read(data, file, block_size, total_frames) do + new_frames = MPEGAudioFrameParser.add_packet(data) + read_bytes(file, block_size, total_frames + length(new_frames)) + end +end diff --git a/test/mp3_frame_parser_test.exs b/test/mp3_frame_parser_test.exs deleted file mode 100644 index a67b30b..0000000 --- a/test/mp3_frame_parser_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule Mp3FrameParserTest do - use ExUnit.Case - doctest Mp3FrameParser - - test "greets the world" do - assert Mp3FrameParser.hello() == :world - end -end diff --git a/test/mpeg_audio_frame_parser/frame_test.exs b/test/mpeg_audio_frame_parser/frame_test.exs new file mode 100644 index 0000000..f5dfe21 --- /dev/null +++ b/test/mpeg_audio_frame_parser/frame_test.exs @@ -0,0 +1,104 @@ +defmodule MPEGAudioFrameParser.FrameTest do + use ExUnit.Case + alias MPEGAudioFrameParser.Frame + import Frame, only: [from_header: 1, header_valid?: 1, frame_length: 1, bytes_missing: 1] + + # MP3, 128kbps, no CRC protection, 44100hz, no padding, stereo + @header1 <<0b11111111111_11_01_0_1001_00_0_0_00_00_0_0_00::size(32)>> + + # MP3, 256kbps, no CRC protection, 48000hz, no padding, stereo + @header2 <<0b11111111111_11_01_0_1101_01_0_0_00_00_0_0_00::size(32)>> + + # MP3, 32kbps, no CRC protection, 22050hz, padding, stereo + @header3 <<0b11111111111_10_01_0_0100_00_1_0_00_00_0_0_00::size(32)>> + + # MP2, 256kbps, no CRC protection, 44100hz, no padding, stereo + @header4 <<0b11111111111_11_10_0_1100_00_0_0_00_00_0_0_00::size(32)>> + + # MP1, 192kbps, no CRC protection, 44100hz, no padding, stereo + @header5 <<0b11111111111_11_11_0_0110_00_0_0_00_00_0_0_00::size(32)>> + + # MP3, 40kbps, CRC protection, 8000hz, no padding, stereo + @header6 <<0b11111111111_00_01_1_0101_10_0_0_00_00_0_0_00::size(32)>> + + # Invalid header, reserved version bit set + @header7 <<0b11111111111_01_01_0_1001_00_0_0_00_00_0_0_00::size(32)>> + + test "parsing version ID" do + assert from_header(@header1).version_id == :version1 + assert from_header(@header2).version_id == :version1 + assert from_header(@header3).version_id == :version2 + assert from_header(@header4).version_id == :version1 + assert from_header(@header5).version_id == :version1 + assert from_header(@header6).version_id == :"version2.5" + assert from_header(@header7).version_id == :reserved + end + + test "parsing layer description" do + assert from_header(@header1).layer == :layer3 + assert from_header(@header2).layer == :layer3 + assert from_header(@header3).layer == :layer3 + assert from_header(@header4).layer == :layer2 + assert from_header(@header5).layer == :layer1 + assert from_header(@header6).layer == :layer3 + end + + test "parsing CRC protection bit" do + refute from_header(@header1).crc_protection + refute from_header(@header2).crc_protection + refute from_header(@header3).crc_protection + refute from_header(@header4).crc_protection + refute from_header(@header5).crc_protection + assert from_header(@header6).crc_protection + end + + test "parsing bitrate" do + assert from_header(@header1).bitrate == 128 + assert from_header(@header2).bitrate == 256 + assert from_header(@header3).bitrate == 32 + assert from_header(@header4).bitrate == 256 + assert from_header(@header5).bitrate == 192 + assert from_header(@header6).bitrate == 40 + end + + test "parsing sample rate" do + assert from_header(@header1).sample_rate == 44100 + assert from_header(@header2).sample_rate == 48000 + assert from_header(@header3).sample_rate == 22050 + assert from_header(@header4).sample_rate == 44100 + assert from_header(@header5).sample_rate == 44100 + assert from_header(@header6).sample_rate == 8000 + end + + test "parsing padding" do + assert from_header(@header1).padding == 0 + assert from_header(@header2).padding == 0 + assert from_header(@header3).padding == 1 + assert from_header(@header4).padding == 0 + assert from_header(@header5).padding == 0 + assert from_header(@header6).padding == 0 + end + + test "header validity" do + assert from_header(@header1) |> header_valid? + assert from_header(@header2) |> header_valid? + assert from_header(@header3) |> header_valid? + assert from_header(@header4) |> header_valid? + assert from_header(@header5) |> header_valid? + assert from_header(@header6) |> header_valid? + refute from_header(@header7) |> header_valid? + end + + test "frame_length" do + assert from_header(@header1) |> frame_length == 417 + assert from_header(@header2) |> frame_length == 768 + assert from_header(@header3) |> frame_length == 105 + assert from_header(@header4) |> frame_length == 835 + assert from_header(@header5) |> frame_length == 208 + assert from_header(@header6) |> frame_length == 360 + end + + test "bytes missing" do + assert from_header(@header1) |> bytes_missing == 413 + end +end diff --git a/test/mpeg_audio_frame_parser/impl_test.exs b/test/mpeg_audio_frame_parser/impl_test.exs new file mode 100644 index 0000000..60d4180 --- /dev/null +++ b/test/mpeg_audio_frame_parser/impl_test.exs @@ -0,0 +1,129 @@ +defmodule MPEGAudioFrameParser.ImplTest do + use ExUnit.Case + + # MP3, 128kbps, no CRC protection, 44100hz, no padding, stereo + @frame1 <<0b11111111111_11_01_0_1001_00_0_0_00_00_0_0_00::size(32), 1::size(3304)>> + @frame2 <<0b11111111111_11_01_0_1001_00_0_0_00_00_0_0_00::size(32), 0::size(3304)>> + + # MP3, 256kbps, no CRC protection, 48000hz, no padding, stereo + @frame3 <<0b11111111111_11_01_0_1101_01_0_0_00_00_0_0_00::size(32), 1::size(6112)>> + + describe "add_packet" do + import MPEGAudioFrameParser.Impl, only: [init: 0, add_packet: 2] + + test "handles a single frame at the start of a packet" do + {:ok, state} = init() + + {:ok, state} = add_packet(state, @frame1) + + assert state.current_frame.data == @frame1 + assert state.current_frame.complete + assert state.frames == [] + end + + test "handles a single frame in the middle of a packet" do + {:ok, state} = init() + + packet = <<0, 1, 2, 3, @frame1::binary>> + {:ok, state} = add_packet(state, packet) + + assert state.current_frame.data == @frame1 + assert state.current_frame.complete + assert state.frames == [] + end + + test "ignores a packet that includes no valid frames at all" do + {:ok, state} = init() + + {:ok, state} = add_packet(state, <<1::size(10240)>>) + + assert state.frames == [] + assert state.current_frame == nil + end + + test "handles two frames in consecutive packets" do + {:ok, state} = init() + + {:ok, state} = add_packet(state, @frame1) + {:ok, state} = add_packet(state, @frame3) + + assert length(state.frames) == 1 + assert List.first(state.frames).data == @frame1 + assert state.current_frame.data == @frame3 + end + + test "handles a frame split unevenly across consecutive packets" do + {:ok, state} = init() + + part1 = :binary.part(@frame1, {0, 256}) + part2 = :binary.part(@frame1, {byte_size(@frame1), -(byte_size(@frame1) - 256)}) + part3 = :binary.part(@frame3, {0, 256}) + + packet = <<8, 0, 1, 0, 0, 0, 7, 90, 93, part1::binary>> + {:ok, state} = add_packet(state, packet) + + packet = <> + {:ok, state} = add_packet(state, packet) + + assert length(state.frames) == 1 + assert List.first(state.frames).data == @frame1 + end + + test "handles three frames in a single packet" do + {:ok, state} = init() + + {:ok, state} = add_packet(state, <<@frame1::binary, @frame1::binary, @frame1::binary>>) + + assert length(state.frames) == 2 + assert Enum.map(state.frames, & &1.data) == [@frame1, @frame1] + end + + test "handles three frames in consecutive packets" do + {:ok, state} = init() + + {:ok, state} = add_packet(state, @frame3) + {:ok, state} = add_packet(state, @frame3) + {:ok, state} = add_packet(state, @frame3) + + assert length(state.frames) == 2 + assert Enum.map(state.frames, & &1.data) == [@frame3, @frame3] + assert state.current_frame.data == @frame3 + end + end + + describe "pop_frame" do + import MPEGAudioFrameParser.Impl, only: [init: 0, add_packet: 2, pop_frame: 1] + + test "returns nil when there are no frames available" do + {:ok, state} = init() + + assert pop_frame(state) == {:ok, nil, state} + end + + test "returns a single frame" do + {:ok, state} = init() + + {:ok, state} = add_packet(state, @frame1) + {:ok, state} = add_packet(state, @frame1) + + {:ok, frame, state} = pop_frame(state) + + assert frame.data == @frame1 + assert state.frames == [] + end + + test "returns multiple frames in the correct order" do + {:ok, state} = init() + + {:ok, state} = add_packet(state, @frame1) + {:ok, state} = add_packet(state, @frame2) + {:ok, state} = add_packet(state, @frame1) + + {:ok, frame, state} = pop_frame(state) + assert frame.data == @frame1 + + {:ok, frame, _state} = pop_frame(state) + assert frame.data == @frame2 + end + end +end diff --git a/test/mpeg_audio_frame_parser_test.exs b/test/mpeg_audio_frame_parser_test.exs new file mode 100644 index 0000000..58251d3 --- /dev/null +++ b/test/mpeg_audio_frame_parser_test.exs @@ -0,0 +1,45 @@ +defmodule MPEGAudioFrameParserTest do + use ExUnit.Case + alias MPEGAudioFrameParser.Frame + doctest MPEGAudioFrameParser + + # MP3, 128kbps, no CRC protection, 44100hz, no padding, stereo + @frame1 <<0b11111111111_11_01_0_1001_00_0_0_00_00_0_0_00::size(32), 1::size(3304)>> + @frame2 <<0b11111111111_11_01_0_1001_00_0_0_00_00_0_0_00::size(32), 0::size(3304)>> + + test "start_link" do + MPEGAudioFrameParser.start_link() + end + + test "add_packet" do + MPEGAudioFrameParser.start_link() + MPEGAudioFrameParser.add_packet(@frame1) + result = MPEGAudioFrameParser.add_packet(@frame2) + + assert [%Frame{data: @frame1}] = result + end + + test "cast_packet" do + MPEGAudioFrameParser.start_link() + MPEGAudioFrameParser.cast_packet(@frame1) + MPEGAudioFrameParser.cast_packet(@frame2) + end + + test "pop_frame" do + MPEGAudioFrameParser.start_link() + MPEGAudioFrameParser.cast_packet(@frame1) + MPEGAudioFrameParser.cast_packet(@frame2) + + assert %Frame{data: @frame1} = MPEGAudioFrameParser.pop_frame() + assert nil == MPEGAudioFrameParser.pop_frame() + end + + test "flush" do + MPEGAudioFrameParser.start_link() + MPEGAudioFrameParser.cast_packet(@frame1) + MPEGAudioFrameParser.cast_packet(@frame2) + MPEGAudioFrameParser.flush() + + assert nil == MPEGAudioFrameParser.pop_frame() + end +end