diff --git a/examples/send_from_file/example.exs b/examples/send_from_file/example.exs index 32eb7f5f..291cf3c8 100644 --- a/examples/send_from_file/example.exs +++ b/examples/send_from_file/example.exs @@ -161,7 +161,7 @@ defmodule Peer do Process.send_after(self(), :send_audio_packet, duration) # values set to 0 are handled by PeerConnection.set_rtp rtp_packet = OpusPayloader.payload(packet) - rtp_packet = %{rtp_packet | timestamp: state.last_audio_timestamp} + rtp_packet = %{rtp_packet | timestamp: trunc(state.last_audio_timestamp)} PeerConnection.send_rtp(state.peer_connection, state.audio_track_id, rtp_packet) # OggReader.next_packet/1 returns duration in ms diff --git a/lib/ex_webrtc/media/ogg/header.ex b/lib/ex_webrtc/media/ogg/header.ex new file mode 100644 index 00000000..bdab1487 --- /dev/null +++ b/lib/ex_webrtc/media/ogg/header.ex @@ -0,0 +1,47 @@ +defmodule ExWebRTC.Media.Ogg.Header do + @moduledoc false + # based on RFC 7845, sec. 5 + + @id_signature "OpusHead" + @comment_signature "OpusTags" + + @vendor "elixir-webrtc" + + @default_preskip 3840 + @default_gain 0 + # mono or stereo + @channel_mapping 0 + + # for now, we ignore the Ogg/Opus header when decoding + @spec decode_id(binary()) :: :ok | {:error, term()} + def decode_id(<<@id_signature, _rest::binary>>), do: :ok + def decode_id(_packet), do: {:error, :invalid_id_header} + + @spec decode_id(binary()) :: :ok | {:error, term()} + def decode_comment(<<@comment_signature, _rest::binary>>), do: :ok + def decode_commend(_packet), do: {:error, :invalid_comment_header} + + @spec create_id(non_neg_integer(), non_neg_integer()) :: binary() + def create_id(sample_rate, channel_count) do + << + @id_signature, + 1, + channel_count, + @default_preskip::little-16, + sample_rate::little-32, + @default_gain::little-16, + @channel_mapping + >> + end + + @spec create_comment() :: binary() + def create_comment do + << + @comment_signature, + byte_size(@vendor)::little-32, + @vendor, + # no additional user comments + 0 + >> + end +end diff --git a/lib/ex_webrtc/media/ogg/page.ex b/lib/ex_webrtc/media/ogg/page.ex new file mode 100644 index 00000000..078d2c4b --- /dev/null +++ b/lib/ex_webrtc/media/ogg/page.ex @@ -0,0 +1,162 @@ +defmodule ExWebRTC.Media.Ogg.Page do + @moduledoc false + # see RFC 3553, sec. 6 for description of the Ogg Page + + import Bitwise + + @crc_params %{ + extend: :crc_32, + poly: 0x04C11DB7, + init: 0x0, + xorout: 0x0, + refin: false, + refout: false + } + + @signature "OggS" + @version 0 + + @type t() :: %__MODULE__{ + continued?: boolean(), + first?: boolean(), + last?: boolean(), + granule_pos: non_neg_integer(), + serial_no: non_neg_integer(), + sequence_no: non_neg_integer(), + packets: [binary()], + rest: binary() + } + + @enforce_keys [:granule_pos, :serial_no, :sequence_no] + defstruct @enforce_keys ++ + [ + continued?: false, + first?: false, + last?: false, + packets: [], + rest: <<>> + ] + + @spec read(File.io_device()) :: {:ok, t()} | {:error, term()} + def read(file) do + with <<@signature, @version, type, granule_pos::little-64, serial_no::little-32, + sequence_no::little-32, _checksum::little-32, + segment_no>> = header <- IO.binread(file, 27), + raw_segment_table when is_binary(raw_segment_table) <- IO.binread(file, segment_no), + segment_table <- :binary.bin_to_list(raw_segment_table), + payload_length <- Enum.sum(segment_table), + payload when is_binary(payload) <- IO.binread(file, payload_length), + :ok <- verify_checksum(header <> raw_segment_table <> payload) do + {packets, rest} = split_packets(segment_table, payload) + + page = %__MODULE__{ + continued?: (type &&& 0x01) != 0, + first?: (type &&& 0x02) != 0, + last?: (type &&& 0x04) != 0, + granule_pos: granule_pos, + serial_no: serial_no, + sequence_no: sequence_no, + packets: packets, + rest: rest + } + + {:ok, page} + else + data when is_binary(data) -> {:error, :invalid_page_header} + :eof -> :eof + {:error, _res} = err -> err + end + end + + @spec write(File.io_device(), t()) :: :ok | {:error, term()} + def write(file, %__MODULE__{} = page) do + with {:ok, segment_table} <- create_segment_table(page.packets, page.rest) do + continued = if page.continued?, do: 0x01, else: 0 + first = if page.first?, do: 0x02, else: 0 + last = if page.last?, do: 0x04, else: 0 + type = first ||| continued ||| last + + before_crc = << + @signature, + @version, + type, + page.granule_pos::little-64, + page.serial_no::little-32, + page.sequence_no::little-32 + >> + + after_crc = + <> <> + :binary.list_to_bin(segment_table) <> + :binary.list_to_bin(page.packets) <> + page.rest + + checksum = CRC.calculate(<>, @crc_params) + packet = <> + + IO.binwrite(file, packet) + end + end + + defp verify_checksum(<>) do + actual_checksum = + <> + |> CRC.calculate(@crc_params) + + if checksum == actual_checksum do + :ok + else + {:error, :invalid_checksum} + end + end + + defp split_packets(segment_table, payload, packets \\ [], packet \\ <<>>) + defp split_packets([], <<>>, packets, packet), do: {Enum.reverse(packets), packet} + + defp split_packets([segment_len | segment_table], payload, packets, packet) do + <> = payload + packet = packet <> segment + + case segment_len do + 255 -> split_packets(segment_table, rest, packets, packet) + _len -> split_packets(segment_table, rest, [packet | packets], <<>>) + end + end + + defp create_segment_table(packets, rest) when rem(byte_size(rest), 255) == 0 do + # normally packet of length that is a multiple of 255 would end with 0-lenght segment + # for the rest (split packet) we don't want that + rest_segments = + case segment_packet(rest) do + [0 | segments] -> segments + [] -> [] + end + + segment_table = + packets + |> Enum.reduce([], fn packet, segments -> + segment_packet(packet) ++ segments + end) + |> then(&Enum.concat(rest_segments, &1)) + |> Enum.reverse() + + if length(segment_table) > 255 do + {:error, :too_many_segments} + else + {:ok, segment_table} + end + end + + defp create_segment_table(_packets, _rest), do: {:error, :rest_too_short} + + # returned segment table for the packet is reversed + # thus the Enum.reverse/1 call in create_segment_table/2 + defp segment_packet(packet, acc \\ []) + defp segment_packet(<<>>, [255 | _rest] = acc), do: [0 | acc] + defp segment_packet(<<>>, acc), do: acc + + defp segment_packet(<<_seg::binary-255, rest::binary>>, acc), + do: segment_packet(rest, [255 | acc]) + + defp segment_packet(packet, acc) when is_binary(packet), do: [byte_size(packet) | acc] +end diff --git a/lib/ex_webrtc/media/ogg_reader.ex b/lib/ex_webrtc/media/ogg_reader.ex index 6ea4d9d8..810868f8 100644 --- a/lib/ex_webrtc/media/ogg_reader.ex +++ b/lib/ex_webrtc/media/ogg_reader.ex @@ -1,6 +1,6 @@ defmodule ExWebRTC.Media.OggReader do @moduledoc """ - Defines Ogg reader. + Reads Opus packets from an Ogg container file. For now, works only with single Opus stream in the container. @@ -10,21 +10,8 @@ defmodule ExWebRTC.Media.OggReader do * [RFC 6716: Definition of the Opus Audio Codec](https://www.rfc-editor.org/rfc/rfc6716.txt) """ - import Bitwise - - @crc_params %{ - extend: :crc_32, - poly: 0x04C11DB7, - init: 0x0, - xorout: 0x0, - refin: false, - refout: false - } - - @signature "OggS" - @id_signature "OpusHead" - @comment_signature "OpusTags" - @version 0 + alias ExWebRTC.Media.Ogg.{Header, Page} + alias ExWebRTC.Media.Opus @opaque t() :: %{ file: File.io_device(), @@ -38,17 +25,18 @@ defmodule ExWebRTC.Media.OggReader do For now, works only with single Opus stream in the container. This function reads the ID and Comment Headers (and, for now, ignores them). """ - @spec open(Path.t()) :: {:ok, t()} | {:error, File.posix() | :invalid_header} + @spec open(Path.t()) :: {:ok, t()} | {:error, term()} def open(path) do with {:ok, file} <- File.open(path), reader <- %{file: file, packets: [], rest: <<>>}, - # for now, we ignore ID Header and Comment Header - {:ok, <<@id_signature, _rest::binary>>, reader} <- do_next_packet(reader), - {:ok, <<@comment_signature, _rest::binary>>, reader} <- do_next_packet(reader) do + {:ok, id_header, reader} <- do_next_packet(reader), + {:ok, comment_header, reader} <- do_next_packet(reader), + :ok <- Header.decode_id(id_header), + :ok <- Header.decode_comment(comment_header) do {:ok, reader} else + :eof -> {:error, :invalid_file} {:error, _res} = err -> err - _other -> {:error, :invalid_header} end end @@ -59,13 +47,10 @@ defmodule ExWebRTC.Media.OggReader do This function also returns the duration of the audio in milliseconds, based on Opus packet TOC sequence (see RFC 6716, sec. 3). It assumes that all of the Ogg packets belong to the same stream. """ - @spec next_packet(t()) :: - {:ok, {binary(), non_neg_integer()}, t()} - | {:error, :invalid_page_header | :not_enough_data} - | :eof + @spec next_packet(t()) :: {:ok, {binary(), non_neg_integer()}, t()} | {:error, term()} | :eof def next_packet(reader) do with {:ok, packet, reader} <- do_next_packet(reader), - {:ok, duration} <- get_packet_duration(packet) do + {:ok, duration} <- Opus.duration(packet) do {:ok, {packet, duration}, reader} end end @@ -75,7 +60,7 @@ defmodule ExWebRTC.Media.OggReader do end defp do_next_packet(%{packets: []} = reader) do - with {:ok, _header, packets, rest} <- read_page(reader.file) do + with {:ok, %Page{packets: packets, rest: rest}} <- Page.read(reader.file) do case packets do [] -> do_next_packet(%{reader | packets: [], rest: reader.rest <> rest}) @@ -87,82 +72,4 @@ defmodule ExWebRTC.Media.OggReader do end end end - - defp read_page(file) do - with <<@signature, @version, type, granule_pos::little-64, serial_no::little-32, - sequence_no::little-32, _checksum::little-32, - segment_no>> = header <- IO.binread(file, 27), - raw_segment_table when is_binary(raw_segment_table) <- IO.binread(file, segment_no), - segment_table <- :binary.bin_to_list(raw_segment_table), - payload_length <- Enum.sum(segment_table), - payload when is_binary(payload) <- IO.binread(file, payload_length), - :ok <- verify_checksum(header <> raw_segment_table <> payload) do - {packets, rest} = split_packets(segment_table, payload) - - type = %{ - fresh?: (type &&& 0x01) != 0, - first?: (type &&& 0x02) != 0, - last?: (type &&& 0x04) != 0 - } - - {:ok, - %{ - type: type, - granule_pos: granule_pos, - serial_no: serial_no, - sequence_no: sequence_no - }, packets, rest} - else - data when is_binary(data) -> {:error, :invalid_page_header} - :eof -> :eof - {:error, _res} = err -> err - end - end - - defp verify_checksum(<>) do - actual_checksum = - <> - |> CRC.calculate(@crc_params) - - if checksum == actual_checksum do - :ok - else - {:error, :invalid_checksum} - end - end - - defp split_packets(segment_table, payload, packets \\ [], packet \\ <<>>) - defp split_packets([], <<>>, packets, packet), do: {Enum.reverse(packets), packet} - - defp split_packets([segment_len | segment_table], payload, packets, packet) do - <> = payload - packet = packet <> segment - - case segment_len do - 255 -> split_packets(segment_table, rest, packets, packet) - _len -> split_packets(segment_table, rest, [packet | packets], <<>>) - end - end - - # computes how much audio Opus packet contains (in ms), based on the TOC sequence - # RFC 6716, sec. 3 - defp get_packet_duration(<>) do - with {:ok, frame_count} <- get_frame_count(rest) do - {:ok, trunc(frame_count * get_frame_duration(config))} - end - end - - defp get_packet_duration(_other), do: {:error, :not_enough_data} - - defp get_frame_count(<<_s::1, 0::2, _rest::binary>>), do: {:ok, 1} - defp get_frame_count(<<_s::1, c::2, _rest::binary>>) when c in 1..2, do: {:ok, 2} - defp get_frame_count(<<_s::1, 3::2, _vp::2, frame_no::5, _rest::binary>>), do: {:ok, frame_no} - defp get_frame_count(_other), do: {:error, :not_enough_data} - - defp get_frame_duration(config) when config in [16, 20, 24, 28], do: 2.5 - defp get_frame_duration(config) when config in [17, 21, 25, 29], do: 5 - defp get_frame_duration(config) when config in [0, 4, 8, 12, 14, 18, 22, 26, 30], do: 10 - defp get_frame_duration(config) when config in [1, 5, 9, 13, 15, 19, 23, 27, 31], do: 20 - defp get_frame_duration(config) when config in [2, 6, 10], do: 40 - defp get_frame_duration(config) when config in [3, 7, 11], do: 60 end diff --git a/lib/ex_webrtc/media/ogg_writer.ex b/lib/ex_webrtc/media/ogg_writer.ex new file mode 100644 index 00000000..42bca1c4 --- /dev/null +++ b/lib/ex_webrtc/media/ogg_writer.ex @@ -0,0 +1,122 @@ +defmodule ExWebRTC.Media.OggWriter do + @moduledoc """ + Writes Opus packets to an Ogg container file. + + For now, works only with packets from a single Opus stream. + + Based on: + * [Xiph's official Ogg documentation](https://xiph.org/ogg/) + * [RFC 7845: Ogg Encapsulation for the Opus Audio Codec](https://www.rfc-editor.org/rfc/rfc7845.txt) + * [RFC 6716: Definition of the Opus Audio Codec](https://www.rfc-editor.org/rfc/rfc6716.txt) + """ + + import Bitwise + + alias ExWebRTC.Media.Ogg.Header + alias ExWebRTC.Media.Opus + + @max_page_len 255 * 255 + @max_serial_no (1 <<< 32) - 1 + + alias ExWebRTC.Media.Ogg.Page + + @opaque t() :: %__MODULE__{ + file: File.io_device(), + page: Page.t(), + seg_count: non_neg_integer() + } + + @enforce_keys [:file, :page, :seg_count] + defstruct @enforce_keys + + @doc """ + Opens a new Ogg writer. + + The writer MUST be closed with close/1, + otherwise some of the data might not be written to a file. + """ + @spec open( + Path.t(), + sample_rate: non_neg_integer(), + channel_count: non_neg_integer() + ) :: {:ok, t()} | {:error, File.posix()} + def open(path, opts \\ []) do + page = %Page{ + serial_no: Enum.random(0..@max_serial_no), + granule_pos: 0, + sequence_no: 0 + } + + with {:ok, file} <- File.open(path, [:write]), + writer <- %__MODULE__{file: file, page: page, seg_count: 0} do + write_headers(writer, opts) + end + end + + @doc """ + Writes an Opus packet to the Ogg file. + """ + @spec write_packet(t(), binary()) :: {:ok, t()} | {:error, term()} + def write_packet(_writer, packet) when byte_size(packet) > @max_page_len do + # we dont handle packets that would have to span more than one page + {:error, :packet_too_long} + end + + def write_packet(%__MODULE__{} = writer, packet) do + seg_count = segment_count(packet) + new_count = writer.seg_count + seg_count + + with {:ok, writer} <- if(new_count > 255, do: write_page(writer), else: {:ok, writer}), + {:ok, duration} <- Opus.duration(packet) do + # sample count == duration in seconds * clock rate == our duration / 1000 * 48_000 + sample_count = 48 * duration + + page = %Page{ + writer.page + | packets: [packet | writer.page.packets], + granule_pos: writer.page.granule_pos + sample_count + } + + {:ok, %__MODULE__{writer | page: page, seg_count: writer.seg_count + seg_count}} + end + end + + @doc """ + Closes the writer. + + This function MUST be called as it writes remaining, bufferd data to the Ogg file. + """ + @spec close(t()) :: :ok | {:error, term()} + def close(%__MODULE__{file: file} = writer) do + with {:ok, _writer} <- write_page(writer, true) do + File.close(file) + end + end + + defp write_page(%__MODULE__{file: file, page: page}, last? \\ false) do + page = %Page{page | last?: last?, packets: Enum.reverse(page.packets)} + + with :ok <- Page.write(file, page) do + page = %Page{page | sequence_no: page.sequence_no + 1, packets: []} + {:ok, %__MODULE__{file: file, page: page, seg_count: 0}} + end + end + + defp write_headers(%__MODULE__{file: file, page: page} = writer, opts) do + sample_rate = Keyword.get(opts, :sample_rate, 48_000) + channel_count = Keyword.get(opts, :channel_count, 1) + + id_header = Header.create_id(sample_rate, channel_count) + comment_header = Header.create_comment() + + id_page = %Page{page | first?: true, sequence_no: 0, packets: [id_header]} + comment_page = %Page{page | sequence_no: 1, packets: [comment_header]} + + with :ok <- Page.write(file, id_page), + :ok <- Page.write(file, comment_page) do + {:ok, %__MODULE__{writer | page: %Page{page | sequence_no: 2}}} + end + end + + defp segment_count(packet), do: div(byte_size(packet), 255) + 1 +end diff --git a/lib/ex_webrtc/media/opus.ex b/lib/ex_webrtc/media/opus.ex new file mode 100644 index 00000000..ffe509f4 --- /dev/null +++ b/lib/ex_webrtc/media/opus.ex @@ -0,0 +1,30 @@ +defmodule ExWebRTC.Media.Opus do + @moduledoc false + # based on RFC 6716, sec. 3 + + @doc """ + Computes how much audio is contained in the Opus packet, based on the TOC sequence. + + Returns the duration in milliseconds. + """ + @spec duration(binary()) :: {:ok, float()} | {:error, term()} + def duration(<>) do + with {:ok, frame_count} <- get_frame_count(rest) do + {:ok, frame_count * get_frame_duration(config)} + end + end + + def duration(_other), do: {:error, :invalid_packet} + + defp get_frame_count(<<_s::1, 0::2, _rest::binary>>), do: {:ok, 1} + defp get_frame_count(<<_s::1, c::2, _rest::binary>>) when c in 1..2, do: {:ok, 2} + defp get_frame_count(<<_s::1, 3::2, _vp::2, frame_no::5, _rest::binary>>), do: {:ok, frame_no} + defp get_frame_count(_other), do: {:error, :invalid_packet} + + defp get_frame_duration(config) when config in [16, 20, 24, 28], do: 2.5 + defp get_frame_duration(config) when config in [17, 21, 25, 29], do: 5 + defp get_frame_duration(config) when config in [0, 4, 8, 12, 14, 18, 22, 26, 30], do: 10 + defp get_frame_duration(config) when config in [1, 5, 9, 13, 15, 19, 23, 27, 31], do: 20 + defp get_frame_duration(config) when config in [2, 6, 10], do: 40 + defp get_frame_duration(config) when config in [3, 7, 11], do: 60 +end diff --git a/test/ex_webrtc/media/ogg/page_test.exs b/test/ex_webrtc/media/ogg/page_test.exs new file mode 100644 index 00000000..b2691117 --- /dev/null +++ b/test/ex_webrtc/media/ogg/page_test.exs @@ -0,0 +1,182 @@ +defmodule ExWEbRTC.Media.Ogg.PageTest do + use ExUnit.Case, async: true + + alias ExWebRTC.Media.Ogg.Page + + @crc_params %{ + extend: :crc_32, + poly: 0x04C11DB7, + init: 0x0, + xorout: 0x0, + refin: false, + refout: false + } + + @signature "OggS" + @version 0 + @header_type 0b00000101 + @granule_pos 113_543 + @serial_no 553_111 + @sequence_no 5 + + # without CRC and segment table + @header << + @signature::binary, + @version, + @header_type, + @granule_pos::little-64, + @serial_no::little-32, + @sequence_no::little-32 + >> + + @long_packet_seg <<255, 255, 678 - 255 - 255>> + @long_packet for _ <- 1..678, do: <<42>>, into: <<>> + @short_packet_seg <<202>> + @short_packet for _ <- 1..202, do: <<58>>, into: <<>> + @split_packet_seg <<255>> + @split_packet for _ <- 1..255, do: <<37>>, into: <<>> + + @page %Page{ + continued?: true, + first?: false, + last?: true, + granule_pos: @granule_pos, + serial_no: @serial_no, + sequence_no: @sequence_no, + packets: [], + rest: <<>> + } + + describe "read/1" do + @tag :tmp_dir + test "correct page with whole packets", %{tmp_dir: tmp_dir} do + file_name = "#{tmp_dir}/audio.ogg" + {:ok, file} = File.open(file_name, [:write]) + + page = << + @header::binary, + 0::32, + 4, + @long_packet_seg::binary, + @short_packet_seg::binary, + @long_packet::binary, + @short_packet::binary + >> + + page = add_checksum(page) + :ok = IO.binwrite(file, page) + + {:ok, file} = File.open(file_name) + assert {:ok, page} = Page.read(file) + + valid_page = %Page{@page | packets: [@long_packet, @short_packet]} + assert valid_page == page + end + + @tag :tmp_dir + test "correct page with split packet", %{tmp_dir: tmp_dir} do + file_name = "#{tmp_dir}/audio.ogg" + {:ok, file} = File.open(file_name, [:write]) + + page = << + @header::binary, + 0::32, + 4, + @long_packet_seg::binary, + @split_packet_seg::binary, + @long_packet::binary, + @split_packet::binary + >> + + page = add_checksum(page) + :ok = IO.binwrite(file, page) + + {:ok, file} = File.open(file_name) + assert {:ok, page} = Page.read(file) + + valid_page = %Page{@page | packets: [@long_packet], rest: @split_packet} + assert valid_page == page + end + + @tag :tmp_dir + test "packet with incorrect checksum", %{tmp_dir: tmp_dir} do + file_name = "#{tmp_dir}/audio.ogg" + {:ok, file} = File.open(file_name, [:write]) + # 2 whole packets + page = << + @header::binary, + 5::32, + 4, + @long_packet_seg::binary, + @split_packet_seg::binary, + @long_packet::binary, + @split_packet + >> + + # no checksum added + :ok = IO.binwrite(file, page) + + {:ok, file} = File.open(file_name) + assert {:error, :invalid_checksum} = Page.read(file) + end + end + + describe "write/2" do + @tag :tmp_dir + test "correct page with whole packets", %{tmp_dir: tmp_dir} do + page = %Page{@page | packets: [@long_packet, @short_packet]} + + file_name = "#{tmp_dir}/audio.ogg" + {:ok, file} = File.open(file_name, [:write]) + :ok = Page.write(file, page) + + {:ok, file} = File.open(file_name) + page = IO.binread(file, :eof) + + checksum = calculate_checksum(page) + + assert << + @header::binary, + ^checksum::little-32, + 4, + @long_packet_seg::binary, + @short_packet_seg::binary, + @long_packet::binary, + @short_packet::binary + >> = page + end + + @tag :tmp_dir + test "correct file with split packet", %{tmp_dir: tmp_dir} do + page = %Page{@page | packets: [@long_packet], rest: @split_packet} + + file_name = "#{tmp_dir}/audio.ogg" + {:ok, file} = File.open(file_name, [:write]) + :ok = Page.write(file, page) + + {:ok, file} = File.open(file_name) + page = IO.binread(file, :eof) + + checksum = calculate_checksum(page) + + assert << + @header, + ^checksum::little-32, + 4, + @long_packet_seg::binary, + @split_packet_seg::binary, + @long_packet::binary, + @split_packet::binary + >> = page + end + end + + defp add_checksum(<> = page) do + checksum = CRC.calculate(page, @crc_params) + <> + end + + defp calculate_checksum(<>) do + CRC.calculate(<>, @crc_params) + end +end diff --git a/test/ex_webrtc/media/ogg_reader_test.exs b/test/ex_webrtc/media/ogg_reader_test.exs index ef192185..a1ea2e6e 100644 --- a/test/ex_webrtc/media/ogg_reader_test.exs +++ b/test/ex_webrtc/media/ogg_reader_test.exs @@ -20,7 +20,7 @@ defmodule ExWebRTC.Media.OggReaderTest do end test "empty file" do - assert {:error, :invalid_header} = OggReader.open("test/fixtures/ogg/empty.ogg") + assert {:error, :invalid_file} = OggReader.open("test/fixtures/ogg/empty.ogg") end test "invalid last page" do diff --git a/test/ex_webrtc/media/ogg_writer_test.exs b/test/ex_webrtc/media/ogg_writer_test.exs new file mode 100644 index 00000000..edb75a1e --- /dev/null +++ b/test/ex_webrtc/media/ogg_writer_test.exs @@ -0,0 +1,149 @@ +defmodule ExWebRTC.Media.OggWriterTest do + use ExUnit.Case, async: true + + alias ExWebRTC.Media.OggWriter + + # dummy 200 byte Opus packet with 20 ms TOC sequence + @packet_size 200 + @opus_packet <<12::5, 0::1, 1::2>> <> for(_ <- 1..199, do: <<13>>, into: <<>>) + + @id_len 19 + @comment_len 26 + @header_len 28 + + @no_flag 0x0 + @first_flag 0x02 + @last_flag 0x04 + + @tag :tmp_dir + test "writes Opus header", %{tmp_dir: tmp_dir} do + file_name = "#{tmp_dir}/test.ogg" + + assert {:ok, writer} = OggWriter.open(file_name) + assert :ok = OggWriter.close(writer) + + {:ok, file} = File.open(file_name) + + assert << + "OggS", + 0, + @first_flag, + # granule_pos (0 for ID header) + 0::64, + serial_no::little-32, + # sequence number + 0::32, + _checksum::32, + # number of segments + 1, + @id_len + >> = IO.binread(file, @header_len) + + assert << + "OpusHead", + 1, + # default channel count + 1, + # default preskip + 3840::little-16, + # default sample rate + 48_000::little-32, + # default gain + 0::little-16, + # channel mapping family + 0 + >> = IO.binread(file, @id_len) + + assert << + "OggS", + 0, + @no_flag, + # granule_pos (0 for comment header) + 0::64, + ^serial_no::little-32, + # next sequence number + 1::little-32, + _checksum::32, + # number of segments + 1, + @comment_len + >> = IO.binread(file, @header_len) + + assert << + "OpusTags", + # vendor string length + 13::little-32, + # vendor string + "elixir-webrtc", + # no more comments + 0 + >> = IO.binread(file, @comment_len) + end + + @tag :tmp_dir + test "writes packets to multiple pages", %{tmp_dir: tmp_dir} do + file_name = "#{tmp_dir}/test.ogg" + + packets_1 = 255 + packets_2 = 5 + + assert {:ok, writer} = OggWriter.open(file_name) + + writer = + Enum.reduce(1..(packets_1 + packets_2), writer, fn _, writer -> + assert {:ok, writer} = OggWriter.write_packet(writer, @opus_packet) + writer + end) + + assert :ok = OggWriter.close(writer) + + {:ok, file} = File.open(file_name) + + # discard Opus headers + _opus_headers = IO.binread(file, 2 * @header_len + @id_len + @comment_len) + + header_1 = IO.binread(file, @header_len - 1) + granule_pos = packets_1 * 20 * 48 + + assert << + "OggS", + 0, + @no_flag, + ^granule_pos::little-64, + serial_no::little-32, + # sequence number + 2::little-32, + _checksum::32, + ^packets_1 + >> = header_1 + + segment_table_1 = IO.binread(file, packets_1) + assert segment_table_1 == for(_ <- 1..packets_1, do: <<@packet_size>>, into: <<>>) + + payload_1 = IO.binread(file, packets_1 * @packet_size) + assert is_binary(payload_1) + + header_2 = IO.binread(file, @header_len - 1) + granule_pos = granule_pos + packets_2 * 20 * 48 + + assert << + "OggS", + 0, + @last_flag, + ^granule_pos::little-64, + ^serial_no::little-32, + # sequence number + 3::little-32, + _checksum::32, + ^packets_2 + >> = header_2 + + segment_table_2 = IO.binread(file, packets_2) + assert segment_table_2 == for(_ <- 1..packets_2, do: <<@packet_size>>, into: <<>>) + + payload_2 = IO.binread(file, packets_2 * @packet_size) + assert is_binary(payload_2) + + assert <<>> == IO.binread(file, :all) + end +end