Add initial implementation

This commit is contained in:
Rob Watson 2018-04-17 11:42:54 +02:00
parent 695fc758a8
commit d29c69c753
20 changed files with 853 additions and 34 deletions

2
.gitignore vendored
View File

@ -20,5 +20,5 @@ erl_crash.dump
*.ez *.ez
# Ignore package tarball (built via "mix hex.build"). # Ignore package tarball (built via "mix hex.build").
mp3_frame_parser-*.tar mpeg_audio_frame_parser-*.tar

View File

@ -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.

View File

@ -10,11 +10,11 @@ use Mix.Config
# You can configure your application as: # 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: # 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: # 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). # here (which is why it is important to import them last).
# #
# import_config "#{Mix.env}.exs" # import_config "#{Mix.env}.exs"
config :logger, discard_threshold: 20_000, level: :info

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: <<>>}, <<leftover::bits, packet::bits>>)
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 = <<state.current_frame.data, packet::bits>>
<<_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}, <<rest, packet::bits>>)
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

View File

@ -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

View File

@ -1,9 +1,9 @@
defmodule Mp3FrameParser.MixProject do defmodule MPEGAudioFrameParser.MixProject do
use Mix.Project use Mix.Project
def project do def project do
[ [
app: :mp3_frame_parser, app: :mpeg_audio_frame_parser,
version: "0.1.0", version: "0.1.0",
elixir: "~> 1.6", elixir: "~> 1.6",
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,

BIN
test/fixtures/test_128_44100.mp3 vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
test/fixtures/test_160_24000.mp3 vendored Normal file

Binary file not shown.

BIN
test/fixtures/test_64_12000.mp3 vendored Normal file

Binary file not shown.

BIN
test/fixtures/test_64_22050.mp2 vendored Normal file

Binary file not shown.

49
test/integration_test.exs Normal file
View File

@ -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

View File

@ -1,8 +0,0 @@
defmodule Mp3FrameParserTest do
use ExUnit.Case
doctest Mp3FrameParser
test "greets the world" do
assert Mp3FrameParser.hello() == :world
end
end

View File

@ -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

View File

@ -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 = <<part2::binary, part3::binary>>
{: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

View File

@ -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