├── config ├── dev.exs ├── prod.exs ├── config.exs └── test.exs ├── assets └── logo.png ├── test ├── sipp │ ├── pcap │ │ ├── g711a.pcap │ │ ├── dtmf_2833_0.pcap │ │ ├── dtmf_2833_1.pcap │ │ ├── dtmf_2833_2.pcap │ │ ├── dtmf_2833_3.pcap │ │ ├── dtmf_2833_4.pcap │ │ ├── dtmf_2833_5.pcap │ │ ├── dtmf_2833_6.pcap │ │ ├── dtmf_2833_7.pcap │ │ ├── dtmf_2833_8.pcap │ │ ├── dtmf_2833_9.pcap │ │ ├── dtmf_2833_pound.pcap │ │ └── dtmf_2833_star.pcap │ └── scenarios │ │ ├── basic │ │ ├── uac_options.xml │ │ ├── uas_options.xml │ │ ├── uac_invite.xml │ │ ├── uac_invite_long.xml │ │ ├── uac_invite_rtp.xml │ │ ├── uas_invite.xml │ │ └── uac_invite_pcma.xml │ │ └── advanced │ │ └── uac_invite_rtp.xml ├── parrot │ ├── sip │ │ ├── headers │ │ │ ├── expires_test.exs │ │ │ ├── max_forwards_test.exs │ │ │ ├── subject_test.exs │ │ │ ├── content_length_test.exs │ │ │ ├── supported_test.exs │ │ │ ├── allow_test.exs │ │ │ ├── call_id_test.exs │ │ │ ├── event_test.exs │ │ │ ├── content_type_test.exs │ │ │ ├── subscription_state_test.exs │ │ │ ├── cseq_test.exs │ │ │ ├── to_test.exs │ │ │ ├── accept_test.exs │ │ │ ├── contact_test.exs │ │ │ ├── from_test.exs │ │ │ ├── route_test.exs │ │ │ └── record_route_test.exs │ │ ├── simple_header_test.exs │ │ ├── message_via_test.exs │ │ ├── method_test.exs │ │ ├── message_test.exs │ │ └── uri_parser_test.exs │ └── media │ │ ├── audio_devices_test.exs │ │ └── media_session_audio_test.exs ├── test_helper.exs ├── support │ └── test_handler.ex └── media │ └── codec_selection_test.exs ├── examples ├── parrot_example_uac │ ├── .formatter.exs │ ├── mix.exs │ ├── .gitignore │ └── demo.exs └── parrot_example_uas │ ├── .formatter.exs │ ├── mix.exs │ ├── .gitignore │ └── README.md ├── .formatter.exs ├── priv └── audio │ ├── parrot-welcome.wav │ └── parrot_welcome.mp3 ├── installer └── parrot_new │ ├── .formatter.exs │ ├── mix.exs │ └── README.md ├── lib ├── parrot │ ├── sip │ │ ├── transport │ │ │ ├── supervisor.ex │ │ │ ├── inet.ex │ │ │ └── source.ex │ │ ├── dialog │ │ │ └── supervisor.ex │ │ ├── transaction │ │ │ └── supervisor.ex │ │ ├── handler_adapter │ │ │ ├── supervisor.ex │ │ │ └── handler_adapter.ex │ │ ├── dns │ │ │ └── resolver.ex │ │ ├── handler.ex │ │ ├── headers │ │ │ ├── subject.ex │ │ │ ├── content_length.ex │ │ │ ├── cseq.ex │ │ │ ├── max_forwards.ex │ │ │ ├── call_id.ex │ │ │ ├── supported.ex │ │ │ └── allow.ex │ │ ├── source.ex │ │ └── handlers │ │ │ └── audio_handler.ex │ ├── supervisor.ex │ ├── application.ex │ ├── media │ │ ├── rtp_packet_logger.ex │ │ ├── g711_chunker.ex │ │ ├── simple_resampler.ex │ │ ├── simple_rtp_receiver.ex │ │ ├── membrane_rtp_pipeline.ex │ │ ├── rtp_packet.ex │ │ ├── media_session_supervisor.ex │ │ └── media_session_manager.ex │ ├── config.ex │ └── parrot_logger.ex └── parrot.ex ├── guides ├── media-handlers.md ├── presentations.md ├── state-machines.md └── rfc-compliance.md ├── docs ├── README.md ├── archive │ ├── mysipapp_membrane_integration_status.md │ └── mysipapp_current_state_summary.md └── media_negotiation_guide.md ├── .gitignore ├── CHANGELOG.md ├── .github └── workflows │ └── ci.yml ├── usage-rules └── media.md ├── README.md └── usage-rules.md /config/dev.exs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/assets/logo.png -------------------------------------------------------------------------------- /test/sipp/pcap/g711a.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/g711a.pcap -------------------------------------------------------------------------------- /examples/parrot_example_uac/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] -------------------------------------------------------------------------------- /examples/parrot_example_uas/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/audio/parrot-welcome.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/priv/audio/parrot-welcome.wav -------------------------------------------------------------------------------- /priv/audio/parrot_welcome.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/priv/audio/parrot_welcome.mp3 -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_0.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_0.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_1.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_1.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_2.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_2.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_3.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_3.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_4.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_4.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_5.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_5.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_6.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_6.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_7.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_7.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_8.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_8.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_9.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_9.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_pound.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_pound.pcap -------------------------------------------------------------------------------- /test/sipp/pcap/dtmf_2833_star.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parrot-platform/parrot_platform/HEAD/test/sipp/pcap/dtmf_2833_star.pcap -------------------------------------------------------------------------------- /installer/parrot_new/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [], 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :parrot_platform, 4 | log_transactions: false, 5 | # This will be set by init/0 6 | allowed_methods: nil, 7 | # This will be set by init/0 8 | uas_options: nil 9 | 10 | config :logger, :console, 11 | format: {Parrot.ParrotLogger, :format}, 12 | metadata: [:file, :line, :function, :state, :call_id, :transaction_id, :dialog_id], 13 | inspect: [limit: 1000, printable_limit: 4096, pretty: false], 14 | level: :debug 15 | -------------------------------------------------------------------------------- /lib/parrot/sip/transport/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Transport.Supervisor do 2 | @moduledoc """ 3 | Parrot SIP Stack 4 | Transport Supervisor 5 | """ 6 | 7 | use Supervisor 8 | 9 | def start_link(args) do 10 | Supervisor.start_link(__MODULE__, args, name: __MODULE__) 11 | end 12 | 13 | @impl true 14 | def init(_args) do 15 | children = [ 16 | {Parrot.Sip.Transport.StateMachine, []} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/parrot/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Supervisor do 2 | use Supervisor 3 | 4 | alias Parrot.Sip.TransactionSupervisor 5 | 6 | def start_link(_args) do 7 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 8 | end 9 | 10 | @impl true 11 | def init(:ok) do 12 | children = [ 13 | {Registry, keys: :unique, name: Parrot.Registry}, 14 | TransactionSupervisor 15 | # Add other supervisors or workers here 16 | ] 17 | 18 | Supervisor.init(children, strategy: :one_for_one) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/parrot/sip/dialog/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Dialog.Supervisor do 2 | use DynamicSupervisor 3 | 4 | def start_link(args) do 5 | DynamicSupervisor.start_link(__MODULE__, args, name: __MODULE__) 6 | end 7 | 8 | def start_child(args) do 9 | spec = {Parrot.Sip.DialogStatem, args} 10 | DynamicSupervisor.start_child(__MODULE__, spec) 11 | end 12 | 13 | def num_active do 14 | DynamicSupervisor.count_children(__MODULE__)[:active] 15 | end 16 | 17 | @impl true 18 | def init([]) do 19 | DynamicSupervisor.init( 20 | strategy: :one_for_one, 21 | max_restarts: 1000, 22 | max_seconds: 1 23 | ) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /examples/parrot_example_uas/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ParrotExampleUas.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :parrot_example_uas, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | defp deps do 21 | [ 22 | # Use local parrot_platform when available 23 | {:parrot_platform, path: "../..", override: true} 24 | # Or use from hex when published: 25 | # {:parrot_platform, "~> 0.0.1-alpha"} 26 | ] 27 | end 28 | end -------------------------------------------------------------------------------- /lib/parrot/sip/transaction/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Transaction.Supervisor do 2 | use DynamicSupervisor 3 | 4 | def start_link(args) do 5 | DynamicSupervisor.start_link(__MODULE__, args, name: __MODULE__) 6 | end 7 | 8 | def start_child(args) do 9 | spec = {Parrot.Sip.TransactionStatem, args} 10 | DynamicSupervisor.start_child(__MODULE__, spec) 11 | end 12 | 13 | def num_active do 14 | DynamicSupervisor.count_children(__MODULE__)[:active] 15 | end 16 | 17 | @impl true 18 | def init([]) do 19 | DynamicSupervisor.init( 20 | strategy: :one_for_one, 21 | max_restarts: 1000, 22 | max_seconds: 1 23 | ) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /examples/parrot_example_uac/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ParrotExampleUac.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :parrot_example_uac, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger, :crypto] 17 | ] 18 | end 19 | 20 | defp deps do 21 | [ 22 | # Use local parrot_platform when available 23 | {:parrot_platform, path: "../..", override: true} 24 | # Or use from hex when published: 25 | # {:parrot_platform, "~> 0.0.1-alpha"} 26 | ] 27 | end 28 | end -------------------------------------------------------------------------------- /test/parrot/sip/headers/expires_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.ExpiresTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Expires headers" do 7 | test "parses Expires header" do 8 | header_value = "3600" 9 | 10 | expires = Headers.Expires.parse(header_value) 11 | 12 | assert expires == 3600 13 | 14 | assert Headers.Expires.format(expires) == header_value 15 | end 16 | end 17 | 18 | describe "creating Expires headers" do 19 | test "creates Expires header" do 20 | expires = Headers.Expires.new(3600) 21 | 22 | assert expires == 3600 23 | 24 | assert Headers.Expires.format(expires) == "3600" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Configure logging based on environment variables before starting tests 2 | sip_trace = System.get_env("SIP_TRACE", "false") == "true" 3 | 4 | # If SIP trace is enabled, we need to allow info level logs to see the traces 5 | # Otherwise use the configured log level 6 | default_level = if sip_trace, do: "info", else: "warning" 7 | log_level = System.get_env("LOG_LEVEL", default_level) |> String.to_existing_atom() 8 | Logger.configure(level: log_level) 9 | 10 | # Also set test configuration 11 | Application.put_env(:parrot, :test_log_level, log_level) 12 | Application.put_env(:parrot, :test_sip_trace, sip_trace) 13 | 14 | Code.require_file("support/uas_handler.ex", __DIR__) 15 | Application.ensure_all_started(:parrot) 16 | ExUnit.start() 17 | -------------------------------------------------------------------------------- /lib/parrot/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | Parrot.Config.init() 6 | 7 | handler_module = ParrotSupport.SipHandler 8 | handler_state = %{} 9 | Application.put_env(:parrot, :sip_handler, {handler_module, handler_state}) 10 | 11 | children = [ 12 | {Registry, keys: :unique, name: Parrot.Registry}, 13 | Parrot.Sip.Transport.Supervisor, 14 | Parrot.Sip.Transaction.Supervisor, 15 | Parrot.Sip.Dialog.Supervisor, 16 | Parrot.Sip.HandlerAdapter.Supervisor, 17 | Parrot.Media.MediaSessionSupervisor 18 | ] 19 | 20 | opts = [strategy: :one_for_one, name: Parrot.Supervisor] 21 | Supervisor.start_link(children, opts) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /examples/parrot_example_uac/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | parrot_example_uac-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ -------------------------------------------------------------------------------- /examples/parrot_example_uas/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | parrot_example_uas-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ -------------------------------------------------------------------------------- /test/parrot/sip/headers/max_forwards_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.MaxForwardsTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Max-Forwards headers" do 7 | test "parses Max-Forwards header" do 8 | header_value = "70" 9 | 10 | max_forwards = Headers.MaxForwards.parse(header_value) 11 | 12 | assert max_forwards == 70 13 | 14 | assert Headers.MaxForwards.format(max_forwards) == "70" 15 | end 16 | end 17 | 18 | describe "creating Max-Forwards headers" do 19 | test "creates Max-Forwards header" do 20 | max_forwards = Headers.MaxForwards.new(70) 21 | 22 | assert max_forwards == 70 23 | 24 | assert Headers.MaxForwards.format(max_forwards) == "70" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /guides/media-handlers.md: -------------------------------------------------------------------------------- 1 | # Media Handlers (Deprecated) 2 | 3 | **Note: This guide refers to an older media handling approach. Please see the [MediaHandler Guide](media-handler.html) for the current event-driven MediaHandler behaviour implementation.** 4 | 5 | This legacy guide is kept for historical reference. The Parrot Framework now uses the `Parrot.MediaHandler` behaviour which provides a more powerful event-driven approach to media handling. 6 | 7 | ## Current Approach 8 | 9 | The modern MediaHandler behaviour provides callbacks for: 10 | - Media session lifecycle events 11 | - Audio playback control 12 | - RTP statistics monitoring 13 | - Codec negotiation 14 | - Error handling 15 | 16 | See the [MediaHandler Guide](media-handler.html) for complete documentation on the current approach. -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Internal Documentation 2 | 3 | This folder contains internal planning and development documentation for the Parrot Framework maintainers. 4 | 5 | ## Current Documents 6 | 7 | - **PROJECT_STATUS.md** - Current implementation status and limitations 8 | - **PRODUCTION_ROADMAP.md** - Development roadmap and future features 9 | - **code_cleanup_plan.md** - Technical debt and code quality improvements needed 10 | 11 | ## User Documentation 12 | 13 | User-facing documentation has been moved to: 14 | - `/guides/` - HexDocs guides (getting started, testing, media handlers, etc.) 15 | - `/CLAUDE_FOR_USERS.md` - Template for users to add to their projects for Claude Code 16 | 17 | ## Archived 18 | 19 | The `/archive/` folder contains completed work documentation for historical reference. -------------------------------------------------------------------------------- /test/sipp/scenarios/basic/uac_options.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ;tag=[call_number] 11 | To: sut 12 | Call-ID: [call_id] 13 | CSeq: 1 OPTIONS 14 | Contact: 15 | Max-Forwards: 70 16 | Accept: application/sdp 17 | Content-Length: 0 18 | 19 | ]]> 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Ignore package tarball (built via "mix hex.build"). 20 | parrot_platform-*.tar 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # sipp 26 | test/sipp/logs/* 27 | !test/sipp/logs/.gitkeep 28 | 29 | .elixir_ls/ 30 | 31 | installer/parrot_new/_build/ 32 | 33 | *.log 34 | 35 | .DS_Store 36 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/subject_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.SubjectTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Subject headers" do 7 | test "parses Subject header" do 8 | header_value = "Project X Discussion" 9 | 10 | subject = Headers.Subject.parse(header_value) 11 | 12 | assert subject.value == "Project X Discussion" 13 | 14 | assert Headers.Subject.format(subject) == header_value 15 | end 16 | end 17 | 18 | describe "creating Subject headers" do 19 | test "creates Subject header" do 20 | subject = Headers.Subject.new("Project X Discussion") 21 | 22 | assert subject.value == "Project X Discussion" 23 | 24 | assert Headers.Subject.format(subject) == "Project X Discussion" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/sipp/scenarios/basic/uas_options.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 19 | Allow: INVITE, ACK, CANCEL, OPTIONS, BYE 20 | Accept: application/sdp 21 | Content-Length: 0 22 | 23 | ]]> 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/content_length_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.ContentLengthTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Content-Length headers" do 7 | test "parses Content-Length header" do 8 | header_value = "142" 9 | 10 | content_length = Headers.ContentLength.parse(header_value) 11 | 12 | assert content_length.value == 142 13 | 14 | assert Headers.ContentLength.format(content_length) == "142" 15 | end 16 | end 17 | 18 | describe "creating Content-Length headers" do 19 | test "creates Content-Length header" do 20 | content_length = Headers.ContentLength.new(142) 21 | 22 | assert content_length.value == 142 23 | 24 | assert Headers.ContentLength.format(content_length) == "142" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/supported_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.SupportedTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Supported headers" do 7 | test "parses Supported header" do 8 | header_value = "path, 100rel, timer" 9 | 10 | supported = Headers.Supported.parse(header_value) 11 | 12 | assert supported == ["path", "100rel", "timer"] 13 | 14 | assert Headers.Supported.format(supported) == header_value 15 | end 16 | end 17 | 18 | describe "creating Supported headers" do 19 | test "creates Supported header" do 20 | supported = Headers.Supported.new(["path", "100rel", "timer"]) 21 | 22 | assert supported == ["path", "100rel", "timer"] 23 | 24 | assert Headers.Supported.format(supported) == "path, 100rel, timer" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /guides/presentations.md: -------------------------------------------------------------------------------- 1 | # Presentations 2 | 3 | This section contains presentations about Parrot Platform given at conferences and meetups. 4 | 5 | ## ClueCon 2025 - Putting the 'T' back in OTP 6 | 7 | **Presented by:** Brandon Youngdale 8 | **Date:** August 2025 9 | **Location:** [ClueCon RTC/Telecom Conference](https://www.cluecon.com/) 10 | 11 |
12 |

📊 View Presentation

13 |

Click the link below to view the full presentation slides:

14 |

15 | 16 | Open ClueCon 2025 Presentation 17 | 18 |

19 |
20 | 21 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/allow_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.AllowTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Allow headers" do 7 | test "parses Allow header" do 8 | header_value = "ACK, BYE, CANCEL, INVITE, OPTIONS" 9 | 10 | allow = Headers.Allow.parse(header_value) 11 | 12 | assert allow == Parrot.Sip.MethodSet.new([:invite, :ack, :cancel, :options, :bye]) 13 | 14 | assert Headers.Allow.format(allow) == header_value 15 | end 16 | end 17 | 18 | describe "creating Allow headers" do 19 | test "creates Allow header" do 20 | expected = Parrot.Sip.MethodSet.new([:invite, :ack, :cancel, :options, :bye]) 21 | 22 | allow = Headers.Allow.new([:invite, :ack, :cancel, :options, :bye]) 23 | 24 | assert allow == expected 25 | 26 | assert Headers.Allow.format(allow) == "ACK, BYE, CANCEL, INVITE, OPTIONS" 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure logging for tests based on environment variables 4 | # Usage: 5 | # mix test # Default: warnings and errors only 6 | # LOG_LEVEL=debug mix test # Show debug logs 7 | # LOG_LEVEL=info mix test # Show info and above 8 | # LOG_LEVEL=error mix test # Errors only (quietest) 9 | # SIP_TRACE=true mix test # Show full SIP messages 10 | # LOG_LEVEL=error SIP_TRACE=true mix test # Minimal logs but show SIP messages 11 | 12 | # Configure default SIP trace setting for tests 13 | sip_trace = System.get_env("SIP_TRACE", "false") == "true" 14 | 15 | # If SIP trace is enabled, we need to allow info level logs to see the traces 16 | # Otherwise use the configured log level 17 | default_level = if sip_trace, do: "info", else: "warning" 18 | log_level = System.get_env("LOG_LEVEL", default_level) |> String.to_existing_atom() 19 | config :logger, level: log_level 20 | 21 | config :parrot_platform, 22 | test_sip_trace: sip_trace, 23 | test_log_level: log_level 24 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/call_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.CallIdTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Call-ID headers" do 7 | test "parses Call-ID header" do 8 | header_value = "a84b4c76e66710@pc33.atlanta.com" 9 | 10 | call_id = Headers.CallId.parse(header_value) 11 | 12 | assert call_id == "a84b4c76e66710@pc33.atlanta.com" 13 | 14 | assert Headers.CallId.format(call_id) == header_value 15 | end 16 | end 17 | 18 | describe "creating Call-ID headers" do 19 | test "creates Call-ID header" do 20 | call_id = Headers.CallId.new("a84b4c76e66710@pc33.atlanta.com") 21 | 22 | assert call_id == "a84b4c76e66710@pc33.atlanta.com" 23 | 24 | assert Headers.CallId.format(call_id) == "a84b4c76e66710@pc33.atlanta.com" 25 | end 26 | 27 | test "generates Call-ID value" do 28 | call_id = Headers.CallId.generate() 29 | 30 | assert is_binary(call_id) 31 | assert String.contains?(call_id, "@") 32 | assert String.length(call_id) >= 10 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/event_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.EventTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Event headers" do 7 | test "parses Event header" do 8 | header_value = "presence" 9 | 10 | event = Headers.Event.parse(header_value) 11 | 12 | assert is_map(event) 13 | assert event.event == "presence" 14 | assert event.parameters == %{} 15 | 16 | assert Headers.Event.format(event) == header_value 17 | end 18 | 19 | test "parses Event header with parameters" do 20 | header_value = "presence;id=1234" 21 | 22 | event = Headers.Event.parse(header_value) 23 | 24 | assert event.event == "presence" 25 | assert event.parameters["id"] == "1234" 26 | 27 | assert Headers.Event.format(event) == header_value 28 | end 29 | end 30 | 31 | describe "creating Event headers" do 32 | test "creates Event header" do 33 | event = Headers.Event.new("presence", %{"id" => "1234"}) 34 | 35 | assert event.event == "presence" 36 | assert event.parameters["id"] == "1234" 37 | 38 | assert Headers.Event.format(event) == "presence;id=1234" 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /installer/parrot_new/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ParrotNew.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :parrot_new, 7 | version: "0.0.1-alpha.3", 8 | elixir: "~> 1.16", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: "Parrot Platform project generators", 12 | package: package() 13 | ] 14 | end 15 | 16 | # Run "mix help compile.app" to learn about applications. 17 | def application do 18 | [ 19 | extra_applications: [:logger, :eex] 20 | ] 21 | end 22 | 23 | # Run "mix help deps" to learn about dependencies. 24 | defp deps do 25 | # No dependencies - this is just a generator 26 | [] 27 | end 28 | 29 | defp package do 30 | [ 31 | licenses: ["GPL-2.0-or-later"], 32 | links: %{ 33 | "GitHub" => "https://github.com/parrot-platform/parrot_platform" 34 | }, 35 | maintainers: ["Brandon Youngdale"], 36 | files: ~w(lib .formatter.exs mix.exs README* LICENSE*), 37 | source_url: "https://github.com/parrot-platform/parrot_platform", 38 | description: """ 39 | Parrot Platform project generators 40 | 41 | Provides `mix parrot.gen.[uas|uac]` tasks to bootstrap new Parrot Platform applications. 42 | """ 43 | ] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /examples/parrot_example_uac/demo.exs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env elixir 2 | 3 | # Demo script for ParrotExampleUac 4 | # 5 | # This script demonstrates making a call with audio devices. 6 | # Make sure parrot_example_uas is running on port 5060 first! 7 | 8 | IO.puts(""" 9 | 🦜 Parrot Example UAC Demo 10 | ======================== 11 | 12 | This demo will: 13 | 1. List available audio devices 14 | 2. Start the UAC 15 | 3. Make a call to the local UAS 16 | 4. Use your microphone and speakers for bidirectional audio 17 | 18 | Make sure parrot_example_uas is running first! 19 | Press Enter to continue... 20 | """) 21 | 22 | IO.gets("") 23 | 24 | # Start the UAC 25 | {:ok, _pid} = ParrotExampleUac.start() 26 | 27 | # List audio devices 28 | IO.puts("\n📱 Available Audio Devices:") 29 | ParrotExampleUac.list_audio_devices() 30 | 31 | IO.puts("\nPress Enter to make a call to sip:service@127.0.0.1:5060") 32 | IO.gets("") 33 | 34 | # Make the call 35 | case ParrotExampleUac.call("sip:service@127.0.0.1:5060") do 36 | :ok -> 37 | IO.puts("\n🎤 Call in progress...") 38 | IO.puts("You can speak into your microphone and hear audio through your speakers.") 39 | IO.puts("\nThe call will automatically hang up when you press Enter in the call window.") 40 | 41 | {:error, reason} -> 42 | IO.puts("\n❌ Failed to make call: #{inspect(reason)}") 43 | end 44 | 45 | # Keep the script running 46 | IO.puts("\nPress Ctrl+C to exit the demo") 47 | :timer.sleep(:infinity) -------------------------------------------------------------------------------- /test/parrot/sip/headers/content_type_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.ContentTypeTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Content-Type headers" do 7 | test "parses Content-Type header" do 8 | header_value = "application/sdp" 9 | 10 | content_type = Headers.ContentType.parse(header_value) 11 | 12 | assert content_type.type == "application" 13 | assert content_type.subtype == "sdp" 14 | assert content_type.parameters == %{} 15 | 16 | assert Headers.ContentType.format(content_type) == header_value 17 | end 18 | 19 | test "parses Content-Type header with parameters" do 20 | header_value = "multipart/mixed; boundary=boundary42" 21 | 22 | content_type = Headers.ContentType.parse(header_value) 23 | 24 | assert content_type.type == "multipart" 25 | assert content_type.subtype == "mixed" 26 | assert content_type.parameters["boundary"] == "boundary42" 27 | 28 | assert Headers.ContentType.format(content_type) == header_value 29 | end 30 | end 31 | 32 | describe "creating Content-Type headers" do 33 | test "creates Content-Type header" do 34 | content_type = Headers.ContentType.new("application", "sdp") 35 | 36 | assert content_type.type == "application" 37 | assert content_type.subtype == "sdp" 38 | 39 | assert Headers.ContentType.format(content_type) == "application/sdp" 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/parrot/media/rtp_packet_logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.RTPPacketLogger do 2 | @moduledoc """ 3 | A simple Membrane filter that logs RTP packet information for debugging. 4 | """ 5 | 6 | use Membrane.Filter 7 | require Logger 8 | 9 | def_input_pad(:input, accepted_format: _any, flow_control: :auto) 10 | def_output_pad(:output, accepted_format: _any, flow_control: :auto) 11 | 12 | def_options( 13 | dest_info: [ 14 | spec: String.t(), 15 | default: "unknown", 16 | description: "Destination info for logging" 17 | ] 18 | ) 19 | 20 | @impl true 21 | def handle_init(_ctx, opts) do 22 | {[], %{counter: 0, dest_info: opts.dest_info}} 23 | end 24 | 25 | @impl true 26 | def handle_buffer(:input, buffer, _ctx, state) do 27 | state = %{state | counter: state.counter + 1} 28 | 29 | if state.counter <= 5 || rem(state.counter, 100) == 0 do 30 | Logger.info( 31 | "RTP packet #{state.counter}: size=#{byte_size(buffer.payload)} bytes to #{state.dest_info}" 32 | ) 33 | 34 | # Try to parse RTP header if it looks like RTP 35 | case buffer.payload do 36 | <> 38 | when version == 2 -> 39 | Logger.info(" RTP header: pt=#{pt} seq=#{seq} marker=#{marker}") 40 | 41 | _ -> 42 | :ok 43 | end 44 | end 45 | 46 | {[buffer: {:output, buffer}], state} 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.0.1-alpha.3] - 2025-08-08 9 | 10 | ### Added 11 | - Presentations section in documentation 12 | - ClueCon 2025 presentation: "Putting the 'T' back in OTP" 13 | - Improved Mermaid diagram support for hexdocs.pm compatibility 14 | - Fixed transaction layer state machine diagram rendering 15 | 16 | ## [0.0.1-alpha.2] - 2025-08-03 17 | 18 | ### Added 19 | - Fixed logo in docs 20 | - Added TODOs to README 21 | - Fixes for the installers 22 | 23 | ## [0.0.1-alpha.1] - 2025-08-03 24 | 25 | ### Added 26 | - Initial alpha release 27 | - Complete SIP protocol stack implementation (RFC 3261) 28 | - `Parrot.SipHandler` behaviour for handling SIP events 29 | - `Parrot.MediaHandler` behaviour for media session callbacks 30 | - RTP audio streaming with PCMA codec support 31 | - gen_statem-based architecture for transactions and dialogs 32 | - Integration with Membrane multimedia libraries for audio processing 33 | - Example application demonstrating handler usage 34 | - Generators for uac and uas 35 | - Documentation and guides 36 | - SIPp integration test suite 37 | 38 | ### Known Issues 39 | - Alpha release - Lots of changes coming 40 | - Limited to G.711 codecs (Opus support planned) 41 | - No TLS or TCP transport support yet 42 | 43 | -------------------------------------------------------------------------------- /installer/parrot_new/README.md: -------------------------------------------------------------------------------- 1 | # Parrot New 2 | 3 | Generators for creating new Parrot Platform applications. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | mix archive.install hex parrot_new 9 | ``` 10 | 11 | Or from GitHub before Hex publication: 12 | 13 | ```bash 14 | # Clone the repository 15 | git clone https://github.com/parrot-platform/parrot_platform.git 16 | cd parrot/installer/parrot_new 17 | mix archive.build 18 | mix archive.install ./parrot_new-0.0.1-alpha.3.ez 19 | ``` 20 | 21 | ## Usage 22 | 23 | ### Generate a UAC (User Agent Client) application: 24 | 25 | ```bash 26 | mix parrot.gen.uac my_uac_app 27 | cd my_uac_app 28 | mix deps.get 29 | iex -S mix 30 | ``` 31 | 32 | ### Generate a UAS (User Agent Server) application: 33 | 34 | ```bash 35 | mix parrot.gen.uas my_uas_app 36 | cd my_uas_app 37 | mix deps.get 38 | iex -S mix 39 | ``` 40 | 41 | ## Options 42 | 43 | Both generators support options: 44 | 45 | - `--module` - Specify the module name (default: derived from app name) 46 | - `--no-audio` - Skip audio device support (SIP signaling only) 47 | 48 | Example: 49 | ```bash 50 | mix parrot.gen.uac my_app --module MyCompany.VoiceApp --no-audio 51 | ``` 52 | 53 | ## About 54 | 55 | These generators create complete Parrot Platform applications with: 56 | 57 | - SIP protocol support (UAC or UAS) 58 | - Optional audio device integration 59 | - G.711 A-law codec support 60 | - Example code and documentation 61 | - Test files 62 | 63 | For more information about Parrot Platform, visit: https://github.com/parrot-platform/parrot_platform 64 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/subscription_state_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.SubscriptionStateTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Subscription-State headers" do 7 | test "parses Subscription-State header" do 8 | header_value = "active;expires=3600" 9 | 10 | subscription_state = Headers.SubscriptionState.parse(header_value) 11 | 12 | assert subscription_state.state == :active 13 | assert subscription_state.parameters["expires"] == "3600" 14 | 15 | assert Headers.SubscriptionState.format(subscription_state) == header_value 16 | end 17 | 18 | test "parses Subscription-State header with reason" do 19 | header_value = "terminated;reason=timeout" 20 | 21 | subscription_state = Headers.SubscriptionState.parse(header_value) 22 | 23 | assert subscription_state.state == :terminated 24 | assert subscription_state.parameters["reason"] == "timeout" 25 | 26 | assert Headers.SubscriptionState.format(subscription_state) == header_value 27 | end 28 | end 29 | 30 | describe "creating Subscription-State headers" do 31 | test "creates Subscription-State header" do 32 | subscription_state = Headers.SubscriptionState.new(:active, %{"expires" => "3600"}) 33 | 34 | assert is_map(subscription_state) 35 | assert subscription_state.state == :active 36 | assert subscription_state.parameters["expires"] == "3600" 37 | 38 | assert Headers.SubscriptionState.format(subscription_state) == "active;expires=3600" 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/parrot/sip/handler_adapter/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.HandlerAdapter.Supervisor do 2 | @moduledoc """ 3 | Supervisor for HandlerAdapter instances. 4 | 5 | This supervisor manages the lifecycle of HandlerAdapter processes, each of which 6 | handles a single SIP transaction. It uses a DynamicSupervisor to create and 7 | manage these short-lived processes. 8 | """ 9 | use DynamicSupervisor 10 | 11 | @doc """ 12 | Starts the HandlerAdapter supervisor. 13 | 14 | ## Parameters 15 | 16 | * `args` - Initialization arguments (typically empty) 17 | 18 | ## Returns 19 | 20 | `{:ok, pid}` if the supervisor starts successfully, or `{:error, reason}` if it fails. 21 | """ 22 | def start_link(args) do 23 | DynamicSupervisor.start_link(__MODULE__, args, name: __MODULE__) 24 | end 25 | 26 | @doc """ 27 | Starts a new HandlerAdapter child process. 28 | 29 | ## Parameters 30 | 31 | * `args` - A tuple containing `{user_handler_module, user_handler_state}` 32 | 33 | ## Returns 34 | 35 | `{:ok, pid}` if the child process starts successfully, or `{:error, reason}` if it fails. 36 | """ 37 | def start_child(args) do 38 | # Args will be {user_handler_module, user_handler_state} 39 | spec = {Parrot.Sip.HandlerAdapter.Core, args} 40 | DynamicSupervisor.start_child(__MODULE__, spec) 41 | end 42 | 43 | @impl true 44 | def init([]) do 45 | DynamicSupervisor.init( 46 | # Each adapter instance is independent 47 | strategy: :one_for_one, 48 | max_restarts: 5, 49 | max_seconds: 10 50 | ) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/parrot/sip/simple_header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.SimpleHeaderTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Parrot.Sip.Parser 5 | 6 | test "parsing headers produces usable map values" do 7 | raw_message = """ 8 | INVITE sip:bob@biloxi.com SIP/2.0\r 9 | Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\r 10 | To: Bob \r 11 | From: Alice ;tag=1928301774\r 12 | Call-ID: a84b4c76e66710@pc33.atlanta.com\r 13 | CSeq: 314159 INVITE\r 14 | Content-Length: 0\r 15 | \r 16 | """ 17 | 18 | {:ok, message} = Parser.parse(raw_message) 19 | 20 | # Check that headers are maps with expected keys 21 | via = message.headers["via"] 22 | assert is_map(via) 23 | assert via.host == "pc33.atlanta.com" 24 | assert via.transport == :udp 25 | 26 | from = message.headers["from"] 27 | assert is_map(from) 28 | assert from.display_name == "Alice" 29 | assert from.uri.scheme == "sip" 30 | assert from.uri.user == "alice" 31 | assert from.uri.host == "atlanta.com" 32 | 33 | to = message.headers["to"] 34 | assert is_map(to) 35 | assert to.display_name == "Bob" 36 | assert to.uri.scheme == "sip" 37 | assert to.uri.user == "bob" 38 | assert to.uri.host == "biloxi.com" 39 | 40 | cseq = message.headers["cseq"] 41 | assert is_map(cseq) 42 | assert cseq.number == 314_159 43 | assert cseq.method == :invite 44 | 45 | # Primitives should be directly accessible 46 | assert message.headers["call-id"] == "a84b4c76e66710@pc33.atlanta.com" 47 | assert message.headers["content-length"].value == 0 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/parrot/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Config do 2 | @moduledoc """ 3 | Configuration management for Parrot SIP Stack 4 | """ 5 | 6 | require Logger 7 | 8 | def init do 9 | # Define allowed SIP methods as a simple list 10 | allowed_methods = [:options, :invite, :ack, :bye, :cancel, :register, :info, :prack, :update] 11 | 12 | Logger.debug("Allowed methods: #{inspect(allowed_methods)}") 13 | 14 | set_allowed_methods(allowed_methods) 15 | set_uas_options(default_uas_options()) 16 | :ok 17 | end 18 | 19 | def allowed_methods do 20 | Application.get_env(:parrot_platform, :allowed_methods, [ 21 | :options, 22 | :invite, 23 | :ack, 24 | :bye, 25 | :cancel 26 | ]) 27 | end 28 | 29 | def set_allowed_methods(methods) when is_list(methods) do 30 | Application.put_env(:parrot_platform, :allowed_methods, methods) 31 | end 32 | 33 | def uas_options do 34 | Application.get_env(:parrot_platform, :uas_options, default_uas_options()) 35 | end 36 | 37 | def set_uas_options(uas_options) when is_map(uas_options) do 38 | Application.put_env(:parrot_platform, :uas_options, uas_options) 39 | end 40 | 41 | def log_transactions do 42 | Application.get_env(:parrot_platform, :log_transactions, false) 43 | end 44 | 45 | defp default_uas_options do 46 | %{ 47 | check_scheme: &check_scheme/1, 48 | to_tag: :auto, 49 | supported: [], 50 | allowed: allowed_methods(), 51 | min_se: 90, 52 | max_forwards: 70 53 | } 54 | end 55 | 56 | defp check_scheme("sip"), do: true 57 | defp check_scheme("sips"), do: true 58 | defp check_scheme("tel"), do: true 59 | defp check_scheme(_), do: false 60 | end 61 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/cseq_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.CSeqTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing CSeq headers" do 7 | test "parses CSeq header" do 8 | header_value = "314159 INVITE" 9 | 10 | cseq = Headers.CSeq.parse(header_value) 11 | 12 | assert cseq.number == 314_159 13 | assert cseq.method == :invite 14 | 15 | assert Headers.CSeq.format(cseq) == header_value 16 | end 17 | 18 | test "parses CSeq header with different methods" do 19 | test_cases = [ 20 | {"1 INVITE", 1, :invite}, 21 | {"2 ACK", 2, :ack}, 22 | {"3 BYE", 3, :bye}, 23 | {"4 CANCEL", 4, :cancel}, 24 | {"5 REGISTER", 5, :register}, 25 | {"6 OPTIONS", 6, :options}, 26 | {"7 SUBSCRIBE", 7, :subscribe}, 27 | {"8 NOTIFY", 8, :notify}, 28 | {"9 REFER", 9, :refer}, 29 | {"10 MESSAGE", 10, :message}, 30 | {"11 INFO", 11, :info}, 31 | {"12 PRACK", 12, :prack}, 32 | {"13 UPDATE", 13, :update} 33 | ] 34 | 35 | for {header_value, expected_number, expected_method} <- test_cases do 36 | cseq = Headers.CSeq.parse(header_value) 37 | assert cseq.number == expected_number 38 | assert cseq.method == expected_method 39 | assert Headers.CSeq.format(cseq) == header_value 40 | end 41 | end 42 | end 43 | 44 | describe "creating CSeq headers" do 45 | test "creates CSeq header" do 46 | cseq = Headers.CSeq.new(314_159, :invite) 47 | 48 | assert cseq.number == 314_159 49 | assert cseq.method == :invite 50 | 51 | assert Headers.CSeq.format(cseq) == "314159 INVITE" 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/sipp/scenarios/basic/uac_invite.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ;tag=[call_number] 8 | To: sut 9 | Call-ID: [call_id] 10 | Cseq: 1 INVITE 11 | Contact: sip:sipp@[local_ip]:[local_port] 12 | Content-Type: application/sdp 13 | Content-Length: [len] 14 | 15 | v=0 16 | o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] 17 | s=- 18 | t=0 0 19 | c=IN IP[media_ip_type] [media_ip] 20 | m=audio [media_port] RTP/AVP 0 21 | a=rtpmap:0 PCMU/8000 22 | ]]> 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ;tag=[call_number] 39 | To: sut [peer_tag_param] 40 | Call-ID: [call_id] 41 | Cseq: 1 ACK 42 | Contact: sip:sipp@[local_ip]:[local_port] 43 | Content-Length: 0 44 | ]]> 45 | 46 | 47 | 48 | 49 | 50 | ;tag=[call_number] 54 | To: sut [peer_tag_param] 55 | Call-ID: [call_id] 56 | Cseq: 2 BYE 57 | Contact: sip:sipp@[local_ip]:[local_port] 58 | Content-Length: 0 59 | ]]> 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | otp: ['27.x'] 16 | elixir: ['1.18.x'] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Elixir 22 | uses: erlef/setup-beam@v1 23 | with: 24 | otp-version: ${{matrix.otp}} 25 | elixir-version: ${{matrix.elixir}} 26 | 27 | - name: Restore dependencies cache 28 | uses: actions/cache@v3 29 | with: 30 | path: | 31 | deps 32 | _build 33 | key: ${{ runner.os }}-mix-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 34 | restore-keys: ${{ runner.os }}-mix-${{ matrix.otp }}-${{ matrix.elixir }}- 35 | 36 | - name: Install dependencies 37 | run: mix deps.get 38 | 39 | - name: Check formatting 40 | run: mix format --check-formatted 41 | 42 | # - name: Compile without warnings 43 | # run: mix compile --warnings-as-errors 44 | 45 | - name: Compile (warnings ok for now) 46 | run: mix compile 47 | 48 | - name: Run tests 49 | run: mix test 50 | 51 | # Optional: Run dialyzer for type checking 52 | # - name: Run dialyzer 53 | # run: mix dialyzer 54 | # if: matrix.elixir == '1.18.x' && matrix.otp == '26.x' 55 | 56 | # Optional: Check documentation can be generated 57 | docs: 58 | name: Generate docs 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: Set up Elixir 63 | uses: erlef/setup-beam@v1 64 | with: 65 | otp-version: '27.x' 66 | elixir-version: '1.18.x' 67 | - name: Install dependencies 68 | run: mix deps.get 69 | - name: Generate docs 70 | run: mix docs 71 | -------------------------------------------------------------------------------- /test/sipp/scenarios/basic/uac_invite_long.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ;tag=[call_number] 8 | To: sut 9 | Call-ID: [call_id] 10 | Cseq: 1 INVITE 11 | Contact: sip:sipp@[local_ip]:[local_port] 12 | Content-Type: application/sdp 13 | Content-Length: [len] 14 | 15 | v=0 16 | o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] 17 | s=- 18 | t=0 0 19 | c=IN IP[media_ip_type] [media_ip] 20 | m=audio [media_port] RTP/AVP 0 21 | a=rtpmap:0 PCMU/8000 22 | ]]> 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ;tag=[call_number] 39 | To: sut [peer_tag_param] 40 | Call-ID: [call_id] 41 | Cseq: 1 ACK 42 | Contact: sip:sipp@[local_ip]:[local_port] 43 | Content-Length: 0 44 | ]]> 45 | 46 | 47 | 48 | 49 | 50 | 51 | ;tag=[call_number] 55 | To: sut [peer_tag_param] 56 | Call-ID: [call_id] 57 | Cseq: 2 BYE 58 | Contact: sip:sipp@[local_ip]:[local_port] 59 | Content-Length: 0 60 | ]]> 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /lib/parrot/parrot_logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.ParrotLogger do 2 | @behaviour :logger_formatter 3 | 4 | def format(level, message, timestamp, metadata) do 5 | time_str = format_time(timestamp) 6 | 7 | file = Keyword.get(metadata, :file, "nofile") 8 | line = Keyword.get(metadata, :line, "0") 9 | function = Keyword.get(metadata, :function, "nofun") 10 | 11 | state = Keyword.get(metadata, :state, nil) 12 | call_id = Keyword.get(metadata, :call_id, nil) 13 | transaction_id = Keyword.get(metadata, :transaction_id, nil) 14 | dialog_id = Keyword.get(metadata, :dialog_id, nil) |> extract_first_dialog_tag() 15 | 16 | parts = 17 | [ 18 | "[#{file}:#{line}]", 19 | "[#{function}]", 20 | state && "[state:#{state}]", 21 | call_id && "[call_id:#{call_id}]", 22 | transaction_id && "[transaction_id:#{transaction_id}]", 23 | dialog_id && "[dialog_id:#{dialog_id}]" 24 | ] 25 | # remove nils 26 | |> Enum.filter(& &1) 27 | 28 | formatted_message = Enum.join(parts, "") <> " " <> to_string(message) 29 | 30 | # Assemble final log line 31 | "#{time_str} [#{level}] #{formatted_message}\n" 32 | end 33 | 34 | @impl :logger_formatter 35 | def format(config, msg) do 36 | :logger_formatter.format(config, msg) 37 | end 38 | 39 | # Extract the first tag key from a dialog_id tuple 40 | defp extract_first_dialog_tag({:dialog_id, {:tag_key, first_tag}, _remote_tag, _callid}) do 41 | first_tag 42 | end 43 | 44 | defp extract_first_dialog_tag(_), do: nil 45 | 46 | defp format_time({date, time}) do 47 | {{year, month, day}, {hour, min, sec, ms}} = {date, time} 48 | 49 | "#{pad2(year)}-#{pad2(month)}-#{pad2(day)} #{pad2(hour)}:#{pad2(min)}:#{pad2(sec)}.#{pad3(ms)}" 50 | end 51 | 52 | @impl :logger_formatter 53 | def check_config(_config) do 54 | :ok 55 | end 56 | 57 | defp pad2(n) when n < 10, do: "0#{n}" 58 | defp pad2(n), do: "#{n}" 59 | 60 | defp pad3(n) when n < 10, do: "00#{n}" 61 | defp pad3(n) when n < 100, do: "0#{n}" 62 | defp pad3(n), do: "#{n}" 63 | end 64 | -------------------------------------------------------------------------------- /test/parrot/media/audio_devices_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.AudioDevicesTest do 2 | use ExUnit.Case, async: true 3 | alias Parrot.Media.AudioDevices 4 | 5 | describe "list_devices/0" do 6 | test "returns error when mix pa_devices is not available" do 7 | # Note: This test will likely fail in CI without PortAudio installed 8 | # We're testing the fallback behavior 9 | result = AudioDevices.list_devices() 10 | 11 | assert match?({:ok, _}, result) or match?({:error, :device_enumeration_failed}, result) 12 | end 13 | end 14 | 15 | describe "get_default_input/0" do 16 | test "returns error when no devices available" do 17 | # This test covers the case when list_devices returns error 18 | # In a real test environment, we'd mock the list_devices function 19 | result = AudioDevices.get_default_input() 20 | 21 | assert match?({:ok, _}, result) or match?({:error, _}, result) 22 | end 23 | end 24 | 25 | describe "get_default_output/0" do 26 | test "returns error when no devices available" do 27 | result = AudioDevices.get_default_output() 28 | 29 | assert match?({:ok, _}, result) or match?({:error, _}, result) 30 | end 31 | end 32 | 33 | describe "validate_device/2" do 34 | test "returns error for non-existent device" do 35 | assert {:error, _} = AudioDevices.validate_device(9999, :input) 36 | end 37 | end 38 | 39 | describe "get_device_info/1" do 40 | test "returns stub info for any valid device ID" do 41 | # Since enumeration is not available, get_device_info returns stub data 42 | result = AudioDevices.get_device_info(9999) 43 | assert {:ok, device_info} = result 44 | assert device_info.id == 9999 45 | assert device_info.name == "Device 9999" 46 | end 47 | end 48 | 49 | describe "parse_device_output/1" do 50 | test "parses device line correctly" do 51 | # Testing the private function behavior through the public API 52 | # This ensures the parsing logic works correctly 53 | 54 | # Note: Since parse_device_output is private, we can't test it directly 55 | # Instead, we'd need to test it through list_devices with mocked output 56 | assert true 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/sipp/scenarios/basic/uac_invite_rtp.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ;tag=[call_number] 10 | To: sut 11 | Call-ID: [call_id] 12 | Cseq: 1 INVITE 13 | Contact: sip:sipp@[local_ip]:[local_port] 14 | Content-Type: application/sdp 15 | Content-Length: [len] 16 | 17 | v=0 18 | o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] 19 | s=- 20 | t=0 0 21 | c=IN IP[media_ip_type] [media_ip] 22 | m=audio [media_port] RTP/AVP 0 23 | a=rtpmap:0 PCMU/8000 24 | ]]> 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ;tag=[call_number] 41 | To: sut [peer_tag_param] 42 | Call-ID: [call_id] 43 | Cseq: 1 ACK 44 | Contact: sip:sipp@[local_ip]:[local_port] 45 | Content-Length: 0 46 | ]]> 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ;tag=[call_number] 65 | To: sut [peer_tag_param] 66 | Call-ID: [call_id] 67 | Cseq: 2 BYE 68 | Contact: sip:sipp@[local_ip]:[local_port] 69 | Content-Length: 0 70 | ]]> 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /test/sipp/scenarios/basic/uas_invite.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 27 | Content-Type: application/sdp 28 | Content-Length: [len] 29 | 30 | v=0 31 | o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] 32 | s=- 33 | c=IN IP[media_ip_type] [media_ip] 34 | t=0 0 35 | m=audio [media_port] RTP/AVP 0 36 | a=rtpmap:0 PCMU/8000 37 | ]]> 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 55 | Content-Length: 0 56 | ]]> 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 83 | -------------------------------------------------------------------------------- /lib/parrot/sip/transport/inet.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Transport.Inet do 2 | @moduledoc """ 3 | Parrot SIP Stack 4 | Inet-related functions 5 | """ 6 | 7 | @type getifaddrs_ifopts :: [ 8 | {:flags, [:up | :broadcast | :loopback | :pointtopoint | :running | :multicast]} 9 | | {:addr, :inet.ip_address()} 10 | | {:netmask, :inet.ip_address()} 11 | | {:broadaddr, :inet.ip_address()} 12 | | {:dstaddr, :inet.ip_address()} 13 | | {:hwaddr, [byte()]} 14 | ] 15 | 16 | @doc """ 17 | Returns the first non-loopback IP address from the system's network interfaces. 18 | """ 19 | @spec first_non_loopack_address() :: :inet.ip_address() 20 | def first_non_loopack_address do 21 | {:ok, if_addrs} = :inet.getifaddrs() 22 | 23 | candidates = 24 | if_addrs 25 | |> Enum.map(fn {_if_name, props} -> 26 | if not is_loopback(props) and has_address(props) do 27 | :proplists.get_value(:addr, props) 28 | end 29 | end) 30 | |> Enum.reject(&is_nil/1) 31 | |> Enum.sort() 32 | 33 | [first | _] = candidates 34 | first 35 | end 36 | 37 | @doc """ 38 | return first ipv4 address 39 | """ 40 | @spec first_ipv4_address() :: :inet.ip_address() 41 | def first_ipv4_address do 42 | {:ok, if_addrs} = :inet.getifaddrs() 43 | 44 | candidates = 45 | if_addrs 46 | |> Enum.map(fn {_if_name, props} -> 47 | addr = :proplists.get_value(:addr, props) 48 | 49 | if not is_loopback(props) and has_address(props) and is_ipv4(addr) do 50 | addr 51 | end 52 | end) 53 | |> Enum.reject(&is_nil/1) 54 | |> Enum.sort() 55 | 56 | case candidates do 57 | # Fallback to localhost if no IPv4 address found 58 | [] -> {127, 0, 0, 1} 59 | [first | _] -> first 60 | end 61 | end 62 | 63 | # Internal implementation 64 | 65 | @spec is_loopback(getifaddrs_ifopts()) :: boolean() 66 | defp is_loopback(props) do 67 | flags = :proplists.get_value(:flags, props) 68 | :loopback in flags 69 | end 70 | 71 | @spec has_address(getifaddrs_ifopts()) :: boolean() 72 | defp has_address(props) do 73 | :proplists.get_value(:addr, props) != :undefined 74 | end 75 | 76 | @spec is_ipv4(:inet.ip_address() | :undefined) :: boolean() 77 | defp is_ipv4({a, b, c, d}) 78 | when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d), 79 | do: true 80 | 81 | defp is_ipv4(_), do: false 82 | end 83 | -------------------------------------------------------------------------------- /guides/state-machines.md: -------------------------------------------------------------------------------- 1 | # Parrot Platform State Machines 2 | 3 | This guide shows the simplified state machines used in Parrot Platform for handling SIP transactions, dialogs, and media sessions. 4 | 5 | ## Transaction State Machine 6 | 7 | The transaction state machine handles reliable SIP message delivery: 8 | 9 | ```mermaid 10 | stateDiagram-v2 11 | [*] --> Trying: New Transaction 12 | 13 | Trying --> Proceeding: 1xx Response 14 | Trying --> Completed: Final Response 15 | 16 | Proceeding --> Completed: Final Response 17 | 18 | Completed --> Confirmed: ACK Received 19 | Completed --> Terminated: Timer Expires 20 | 21 | Confirmed --> Terminated: Timer Expires 22 | 23 | Terminated --> [*] 24 | 25 | note right of Trying: Initial state 26 | note right of Proceeding: Provisional responses 27 | note right of Completed: Final response sent/received 28 | note right of Confirmed: INVITE only 29 | note right of Terminated: Cleanup 30 | ``` 31 | 32 | ## Dialog State Machine 33 | 34 | The dialog state machine manages SIP dialog lifecycle: 35 | 36 | ```mermaid 37 | stateDiagram-v2 38 | [*] --> Early: INVITE Sent/Received 39 | 40 | Early --> Confirmed: 2xx Response 41 | Early --> Terminated: Error/Cancel 42 | 43 | Confirmed --> Terminated: BYE Request 44 | 45 | Terminated --> [*] 46 | 47 | note right of Early: Dialog establishing 48 | note right of Confirmed: Active dialog 49 | note right of Terminated: Dialog ended 50 | ``` 51 | 52 | ## Media Session State Machine 53 | 54 | The media session state machine handles audio streaming: 55 | 56 | ```mermaid 57 | stateDiagram-v2 58 | [*] --> Idle: Session Created 59 | 60 | Idle --> Negotiating: SDP Offer 61 | 62 | Negotiating --> Ready: SDP Answer 63 | Negotiating --> Failed: Error 64 | 65 | Ready --> Active: Start RTP 66 | 67 | Active --> Stopping: End Call 68 | 69 | Stopping --> [*] 70 | Failed --> [*] 71 | 72 | note right of Idle: No media yet 73 | note right of Negotiating: Exchange SDP 74 | note right of Ready: Ports allocated 75 | note right of Active: Audio flowing 76 | note right of Stopping: Cleanup 77 | ``` 78 | 79 | ## How They Work Together 80 | 81 | 1. **Transaction** ensures reliable message delivery 82 | 2. **Dialog** maintains the call context 83 | 3. **Media** handles the actual audio stream 84 | 85 | All three use Erlang's `gen_statem` behavior for robust state management. -------------------------------------------------------------------------------- /test/parrot/sip/headers/to_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.ToTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing To headers" do 7 | test "parses To header with display name and tag" do 8 | header_value = "Bob ;tag=a6c85cf" 9 | 10 | to = Headers.To.parse(header_value) 11 | 12 | assert to.display_name == "Bob" 13 | uri = to.uri 14 | assert is_struct(uri, Parrot.Sip.Uri) 15 | assert uri.scheme == "sip" 16 | assert uri.user == "bob" 17 | assert uri.host == "biloxi.com" 18 | assert to.parameters["tag"] == "a6c85cf" 19 | 20 | formatted = Headers.To.format(to) 21 | assert String.match?(formatted, ~r/Bob ;tag=a6c85cf/) 22 | end 23 | 24 | test "parses To header without display name" do 25 | header_value = ";tag=a6c85cf" 26 | 27 | to = Headers.To.parse(header_value) 28 | 29 | assert to.display_name == nil 30 | uri = to.uri 31 | assert is_struct(uri, Parrot.Sip.Uri) 32 | assert uri.scheme == "sip" 33 | assert uri.user == "bob" 34 | assert uri.host == "biloxi.com" 35 | assert to.parameters["tag"] == "a6c85cf" 36 | 37 | formatted = Headers.To.format(to) 38 | assert String.match?(formatted, ~r/;tag=a6c85cf/) 39 | end 40 | 41 | test "parses To header without tag" do 42 | header_value = "Bob " 43 | 44 | to = Headers.To.parse(header_value) 45 | 46 | assert to.display_name == "Bob" 47 | uri = to.uri 48 | assert is_struct(uri, Parrot.Sip.Uri) 49 | assert uri.scheme == "sip" 50 | assert uri.user == "bob" 51 | assert uri.host == "biloxi.com" 52 | assert to.parameters == %{} 53 | 54 | formatted = Headers.To.format(to) 55 | assert String.match?(formatted, ~r/Bob /) 56 | end 57 | end 58 | 59 | describe "creating To headers" do 60 | test "creates To header" do 61 | to = Headers.To.new("sip:bob@biloxi.com", "Bob", %{"tag" => "a6c85cf"}) 62 | 63 | assert to.display_name == "Bob" 64 | uri = to.uri 65 | assert is_struct(uri, Parrot.Sip.Uri) 66 | assert uri.scheme == "sip" 67 | assert uri.user == "bob" 68 | assert uri.host == "biloxi.com" 69 | assert to.parameters["tag"] == "a6c85cf" 70 | 71 | formatted = Headers.To.format(to) 72 | assert String.match?(formatted, ~r/Bob ;tag=a6c85cf/) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /usage-rules/media.md: -------------------------------------------------------------------------------- 1 | # Parrot Media Handling Rules 2 | 3 | ## MediaHandler Behaviour 4 | 5 | The `Parrot.MediaHandler` behaviour provides callbacks for media session events. 6 | 7 | ### Required callback 8 | ```elixir 9 | @impl true 10 | def init(args), do: {:ok, initial_state} 11 | ``` 12 | 13 | ### Key callbacks for audio playback 14 | ```elixir 15 | # Called when media stream starts 16 | def handle_stream_start(session_id, direction, state) do 17 | # Return media action 18 | {{:play, "audio.wav"}, state} 19 | end 20 | 21 | # Called when audio playback completes 22 | def handle_play_complete(file_path, state) do 23 | # Return next action or :stop 24 | {:stop, state} 25 | end 26 | ``` 27 | 28 | ### Media actions 29 | - `{:play, file_path}` - Play audio file 30 | - `{:play, file_path, opts}` - Play with options 31 | - `:stop` - Stop media 32 | - `:pause` - Pause playback 33 | - `:resume` - Resume playback 34 | - `:noreply` - No action 35 | 36 | 37 | ### Codec negotiation 38 | ```elixir 39 | def handle_codec_negotiation(offered, supported, state) do 40 | # Select preferred codec 41 | cond do 42 | :pcmu in offered and :pcmu in supported -> {:ok, :pcmu, state} 43 | :pcma in offered and :pcma in supported -> {:ok, :pcma, state} 44 | true -> {:error, :no_common_codec, state} 45 | end 46 | end 47 | ``` 48 | 49 | ## MediaSession Integration 50 | 51 | Always create MediaSession in your SipHandler: 52 | 53 | ```elixir 54 | def handle_invite(request, state) do 55 | {:ok, _pid} = Parrot.Media.MediaSession.start_link( 56 | id: "call_#{System.unique_integer()}", 57 | role: :uas, # or :uac for outbound 58 | media_handler: __MODULE__, 59 | handler_args: %{welcome: "welcome.wav"} 60 | ) 61 | 62 | # Process SDP and respond 63 | case Parrot.Media.MediaSession.process_offer(id, request.body) do 64 | {:ok, sdp_answer} -> {:respond, 200, "OK", %{}, sdp_answer} 65 | {:error, _} -> {:respond, 488, "Not Acceptable Here", %{}, ""} 66 | end 67 | end 68 | ``` 69 | ``` 70 | ``` 71 | ``` 72 | ``` 73 | 74 | ## Common Media Patterns 75 | 76 | ### Playlist 77 | ```elixir 78 | def init(args) do 79 | {:ok, %{playlist: ["welcome.wav", "menu.wav", "goodbye.wav"], index: 0}} 80 | end 81 | 82 | def handle_stream_start(_, :outbound, state) do 83 | {{:play, Enum.at(state.playlist, 0)}, state} 84 | end 85 | 86 | def handle_play_complete(_, state) do 87 | next_index = state.index + 1 88 | if next_index < length(state.playlist) do 89 | {{:play, Enum.at(state.playlist, next_index)}, %{state | index: next_index}} 90 | else 91 | {:stop, state} 92 | end 93 | end 94 | ``` 95 | 96 | -------------------------------------------------------------------------------- /test/sipp/scenarios/basic/uac_invite_pcma.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ;tag=[call_number] 10 | To: sut 11 | Call-ID: [call_id] 12 | Cseq: 1 INVITE 13 | Contact: sip:sipp@[local_ip]:[local_port] 14 | Content-Type: application/sdp 15 | Content-Length: [len] 16 | 17 | v=0 18 | o=user1 53655765 2353687637 IN IP4 [local_ip] 19 | s=- 20 | t=0 0 21 | c=IN IP4 [local_ip] 22 | m=audio 6000 RTP/AVP 8 23 | a=rtpmap:8 PCMA/8000 24 | ]]> 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ;tag=[call_number] 39 | To: sut [peer_tag_param] 40 | Call-ID: [call_id] 41 | Cseq: 1 ACK 42 | Contact: sip:sipp@[local_ip]:[local_port] 43 | Content-Length: 0 44 | ]]> 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ;tag=[call_number] 56 | To: sut [peer_tag_param] 57 | Call-ID: [call_id] 58 | Cseq: 2 BYE 59 | Contact: sip:sipp@[local_ip]:[local_port] 60 | Content-Length: 0 61 | ]]> 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /lib/parrot.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot do 2 | @moduledoc """ 3 | Parrot Platform - Putting the "T" back in OTP. 4 | 5 | Parrot Platform provides Elixir libraries and OTP behaviours for building real-time 6 | communication applications. It includes a complete SIP protocol stack implementation 7 | with integrated media handling capabilities. 8 | 9 | ## Overview 10 | 11 | Parrot provides two main OTP behaviours for building VoIP applications: 12 | 13 | 1. **`Parrot.SipHandler`** - For handling SIP protocol events 14 | 2. **`Parrot.MediaHandler`** - For handling media session events 15 | 16 | ## Quick Start 17 | 18 | Here's a minimal example that handles both SIP and media: 19 | 20 | defmodule MyVoIPApp do 21 | use Parrot.SipHandler 22 | @behaviour Parrot.MediaHandler 23 | 24 | # Handle incoming calls 25 | @impl true 26 | def handle_invite(request, state) do 27 | # Create media session 28 | {:ok, _pid} = Parrot.Media.MediaSession.start_link( 29 | id: "call_123", 30 | role: :uas, 31 | media_handler: __MODULE__, 32 | handler_args: %{welcome_file: "welcome.wav"} 33 | ) 34 | 35 | # Accept the call 36 | {:respond, 200, "OK", %{}, sdp_answer} 37 | end 38 | 39 | # Play audio when media starts 40 | @impl Parrot.MediaHandler 41 | def handle_stream_start(session_id, :outbound, state) do 42 | {{:play, state.welcome_file}, state} 43 | end 44 | end 45 | 46 | ## Architecture 47 | 48 | Parrot uses Erlang's `gen_statem` behaviour extensively for managing: 49 | 50 | - **Transactions**: SIP transaction state machines (RFC 3261 compliant) 51 | - **Dialogs**: SIP dialog lifecycle management 52 | - **Media Sessions**: RTP audio streaming state management 53 | 54 | ## Features 55 | 56 | - Full SIP protocol stack (RFC 3261) 57 | - G.711 audio codec support (PCMU/PCMA) 58 | - RTP/RTCP media streaming 59 | - Extensible handler pattern 60 | - Built on Membrane multimedia framework 61 | - Production-ready supervision trees 62 | 63 | ## Components 64 | 65 | - `Parrot.Sip` - SIP protocol implementation 66 | - `Parrot.Media` - Media handling and RTP streaming 67 | - `Parrot.SipHandler` - Behaviour for SIP event callbacks 68 | - `Parrot.MediaHandler` - Behaviour for media event callbacks 69 | 70 | See the [Getting Started](overview.html) guide or 71 | [ParrotExampleApp](https://github.com/source/parrot/examples/parrot_example_uas) 72 | for complete examples. 73 | """ 74 | end 75 | -------------------------------------------------------------------------------- /test/sipp/scenarios/advanced/uac_invite_rtp.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ;tag=[call_number] 9 | To: sut 10 | Call-ID: [call_id] 11 | Cseq: 1 INVITE 12 | Contact: sip:sipp@[local_ip]:[local_port] 13 | Content-Type: application/sdp 14 | Content-Length: [len] 15 | 16 | v=0 17 | o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] 18 | s=- 19 | t=0 0 20 | c=IN IP[media_ip_type] [media_ip] 21 | m=audio [media_port] RTP/AVP 0 22 | a=rtpmap:0 PCMU/8000 23 | ]]> 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ;tag=[call_number] 48 | To: sut [peer_tag_param] 49 | Call-ID: [call_id] 50 | Cseq: 1 ACK 51 | Contact: sip:sipp@[local_ip]:[local_port] 52 | Content-Length: 0 53 | ]]> 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | ;tag=[call_number] 72 | To: sut [peer_tag_param] 73 | Call-ID: [call_id] 74 | Cseq: 2 BYE 75 | Contact: sip:sipp@[local_ip]:[local_port] 76 | Content-Length: 0 77 | ]]> 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /lib/parrot/sip/dns/resolver.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Dns.Resolver do 2 | require Logger 3 | 4 | @doc """ 5 | Resolves a SIP URI host to IP address and port using DNS SRV records. 6 | Falls back to A record lookup if SRV lookup fails. 7 | """ 8 | def resolve(host, transport \\ :udp) when is_binary(host) do 9 | # Convert host to charlist for erlang DNS functions 10 | host_charlist = String.to_charlist(host) 11 | 12 | # Try SRV lookup first 13 | case srv_lookup(host_charlist, transport) do 14 | {:ok, {ip, port}} -> 15 | {:ok, {ip, port}} 16 | 17 | {:error, _reason} -> 18 | # Fallback to A record lookup 19 | case a_record_lookup(host_charlist) do 20 | {:ok, ip} -> {:ok, {ip, default_port(transport)}} 21 | {:error, _} = error -> error 22 | end 23 | end 24 | end 25 | 26 | @doc """ 27 | Performs SRV record lookup for SIP services. 28 | """ 29 | def srv_lookup(host, transport) do 30 | service = service_prefix(transport) 31 | srv_record = ~c"_sip._#{service}.#{host}" 32 | 33 | case :inet_res.lookup(srv_record, :in, :srv) do 34 | [] -> 35 | {:error, :no_srv_record} 36 | 37 | records when is_list(records) -> 38 | # Sort by priority and weight 39 | sorted_records = sort_srv_records(records) 40 | 41 | case select_record(sorted_records) do 42 | {_priority, _weight, port, target} -> 43 | case a_record_lookup(target) do 44 | {:ok, ip} -> {:ok, {ip, port}} 45 | error -> error 46 | end 47 | 48 | nil -> 49 | {:error, :no_valid_srv_record} 50 | end 51 | end 52 | end 53 | 54 | @doc """ 55 | Performs A record lookup. 56 | """ 57 | def a_record_lookup(host) do 58 | case :inet_res.lookup(host, :in, :a) do 59 | [] -> {:error, :no_a_record} 60 | [ip | _rest] -> {:ok, ip} 61 | end 62 | end 63 | 64 | # Private functions 65 | 66 | defp service_prefix(:udp), do: "udp" 67 | defp service_prefix(:tcp), do: "tcp" 68 | defp service_prefix(:tls), do: "tls" 69 | 70 | defp default_port(:udp), do: 5060 71 | defp default_port(:tcp), do: 5060 72 | defp default_port(:tls), do: 5061 73 | 74 | defp sort_srv_records(records) do 75 | Enum.sort(records, fn {priority1, weight1, _, _}, {priority2, weight2, _, _} -> 76 | cond do 77 | priority1 < priority2 -> true 78 | priority1 > priority2 -> false 79 | weight1 >= weight2 -> true 80 | true -> false 81 | end 82 | end) 83 | end 84 | 85 | defp select_record([]), do: nil 86 | defp select_record([record | _]), do: record 87 | end 88 | -------------------------------------------------------------------------------- /lib/parrot/sip/handler_adapter/handler_adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.HandlerAdapter do 2 | @moduledoc """ 3 | Adapter between the user-friendly Handler API and the internal SIP stack. 4 | 5 | This module serves as the public API for the HandlerAdapter system, allowing 6 | user code to interface with the SIP stack through a simplified API. 7 | 8 | Under the hood, it uses gen_statem processes to manage the lifecycle of SIP 9 | transactions and dialogs, with each instance handling a single primary request. 10 | """ 11 | 12 | @doc """ 13 | Creates a new HandlerAdapter instance. 14 | 15 | This function creates a new Handler struct that wraps the user's handler module, 16 | allowing it to interface with the SIP stack's internal handler system. 17 | 18 | ## Parameters 19 | 20 | * `user_handler_module` - The module that implements the `Parrot.Handler` behaviour 21 | * `user_handler_state` - The initial state to pass to the user handler's callbacks 22 | 23 | ## Returns 24 | 25 | A new Handler struct that can be used with the SIP stack. 26 | 27 | ## Example 28 | 29 | {:ok, handler} = MyApp.SipHandler.start_link([]) 30 | adapter = HandlerAdapter.new(MyApp.SipHandler, handler) 31 | """ 32 | def new(user_handler_module, user_handler_state) do 33 | Parrot.Sip.HandlerAdapter.Core.new(user_handler_module, user_handler_state) 34 | end 35 | 36 | @doc """ 37 | Returns a child specification for starting this adapter under a supervisor. 38 | 39 | This function conforms to the OTP child specification and is used when the 40 | HandlerAdapter is added to a supervision tree. 41 | 42 | ## Parameters 43 | 44 | * `args` - Arguments to pass to the `start_link/1` function 45 | 46 | ## Returns 47 | 48 | A child specification map compatible with supervisors. 49 | 50 | ## Example 51 | 52 | Supervisor.start_child(MySupervisor, HandlerAdapter.child_spec(args)) 53 | """ 54 | def child_spec(args) do 55 | Parrot.Sip.HandlerAdapter.Core.child_spec(args) 56 | end 57 | 58 | @doc """ 59 | Starts a new HandlerAdapter process. 60 | 61 | Initializes a new gen_statem process that will handle a single SIP transaction, 62 | interfacing between the user's handler module and the internal SIP stack. 63 | 64 | ## Parameters 65 | 66 | * `args` - A tuple containing the user's handler module and state 67 | 68 | ## Returns 69 | 70 | `{:ok, pid}` if the process was started successfully, or `{:error, reason}` if it failed. 71 | 72 | This function is typically called by the HandlerAdapterSupervisor rather than directly. 73 | """ 74 | def start_link(args) do 75 | Parrot.Sip.HandlerAdapter.Core.start_link(args) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/support/test_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.TestHandler do 2 | @moduledoc """ 3 | Test handler module for SIP unit tests. 4 | 5 | This module implements the Parrot.Sip.Handler behavior and provides 6 | simple implementations that can be used in unit tests without requiring 7 | complex setup. 8 | """ 9 | 10 | @behaviour Parrot.Sip.Handler 11 | 12 | require Logger 13 | 14 | @impl Parrot.Sip.Handler 15 | def transp_request(_msg, _args) do 16 | Logger.debug("TestHandler: transp_request called") 17 | :process_transaction 18 | end 19 | 20 | @impl Parrot.Sip.Handler 21 | def transaction(_trans, sip_msg, _args) do 22 | method = sip_msg.method 23 | Logger.debug("TestHandler: transaction called for method: #{method}") 24 | 25 | case method do 26 | :invite -> :process_uas 27 | :register -> :process_uas 28 | :bye -> :process_uas 29 | :cancel -> :ok 30 | :ack -> :ok 31 | _ -> :process_uas 32 | end 33 | end 34 | 35 | @impl Parrot.Sip.Handler 36 | def transaction_stop(_trans, reason, _args) do 37 | Logger.debug("TestHandler: transaction_stop called with reason: #{inspect(reason)}") 38 | :ok 39 | end 40 | 41 | @impl Parrot.Sip.Handler 42 | def uas_request(_uas, sip_msg, _args) do 43 | method = sip_msg.method 44 | Logger.debug("TestHandler: uas_request called for method: #{method}") 45 | :ok 46 | end 47 | 48 | @impl Parrot.Sip.Handler 49 | def uas_cancel(_uas_id, _args) do 50 | Logger.debug("TestHandler: uas_cancel called") 51 | :ok 52 | end 53 | 54 | @impl Parrot.Sip.Handler 55 | def process_ack(_sip_msg, _args) do 56 | Logger.debug("TestHandler: process_ack called") 57 | :ok 58 | end 59 | 60 | @doc """ 61 | Creates a proper Handler struct for use in tests. 62 | 63 | Uses test configuration from environment variables: 64 | - LOG_LEVEL: Controls log level (default: info) 65 | - SIP_TRACE: Enables SIP message tracing (default: false) 66 | 67 | ## Examples 68 | 69 | iex> handler = Parrot.Sip.TestHandler.new() 70 | iex> handler.module 71 | Parrot.Sip.TestHandler 72 | """ 73 | def new(args \\ nil, opts \\ []) do 74 | # Get test configuration from Application env (set in config/test.exs) 75 | test_log_level = Application.get_env(:parrot_platform, :test_log_level, :warning) 76 | test_sip_trace = Application.get_env(:parrot_platform, :test_sip_trace, false) 77 | 78 | # Allow overrides from opts 79 | log_level = Keyword.get(opts, :log_level, test_log_level) 80 | sip_trace = Keyword.get(opts, :sip_trace, test_sip_trace) 81 | 82 | Parrot.Sip.Handler.new(__MODULE__, args, 83 | log_level: log_level, 84 | sip_trace: sip_trace 85 | ) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/parrot/sip/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Handler do 2 | @moduledoc """ 3 | Parrot SIP Stack 4 | SIP stack handler 5 | """ 6 | 7 | @type handler :: %__MODULE__{ 8 | module: module(), 9 | args: term(), 10 | log_level: atom() | nil, 11 | sip_trace: boolean() | nil 12 | } 13 | 14 | @type transp_request_ret :: :noreply | :process_transaction 15 | 16 | defstruct [:module, :args, :log_level, :sip_trace] 17 | 18 | @callback transp_request(Parrot.Sip.Message.t(), any()) :: :process_transaction | :noreply 19 | @callback transaction(Parrot.Sip.Transaction.t(), Parrot.Sip.Message.t(), any()) :: 20 | :process_uas | :ok 21 | @callback transaction_stop(Parrot.Sip.Transaction.t(), any(), any()) :: :ok 22 | @callback uas_request(Parrot.Sip.UAS.t(), Parrot.Sip.Message.t(), any()) :: :ok 23 | @callback uas_cancel(Parrot.Sip.UAS.id(), any()) :: :ok 24 | @callback process_ack(Parrot.Sip.Message.t(), any()) :: :ok 25 | 26 | @spec new(module(), any()) :: handler() 27 | @spec new(module(), any(), keyword()) :: handler() 28 | 29 | def new(module, args, opts \\ []) do 30 | %__MODULE__{ 31 | module: module, 32 | args: args, 33 | log_level: Keyword.get(opts, :log_level), 34 | sip_trace: Keyword.get(opts, :sip_trace) 35 | } 36 | end 37 | 38 | @spec args(handler()) :: any() 39 | def args(%__MODULE__{args: args}), do: args 40 | 41 | @spec transp_request(Parrot.Sip.Message.t(), handler()) :: transp_request_ret() 42 | def transp_request(msg, %__MODULE__{module: mod, args: args}) do 43 | mod.transp_request(msg, args) 44 | end 45 | 46 | @spec transaction(Parrot.Sip.Transaction.t(), Parrot.Sip.Message.t(), handler()) :: 47 | :ok | :process_uas 48 | def transaction(trans, sip_msg, %__MODULE__{module: mod, args: args}) do 49 | mod.transaction(trans, sip_msg, args) 50 | end 51 | 52 | @spec transaction_stop(Parrot.Sip.Transaction.t(), term(), handler()) :: :ok 53 | def transaction_stop(trans, trans_result, %__MODULE__{module: mod, args: args}) do 54 | mod.transaction_stop(trans, trans_result, args) 55 | end 56 | 57 | @spec uas_request(Parrot.Sip.UAS.t(), Parrot.Sip.Message.t(), handler()) :: :ok 58 | def uas_request(uas, req_sip_msg, %__MODULE__{module: mod, args: args}) do 59 | mod.uas_request(uas, req_sip_msg, args) 60 | end 61 | 62 | @spec uas_cancel(Parrot.Sip.UAS.id(), handler()) :: :ok 63 | def uas_cancel(uas_id, %__MODULE__{module: mod, args: args}) do 64 | mod.uas_cancel(uas_id, args) 65 | end 66 | 67 | @spec process_ack(Parrot.Sip.Message.t(), handler()) :: :ok 68 | def process_ack(sip_msg, %__MODULE__{module: mod, args: args}) do 69 | mod.process_ack(sip_msg, args) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Parrot Logo 3 |

4 | 5 |

6 | 7 | Build Status 8 | 9 | 10 | Hex Version 11 | 12 | 13 | Hex Docs 14 | 15 |

16 | 17 | Ask DeepWiki 18 | 19 | # Parrot Platform 20 | 21 | > Putting the "T" back in OTP. 22 | 23 | Parrot Platform provides Elixir libraries and OTP behaviours for building real-time communication services using SIP and RTP. 24 | 25 | ## Key Features 26 | 27 | - **SIP Protocol Stack**: Full RFC 3261 compliant SIP implementation 28 | - **Handler Pattern**: Flexible callback system for SIP events 29 | - `Parrot.UasHandler` for UAS (server) applications 30 | - `Parrot.UacHandler` for UAC (client) applications 31 | - **RTP Audio**: Built-in support for G.711 (PCMA) audio streaming 32 | - **Audio Devices**: System audio device support via PortAudio plugin 33 | - **gen_statem Architecture**: Robust state machine implementation for transactions and dialogs 34 | - **Media Integration**: Audio processing through Membrane multimedia libraries 35 | 36 | ## Getting started 37 | 38 | Get started with the Parrot Platform by follow the instructions at https://hexdocs.pm/parrot_platform/overview.html#quick-start 39 | 40 | ### Brandon's Notes 41 | 42 | Next steps: 43 | - [ ] Add git push hook check for mix format 44 | - [ ] Get github actions working / CICD passing 45 | - [ ] add OPUS support to uas and uac examples and generators 46 | - [ ] create proper silence generator for scenarios where we need it 47 | - [ ] better pattern matching in media modules 48 | - [ ] better pattern matching in examples/generators 49 | - [ ] implement B2BUA and media "bridge"-ing 50 | - [ ] load test 51 | - [x] create Parrot Platform audio file welcome message for basic sample app generator to use 52 | - [x] update Parrot.SipHandler to Parrot.UasHandler 53 | - [x] figure out if Handler adapter is a good way to handle things 54 | - [x] Control Parrot logging levels from the handler behavior 55 | - [x] update docs to show a pattern matching example of INVITE handling 56 | - [x] build basic app generator (mix parrot.gen.uas creates UAS applications) 57 | 58 | ## License 59 | 60 | This project is licensed under the [GNU General Public License v2.0](./LICENSE). 61 | -------------------------------------------------------------------------------- /lib/parrot/sip/headers/subject.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.Subject do 2 | @moduledoc """ 3 | Module for working with SIP Subject headers as defined in RFC 3261 Section 20.36. 4 | 5 | The Subject header field provides a summary or indicates the nature of the call, 6 | allowing call filtering without having to parse the session description. 7 | The session description does not have to use the same subject indication as 8 | the invitation. 9 | 10 | The Subject header serves several purposes: 11 | - Providing human-readable information about the call or message 12 | - Enabling call filtering and screening based on subject 13 | - Supporting automatic call distribution (ACD) systems 14 | - Facilitating call logging and history display 15 | 16 | The Subject header is similar to the Subject header in email and follows 17 | similar conventions. It supports UTF-8 encoding for internationalization. 18 | 19 | References: 20 | - RFC 3261 Section 20.36: Subject Header Field 21 | - RFC 2047: MIME (Multipurpose Internet Mail Extensions) Part Three 22 | - RFC 2822: Internet Message Format (Subject header precedent) 23 | """ 24 | 25 | defstruct [:value] 26 | 27 | @type t :: %__MODULE__{ 28 | value: String.t() 29 | } 30 | 31 | @doc """ 32 | Creates a new Subject header. 33 | 34 | ## Examples 35 | 36 | iex> Parrot.Sip.Headers.Subject.new("Project X Discussion") 37 | %Parrot.Sip.Headers.Subject{value: "Project X Discussion"} 38 | """ 39 | @spec new(String.t()) :: t() 40 | def new(value) when is_binary(value) do 41 | %__MODULE__{value: value} 42 | end 43 | 44 | @doc """ 45 | Parses a Subject header string into a struct. 46 | 47 | ## Examples 48 | 49 | iex> Parrot.Sip.Headers.Subject.parse("Project X Discussion") 50 | %Parrot.Sip.Headers.Subject{value: "Project X Discussion"} 51 | """ 52 | @spec parse(String.t()) :: t() 53 | def parse(string) when is_binary(string) do 54 | %__MODULE__{value: string} 55 | end 56 | 57 | @doc """ 58 | Formats a Subject struct as a string. 59 | 60 | ## Examples 61 | 62 | iex> subject = %Parrot.Sip.Headers.Subject{value: "Project X Discussion"} 63 | iex> Parrot.Sip.Headers.Subject.format(subject) 64 | "Project X Discussion" 65 | """ 66 | @spec format(t()) :: String.t() 67 | def format(%__MODULE__{} = subject) do 68 | subject.value 69 | end 70 | 71 | @doc """ 72 | Alias for format/1 for consistency with other header modules. 73 | 74 | ## Examples 75 | 76 | iex> subject = %Parrot.Sip.Headers.Subject{value: "Project X Discussion"} 77 | iex> Parrot.Sip.Headers.Subject.to_string(subject) 78 | "Project X Discussion" 79 | """ 80 | @spec to_string(t()) :: String.t() 81 | def to_string(%__MODULE__{} = subject), do: format(subject) 82 | end 83 | -------------------------------------------------------------------------------- /lib/parrot/sip/source.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Source do 2 | @moduledoc """ 3 | SIP Source module. 4 | 5 | Represents the source of a SIP message, including local and remote addresses, 6 | transport, and source identifier. 7 | """ 8 | 9 | @typedoc """ 10 | Represents a SIP message source 11 | """ 12 | @type t :: %__MODULE__{ 13 | local: {:inet.ip_address(), :inet.port_number()}, 14 | remote: {:inet.ip_address(), :inet.port_number()}, 15 | transport: atom(), 16 | source_id: String.t() | nil 17 | } 18 | 19 | @type source_id :: {module(), term()} 20 | 21 | defstruct [:local, :remote, :transport, :source_id] 22 | 23 | @doc """ 24 | Creates a new source struct. 25 | 26 | ## Parameters 27 | - `local`: Tuple containing local IP address and port 28 | - `remote`: Tuple containing remote IP address and port 29 | - `transport`: Transport protocol (e.g., `:udp`, `:tcp`, `:tls`) 30 | - `source_id`: Optional source identifier 31 | 32 | ## Returns 33 | - `t()`: A new source struct 34 | """ 35 | @spec new( 36 | local :: {:inet.ip_address(), :inet.port_number()}, 37 | remote :: {:inet.ip_address(), :inet.port_number()}, 38 | transport :: atom(), 39 | source_id :: String.t() | nil 40 | ) :: t() 41 | def new(local, remote, transport, source_id \\ nil) do 42 | %__MODULE__{ 43 | local: local, 44 | remote: remote, 45 | transport: transport, 46 | source_id: source_id 47 | } 48 | end 49 | 50 | @doc """ 51 | Creates a source ID from a module and options. 52 | 53 | ## Parameters 54 | - `module`: The module associated with the source 55 | - `options`: Source-specific options 56 | 57 | ## Returns 58 | - `source_id()`: A source ID tuple 59 | """ 60 | @spec make_source_id(module(), term()) :: source_id() 61 | def make_source_id(module, options) do 62 | {module, options} 63 | end 64 | 65 | @doc """ 66 | Gets the local address from a source. 67 | 68 | ## Parameters 69 | - `source`: Source struct 70 | 71 | ## Returns 72 | - Tuple of {host, port} 73 | """ 74 | @spec local(t()) :: {:inet.ip_address(), :inet.port_number()} 75 | def local(%__MODULE__{local: local}), do: local 76 | 77 | @doc """ 78 | Gets the remote address from a source. 79 | 80 | ## Parameters 81 | - `source`: Source struct 82 | 83 | ## Returns 84 | - Tuple of {host, port} 85 | """ 86 | @spec remote(t()) :: {:inet.ip_address(), :inet.port_number()} 87 | def remote(%__MODULE__{remote: remote}), do: remote 88 | 89 | @doc """ 90 | Sends a response to a source. 91 | 92 | ## Parameters 93 | - `response`: SIP message containing a response 94 | - `message`: Original SIP message 95 | 96 | ## Returns 97 | - `:ok`: Response was sent successfully 98 | """ 99 | @spec send_response(Parrot.Sip.Message.t(), Parrot.Sip.Message.t()) :: :ok 100 | def send_response(_response, _message) do 101 | # This is a callback that will be implemented by transport modules 102 | # For now, just return :ok as a placeholder 103 | :ok 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/parrot/sip/handlers/audio_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Handlers.AudioHandler do 2 | @moduledoc """ 3 | SIP handler that plays audio files during calls. 4 | 5 | This handler responds to INVITE requests by: 6 | 1. Parsing the SDP to get remote RTP endpoint 7 | 2. Starting a media session 8 | 3. Playing audio when the call is established 9 | """ 10 | 11 | @behaviour Parrot.UasHandler 12 | 13 | require Logger 14 | 15 | alias Parrot.Media.MediaSession 16 | 17 | defstruct [ 18 | :media_session, 19 | :call_state, 20 | :remote_sdp, 21 | :local_rtp_port 22 | ] 23 | 24 | @impl true 25 | def init(_args) do 26 | # Generate a random local RTP port 27 | local_rtp_port = 20000 + :rand.uniform(10000) 28 | 29 | {:ok, 30 | %__MODULE__{ 31 | call_state: :idle, 32 | local_rtp_port: local_rtp_port 33 | }} 34 | end 35 | 36 | @impl true 37 | def handle_invite(msg, state) do 38 | Logger.info("AudioHandler: Received INVITE") 39 | 40 | # Parse SDP from request body 41 | if msg.body == "" do 42 | Logger.error("No SDP in INVITE request") 43 | {:respond, 400, "Bad Request", %{}, "", state} 44 | else 45 | # Start media session with a unique ID 46 | session_id = "audio_handler_#{:erlang.phash2({msg.headers["call-id"], :os.timestamp()})}" 47 | 48 | {:ok, session} = 49 | MediaSession.start_link( 50 | id: session_id, 51 | audio_file: get_audio_file() 52 | ) 53 | 54 | # Process the SDP offer 55 | case MediaSession.process_offer(session, msg.body) do 56 | {:ok, sdp_answer} -> 57 | new_state = %{state | media_session: session, call_state: :ringing} 58 | 59 | # Send 200 OK with SDP answer 60 | headers = %{"content-type" => "application/sdp"} 61 | {:respond, 200, "OK", headers, sdp_answer, new_state} 62 | 63 | {:error, reason} -> 64 | Logger.error("Failed to process SDP offer: #{inspect(reason)}") 65 | {:respond, 488, "Not Acceptable Here", %{}, "", state} 66 | end 67 | end 68 | end 69 | 70 | @impl true 71 | def handle_ack(_msg, state) do 72 | Logger.info("AudioHandler: Received ACK") 73 | 74 | if state.media_session do 75 | # Start playing audio 76 | MediaSession.start_media(state.media_session) 77 | end 78 | 79 | {:ok, %{state | call_state: :active}} 80 | end 81 | 82 | @impl true 83 | def handle_bye(_msg, state) do 84 | Logger.info("AudioHandler: Received BYE") 85 | 86 | # Stop media session 87 | if state.media_session do 88 | MediaSession.terminate_session(state.media_session) 89 | end 90 | 91 | {:respond, 200, "OK", %{}, "", %{state | call_state: :terminated}} 92 | end 93 | 94 | @impl true 95 | def handle_info(_msg, state) do 96 | {:noreply, state} 97 | end 98 | 99 | # Private functions 100 | 101 | defp get_audio_file do 102 | # For now, use a default file 103 | # In production, this would be configurable 104 | Path.join(:code.priv_dir(:parrot_platform), "audio/music.pcmu") 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/parrot/media/g711_chunker.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.G711Chunker do 2 | @moduledoc """ 3 | Chunks G.711 encoded audio into RTP-sized packets. 4 | 5 | This module is a Membrane filter that takes G.711 encoded audio buffers and 6 | splits them into appropriately sized chunks for RTP transmission. For G.711 7 | audio at 8kHz, each 20ms RTP packet contains 160 samples (160 bytes). 8 | 9 | ## Example 10 | 11 | # In your pipeline 12 | child(:g711_chunker, %Parrot.Media.G711Chunker{ 13 | chunk_duration: 20 # milliseconds 14 | }) 15 | 16 | ## Options 17 | 18 | * `:chunk_duration` - Duration of each chunk in milliseconds (default: 20ms) 19 | """ 20 | 21 | use Membrane.Filter 22 | 23 | alias Membrane.{Buffer, G711} 24 | 25 | def_input_pad(:input, accepted_format: G711, flow_control: :auto) 26 | def_output_pad(:output, accepted_format: G711, flow_control: :auto) 27 | 28 | def_options( 29 | chunk_duration: [ 30 | spec: pos_integer(), 31 | default: 20, 32 | description: "Chunk duration in milliseconds" 33 | ] 34 | ) 35 | 36 | @impl true 37 | def handle_init(_ctx, opts) do 38 | # For G.711: 8000 Hz sample rate, 1 byte per sample 39 | # samples per chunk 40 | chunk_size = div(8000 * opts.chunk_duration, 1000) 41 | 42 | {[], 43 | %{ 44 | chunk_size: chunk_size, 45 | accumulator: <<>>, 46 | pts: 0, 47 | chunk_duration_ns: opts.chunk_duration * 1_000_000 48 | }} 49 | end 50 | 51 | @impl true 52 | def handle_stream_format(:input, stream_format, _ctx, state) do 53 | {[stream_format: {:output, stream_format}], state} 54 | end 55 | 56 | @impl true 57 | def handle_buffer(:input, %Buffer{payload: payload} = buffer, _ctx, state) do 58 | accumulator = state.accumulator <> payload 59 | 60 | {buffers, rest, pts} = 61 | chunk_data(accumulator, state.chunk_size, state.pts, state.chunk_duration_ns, []) 62 | 63 | state = %{state | accumulator: rest, pts: pts} 64 | 65 | # Preserve metadata from original buffer on the chunks 66 | output_buffers = 67 | Enum.map(buffers, fn {chunk, chunk_pts} -> 68 | %Buffer{ 69 | payload: chunk, 70 | pts: chunk_pts, 71 | metadata: buffer.metadata 72 | } 73 | end) 74 | 75 | {[buffer: {:output, output_buffers}], state} 76 | end 77 | 78 | @impl true 79 | def handle_end_of_stream(:input, _ctx, state) do 80 | # Send any remaining data as the last chunk 81 | actions = 82 | if byte_size(state.accumulator) > 0 do 83 | buffer = %Buffer{ 84 | payload: state.accumulator, 85 | pts: state.pts 86 | } 87 | 88 | [buffer: {:output, buffer}, end_of_stream: :output] 89 | else 90 | [end_of_stream: :output] 91 | end 92 | 93 | {actions, state} 94 | end 95 | 96 | defp chunk_data(data, chunk_size, pts, duration, acc) when byte_size(data) >= chunk_size do 97 | <> = data 98 | chunk_data(rest, chunk_size, pts + duration, duration, [{chunk, pts} | acc]) 99 | end 100 | 101 | defp chunk_data(data, _chunk_size, pts, _duration, acc) do 102 | {Enum.reverse(acc), data, pts} 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/parrot/media/simple_resampler.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.SimpleResampler do 2 | @moduledoc """ 3 | A simple audio resampler that downsamples from 48kHz to 8kHz. 4 | This is a basic implementation for testing - production code should use FFmpeg resampler. 5 | """ 6 | 7 | use Membrane.Filter 8 | 9 | alias Membrane.{Buffer, RawAudio} 10 | 11 | def_input_pad(:input, 12 | accepted_format: %RawAudio{ 13 | sample_format: :s16le, 14 | sample_rate: 48000, 15 | channels: 1 16 | }, 17 | availability: :always 18 | ) 19 | 20 | def_output_pad(:output, 21 | accepted_format: %RawAudio{ 22 | sample_format: :s16le, 23 | sample_rate: 8000, 24 | channels: 1 25 | }, 26 | availability: :always 27 | ) 28 | 29 | @impl true 30 | def handle_init(_ctx, _opts) do 31 | state = %{ 32 | accumulator: <<>>, 33 | # 48000 / 8000 = 6 34 | ratio: 6 35 | } 36 | 37 | {[], state} 38 | end 39 | 40 | @impl true 41 | def handle_stream_format(:input, _stream_format, _ctx, state) do 42 | output_format = %RawAudio{ 43 | sample_format: :s16le, 44 | sample_rate: 8000, 45 | channels: 1 46 | } 47 | 48 | {[stream_format: {:output, output_format}], state} 49 | end 50 | 51 | @impl true 52 | def handle_buffer(:input, buffer, _ctx, state) do 53 | # Simple downsampling - take every 6th sample 54 | # This is not a high-quality resampler but works for testing 55 | combined = state.accumulator <> buffer.payload 56 | 57 | # Process complete samples (2 bytes per sample for s16le) 58 | sample_size = 2 59 | 60 | complete_size = 61 | div(byte_size(combined), sample_size * state.ratio) * sample_size * state.ratio 62 | 63 | <> = combined 64 | 65 | # Downsample by taking every 6th sample 66 | downsampled = downsample_audio(to_process, state.ratio) 67 | 68 | output_buffer = %Buffer{ 69 | payload: downsampled, 70 | metadata: buffer.metadata, 71 | pts: buffer.pts, 72 | dts: buffer.dts 73 | } 74 | 75 | new_state = %{state | accumulator: remainder} 76 | 77 | if byte_size(downsampled) > 0 do 78 | {[buffer: {:output, output_buffer}], new_state} 79 | else 80 | {[], new_state} 81 | end 82 | end 83 | 84 | @impl true 85 | def handle_end_of_stream(:input, _ctx, state) do 86 | # Process any remaining samples 87 | if byte_size(state.accumulator) > 0 do 88 | downsampled = downsample_audio(state.accumulator, state.ratio) 89 | 90 | if byte_size(downsampled) > 0 do 91 | buffer = %Buffer{payload: downsampled} 92 | {[buffer: {:output, buffer}, end_of_stream: :output], %{state | accumulator: <<>>}} 93 | else 94 | {[end_of_stream: :output], %{state | accumulator: <<>>}} 95 | end 96 | else 97 | {[end_of_stream: :output], state} 98 | end 99 | end 100 | 101 | defp downsample_audio(data, ratio) do 102 | # Take every nth sample where n = ratio 103 | samples = for <>, do: sample 104 | 105 | samples 106 | |> Enum.take_every(ratio) 107 | |> Enum.map(&<<&1::signed-little-16>>) 108 | |> Enum.join() 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/parrot/sip/headers/content_length.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.ContentLength do 2 | @moduledoc """ 3 | Module for working with SIP Content-Length headers as defined in RFC 3261 Section 20.14. 4 | 5 | The Content-Length header indicates the size of the message body in bytes. It's essential 6 | for message framing, especially when using stream-based transport protocols like TCP. 7 | 8 | Content-Length serves critical functions in SIP: 9 | - Enabling correct framing of messages over stream transports 10 | - Preventing truncated message reception 11 | - Allowing endpoints to determine when a complete message has been received 12 | - Supporting pipelining of multiple requests over a single connection 13 | 14 | As specified in RFC 3261 Section 18.3, if no body is present in a message, the 15 | Content-Length value should be 0. The header is mandatory for messages sent over TCP. 16 | 17 | References: 18 | - RFC 3261 Section 7.4.1: Header Field Format 19 | - RFC 3261 Section 18.3: Framing 20 | - RFC 3261 Section 20.14: Content-Length Header Field 21 | """ 22 | 23 | defstruct [:value] 24 | 25 | @type t :: %__MODULE__{ 26 | value: non_neg_integer() 27 | } 28 | 29 | @doc """ 30 | Creates a new Content-Length header with the specified value. 31 | 32 | ## Examples 33 | 34 | iex> Parrot.Sip.Headers.ContentLength.new(42) 35 | %Parrot.Sip.Headers.ContentLength{value: 42} 36 | 37 | """ 38 | @spec new(non_neg_integer()) :: t() 39 | def new(length) when is_integer(length) and length >= 0 do 40 | %__MODULE__{value: length} 41 | end 42 | 43 | @doc """ 44 | Parses a Content-Length header value. 45 | 46 | ## Examples 47 | 48 | iex> Parrot.Sip.Headers.ContentLength.parse("42") 49 | %Parrot.Sip.Headers.ContentLength{value: 42} 50 | 51 | """ 52 | @spec parse(String.t()) :: t() 53 | def parse(string) when is_binary(string) do 54 | value = 55 | string 56 | |> String.trim() 57 | |> String.to_integer() 58 | 59 | %__MODULE__{value: value} 60 | end 61 | 62 | @doc """ 63 | Formats a Content-Length header value. 64 | 65 | ## Examples 66 | 67 | iex> content_length = %Parrot.Sip.Headers.ContentLength{value: 42} 68 | iex> Parrot.Sip.Headers.ContentLength.format(content_length) 69 | "42" 70 | 71 | """ 72 | @spec format(t()) :: String.t() 73 | def format(%__MODULE__{} = content_length) do 74 | Integer.to_string(content_length.value) 75 | end 76 | 77 | @doc """ 78 | Calculates the Content-Length for a given body. 79 | 80 | ## Examples 81 | 82 | iex> Parrot.Sip.Headers.ContentLength.calculate("Hello, world!") 83 | 13 84 | 85 | """ 86 | @spec calculate(String.t() | nil) :: non_neg_integer() 87 | def calculate(nil), do: 0 88 | 89 | def calculate(body) when is_binary(body) do 90 | byte_size(body) 91 | end 92 | 93 | @doc """ 94 | Creates a Content-Length header value for a given body. 95 | 96 | ## Examples 97 | 98 | iex> Parrot.Sip.Headers.ContentLength.create("Hello, world!") 99 | %Parrot.Sip.Headers.ContentLength{value: 13} 100 | 101 | """ 102 | @spec create(String.t() | nil) :: t() 103 | def create(body) do 104 | new(calculate(body)) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/parrot/sip/headers/cseq.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.CSeq do 2 | @moduledoc """ 3 | Module for working with SIP CSeq headers as defined in RFC 3261 Section 20.16. 4 | 5 | The CSeq (Command Sequence) header field serves as a way to identify and order 6 | transactions within a dialog. It consists of a sequence number and a method name. 7 | 8 | The CSeq serves several critical functions in SIP: 9 | - Uniquely identifying transactions within dialogs 10 | - Distinguishing between new requests and retransmissions 11 | - Ensuring proper message ordering 12 | - Matching responses to requests 13 | 14 | Each new request within a dialog increments the CSeq number. 15 | ACK and CANCEL requests use the same CSeq number as the request they reference 16 | but with different methods, as described in RFC 3261 Sections 17.1.1.3 and 9.1. 17 | 18 | References: 19 | - RFC 3261 Section 8.1.1.5: CSeq 20 | - RFC 3261 Section 12.2.1.1: UAC Behavior - Generating the Request (CSeq in dialogs) 21 | - RFC 3261 Section 17.1.1.3: CSeq for CANCEL 22 | - RFC 3261 Section 20.16: CSeq Header Field 23 | """ 24 | 25 | defstruct [ 26 | # Integer sequence number 27 | :number, 28 | # Atom representing the method (:invite, :ack, etc.) 29 | :method 30 | ] 31 | 32 | @type t :: %__MODULE__{ 33 | number: integer(), 34 | method: atom() 35 | } 36 | 37 | @doc """ 38 | Creates a new CSeq header. 39 | """ 40 | @spec new(integer(), atom()) :: t() 41 | def new(number, method) when is_integer(number) and is_atom(method) do 42 | %__MODULE__{ 43 | number: number, 44 | method: method 45 | } 46 | end 47 | 48 | @doc """ 49 | Converts a CSeq header to a string representation. 50 | """ 51 | @spec format(t()) :: String.t() 52 | def format(cseq) do 53 | method_str = cseq.method |> Atom.to_string() |> String.upcase() 54 | "#{cseq.number} #{method_str}" 55 | end 56 | 57 | @doc """ 58 | Increments the sequence number of a CSeq header. 59 | """ 60 | @spec increment(t()) :: t() 61 | def increment(cseq) do 62 | %{cseq | number: cseq.number + 1} 63 | end 64 | 65 | @doc """ 66 | Creates a new CSeq header with the same sequence number but a different method. 67 | """ 68 | @spec with_method(t(), atom()) :: t() 69 | def with_method(cseq, method) when is_atom(method) do 70 | %{cseq | method: method} 71 | end 72 | 73 | @doc """ 74 | Parses a CSeq header string into a CSeq struct. 75 | 76 | ## Examples 77 | 78 | iex> Parrot.Sip.Headers.CSeq.parse("314159 INVITE") 79 | %Parrot.Sip.Headers.CSeq{number: 314159, method: :invite} 80 | 81 | iex> Parrot.Sip.Headers.CSeq.parse("1 ACK") 82 | %Parrot.Sip.Headers.CSeq{number: 1, method: :ack} 83 | 84 | """ 85 | @spec parse(String.t()) :: t() 86 | def parse(string) when is_binary(string) do 87 | # Split into number and method 88 | [number_str, method_str] = String.split(string, " ", parts: 2) 89 | 90 | # Parse the number 91 | {number, _} = Integer.parse(number_str) 92 | 93 | # Parse the method (convert to lowercase atom) 94 | method = method_str |> String.downcase() |> String.to_atom() 95 | 96 | %__MODULE__{ 97 | number: number, 98 | method: method 99 | } 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/parrot/media/simple_rtp_receiver.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.SimpleRTPReceiver do 2 | @moduledoc """ 3 | A simple RTP receiver that extracts audio payload from RTP packets. 4 | This bypasses the complexity of the RTP SessionBin for basic G.711 audio. 5 | """ 6 | 7 | use Membrane.Filter 8 | 9 | alias Membrane.Buffer 10 | alias Membrane.G711 11 | 12 | def_input_pad(:input, 13 | accepted_format: %Membrane.RemoteStream{type: :packetized}, 14 | availability: :always 15 | ) 16 | 17 | def_output_pad(:output, 18 | accepted_format: %G711{encoding: _encoding}, 19 | availability: :always 20 | ) 21 | 22 | def_options( 23 | clock_rate: [ 24 | spec: pos_integer(), 25 | default: 8000, 26 | description: "Clock rate for the codec" 27 | ], 28 | selected_codec: [ 29 | spec: :pcmu | :pcma, 30 | default: :pcmu, 31 | description: "Selected codec" 32 | ] 33 | ) 34 | 35 | @impl true 36 | def handle_init(_ctx, opts) do 37 | state = %{ 38 | clock_rate: opts.clock_rate, 39 | selected_codec: opts.selected_codec, 40 | last_timestamp: nil, 41 | last_seq_num: nil 42 | } 43 | 44 | {[], state} 45 | end 46 | 47 | @impl true 48 | def handle_playing(_ctx, state) do 49 | {[], state} 50 | end 51 | 52 | @impl true 53 | def handle_stream_format(:input, _stream_format, _ctx, state) do 54 | # Output the appropriate G.711 format 55 | format = 56 | case state.selected_codec do 57 | :pcma -> %G711{encoding: :PCMA} 58 | :pcmu -> %G711{encoding: :PCMU} 59 | end 60 | 61 | {[stream_format: {:output, format}], state} 62 | end 63 | 64 | @impl true 65 | def handle_buffer(:input, buffer, _ctx, state) do 66 | require Logger 67 | Logger.debug("SimpleRTPReceiver got buffer, size: #{byte_size(buffer.payload)}") 68 | 69 | # Parse RTP packet 70 | case parse_rtp_packet(buffer.payload) do 71 | {:ok, payload, seq_num, timestamp, payload_type} -> 72 | Logger.debug( 73 | "RTP packet received: seq=#{seq_num}, ts=#{timestamp}, pt=#{payload_type}, payload_size=#{byte_size(payload)}" 74 | ) 75 | 76 | # Create output buffer with just the audio payload 77 | output_buffer = %Buffer{ 78 | payload: payload, 79 | metadata: buffer.metadata, 80 | pts: buffer.pts, 81 | dts: buffer.dts 82 | } 83 | 84 | {[buffer: {:output, output_buffer}], state} 85 | 86 | {:error, reason} -> 87 | Logger.warning( 88 | "Failed to parse RTP packet: #{reason}, buffer size: #{byte_size(buffer.payload)}" 89 | ) 90 | 91 | # Skip malformed packets 92 | {[], state} 93 | end 94 | end 95 | 96 | # Simple RTP parser - extracts payload from RTP packet 97 | defp parse_rtp_packet(<< 98 | _version::2, 99 | _padding::1, 100 | _extension::1, 101 | cc::4, 102 | _marker::1, 103 | payload_type::7, 104 | seq_num::16, 105 | timestamp::32, 106 | _ssrc::32, 107 | _csrc::binary-size(cc * 4), 108 | payload::binary 109 | >>) do 110 | {:ok, payload, seq_num, timestamp, payload_type} 111 | end 112 | 113 | defp parse_rtp_packet(_), do: {:error, :invalid_packet} 114 | end 115 | -------------------------------------------------------------------------------- /lib/parrot/media/membrane_rtp_pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.MembraneRtpPipeline do 2 | @moduledoc """ 3 | Proper Membrane pipeline for RTP audio streaming. 4 | Uses Membrane's built-in RTP and audio handling capabilities. 5 | """ 6 | 7 | use Membrane.Pipeline 8 | require Logger 9 | 10 | alias Membrane.RTP 11 | 12 | @impl true 13 | def handle_init(_ctx, opts) do 14 | Logger.info("MembraneRtpPipeline: Starting for session #{opts.session_id}") 15 | 16 | structure = [ 17 | # Source - read WAV file 18 | child(:file_source, %Membrane.File.Source{ 19 | location: opts.audio_file 20 | }), 21 | 22 | # Parse WAV file 23 | child(:wav_parser, Membrane.WAV.Parser), 24 | 25 | # Convert to RTP payload format 26 | child(:rtp_payloader, %RTP.PayloaderBin{ 27 | payloader: RTP.G711.Payloader, 28 | # PCMU 29 | payload_type: 0, 30 | clock_rate: 8000, 31 | ssrc: :rand.uniform(0xFFFFFFFF) 32 | }), 33 | 34 | # Send via UDP 35 | child(:udp_sink, %Membrane.UDP.Sink{ 36 | destination_address: parse_ip(opts.remote_rtp_address), 37 | destination_port_no: opts.remote_rtp_port, 38 | local_port_no: opts.local_rtp_port 39 | }), 40 | 41 | # Pipeline connections 42 | get_child(:file_source) 43 | |> get_child(:wav_parser) 44 | |> get_child(:rtp_payloader) 45 | |> get_child(:udp_sink) 46 | ] 47 | 48 | {[spec: structure], 49 | %{ 50 | session_id: opts.session_id, 51 | media_handler: opts.media_handler, 52 | handler_state: opts.handler_state, 53 | audio_file: opts.audio_file 54 | }} 55 | end 56 | 57 | @impl true 58 | def handle_element_start_of_stream(:udp_sink, _pad, _ctx, state) do 59 | Logger.info("MembraneRtpPipeline #{state.session_id}: Started streaming") 60 | 61 | if state.media_handler do 62 | state.media_handler.handle_playback_started( 63 | state.session_id, 64 | state.audio_file, 65 | state.handler_state 66 | ) 67 | end 68 | 69 | {[], state} 70 | end 71 | 72 | @impl true 73 | def handle_element_end_of_stream(:udp_sink, _pad, _ctx, state) do 74 | Logger.info("MembraneRtpPipeline #{state.session_id}: Finished streaming") 75 | 76 | if state.media_handler do 77 | case state.media_handler.handle_play_complete( 78 | state.audio_file, 79 | state.handler_state 80 | ) do 81 | {{:play, next_file}, _new_handler_state} -> 82 | Logger.info("MembraneRtpPipeline #{state.session_id}: Playing next file: #{next_file}") 83 | # In a real implementation, you'd update the file source here 84 | # For now, we'll just terminate 85 | {[terminate: :normal], state} 86 | 87 | {:stop, _new_handler_state} -> 88 | {[terminate: :normal], state} 89 | 90 | _ -> 91 | {[terminate: :normal], state} 92 | end 93 | else 94 | {[terminate: :normal], state} 95 | end 96 | end 97 | 98 | defp parse_ip(ip) when is_binary(ip) do 99 | case :inet.parse_address(String.to_charlist(ip)) do 100 | {:ok, ip_tuple} -> ip_tuple 101 | _ -> {127, 0, 0, 1} 102 | end 103 | end 104 | 105 | defp parse_ip(ip) when is_tuple(ip), do: ip 106 | defp parse_ip(_), do: {127, 0, 0, 1} 107 | end 108 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/accept_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.AcceptTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Accept headers" do 7 | test "parses Accept header" do 8 | header_value = "application/sdp" 9 | 10 | accept = Headers.Accept.parse(header_value) 11 | 12 | assert accept.type == "application" 13 | assert accept.subtype == "sdp" 14 | assert accept.parameters == %{} 15 | assert accept.q_value == nil 16 | 17 | assert Headers.Accept.format(accept) == header_value 18 | end 19 | 20 | test "parses Accept header with parameters" do 21 | header_value = "application/sdp;charset=UTF-8;q=0.8" 22 | 23 | accept = Headers.Accept.parse(header_value) 24 | 25 | assert accept.type == "application" 26 | assert accept.subtype == "sdp" 27 | assert accept.parameters == %{"charset" => "UTF-8"} 28 | assert accept.q_value == 0.8 29 | 30 | assert Headers.Accept.format(accept) == header_value 31 | end 32 | 33 | test "parses Accept header with only q value" do 34 | header_value = "application/sdp;q=0.5" 35 | 36 | accept = Headers.Accept.parse(header_value) 37 | 38 | assert accept.type == "application" 39 | assert accept.subtype == "sdp" 40 | assert accept.parameters == %{} 41 | assert accept.q_value == 0.5 42 | 43 | assert Headers.Accept.format(accept) == header_value 44 | end 45 | end 46 | 47 | describe "creating Accept headers" do 48 | test "creates Accept header" do 49 | accept = Headers.Accept.new("application", "sdp") 50 | 51 | assert accept.type == "application" 52 | assert accept.subtype == "sdp" 53 | assert accept.parameters == %{} 54 | assert accept.q_value == nil 55 | 56 | assert Headers.Accept.format(accept) == "application/sdp" 57 | end 58 | 59 | test "creates Accept header with parameters and q value" do 60 | accept = Headers.Accept.new("application", "sdp", %{"charset" => "UTF-8"}, 0.8) 61 | 62 | assert accept.type == "application" 63 | assert accept.subtype == "sdp" 64 | assert accept.parameters == %{"charset" => "UTF-8"} 65 | assert accept.q_value == 0.8 66 | 67 | assert Headers.Accept.format(accept) == "application/sdp;charset=UTF-8;q=0.8" 68 | end 69 | 70 | test "creates SDP Accept header" do 71 | accept = Headers.Accept.sdp() 72 | 73 | assert accept.type == "application" 74 | assert accept.subtype == "sdp" 75 | assert accept.parameters == %{} 76 | assert accept.q_value == nil 77 | 78 | assert Headers.Accept.format(accept) == "application/sdp" 79 | end 80 | 81 | test "creates wildcard Accept header" do 82 | accept = Headers.Accept.all() 83 | 84 | assert accept.type == "*" 85 | assert accept.subtype == "*" 86 | assert accept.parameters == %{} 87 | assert accept.q_value == nil 88 | 89 | assert Headers.Accept.format(accept) == "*/*" 90 | end 91 | end 92 | 93 | describe "string conversion" do 94 | test "to_string is alias for format" do 95 | accept = Headers.Accept.new("application", "sdp") 96 | 97 | assert Headers.Accept.to_string(accept) == Headers.Accept.format(accept) 98 | assert Headers.Accept.to_string(accept) == "application/sdp" 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /docs/archive/mysipapp_membrane_integration_status.md: -------------------------------------------------------------------------------- 1 | # MySipApp Membrane Integration Status 2 | 3 | ## Goal 4 | Make MySipApp work with gophone (SIP client) to handle voice calls and play audio files through RTP streaming using Membrane Framework. 5 | 6 | ## What We're Trying to Accomplish 7 | 1. MySipApp receives SIP INVITE from gophone 8 | 2. Negotiates audio codec (PCMA/PCMU) via SDP 9 | 3. Sends 200 OK response 10 | 4. On receiving ACK, starts streaming audio file via RTP to the caller 11 | 5. Handles BYE to terminate the call cleanly 12 | 13 | ## Current Issues 14 | 15 | ### Issue 1: Missing handle_element_end_of_stream callback 16 | **Problem**: The `MembraneAlawPipeline` only handles end_of_stream for `:udp_sink`, but Membrane sends end_of_stream events for ALL elements in the pipeline. 17 | 18 | **Error**: 19 | ``` 20 | ** (FunctionClauseError) no function clause matching in Parrot.Media.MembraneAlawPipeline.handle_element_end_of_stream/4 21 | ``` 22 | 23 | **Current code**: 24 | ```elixir 25 | def handle_element_end_of_stream(:udp_sink, _pad, _ctx, state) do 26 | # Only handles udp_sink 27 | end 28 | ``` 29 | 30 | **Fix needed**: Add a catch-all clause to handle all elements. 31 | 32 | ### Issue 2: Audio files are too short 33 | The welcome.wav file appears to be extremely short (ends immediately), causing the pipeline to crash right after starting. This might be because: 34 | - The converted 16-bit files are corrupted or empty 35 | - The file is actually very short 36 | 37 | ### Issue 3: RTP Serializer error 38 | **Error**: 39 | ``` 40 | ** (KeyError) key :rtp not found in: %{} 41 | ``` 42 | This happens in `Membrane.RTP.OutboundTrackingSerializer` when trying to update stats. 43 | 44 | ## What Has Been Fixed So Far 45 | 46 | 1. **MediaSession 3-tuple handling**: Fixed to handle `{:ok, supervisor_pid, pipeline_pid}` from `Membrane.Pipeline.start_link/2` 47 | 2. **WAV file format**: Converted from WAVE_FORMAT_EXTENSIBLE (32-bit) to standard PCM (16-bit) 48 | 3. **Added generic handle_element_start_of_stream**: Now handles start_of_stream for all elements, not just udp_sink 49 | 50 | ## Current Call Flow 51 | 52 | 1. ✅ INVITE received -> 100 Trying sent 53 | 2. ✅ SDP negotiation successful (PCMA codec selected) 54 | 3. ✅ 200 OK sent with SDP answer 55 | 4. ✅ ACK received 56 | 5. ✅ Media session started 57 | 6. ✅ Membrane pipeline created 58 | 7. ❌ Pipeline crashes on end_of_stream (file too short or corrupted) 59 | 60 | ## Next Steps 61 | 62 | 1. **Fix handle_element_end_of_stream**: Add generic handler for all elements 63 | 2. **Check audio files**: Verify the converted WAV files are valid and have actual audio content 64 | 3. **Fix RTP serializer issue**: Might need to update stream format or metadata 65 | 4. **Test with longer audio file**: Ensure the audio file has sufficient duration for testing 66 | 67 | ## File Locations 68 | 69 | - MySipApp: `/Users/byoungdale/ElixirProjects/parrot/examples/simple_uas_app/lib/my_sip_app.ex` 70 | - MembraneAlawPipeline: `/Users/byoungdale/ElixirProjects/parrot/lib/parrot/media/membrane_alaw_pipeline.ex` 71 | - MediaSession: `/Users/byoungdale/ElixirProjects/parrot/lib/parrot/media/media_session.ex` 72 | - Audio files: `/Users/byoungdale/ElixirProjects/parrot/priv/audio/` 73 | 74 | ## Testing Command 75 | ```bash 76 | # Terminal 1 - Start MySipApp 77 | iex -S mix 78 | iex> Code.require_file("examples/simple_uas_app/lib/my_sip_app.ex") 79 | iex> MySipApp.start() 80 | 81 | # Terminal 2 - Make call with gophone 82 | gophone dial sip:service@127.0.0.1:5060 83 | ``` -------------------------------------------------------------------------------- /test/media/codec_selection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.CodecSelectionTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Parrot.Media.MediaSession 5 | 6 | describe "codec selection" do 7 | test "SDP offer with PCMA and PCMU selects PCMA when preferred" do 8 | # Start a media session with PCMA preference 9 | session_opts = [ 10 | id: "test-session-1", 11 | dialog_id: "test-dialog-1", 12 | role: :uas, 13 | supported_codecs: [:pcma, :pcmu] 14 | ] 15 | 16 | {:ok, session_pid} = MediaSession.start_link(session_opts) 17 | 18 | # SDP offer with both PCMU (0) and PCMA (8) 19 | sdp_offer = """ 20 | v=0 21 | o=- 123456 123456 IN IP4 192.168.1.100 22 | s=Test Session 23 | c=IN IP4 192.168.1.100 24 | t=0 0 25 | m=audio 5004 RTP/AVP 0 8 26 | a=rtpmap:0 PCMU/8000 27 | a=rtpmap:8 PCMA/8000 28 | a=sendrecv 29 | """ 30 | 31 | # Process offer should select PCMA (first in our preference list) 32 | {:ok, sdp_answer} = MediaSession.process_offer(session_pid, sdp_offer) 33 | 34 | # Verify answer contains PCMA (8) 35 | assert sdp_answer =~ "m=audio" 36 | assert sdp_answer =~ "RTP/AVP 8" 37 | assert sdp_answer =~ "a=rtpmap:8 PCMA/8000" 38 | 39 | # Clean up 40 | MediaSession.terminate_session(session_pid) 41 | end 42 | 43 | test "SDP offer with unsupported codec falls back to default" do 44 | # Start a media session with PCMA only 45 | session_opts = [ 46 | id: "test-session-2", 47 | dialog_id: "test-dialog-2", 48 | role: :uas, 49 | supported_codecs: [:pcma] 50 | ] 51 | 52 | {:ok, session_pid} = MediaSession.start_link(session_opts) 53 | 54 | # SDP offer with only PCMU (0) which we don't support 55 | sdp_offer = """ 56 | v=0 57 | o=- 123456 123456 IN IP4 192.168.1.100 58 | s=Test Session 59 | c=IN IP4 192.168.1.100 60 | t=0 0 61 | m=audio 5004 RTP/AVP 0 62 | a=rtpmap:0 PCMU/8000 63 | a=sendrecv 64 | """ 65 | 66 | # Process offer should select PCMA as fallback 67 | {:ok, sdp_answer} = MediaSession.process_offer(session_pid, sdp_offer) 68 | 69 | # Verify answer contains PCMA (8) 70 | assert sdp_answer =~ "m=audio" 71 | assert sdp_answer =~ "RTP/AVP 8" 72 | assert sdp_answer =~ "a=rtpmap:8 PCMA/8000" 73 | 74 | # Clean up 75 | MediaSession.terminate_session(session_pid) 76 | end 77 | 78 | test "UAC generates offer with supported codecs" do 79 | # Start a media session as UAC 80 | session_opts = [ 81 | id: "test-session-3", 82 | dialog_id: "test-dialog-3", 83 | role: :uac, 84 | supported_codecs: [:pcma, :opus] 85 | ] 86 | 87 | {:ok, session_pid} = MediaSession.start_link(session_opts) 88 | 89 | # Generate offer 90 | {:ok, sdp_offer} = MediaSession.generate_offer(session_pid) 91 | 92 | # Verify offer contains both codecs 93 | assert sdp_offer =~ "m=audio" 94 | assert sdp_offer =~ "RTP/AVP 8 111" or sdp_offer =~ "RTP/AVP 111 8" 95 | assert sdp_offer =~ "a=rtpmap:8 PCMA/8000" 96 | # Opus codec should be included (channels may or may not be included) 97 | assert sdp_offer =~ "a=rtpmap:111 opus/48000" 98 | 99 | # Clean up 100 | MediaSession.terminate_session(session_pid) 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/parrot/media/rtp_packet.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.RtpPacket do 2 | @moduledoc """ 3 | RTP packet creation and parsing. 4 | 5 | RTP packet format: 6 | 0 1 2 3 7 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 8 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 9 | |V=2|P|X| CC |M| PT | sequence number | 10 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 11 | | timestamp | 12 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 13 | | synchronization source (SSRC) identifier | 14 | +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+ 15 | """ 16 | 17 | import Bitwise 18 | 19 | @rtp_version 2 20 | # G.711 μ-law 21 | @payload_type_pcmu 0 22 | 23 | defstruct version: @rtp_version, 24 | padding: false, 25 | extension: false, 26 | # CSRC count 27 | cc: 0, 28 | marker: false, 29 | payload_type: @payload_type_pcmu, 30 | sequence_number: 0, 31 | timestamp: 0, 32 | ssrc: 0, 33 | payload: <<>> 34 | 35 | @doc """ 36 | Creates a new RTP packet with the given payload. 37 | """ 38 | def new(payload, opts \\ []) do 39 | %__MODULE__{ 40 | payload_type: Keyword.get(opts, :payload_type, @payload_type_pcmu), 41 | sequence_number: Keyword.get(opts, :sequence_number, 0), 42 | timestamp: Keyword.get(opts, :timestamp, 0), 43 | ssrc: Keyword.get(opts, :ssrc, :rand.uniform(0xFFFFFFFF)), 44 | marker: Keyword.get(opts, :marker, false), 45 | payload: payload 46 | } 47 | end 48 | 49 | @doc """ 50 | Encodes an RTP packet to binary format. 51 | """ 52 | def encode(%__MODULE__{} = packet) do 53 | # First byte: V(2), P(1), X(1), CC(4) 54 | byte1 = 55 | @rtp_version <<< 6 ||| 56 | bool_to_bit(packet.padding) <<< 5 ||| 57 | bool_to_bit(packet.extension) <<< 4 ||| 58 | (packet.cc &&& 0x0F) 59 | 60 | # Second byte: M(1), PT(7) 61 | byte2 = bool_to_bit(packet.marker) <<< 7 ||| (packet.payload_type &&& 0x7F) 62 | 63 | # Build the header 64 | header = << 65 | byte1::8, 66 | byte2::8, 67 | packet.sequence_number::16, 68 | packet.timestamp::32, 69 | packet.ssrc::32 70 | >> 71 | 72 | # Combine header and payload 73 | header <> packet.payload 74 | end 75 | 76 | @doc """ 77 | Decodes a binary RTP packet. 78 | """ 79 | def decode(<< 80 | v::2, 81 | p::1, 82 | x::1, 83 | cc::4, 84 | m::1, 85 | pt::7, 86 | seq::16, 87 | ts::32, 88 | ssrc::32, 89 | rest::binary 90 | >>) do 91 | # Skip CSRC identifiers if present 92 | csrc_size = cc * 4 93 | <<_csrc::binary-size(csrc_size), payload::binary>> = rest 94 | 95 | {:ok, 96 | %__MODULE__{ 97 | version: v, 98 | padding: p == 1, 99 | extension: x == 1, 100 | cc: cc, 101 | marker: m == 1, 102 | payload_type: pt, 103 | sequence_number: seq, 104 | timestamp: ts, 105 | ssrc: ssrc, 106 | payload: payload 107 | }} 108 | end 109 | 110 | def decode(_), do: {:error, :invalid_packet} 111 | 112 | defp bool_to_bit(true), do: 1 113 | defp bool_to_bit(false), do: 0 114 | end 115 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/contact_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.ContactTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing Contact headers" do 7 | test "parses Contact header with display name" do 8 | header_value = "Alice " 9 | 10 | contact = Headers.Contact.parse(header_value) 11 | 12 | assert contact.display_name == "Alice" 13 | uri = contact.uri 14 | assert is_struct(uri, Parrot.Sip.Uri) 15 | assert uri.scheme == "sip" 16 | assert uri.user == "alice" 17 | assert uri.host == "pc33.atlanta.com" 18 | assert contact.parameters == %{} 19 | 20 | formatted = Headers.Contact.format(contact) 21 | assert String.match?(formatted, ~r//) 22 | end 23 | 24 | test "parses Contact header without display name" do 25 | header_value = "" 26 | 27 | contact = Headers.Contact.parse(header_value) 28 | 29 | # String values can be empty strings instead of nil when parsed 30 | assert contact.display_name == nil or contact.display_name == "" 31 | uri = contact.uri 32 | assert is_struct(uri, Parrot.Sip.Uri) 33 | assert uri.scheme == "sip" 34 | assert uri.user == "alice" 35 | assert uri.host == "pc33.atlanta.com" 36 | 37 | assert Headers.Contact.format(contact) == header_value 38 | end 39 | 40 | test "parses Contact header with parameters" do 41 | header_value = ";expires=3600;q=0.8" 42 | 43 | contact = Headers.Contact.parse(header_value) 44 | 45 | # String values can be empty strings instead of nil when parsed 46 | assert contact.display_name == nil or contact.display_name == "" 47 | uri = contact.uri 48 | assert is_struct(uri, Parrot.Sip.Uri) 49 | assert uri.scheme == "sip" 50 | assert uri.user == "alice" 51 | assert uri.host == "pc33.atlanta.com" 52 | assert contact.parameters["expires"] == "3600" 53 | # The q-value might be formatted differently after parsing 54 | assert String.starts_with?(contact.parameters["q"], "0.") 55 | 56 | formatted = Headers.Contact.format(contact) 57 | assert String.match?(formatted, ~r/;.*expires=3600/) 58 | assert String.match?(formatted, ~r/q=0\.[0-9]/) 59 | end 60 | 61 | test "parses Contact header with wildcard" do 62 | header_value = "*" 63 | 64 | contact = Headers.Contact.parse(header_value) 65 | 66 | assert contact.wildcard == true 67 | 68 | formatted = Headers.Contact.format(contact) 69 | assert String.trim(formatted) == String.trim(header_value) 70 | end 71 | end 72 | 73 | describe "creating Contact headers" do 74 | test "creates Contact header" do 75 | contact = Headers.Contact.new("sip:alice@pc33.atlanta.com", "Alice") 76 | 77 | assert contact.display_name == "Alice" 78 | uri = contact.uri 79 | assert is_struct(uri, Parrot.Sip.Uri) 80 | assert uri.scheme == "sip" 81 | assert uri.user == "alice" 82 | assert uri.host == "pc33.atlanta.com" 83 | 84 | formatted = Headers.Contact.format(contact) 85 | assert String.match?(formatted, ~r/Alice /) 86 | end 87 | 88 | test "creates wildcard Contact header" do 89 | contact = Headers.Contact.wildcard() 90 | 91 | assert contact.wildcard == true 92 | 93 | formatted = Headers.Contact.format(contact) 94 | assert String.trim(formatted) == "*" 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/parrot/media/media_session_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.MediaSessionSupervisor do 2 | @moduledoc """ 3 | Supervisor for MediaSession processes. 4 | 5 | This supervisor manages all active media sessions using a simple_one_for_one 6 | strategy, allowing dynamic creation of media sessions as needed. 7 | """ 8 | 9 | use DynamicSupervisor 10 | 11 | require Logger 12 | 13 | @doc """ 14 | Starts the MediaSession supervisor. 15 | """ 16 | @spec start_link(keyword()) :: Supervisor.on_start() 17 | def start_link(opts \\ []) do 18 | DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__) 19 | end 20 | 21 | @doc """ 22 | Starts a new MediaSession under this supervisor. 23 | 24 | ## Options 25 | 26 | - `:id` - Session ID (required) 27 | - `:dialog_id` - Dialog ID this session belongs to (required) 28 | - `:role` - `:uac` or `:uas` (required) 29 | - `:owner` - Owner process PID (optional, defaults to caller) 30 | - `:audio_file` - Path to audio file to play (optional) 31 | """ 32 | @spec start_session(keyword()) :: DynamicSupervisor.on_start_child() 33 | def start_session(opts) do 34 | case validate_opts(opts) do 35 | :ok -> 36 | spec = {Parrot.Media.MediaSession, opts} 37 | DynamicSupervisor.start_child(__MODULE__, spec) 38 | 39 | {:error, reason} -> 40 | {:error, {:invalid_opts, reason}} 41 | end 42 | end 43 | 44 | @doc """ 45 | Stops a MediaSession. 46 | """ 47 | @spec stop_session(pid()) :: :ok | {:error, :not_found} 48 | def stop_session(pid) when is_pid(pid) do 49 | DynamicSupervisor.terminate_child(__MODULE__, pid) 50 | end 51 | 52 | @doc """ 53 | Lists all active MediaSession processes. 54 | """ 55 | @spec list_sessions() :: [pid()] 56 | def list_sessions do 57 | children = DynamicSupervisor.which_children(__MODULE__) 58 | 59 | for {_, pid, :worker, _} <- children, is_pid(pid) do 60 | pid 61 | end 62 | end 63 | 64 | @doc """ 65 | Counts the number of active MediaSession processes. 66 | """ 67 | @spec count_sessions() :: non_neg_integer() 68 | def count_sessions do 69 | DynamicSupervisor.count_children(__MODULE__).active 70 | end 71 | 72 | @doc """ 73 | Finds a MediaSession by its ID. 74 | """ 75 | @spec find_session(String.t()) :: {:ok, pid()} | {:error, :not_found} 76 | def find_session(session_id) when is_binary(session_id) do 77 | case Registry.lookup(Parrot.Registry, {:media_session, session_id}) do 78 | [{pid, _}] -> {:ok, pid} 79 | [] -> {:error, :not_found} 80 | end 81 | end 82 | 83 | # Callbacks 84 | 85 | @impl true 86 | def init(_opts) do 87 | Logger.info("MediaSessionSupervisor starting") 88 | DynamicSupervisor.init(strategy: :one_for_one) 89 | end 90 | 91 | # Private functions 92 | 93 | defp validate_opts(opts) do 94 | with :ok <- validate_required(opts, :id), 95 | :ok <- validate_required(opts, :dialog_id), 96 | :ok <- validate_role(opts) do 97 | :ok 98 | end 99 | end 100 | 101 | defp validate_required(opts, key) do 102 | if Keyword.has_key?(opts, key) do 103 | :ok 104 | else 105 | {:error, {:missing_required_option, key}} 106 | end 107 | end 108 | 109 | defp validate_role(opts) do 110 | case Keyword.get(opts, :role) do 111 | :uac -> :ok 112 | :uas -> :ok 113 | nil -> {:error, {:missing_required_option, :role}} 114 | other -> {:error, {:invalid_role, other}} 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/from_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.FromTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | 6 | describe "parsing From headers" do 7 | test "parses From header with display name and tag" do 8 | header_value = "Alice ;tag=1928301774" 9 | 10 | from = Headers.From.parse(header_value) 11 | 12 | assert from.display_name == "Alice" 13 | uri = from.uri 14 | assert is_struct(uri, Parrot.Sip.Uri) 15 | assert uri.scheme == "sip" 16 | assert uri.user == "alice" 17 | assert uri.host == "atlanta.com" 18 | assert from.parameters["tag"] == "1928301774" 19 | 20 | formatted = Headers.From.format(from) 21 | assert String.match?(formatted, ~r/Alice ;tag=1928301774/) 22 | end 23 | 24 | test "parses From header without display name" do 25 | header_value = ";tag=1928301774" 26 | 27 | from = Headers.From.parse(header_value) 28 | 29 | assert from.display_name == nil 30 | uri = from.uri 31 | assert is_struct(uri, Parrot.Sip.Uri) 32 | assert uri.scheme == "sip" 33 | assert uri.user == "alice" 34 | assert uri.host == "atlanta.com" 35 | assert from.parameters["tag"] == "1928301774" 36 | 37 | formatted = Headers.From.format(from) 38 | assert String.match?(formatted, ~r/;tag=1928301774/) 39 | end 40 | 41 | test "parses From header without tag" do 42 | header_value = "Alice " 43 | 44 | from = Headers.From.parse(header_value) 45 | 46 | assert from.display_name == "Alice" 47 | uri = from.uri 48 | assert is_struct(uri, Parrot.Sip.Uri) 49 | assert uri.scheme == "sip" 50 | assert uri.user == "alice" 51 | assert uri.host == "atlanta.com" 52 | assert from.parameters == %{} 53 | 54 | formatted = Headers.From.format(from) 55 | assert String.match?(formatted, ~r/Alice /) 56 | end 57 | 58 | test "parses From header with quoted display name" do 59 | header_value = ~s("Alice Smith" ;tag=1928301774) 60 | 61 | from = Headers.From.parse(header_value) 62 | 63 | assert from.display_name == "\"Alice Smith\"" 64 | uri = from.uri 65 | assert is_struct(uri, Parrot.Sip.Uri) 66 | assert uri.scheme == "sip" 67 | assert uri.user == "alice" 68 | assert uri.host == "atlanta.com" 69 | assert from.parameters["tag"] == "1928301774" 70 | 71 | formatted = Headers.From.format(from) 72 | assert String.match?(formatted, ~r/"Alice Smith" ;tag=1928301774/) 73 | end 74 | end 75 | 76 | describe "creating From headers" do 77 | test "creates From header" do 78 | from = Headers.From.new("sip:alice@atlanta.com", "Alice", "1928301774") 79 | 80 | assert from.display_name == "Alice" 81 | uri = from.uri 82 | assert is_struct(uri, Parrot.Sip.Uri) 83 | assert uri.scheme == "sip" 84 | assert uri.user == "alice" 85 | assert uri.host == "atlanta.com" 86 | assert from.parameters["tag"] == "1928301774" 87 | 88 | formatted = Headers.From.format(from) 89 | assert String.match?(formatted, ~r/Alice ;tag=1928301774/) 90 | end 91 | 92 | test "generates tag parameter" do 93 | tag = Headers.From.generate_tag() 94 | 95 | assert is_binary(tag) 96 | assert String.length(tag) >= 8 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/parrot/sip/headers/max_forwards.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.MaxForwards do 2 | @moduledoc """ 3 | Module for working with SIP Max-Forwards headers as defined in RFC 3261 Section 20.22. 4 | 5 | The Max-Forwards header is used to limit the number of proxies or gateways 6 | that can forward a request. It consists of a single integer value that is 7 | decremented by each proxy that forwards the message. 8 | 9 | Max-Forwards serves important purposes in SIP: 10 | - Preventing infinite loops in proxy networks 11 | - Detecting forwarding loops caused by misconfiguration 12 | - Limiting the request scope to a specific number of hops 13 | - Triggering 483 (Too Many Hops) responses when reaching zero 14 | 15 | Each proxy MUST decrement the Max-Forwards value by one when forwarding 16 | a request. The recommended initial value is 70, as specified in RFC 3261 17 | Section 8.1.1.6. 18 | 19 | References: 20 | - RFC 3261 Section 8.1.1.6: Max-Forwards 21 | - RFC 3261 Section 16.6: Request Forwarding (proxy behavior) 22 | - RFC 3261 Section 20.22: Max-Forwards Header Field 23 | - RFC 3261 Section 21.4.20: 483 Too Many Hops 24 | """ 25 | 26 | @doc """ 27 | Creates a new Max-Forwards header with the specified value. 28 | 29 | ## Examples 30 | 31 | iex> Parrot.Sip.Headers.MaxForwards.new(70) 32 | 70 33 | """ 34 | @spec new(integer()) :: integer() 35 | def new(value) when is_integer(value) and value >= 0, do: value 36 | 37 | @doc """ 38 | Creates a new Max-Forwards header with the default value of 70. 39 | 40 | ## Examples 41 | 42 | iex> Parrot.Sip.Headers.MaxForwards.default() 43 | 70 44 | """ 45 | @spec default() :: integer() 46 | def default(), do: 70 47 | 48 | @doc """ 49 | Parses a Max-Forwards header string into an integer value. 50 | 51 | ## Examples 52 | 53 | iex> Parrot.Sip.Headers.MaxForwards.parse("70") 54 | 70 55 | 56 | iex> Parrot.Sip.Headers.MaxForwards.parse("0") 57 | 0 58 | """ 59 | @spec parse(String.t()) :: integer() 60 | def parse(string) when is_binary(string) do 61 | case Integer.parse(string) do 62 | {value, ""} when value >= 0 -> value 63 | _ -> raise ArgumentError, "Invalid Max-Forwards value: #{string}" 64 | end 65 | end 66 | 67 | @doc """ 68 | Formats a Max-Forwards value as a string. 69 | 70 | ## Examples 71 | 72 | iex> Parrot.Sip.Headers.MaxForwards.format(70) 73 | "70" 74 | 75 | iex> Parrot.Sip.Headers.MaxForwards.format(0) 76 | "0" 77 | """ 78 | @spec format(integer()) :: String.t() 79 | def format(value) when is_integer(value) and value >= 0, do: Integer.to_string(value) 80 | 81 | @doc """ 82 | Decrements the Max-Forwards value by 1. 83 | Returns nil if value is already 0. 84 | 85 | ## Examples 86 | 87 | iex> Parrot.Sip.Headers.MaxForwards.decrement(70) 88 | 69 89 | 90 | iex> Parrot.Sip.Headers.MaxForwards.decrement(1) 91 | 0 92 | 93 | iex> Parrot.Sip.Headers.MaxForwards.decrement(0) 94 | nil 95 | """ 96 | @spec decrement(integer()) :: integer() | nil 97 | def decrement(value) when is_integer(value) and value > 0, do: value - 1 98 | def decrement(0), do: nil 99 | 100 | @doc """ 101 | Checks if the Max-Forwards value has reached 0. 102 | 103 | ## Examples 104 | 105 | iex> Parrot.Sip.Headers.MaxForwards.zero?(0) 106 | true 107 | 108 | iex> Parrot.Sip.Headers.MaxForwards.zero?(1) 109 | false 110 | """ 111 | @spec zero?(integer()) :: boolean() 112 | def zero?(value) when is_integer(value), do: value == 0 113 | end 114 | -------------------------------------------------------------------------------- /test/parrot/sip/message_via_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.MessageViaTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Parrot.Sip.Message 5 | alias Parrot.Sip.Headers.Via 6 | 7 | test "handles Via headers as strings and converts to structs" do 8 | message = 9 | Message.new_request(:invite, "sip:bob@example.com", %{}, 10 | dialog_id: "dlg-via", 11 | transaction_id: "txn-via" 12 | ) 13 | 14 | # Set a Via header as a string 15 | message = 16 | Message.set_header( 17 | message, 18 | "via", 19 | "SIP/2.0/UDP client.atlanta.com:5060;branch=z9hG4bK74bf9" 20 | ) 21 | 22 | assert message.dialog_id == "dlg-via" 23 | assert message.transaction_id == "txn-via" 24 | 25 | # Get the Via header, should be a struct 26 | via = Message.get_header(message, "via") 27 | assert is_struct(via, Via) 28 | assert via.host == "client.atlanta.com" 29 | assert via.port == 5060 30 | assert via.transport == :udp 31 | assert via.parameters["branch"] == "z9hG4bK74bf9" 32 | 33 | # Set a Via header as a list of strings 34 | message = 35 | Message.set_header( 36 | message, 37 | "via", 38 | [ 39 | "SIP/2.0/UDP client.atlanta.com:5060;branch=z9hG4bK74bf9", 40 | "SIP/2.0/TCP server.biloxi.com:5061;branch=z9hG4bK123" 41 | ] 42 | ) 43 | 44 | assert message.dialog_id == "dlg-via" 45 | assert message.transaction_id == "txn-via" 46 | 47 | # Get all Via headers, should be a list of structs 48 | vias = Message.all_vias(message) 49 | assert is_list(vias) 50 | assert length(vias) == 2 51 | assert Enum.all?(vias, &is_struct(&1, Via)) 52 | 53 | # First Via should be from client.atlanta.com 54 | first_via = List.first(vias) 55 | assert first_via.host == "client.atlanta.com" 56 | assert first_via.transport == :udp 57 | 58 | # Second Via should be from server.biloxi.com 59 | second_via = List.last(vias) 60 | assert second_via.host == "server.biloxi.com" 61 | assert second_via.transport == :tcp 62 | end 63 | 64 | test "can modify Via headers with MessageHelper" do 65 | alias Parrot.Sip.MessageHelper 66 | 67 | message = 68 | Message.new_request(:invite, "sip:bob@example.com", %{}, 69 | dialog_id: "dlg-via2", 70 | transaction_id: "txn-via2" 71 | ) 72 | 73 | message = 74 | Message.set_header( 75 | message, 76 | "via", 77 | "SIP/2.0/UDP client.atlanta.com:5060;branch=z9hG4bK74bf9" 78 | ) 79 | 80 | assert message.dialog_id == "dlg-via2" 81 | assert message.transaction_id == "txn-via2" 82 | 83 | # Add received parameter 84 | updated = MessageHelper.set_received_parameter(message, "192.168.1.1") 85 | via = Message.get_header(updated, "via") 86 | 87 | # Via should be a struct and have the received parameter 88 | assert is_struct(via, Via) 89 | assert via.parameters["received"] == "192.168.1.1" 90 | 91 | # Convert back to string for assertion 92 | via_string = Via.format(via) 93 | assert via_string =~ "received=192.168.1.1" 94 | 95 | # Add rport parameter 96 | with_rport = MessageHelper.set_rport_parameter(message, 12345) 97 | rport_via = Message.get_header(with_rport, "via") 98 | 99 | # Via should be a struct and have the rport parameter 100 | assert is_struct(rport_via, Via) 101 | assert rport_via.parameters["rport"] == "12345" 102 | 103 | # Convert back to string for assertion 104 | rport_string = Via.format(rport_via) 105 | assert rport_string =~ "rport=12345" 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/parrot/sip/headers/route_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.RouteTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | alias Parrot.Sip.Uri 6 | 7 | describe "parsing Route headers" do 8 | test "parses Route header" do 9 | header_value = "" 10 | 11 | route = Headers.Route.parse(header_value) 12 | 13 | assert route.uri.scheme == "sip" 14 | assert route.uri.host == "proxy1.example.com" 15 | assert route.uri.parameters["lr"] == "" 16 | 17 | formatted = Headers.Route.format(route) 18 | assert String.trim(formatted) == String.trim(header_value) 19 | end 20 | 21 | test "parses Route header with display name" do 22 | header_value = "Example Proxy " 23 | 24 | route = Headers.Route.parse(header_value) 25 | 26 | assert route.display_name == "Example Proxy" 27 | assert route.uri.scheme == "sip" 28 | assert route.uri.host == "proxy1.example.com" 29 | assert route.uri.parameters["lr"] == "" 30 | 31 | formatted = Headers.Route.format(route) 32 | assert String.trim(formatted) == String.trim(header_value) 33 | end 34 | 35 | test "parses Route header with parameters" do 36 | header_value = ";param=value" 37 | 38 | route = Headers.Route.parse(header_value) 39 | 40 | assert route.uri.scheme == "sip" 41 | assert route.uri.host == "proxy1.example.com" 42 | assert route.uri.parameters["lr"] == "" 43 | assert route.parameters["param"] == "value" 44 | 45 | formatted = Headers.Route.format(route) 46 | assert String.trim(formatted) == String.trim(header_value) 47 | end 48 | 49 | test "parses list of Route headers" do 50 | header_value = ", " 51 | 52 | routes = Headers.Route.parse_list(header_value) 53 | 54 | assert length(routes) == 2 55 | assert Enum.at(routes, 0).uri.host == "proxy1.example.com" 56 | assert Enum.at(routes, 1).uri.host == "proxy2.example.com" 57 | 58 | formatted = Headers.Route.format_list(routes) 59 | assert String.trim(formatted) == String.trim(header_value) 60 | end 61 | end 62 | 63 | describe "creating Route headers" do 64 | test "creates Route header" do 65 | uri = %Uri{scheme: "sip", host: "proxy1.example.com", parameters: %{"lr" => ""}} 66 | route = Headers.Route.new(uri) 67 | 68 | assert route.uri.scheme == "sip" 69 | assert route.uri.host == "proxy1.example.com" 70 | assert route.uri.parameters["lr"] == "" 71 | 72 | formatted = Headers.Route.format(route) 73 | assert formatted == "" 74 | end 75 | 76 | test "creates Route header with string URI" do 77 | route = Headers.Route.new("sip:proxy1.example.com;lr") 78 | 79 | assert route.uri.scheme == "sip" 80 | assert route.uri.host == "proxy1.example.com" 81 | assert route.uri.parameters["lr"] == "" 82 | 83 | formatted = Headers.Route.format(route) 84 | assert formatted == "" 85 | end 86 | 87 | test "creates Route header with display name" do 88 | route = Headers.Route.new("sip:proxy1.example.com;lr", "Example Proxy") 89 | 90 | assert route.display_name == "Example Proxy" 91 | assert route.uri.scheme == "sip" 92 | assert route.uri.host == "proxy1.example.com" 93 | assert route.uri.parameters["lr"] == "" 94 | 95 | formatted = Headers.Route.format(route) 96 | assert formatted == "Example Proxy " 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/parrot/sip/headers/call_id.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.CallId do 2 | @moduledoc """ 3 | Module for working with SIP Call-ID headers as defined in RFC 3261 Section 20.8. 4 | 5 | The Call-ID header uniquely identifies a specific invitation or 6 | all registrations of a particular client. It is a globally unique identifier 7 | for the call and must be the same for all requests and responses in a given dialog. 8 | 9 | Call-ID serves multiple purposes in SIP: 10 | - Uniquely identifying dialogs (along with From and To tags) 11 | - Matching requests and responses for stateless proxies 12 | - Detecting duplicate requests 13 | - Distinguishing between separate registrations from the same client 14 | 15 | The syntax typically includes a random string followed by @ and a domain name, 16 | providing a simple mechanism to generate globally unique identifiers without 17 | centralized coordination. 18 | 19 | References: 20 | - RFC 3261 Section 8.1.1.4: Call-ID 21 | - RFC 3261 Section 12.1.1: UAC Behavior (Call-ID role in dialog) 22 | - RFC 3261 Section 19.3: Dialog Identifiers 23 | - RFC 3261 Section 20.8: Call-ID Header Field 24 | """ 25 | 26 | @doc """ 27 | Creates a new Call-ID header. 28 | 29 | ## Examples 30 | 31 | iex> Parrot.Sip.Headers.CallId.new("a84b4c76e66710@pc33.atlanta.com") 32 | "a84b4c76e66710@pc33.atlanta.com" 33 | 34 | """ 35 | @spec new(String.t()) :: String.t() 36 | def new(call_id) when is_binary(call_id) do 37 | call_id 38 | end 39 | 40 | @doc """ 41 | Parses a Call-ID header value. 42 | 43 | ## Examples 44 | 45 | iex> Parrot.Sip.Headers.CallId.parse("a84b4c76e66710@pc33.atlanta.com") 46 | "a84b4c76e66710@pc33.atlanta.com" 47 | 48 | """ 49 | @spec parse(String.t()) :: String.t() 50 | def parse(string) when is_binary(string) do 51 | String.trim(string) 52 | end 53 | 54 | @doc """ 55 | Formats a Call-ID header value. 56 | 57 | ## Examples 58 | 59 | iex> Parrot.Sip.Headers.CallId.format("a84b4c76e66710@pc33.atlanta.com") 60 | "a84b4c76e66710@pc33.atlanta.com" 61 | 62 | """ 63 | @spec format(String.t()) :: String.t() 64 | def format(call_id) when is_binary(call_id) do 65 | call_id 66 | end 67 | 68 | @doc """ 69 | Alias for format/1 for consistency with other header modules. 70 | 71 | ## Examples 72 | 73 | iex> Parrot.Sip.Headers.CallId.to_string("a84b4c76e66710@pc33.atlanta.com") 74 | "a84b4c76e66710@pc33.atlanta.com" 75 | 76 | """ 77 | @spec to_string(String.t()) :: String.t() 78 | def to_string(call_id) when is_binary(call_id), do: format(call_id) 79 | 80 | @doc """ 81 | Generates a unique Call-ID. 82 | 83 | The generated Call-ID consists of a random string, the '@' character, 84 | and a hostname or IP address. 85 | 86 | ## Examples 87 | 88 | iex> call_id = Parrot.Sip.Headers.CallId.generate("example.com") 89 | iex> String.contains?(call_id, "@example.com") 90 | true 91 | 92 | """ 93 | @spec generate(String.t()) :: String.t() 94 | def generate(host) when is_binary(host) do 95 | random = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) 96 | "#{random}@#{host}" 97 | end 98 | 99 | @doc """ 100 | Generates a unique Call-ID with a default hostname. 101 | 102 | ## Examples 103 | 104 | iex> call_id = Parrot.Sip.Headers.CallId.generate() 105 | iex> String.contains?(call_id, "@") 106 | true 107 | 108 | """ 109 | @spec generate() :: String.t() 110 | def generate do 111 | # Use a default hostname or get the local hostname 112 | host = "localhost" 113 | generate(host) 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /docs/archive/mysipapp_current_state_summary.md: -------------------------------------------------------------------------------- 1 | # MySipApp Current State Summary 2 | 3 | ## Overview 4 | MySipApp is now successfully implementing G.711 A-law (PCMA) RTP streaming using Membrane Framework. The audio streaming should now work correctly with proper packet generation. 5 | 6 | ## Key Changes Made (Latest Update) 7 | 8 | ### 1. Fixed RTP Packet Generation ✅ 9 | - **Problem**: Only 5 RTP packets were being sent for the entire audio file (packets were ~1036 bytes each) 10 | - **Root Cause**: The G711 payloader was creating packets that were too large instead of proper 20ms packets 11 | - **Solution**: Updated to use `Membrane.RTP.PayloaderBin` pattern from membrane_rtc_engine examples 12 | - **Result**: Should now generate proper 20ms packets (160 bytes of audio data each) 13 | 14 | ### 2. Pipeline Architecture Update ✅ 15 | ```elixir 16 | # Updated pipeline in membrane_alaw_pipeline.ex: 17 | File.Source -> WAV.Parser -> PTSSetter -> G711Encoder -> PayloaderBin -> Realtimer -> OutboundTrackingSerializer -> RTPPacketLogger -> UDP.Sink 18 | ``` 19 | 20 | Key improvements: 21 | - Using `PayloaderBin` for proper RTP payloading 22 | - `Realtimer` placed AFTER payloading (as per successful examples) 23 | - `OutboundTrackingSerializer` for proper RTP packet serialization 24 | - Consistent SSRC usage throughout the pipeline 25 | 26 | ### 3. Audio File Configuration ✅ 27 | - Using `ivr-congratulations_you_pressed_star.wav` (standard PCM format) 28 | - File is confirmed to be: RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 8000 Hz 29 | 30 | ### 4. RTP Destination Fix ✅ 31 | - Now correctly sending to the client's RTP address (e.g., 192.168.1.161:51710) 32 | - Fixed SDP connection data parsing 33 | 34 | ## Current Status 35 | 36 | ### Working: 37 | - ✅ SIP signaling (INVITE/200 OK/ACK/BYE) 38 | - ✅ SDP negotiation 39 | - ✅ RTP endpoint detection 40 | - ✅ Pipeline creation and streaming 41 | - ✅ Correct RTP destination addressing 42 | - ✅ Proper WAV file format handling 43 | 44 | ### Expected Behavior: 45 | - When gophone calls MySipApp, it should now hear the "Congratulations, you pressed star" audio message 46 | - RTP packets should be sent at ~50 packets/second for 8kHz audio 47 | - Each packet should contain 20ms of audio (160 bytes) 48 | 49 | ## Testing Instructions 50 | 51 | 1. **Start MySipApp**: 52 | ```bash 53 | mix run test_mysipapp.exs 54 | ``` 55 | 56 | 2. **Make a call with gophone**: 57 | ```bash 58 | gophone dial sip:service@127.0.0.1:5060 59 | ``` 60 | 61 | 3. **Expected result**: You should hear the IVR message 62 | 63 | ## Next Steps (If Audio Still Not Heard) 64 | 65 | 1. Check gophone logs for any RTP-related errors 66 | 2. Use Wireshark to verify RTP packets are being received 67 | 3. Verify gophone is correctly decoding G.711 A-law 68 | 69 | ## Technical Details 70 | 71 | ### Membrane Components Used: 72 | - `Membrane.RTP.PayloaderBin` - Proper RTP payloading 73 | - `Membrane.RTP.G711.Payloader` - G.711 specific payloader 74 | - `Membrane.RTP.OutboundTrackingSerializer` - RTP packet serialization 75 | - `Membrane.Realtimer` - Real-time pacing of packets 76 | - `Membrane.UDP.Sink` - UDP packet transmission 77 | 78 | ### Custom Components: 79 | - `Parrot.Media.PTSSetter` - Adds presentation timestamps to buffers 80 | - `Parrot.Media.G711TimestampEncoder` - G.711 encoder that preserves timestamps 81 | - `Parrot.Media.RTPPacketLogger` - Debug logging for RTP packets 82 | 83 | ### Key Configuration: 84 | - SSRC: Randomly generated but consistent throughout pipeline 85 | - Payload Type: 8 (PCMA/A-law) 86 | - Clock Rate: 8000 Hz 87 | - Packet Size: 20ms (160 bytes of audio data) 88 | 89 | ## Previous Issues (All Fixed) 90 | 1. **RTP Serializer Error**: Fixed by using integrated PayloaderBin 91 | 2. **WAV Format Issues**: Fixed by using standard PCM format 92 | 3. **Packet Size Issues**: Fixed by proper payloader configuration 93 | 4. **RTP Destination**: Fixed by correct SDP parsing -------------------------------------------------------------------------------- /test/parrot/sip/headers/record_route_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.RecordRouteTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Headers 5 | alias Parrot.Sip.Uri 6 | 7 | describe "parsing Record-Route headers" do 8 | test "parses Record-Route header" do 9 | header_value = "" 10 | 11 | record_route = Headers.RecordRoute.parse(header_value) 12 | 13 | assert record_route.uri.scheme == "sip" 14 | assert record_route.uri.host == "proxy1.example.com" 15 | assert record_route.uri.parameters["lr"] == "" 16 | 17 | formatted = Headers.RecordRoute.format(record_route) 18 | # Use string.trim to avoid any whitespace differences in formatted output 19 | assert String.trim(formatted) == String.trim(header_value) 20 | end 21 | 22 | test "parses Record-Route header with display name" do 23 | header_value = "Example Proxy " 24 | 25 | record_route = Headers.RecordRoute.parse(header_value) 26 | 27 | assert record_route.display_name == "Example Proxy" 28 | assert record_route.uri.scheme == "sip" 29 | assert record_route.uri.host == "proxy1.example.com" 30 | assert record_route.uri.parameters["lr"] == "" 31 | 32 | formatted = Headers.RecordRoute.format(record_route) 33 | assert String.trim(formatted) == String.trim(header_value) 34 | end 35 | 36 | test "parses Record-Route header with parameters" do 37 | header_value = ";param=value" 38 | 39 | record_route = Headers.RecordRoute.parse(header_value) 40 | 41 | assert record_route.uri.scheme == "sip" 42 | assert record_route.uri.host == "proxy1.example.com" 43 | assert record_route.uri.parameters["lr"] == "" 44 | assert record_route.parameters["param"] == "value" 45 | 46 | formatted = Headers.RecordRoute.format(record_route) 47 | assert String.trim(formatted) == String.trim(header_value) 48 | end 49 | 50 | test "parses list of Record-Route headers" do 51 | header_value = ", " 52 | 53 | record_routes = Headers.RecordRoute.parse_list(header_value) 54 | 55 | assert length(record_routes) == 2 56 | assert Enum.at(record_routes, 0).uri.host == "proxy1.example.com" 57 | assert Enum.at(record_routes, 1).uri.host == "proxy2.example.com" 58 | 59 | formatted = Headers.RecordRoute.format_list(record_routes) 60 | assert String.trim(formatted) == String.trim(header_value) 61 | end 62 | end 63 | 64 | describe "creating Record-Route headers" do 65 | test "creates Record-Route header" do 66 | uri = %Uri{scheme: "sip", host: "proxy1.example.com", parameters: %{"lr" => ""}} 67 | record_route = Headers.RecordRoute.new(uri) 68 | 69 | assert record_route.uri.scheme == "sip" 70 | assert record_route.uri.host == "proxy1.example.com" 71 | assert record_route.uri.parameters["lr"] == "" 72 | 73 | assert Headers.RecordRoute.format(record_route) == "" 74 | end 75 | 76 | test "creates Record-Route header with string URI" do 77 | record_route = Headers.RecordRoute.new("sip:proxy1.example.com;lr") 78 | 79 | assert record_route.uri.scheme == "sip" 80 | assert record_route.uri.host == "proxy1.example.com" 81 | assert record_route.uri.parameters["lr"] == "" 82 | 83 | formatted = Headers.RecordRoute.format(record_route) 84 | assert formatted == "" 85 | end 86 | 87 | test "creates Record-Route header with display name" do 88 | record_route = Headers.RecordRoute.new("sip:proxy1.example.com;lr", "Example Proxy") 89 | 90 | assert record_route.display_name == "Example Proxy" 91 | assert record_route.uri.scheme == "sip" 92 | assert record_route.uri.host == "proxy1.example.com" 93 | assert record_route.uri.parameters["lr"] == "" 94 | 95 | formatted = Headers.RecordRoute.format(record_route) 96 | assert formatted == "Example Proxy " 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /usage-rules.md: -------------------------------------------------------------------------------- 1 | # Parrot Platform Usage Rules 2 | 3 | Parrot Platform provides Elixir libraries and OTP behaviours for building telecom applications with SIP protocol and media handling. 4 | 5 | ## Core Concepts 6 | 7 | ### Always use OTP behaviours 8 | - Implement `Parrot.SipHandler` for SIP protocol events 9 | - Implement `Parrot.MediaHandler` for media session events 10 | - Both behaviours can be implemented in the same module 11 | 12 | ### Pattern matching is critical 13 | Always use pattern matching on SIP messages instead of conditionals: 14 | 15 | ```elixir 16 | # GOOD 17 | def handle_invite(%{headers: %{"from" => %From{uri: %{user: "alice"}}}} = msg, state) 18 | def handle_invite(%{method: "INVITE"} = msg, state) 19 | 20 | # BAD 21 | def handle_invite(msg, state) do 22 | if msg.headers["from"].uri.user == "alice" do 23 | ``` 24 | 25 | ## Quick Start Pattern 26 | 27 | ```elixir 28 | defmodule MyApp do 29 | use Parrot.SipHandler 30 | @behaviour Parrot.MediaHandler 31 | 32 | # SIP handler - handles protocol events 33 | def handle_invite(request, state) do 34 | {:ok, _pid} = Parrot.Media.MediaSession.start_link( 35 | id: generate_call_id(), 36 | role: :uas, 37 | media_handler: __MODULE__, 38 | handler_args: %{} 39 | ) 40 | 41 | case Parrot.Media.MediaSession.process_offer(call_id, request.body) do 42 | {:ok, sdp_answer} -> {:respond, 200, "OK", %{}, sdp_answer} 43 | {:error, _} -> {:respond, 488, "Not Acceptable Here", %{}, ""} 44 | end 45 | end 46 | 47 | # MediaHandler - handles media events 48 | def handle_stream_start(_id, :outbound, state) do 49 | {{:play, "welcome.wav"}, state} 50 | end 51 | end 52 | ``` 53 | 54 | ## Key Modules 55 | 56 | - `Parrot.Sip.Transport.StateMachine` - Start UDP/TCP transports 57 | - `Parrot.Media.MediaSession` - Manage media sessions 58 | - `Parrot.Sip.Message` - SIP message structure 59 | - `Parrot.Sip.Dialog` - Dialog management 60 | 61 | ## Common Patterns 62 | 63 | ### Starting a UAS (server) 64 | ```elixir 65 | handler = Parrot.Sip.Handler.new(MyApp.SipHandler, %{}, log_level: :info) 66 | Parrot.Sip.Transport.StateMachine.start_udp(%{ 67 | listen_port: 5060, 68 | handler: handler 69 | }) 70 | ``` 71 | 72 | ### SipHandler callbacks 73 | - `handle_invite/2` - Incoming calls 74 | - `handle_ack/2` - Call confirmation 75 | - `handle_bye/2` - Call termination 76 | - `handle_cancel/2` - Call cancellation 77 | - `handle_response/2` - SIP responses 78 | - `handle_request/2` - Other SIP methods 79 | 80 | ### MediaHandler callbacks 81 | - `handle_stream_start/3` - Media begins, return `{:play, file}` to play audio 82 | - `handle_play_complete/2` - Audio finished, return next action 83 | - `handle_codec_negotiation/3` - Select codec preference 84 | 85 | ## Important Notes 86 | 87 | - Uses gen_statem extensively (NOT just GenServer) 88 | - SIP transactions and dialogs are state machines 89 | - Media sessions integrate with Membrane multimedia libraries 90 | - Pattern match on message structs for clean code 91 | - Let it crash - supervisors handle failures 92 | 93 | ## Testing 94 | 95 | ```bash 96 | # Run all tests 97 | mix test 98 | 99 | # Run SIPp integration tests 100 | mix test test/sipp/test_scenarios.exs 101 | 102 | # Enable SIP tracing 103 | SIP_TRACE=true mix test 104 | ``` 105 | 106 | ## Common Mistakes 107 | 108 | 1. **Not pattern matching** - Always pattern match SIP messages 109 | 2. **Fighting gen_statem** - Embrace state machines for transactions/dialogs 110 | 3. **Ignoring media callbacks** - Implement MediaHandler for audio 111 | 4. **Not handling all SIP methods** - Implement handle_request/2 fallback 112 | 113 | ## Example: Simple IVR 114 | 115 | ```elixir 116 | defmodule MyIVR do 117 | use Parrot.SipHandler 118 | @behaviour Parrot.MediaHandler 119 | 120 | def init(_), do: {:ok, %{menu_files: ["welcome.wav", "menu.wav"]}} 121 | 122 | def handle_stream_start(_, :outbound, state) do 123 | {{:play, hd(state.menu_files)}, state} 124 | end 125 | 126 | def handle_play_complete(file, state) do 127 | remaining = tl(state.menu_files) 128 | if remaining == [] do 129 | {:stop, state} 130 | else 131 | {{:play, hd(remaining)}, %{state | menu_files: remaining}} 132 | end 133 | end 134 | end 135 | ``` -------------------------------------------------------------------------------- /lib/parrot/sip/headers/supported.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.Supported do 2 | @moduledoc """ 3 | Module for working with SIP Supported headers as defined in RFC 3261 Section 20.37. 4 | 5 | The Supported header field enumerates all the extensions supported 6 | by the User Agent Client (UAC) or User Agent Server (UAS). The Supported 7 | header field contains a list of option tags, described in Section 19.2 of RFC 3261. 8 | 9 | The Supported header plays a key role in SIP capability negotiation: 10 | - Declaring optional extensions that the UA understands 11 | - Enabling feature negotiation between endpoints 12 | - Working with Require and Proxy-Require headers for mandatory features 13 | - Facilitating backward compatibility and protocol extensibility 14 | 15 | Common option tags include: 16 | - 100rel: Reliable provisional responses (RFC 3262) 17 | - timer: Session timers (RFC 4028) 18 | - replaces: Call transfer (RFC 3891) 19 | - path: Path extension for registrations (RFC 3327) 20 | - gruu: Globally Routable User Agent URIs (RFC 5627) 21 | 22 | References: 23 | - RFC 3261 Section 19.2: Option Tags 24 | - RFC 3261 Section 20.32: Require Header Field 25 | - RFC 3261 Section 20.37: Supported Header Field 26 | - RFC 3261 Section 20.40: Unsupported Header Field 27 | - IANA SIP Option Tags Registry 28 | """ 29 | 30 | @doc """ 31 | Creates a new Supported header with the specified options. 32 | 33 | ## Examples 34 | 35 | iex> Parrot.Sip.Headers.Supported.new(["path", "100rel"]) 36 | ["path", "100rel"] 37 | """ 38 | @spec new([String.t()]) :: [String.t()] 39 | def new(options) when is_list(options), do: options 40 | 41 | @doc """ 42 | Parses a Supported header string into a list of option tags. 43 | 44 | ## Examples 45 | 46 | iex> Parrot.Sip.Headers.Supported.parse("path, 100rel") 47 | ["path", "100rel"] 48 | 49 | iex> Parrot.Sip.Headers.Supported.parse("") 50 | [] 51 | """ 52 | @spec parse(String.t()) :: [String.t()] 53 | def parse(""), do: [] 54 | 55 | def parse(string) when is_binary(string) do 56 | string 57 | |> String.split(",") 58 | |> Enum.map(&String.trim/1) 59 | |> Enum.filter(&(&1 != "")) 60 | end 61 | 62 | @doc """ 63 | Formats a list of Supported options as a string. 64 | 65 | ## Examples 66 | 67 | iex> Parrot.Sip.Headers.Supported.format(["path", "100rel"]) 68 | "path, 100rel" 69 | 70 | iex> Parrot.Sip.Headers.Supported.format([]) 71 | "" 72 | """ 73 | @spec format([String.t()]) :: String.t() 74 | def format([]), do: "" 75 | 76 | def format(options) when is_list(options) do 77 | Enum.join(options, ", ") 78 | end 79 | 80 | @doc """ 81 | Adds an option to the Supported header. 82 | 83 | ## Examples 84 | 85 | iex> Parrot.Sip.Headers.Supported.add(["path"], "100rel") 86 | ["path", "100rel"] 87 | 88 | iex> Parrot.Sip.Headers.Supported.add(["path", "100rel"], "path") 89 | ["path", "100rel"] 90 | """ 91 | @spec add([String.t()], String.t()) :: [String.t()] 92 | def add(options, option) when is_list(options) and is_binary(option) do 93 | if option in options do 94 | options 95 | else 96 | options ++ [option] 97 | end 98 | end 99 | 100 | @doc """ 101 | Removes an option from the Supported header. 102 | 103 | ## Examples 104 | 105 | iex> Parrot.Sip.Headers.Supported.remove(["path", "100rel"], "path") 106 | ["100rel"] 107 | 108 | iex> Parrot.Sip.Headers.Supported.remove(["path"], "100rel") 109 | ["path"] 110 | """ 111 | @spec remove([String.t()], String.t()) :: [String.t()] 112 | def remove(options, option) when is_list(options) and is_binary(option) do 113 | Enum.filter(options, &(&1 != option)) 114 | end 115 | 116 | @doc """ 117 | Checks if a specific option is supported. 118 | 119 | ## Examples 120 | 121 | iex> Parrot.Sip.Headers.Supported.supports?(["path", "100rel"], "path") 122 | true 123 | 124 | iex> Parrot.Sip.Headers.Supported.supports?(["path"], "100rel") 125 | false 126 | """ 127 | @spec supports?([String.t()], String.t()) :: boolean() 128 | def supports?(options, option) when is_list(options) and is_binary(option) do 129 | option in options 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /test/parrot/media/media_session_audio_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.MediaSessionAudioTest do 2 | use ExUnit.Case, async: true 3 | alias Parrot.Media.MediaSession 4 | 5 | describe "audio configuration" do 6 | test "defaults audio_source to :file when audio_file is provided" do 7 | {:ok, pid} = 8 | MediaSession.start_link( 9 | id: "test-audio-1", 10 | dialog_id: "dialog-1", 11 | role: :uas, 12 | audio_file: "/path/to/audio.wav" 13 | ) 14 | 15 | {_state_name, data} = :sys.get_state(pid) 16 | assert data.audio_source == :file 17 | assert data.audio_sink == :none 18 | 19 | MediaSession.terminate_session(pid) 20 | end 21 | 22 | test "defaults audio_source to :silence when no audio_file" do 23 | {:ok, pid} = 24 | MediaSession.start_link( 25 | id: "test-audio-2", 26 | dialog_id: "dialog-2", 27 | role: :uas 28 | ) 29 | 30 | {_state_name, data} = :sys.get_state(pid) 31 | assert data.audio_source == :silence 32 | assert data.audio_sink == :none 33 | 34 | MediaSession.terminate_session(pid) 35 | end 36 | 37 | test "accepts explicit audio_source and audio_sink configuration" do 38 | {:ok, pid} = 39 | MediaSession.start_link( 40 | id: "test-audio-3", 41 | dialog_id: "dialog-3", 42 | role: :uas, 43 | audio_source: :device, 44 | audio_sink: :device, 45 | input_device_id: 0, 46 | output_device_id: 1 47 | ) 48 | 49 | {_state_name, data} = :sys.get_state(pid) 50 | assert data.audio_source == :device 51 | assert data.audio_sink == :device 52 | assert data.input_device_id == 0 53 | assert data.output_device_id == 1 54 | 55 | MediaSession.terminate_session(pid) 56 | end 57 | 58 | test "configures recording to file" do 59 | {:ok, pid} = 60 | MediaSession.start_link( 61 | id: "test-audio-4", 62 | dialog_id: "dialog-4", 63 | role: :uas, 64 | audio_source: :silence, 65 | audio_sink: :file, 66 | output_file: "/tmp/recording.wav" 67 | ) 68 | 69 | {_state_name, data} = :sys.get_state(pid) 70 | assert data.audio_source == :silence 71 | assert data.audio_sink == :file 72 | assert data.output_file == "/tmp/recording.wav" 73 | 74 | MediaSession.terminate_session(pid) 75 | end 76 | end 77 | 78 | describe "pipeline selection" do 79 | setup do 80 | # Create a simple SDP offer for testing 81 | sdp_offer = """ 82 | v=0 83 | o=- 123456 123456 IN IP4 127.0.0.1 84 | s=Test Session 85 | c=IN IP4 127.0.0.1 86 | t=0 0 87 | m=audio 20000 RTP/AVP 8 88 | a=rtpmap:8 PCMA/8000 89 | a=sendrecv 90 | """ 91 | 92 | {:ok, sdp_offer: sdp_offer} 93 | end 94 | 95 | test "selects PortAudioPipeline when using device audio", %{sdp_offer: sdp_offer} do 96 | {:ok, pid} = 97 | MediaSession.start_link( 98 | id: "test-pipeline-1", 99 | dialog_id: "dialog-5", 100 | role: :uas, 101 | audio_source: :device 102 | ) 103 | 104 | # Process offer to trigger pipeline selection 105 | {:ok, _answer} = MediaSession.process_offer(pid, sdp_offer) 106 | 107 | {_state_name, data} = :sys.get_state(pid) 108 | assert data.pipeline_module == Parrot.Media.PortAudioPipeline 109 | 110 | MediaSession.terminate_session(pid) 111 | end 112 | 113 | test "selects codec-specific pipeline for file-only audio", %{sdp_offer: sdp_offer} do 114 | {:ok, pid} = 115 | MediaSession.start_link( 116 | id: "test-pipeline-2", 117 | dialog_id: "dialog-6", 118 | role: :uas, 119 | audio_source: :file, 120 | audio_sink: :none, 121 | audio_file: "/path/to/audio.wav" 122 | ) 123 | 124 | # Process offer to trigger pipeline selection 125 | {:ok, _answer} = MediaSession.process_offer(pid, sdp_offer) 126 | 127 | {_state_name, data} = :sys.get_state(pid) 128 | # Should use the codec-specific pipeline (MembraneAlawPipeline for PCMA) 129 | assert data.pipeline_module == Parrot.Media.MembraneAlawPipeline 130 | 131 | MediaSession.terminate_session(pid) 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/parrot/sip/method_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.MethodTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Parrot.Sip.Method 5 | 6 | describe "standard_methods/0" do 7 | test "returns all standard SIP methods" do 8 | methods = Method.standard_methods() 9 | assert is_list(methods) 10 | assert Enum.member?(methods, :invite) 11 | assert Enum.member?(methods, :ack) 12 | assert Enum.member?(methods, :bye) 13 | assert Enum.member?(methods, :cancel) 14 | assert Enum.member?(methods, :register) 15 | assert Enum.member?(methods, :options) 16 | assert Enum.member?(methods, :subscribe) 17 | assert Enum.member?(methods, :notify) 18 | assert Enum.member?(methods, :publish) 19 | assert Enum.member?(methods, :message) 20 | end 21 | end 22 | 23 | describe "is_standard?/1" do 24 | test "returns true for standard methods" do 25 | assert Method.is_standard?(:invite) 26 | assert Method.is_standard?(:ack) 27 | assert Method.is_standard?(:bye) 28 | assert Method.is_standard?(:cancel) 29 | end 30 | 31 | test "returns false for non-standard methods" do 32 | assert not Method.is_standard?(:custom) 33 | assert not Method.is_standard?(:CUSTOM) 34 | assert not Method.is_standard?(123) 35 | assert not Method.is_standard?(nil) 36 | end 37 | end 38 | 39 | describe "parse/1" do 40 | test "correctly parses standard methods" do 41 | assert {:ok, :invite} = Method.parse("INVITE") 42 | assert {:ok, :ack} = Method.parse("ACK") 43 | assert {:ok, :register} = Method.parse("REGISTER") 44 | assert {:ok, :subscribe} = Method.parse("subscribe") 45 | end 46 | 47 | test "parses custom methods as uppercase atoms" do 48 | assert {:ok, :CUSTOM} = Method.parse("CUSTOM") 49 | assert {:ok, :"X-CUSTOM"} = Method.parse("X-CUSTOM") 50 | end 51 | 52 | test "returns error for invalid methods" do 53 | assert {:error, :invalid_method} = Method.parse(123) 54 | assert {:error, :invalid_method} = Method.parse(nil) 55 | end 56 | end 57 | 58 | describe "parse!/1" do 59 | test "correctly parses standard methods" do 60 | assert :invite = Method.parse!("INVITE") 61 | assert :ack = Method.parse!("ACK") 62 | assert :register = Method.parse!("REGISTER") 63 | end 64 | 65 | test "parses custom methods as uppercase atoms" do 66 | assert :CUSTOM = Method.parse!("CUSTOM") 67 | assert :"X-CUSTOM" = Method.parse!("X-CUSTOM") 68 | end 69 | 70 | test "raises error for invalid methods" do 71 | assert_raise ArgumentError, fn -> Method.parse!(123) end 72 | assert_raise ArgumentError, fn -> Method.parse!(nil) end 73 | end 74 | end 75 | 76 | describe "to_string/1" do 77 | test "correctly converts standard methods to strings" do 78 | assert "INVITE" = Method.to_string(:invite) 79 | assert "ACK" = Method.to_string(:ack) 80 | assert "REGISTER" = Method.to_string(:register) 81 | end 82 | 83 | test "preserves custom method capitalization" do 84 | assert "CUSTOM" = Method.to_string(:CUSTOM) 85 | assert "X-CUSTOM" = Method.to_string(:"X-CUSTOM") 86 | end 87 | end 88 | 89 | describe "method properties" do 90 | test "allows_body?/1" do 91 | assert Method.allows_body?(:invite) 92 | assert Method.allows_body?(:register) 93 | assert Method.allows_body?(:options) 94 | end 95 | 96 | test "creates_dialog?/1" do 97 | assert Method.creates_dialog?(:invite) 98 | assert Method.creates_dialog?(:subscribe) 99 | assert Method.creates_dialog?(:refer) 100 | assert not Method.creates_dialog?(:register) 101 | assert not Method.creates_dialog?(:options) 102 | end 103 | 104 | test "requires_contact?/1" do 105 | assert Method.requires_contact?(:invite) 106 | assert Method.requires_contact?(:register) 107 | assert Method.requires_contact?(:subscribe) 108 | assert not Method.requires_contact?(:options) 109 | assert not Method.requires_contact?(:ack) 110 | end 111 | 112 | test "can_cancel?/1" do 113 | assert Method.can_cancel?(:invite) 114 | assert Method.can_cancel?(:subscribe) 115 | assert Method.can_cancel?(:register) 116 | assert not Method.can_cancel?(:ack) 117 | assert not Method.can_cancel?(:bye) 118 | assert not Method.can_cancel?(:cancel) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/parrot/sip/message_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.MessageTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.Message 5 | alias Parrot.Sip.Headers.{Via, From, To, CSeq, Contact} 6 | 7 | describe "to_binary/1 with header lists" do 8 | test "serializes message with Via header list" do 9 | via1 = Via.new("proxy1.example.com", "udp", 5060, %{"branch" => "z9hG4bK111"}) 10 | via2 = Via.new("proxy2.example.com", "tcp", 5061, %{"branch" => "z9hG4bK222"}) 11 | 12 | message = 13 | Message.new_request(:invite, "sip:bob@example.com", %{ 14 | "via" => [via1, via2], 15 | "from" => From.new("sip:alice@example.com", "Alice", "tag123"), 16 | "to" => To.new("sip:bob@example.com", "Bob"), 17 | "cseq" => CSeq.new(1, :invite), 18 | "call-id" => "call123@example.com", 19 | "contact" => Contact.new("sip:alice@192.168.1.100:5060") 20 | }) 21 | 22 | binary = Message.to_binary(message) 23 | 24 | # Check that the message starts with the request line 25 | assert binary =~ ~r/^INVITE sip:bob@example.com SIP\/2.0\r\n/ 26 | 27 | # Check that Via header appears first after the request line 28 | lines = String.split(binary, "\r\n") 29 | assert Enum.at(lines, 1) =~ ~r/^Via: / 30 | 31 | # Check that Via headers are properly formatted 32 | assert binary =~ 33 | "Via: SIP/2.0/UDP proxy1.example.com:5060;branch=z9hG4bK111, SIP/2.0/TCP proxy2.example.com:5061;branch=z9hG4bK222\r\n" 34 | 35 | # Check other headers are present and formatted 36 | assert binary =~ "From: Alice ;tag=tag123\r\n" 37 | assert binary =~ "To: Bob \r\n" 38 | assert binary =~ "Cseq: 1 INVITE\r\n" 39 | assert binary =~ "Call-Id: call123@example.com\r\n" 40 | assert binary =~ "Contact: \r\n" 41 | end 42 | 43 | test "serializes message with single Via header in list" do 44 | via = Via.new("proxy.example.com", "udp", 5060, %{"branch" => "z9hG4bK123"}) 45 | 46 | message = 47 | Message.new_request(:register, "sip:registrar.example.com", %{ 48 | "via" => [via], 49 | "from" => From.new("sip:alice@example.com", nil, "tag456"), 50 | "to" => To.new("sip:alice@example.com"), 51 | "cseq" => CSeq.new(1, :register), 52 | "call-id" => "reg123@example.com" 53 | }) 54 | 55 | binary = Message.to_binary(message) 56 | 57 | # Via should still be formatted correctly even with single element list 58 | assert binary =~ "Via: SIP/2.0/UDP proxy.example.com:5060;branch=z9hG4bK123\r\n" 59 | end 60 | 61 | test "serializes message with empty Via header list" do 62 | message = 63 | Message.new_request(:options, "sip:server.example.com", %{ 64 | "via" => [], 65 | "from" => From.new("sip:alice@example.com", nil, "tag789"), 66 | "to" => To.new("sip:server.example.com"), 67 | "cseq" => CSeq.new(1, :options), 68 | "call-id" => "opt123@example.com" 69 | }) 70 | 71 | binary = Message.to_binary(message) 72 | 73 | # Empty Via list should result in "Via: \r\n" 74 | assert binary =~ "Via: \r\n" 75 | end 76 | 77 | test "serializes message with mixed header types" do 78 | via = Via.new("proxy.example.com", "udp", 5060, %{"branch" => "z9hG4bK999"}) 79 | 80 | message = 81 | Message.new_request(:invite, "sip:bob@example.com", %{ 82 | "via" => [via], 83 | "from" => From.new("sip:alice@example.com", "Alice", "tagABC"), 84 | "to" => To.new("sip:bob@example.com"), 85 | "cseq" => CSeq.new(1, :invite), 86 | "call-id" => "mixed123@example.com", 87 | "contact" => Contact.new("sip:alice@host.com"), 88 | # String header 89 | "content-type" => "application/sdp", 90 | # Integer header 91 | "max-forwards" => 70, 92 | # String header 93 | "user-agent" => "Parrot/1.0" 94 | }) 95 | 96 | binary = Message.to_binary(message) 97 | 98 | # Check all headers are formatted correctly 99 | assert binary =~ "Via: SIP/2.0/UDP proxy.example.com:5060;branch=z9hG4bK999\r\n" 100 | assert binary =~ "From: Alice ;tag=tagABC\r\n" 101 | assert binary =~ "Content-Type: application/sdp\r\n" 102 | assert binary =~ "Max-Forwards: 70\r\n" 103 | assert binary =~ "User-Agent: Parrot/1.0\r\n" 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /docs/media_negotiation_guide.md: -------------------------------------------------------------------------------- 1 | # Media Negotiation Guide 2 | 3 | This guide explains the proper way to handle media sessions in Parrot Platform to avoid common pitfalls like RTP port mismatches. 4 | 5 | ## The Problem 6 | 7 | The current implementation has a fundamental issue where: 8 | 1. UAC generates a random RTP port and puts it in the INVITE 9 | 2. MediaSession is created later with that port number 10 | 3. But MediaSession.process_offer allocates a NEW port, ignoring the pre-allocated one 11 | 4. Result: The pipeline listens on the wrong port and no audio is heard 12 | 13 | ## The Solution: MediaSession-First Architecture 14 | 15 | ### Key Principles 16 | 17 | 1. **MediaSession owns port allocation** - Never allocate RTP ports outside of MediaSession 18 | 2. **Create MediaSession before INVITE** - For UAC, create the session first, then get the SDP 19 | 3. **Use consistent flow** - UAC uses generate_offer/process_answer, UAS uses process_offer/start_media 20 | 21 | ### For UAC (User Agent Client) 22 | 23 | ```elixir 24 | # Step 1: Create MediaSession FIRST 25 | {:ok, media_session} = MediaSession.start_link( 26 | id: "unique-session-id", 27 | role: :uac, 28 | audio_source: :device, 29 | audio_sink: :device, 30 | input_device_id: mic_id, 31 | output_device_id: speaker_id 32 | ) 33 | 34 | # Step 2: Generate SDP offer (this allocates the RTP port) 35 | {:ok, sdp_offer} = MediaSession.generate_offer(media_session) 36 | 37 | # Step 3: Create and send INVITE with the SDP 38 | invite = create_invite_with_sdp(sdp_offer) 39 | {:uac_id, trans} = UAC.request(invite, callback) 40 | 41 | # Step 4: When 200 OK arrives, process the answer 42 | # In your callback: 43 | {:response, %{status_code: 200, body: sdp_answer}} -> 44 | :ok = MediaSession.process_answer(media_session, sdp_answer) 45 | :ok = MediaSession.start_media(media_session) 46 | # Send ACK... 47 | ``` 48 | 49 | ### For UAS (User Agent Server) 50 | 51 | ```elixir 52 | # Step 1: When INVITE arrives, create MediaSession 53 | {:ok, media_session} = MediaSession.start_link( 54 | id: "unique-session-id", 55 | role: :uas, 56 | audio_source: :device, 57 | audio_sink: :device 58 | ) 59 | 60 | # Step 2: Process the offer and generate answer 61 | {:ok, sdp_answer} = MediaSession.process_offer(media_session, invite_sdp) 62 | 63 | # Step 3: Send 200 OK with the answer 64 | send_200_ok_with_sdp(sdp_answer) 65 | 66 | # Step 4: When ACK arrives, start media 67 | :ok = MediaSession.start_media(media_session) 68 | ``` 69 | 70 | ## Using MediaSessionManager 71 | 72 | The MediaSessionManager provides a cleaner API that handles the common patterns: 73 | 74 | ### UAC Example 75 | 76 | ```elixir 77 | # Prepare session and get SDP in one call 78 | {:ok, session, sdp_offer} = MediaSessionManager.prepare_uac_session( 79 | id: "call-123", 80 | audio_source: :device, 81 | audio_sink: :device, 82 | input_device_id: 1, 83 | output_device_id: 2 84 | ) 85 | 86 | # Send INVITE with sdp_offer... 87 | 88 | # When 200 OK arrives: 89 | :ok = MediaSessionManager.complete_uac_setup(session, sdp_answer) 90 | ``` 91 | 92 | ### UAS Example 93 | 94 | ```elixir 95 | # When INVITE arrives: 96 | {:ok, session, sdp_answer} = MediaSessionManager.prepare_uas_session( 97 | id: "call-456", 98 | sdp_offer: invite_sdp, 99 | audio_source: :device, 100 | audio_sink: :device 101 | ) 102 | 103 | # Send 200 OK with sdp_answer... 104 | 105 | # When ACK arrives: 106 | :ok = MediaSessionManager.complete_uas_setup(session) 107 | ``` 108 | 109 | ## Common Mistakes to Avoid 110 | 111 | 1. **Don't allocate RTP ports manually** - Let MediaSession handle it 112 | 2. **Don't call process_offer for UAC** - Use process_answer instead 113 | 3. **Don't create MediaSession after sending INVITE** - Create it first 114 | 4. **Don't forget to call start_media** - This actually starts the audio flow 115 | 116 | ## Audio Quality Issues 117 | 118 | If audio is "fuzzy", check: 119 | - Sample rate matching (should be 8kHz for G.711) 120 | - Proper resampling from mic input (usually 48kHz) to 8kHz 121 | - Network conditions (packet loss, jitter) 122 | - Audio device configuration 123 | 124 | ## Migration Guide 125 | 126 | To update existing code: 127 | 128 | 1. Move MediaSession creation before INVITE generation 129 | 2. Replace manual port allocation with MediaSession.generate_offer 130 | 3. For UAC, replace process_offer with process_answer 131 | 4. Consider using MediaSessionManager for cleaner code 132 | 133 | ## Example Files 134 | 135 | - `/examples/uac_with_proper_media.exs` - Correct UAC implementation 136 | - `/lib/parrot/media/media_session_manager.ex` - Helper module for common patterns -------------------------------------------------------------------------------- /lib/parrot/media/media_session_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Media.MediaSessionManager do 2 | @moduledoc """ 3 | Manages media sessions and ensures proper port allocation. 4 | 5 | This module provides a better architecture where: 6 | 1. MediaSession is created BEFORE sending INVITE 7 | 2. MediaSession allocates and owns the RTP port 8 | 3. The allocated port is used in the SDP 9 | """ 10 | 11 | alias Parrot.Media.MediaSession 12 | require Logger 13 | 14 | @doc """ 15 | Prepares a media session for an outgoing call (UAC). 16 | 17 | This should be called BEFORE creating the INVITE. It will: 18 | 1. Create a MediaSession 19 | 2. Allocate an RTP port 20 | 3. Generate the SDP offer 21 | 22 | ## Options 23 | - `:id` - Unique session ID (required) 24 | - `:dialog_id` - SIP dialog ID (optional, can be set later) 25 | - `:audio_source` - Source of audio (:device, :file, :silence) 26 | - `:audio_sink` - Where to play audio (:device, :file, :none) 27 | - `:input_device_id` - Audio input device ID 28 | - `:output_device_id` - Audio output device ID 29 | 30 | ## Returns 31 | - `{:ok, session_pid, sdp_offer}` - Success 32 | - `{:error, reason}` - Failure 33 | """ 34 | def prepare_uac_session(opts) do 35 | # Ensure we have required options 36 | _id = Keyword.fetch!(opts, :id) 37 | 38 | # Start MediaSession as UAC 39 | session_opts = Keyword.put(opts, :role, :uac) 40 | 41 | case MediaSession.start_link(session_opts) do 42 | {:ok, session_pid} -> 43 | # Generate SDP offer (this allocates the port) 44 | case MediaSession.generate_offer(session_pid) do 45 | {:ok, sdp_offer} -> 46 | {:ok, session_pid, sdp_offer} 47 | 48 | {:error, reason} -> 49 | # Clean up on failure 50 | GenServer.stop(session_pid) 51 | {:error, {:sdp_generation_failed, reason}} 52 | end 53 | 54 | {:error, reason} -> 55 | {:error, {:session_start_failed, reason}} 56 | end 57 | end 58 | 59 | @doc """ 60 | Prepares a media session for an incoming call (UAS). 61 | 62 | This should be called when receiving an INVITE. 63 | 64 | ## Options 65 | Same as `prepare_uac_session/1` plus: 66 | - `:sdp_offer` - The SDP offer from the INVITE (required) 67 | 68 | ## Returns 69 | - `{:ok, session_pid, sdp_answer}` - Success with SDP answer 70 | - `{:error, reason}` - Failure 71 | """ 72 | def prepare_uas_session(opts) do 73 | # Ensure we have required options 74 | _id = Keyword.fetch!(opts, :id) 75 | sdp_offer = Keyword.fetch!(opts, :sdp_offer) 76 | 77 | # Start MediaSession as UAS 78 | session_opts = 79 | opts 80 | |> Keyword.put(:role, :uas) 81 | |> Keyword.delete(:sdp_offer) 82 | 83 | case MediaSession.start_link(session_opts) do 84 | {:ok, session_pid} -> 85 | # Process offer and generate answer 86 | case MediaSession.process_offer(session_pid, sdp_offer) do 87 | {:ok, sdp_answer} -> 88 | {:ok, session_pid, sdp_answer} 89 | 90 | {:error, reason} -> 91 | # Clean up on failure 92 | GenServer.stop(session_pid) 93 | {:error, {:negotiation_failed, reason}} 94 | end 95 | 96 | {:error, reason} -> 97 | {:error, {:session_start_failed, reason}} 98 | end 99 | end 100 | 101 | @doc """ 102 | Completes call setup for UAC after receiving 200 OK. 103 | 104 | ## Parameters 105 | - `session_pid` - The MediaSession process 106 | - `sdp_answer` - The SDP answer from 200 OK 107 | 108 | ## Returns 109 | - `:ok` - Success, media is now flowing 110 | - `{:error, reason}` - Failure 111 | """ 112 | def complete_uac_setup(session_pid, sdp_answer) do 113 | case MediaSession.process_answer(session_pid, sdp_answer) do 114 | :ok -> 115 | # Start media flow 116 | MediaSession.start_media(session_pid) 117 | 118 | {:error, reason} -> 119 | {:error, {:answer_processing_failed, reason}} 120 | end 121 | end 122 | 123 | @doc """ 124 | Completes call setup for UAS after sending 200 OK and receiving ACK. 125 | 126 | ## Parameters 127 | - `session_pid` - The MediaSession process 128 | 129 | ## Returns 130 | - `:ok` - Success, media is now flowing 131 | """ 132 | def complete_uas_setup(session_pid) do 133 | MediaSession.start_media(session_pid) 134 | end 135 | 136 | # @doc """ 137 | # Gets the allocated RTP port from a session. 138 | 139 | # Useful for debugging or if you need the port for other purposes. 140 | # """ 141 | # def get_local_rtp_port(session_pid) do 142 | # case MediaSession.get_state(session_pid) do 143 | # {:ok, state} -> 144 | # {:ok, state.local_rtp_port} 145 | 146 | # error -> 147 | # error 148 | # end 149 | # end 150 | end 151 | -------------------------------------------------------------------------------- /test/parrot/sip/uri_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.UriParserTest do 2 | use ExUnit.Case 3 | 4 | alias Parrot.Sip.UriParser 5 | 6 | describe "parse/1" do 7 | test "parses basic SIP URI" do 8 | uri_string = "sip:alice@atlanta.com" 9 | 10 | assert {:ok, components} = UriParser.parse(uri_string) 11 | assert components.scheme == "sip" 12 | assert components.user == "alice" 13 | assert components.host == "atlanta.com" 14 | assert components.port == nil 15 | assert components.parameters == %{} 16 | assert components.headers == %{} 17 | end 18 | 19 | test "parses SIP URI with port" do 20 | uri_string = "sip:alice@atlanta.com:5060" 21 | 22 | assert {:ok, components} = UriParser.parse(uri_string) 23 | assert components.scheme == "sip" 24 | assert components.user == "alice" 25 | assert components.host == "atlanta.com" 26 | assert components.port == 5060 27 | assert components.parameters == %{} 28 | assert components.headers == %{} 29 | end 30 | 31 | test "parses SIP URI with parameters" do 32 | uri_string = "sip:alice@atlanta.com;transport=tcp;user=phone" 33 | 34 | assert {:ok, components} = UriParser.parse(uri_string) 35 | assert components.scheme == "sip" 36 | assert components.user == "alice" 37 | assert components.host == "atlanta.com" 38 | assert components.port == nil 39 | assert components.parameters == %{"transport" => "tcp", "user" => "phone"} 40 | assert components.headers == %{} 41 | end 42 | 43 | test "parses SIP URI with headers" do 44 | uri_string = "sip:alice@atlanta.com?subject=project%20x&priority=urgent" 45 | 46 | assert {:ok, components} = UriParser.parse(uri_string) 47 | assert components.scheme == "sip" 48 | assert components.user == "alice" 49 | assert components.host == "atlanta.com" 50 | assert components.port == nil 51 | assert components.parameters == %{} 52 | assert components.headers == %{"subject" => "project%20x", "priority" => "urgent"} 53 | end 54 | 55 | test "returns error for invalid scheme" do 56 | uri_string = "invalid:alice@atlanta.com" 57 | 58 | assert {:error, reason} = UriParser.parse(uri_string) 59 | assert reason =~ "Invalid scheme" 60 | end 61 | end 62 | 63 | describe "extract_parameters/1" do 64 | test "extracts simple parameters" do 65 | params_str = "transport=tcp;user=phone" 66 | params = UriParser.extract_parameters(params_str) 67 | 68 | assert params == %{"transport" => "tcp", "user" => "phone"} 69 | end 70 | 71 | test "extracts parameters without values" do 72 | params_str = "lr;transport=tcp" 73 | params = UriParser.extract_parameters(params_str) 74 | 75 | assert params == %{"lr" => "", "transport" => "tcp"} 76 | end 77 | 78 | test "returns empty map for empty string" do 79 | assert UriParser.extract_parameters("") == %{} 80 | end 81 | end 82 | 83 | describe "extract_headers/1" do 84 | test "extracts simple headers" do 85 | headers_str = "subject=project&priority=urgent" 86 | headers = UriParser.extract_headers(headers_str) 87 | 88 | assert headers == %{"subject" => "project", "priority" => "urgent"} 89 | end 90 | 91 | test "extracts headers without values" do 92 | headers_str = "empty&subject=project" 93 | headers = UriParser.extract_headers(headers_str) 94 | 95 | assert headers == %{"empty" => "", "subject" => "project"} 96 | end 97 | 98 | test "returns empty map for empty string" do 99 | assert UriParser.extract_headers("") == %{} 100 | end 101 | end 102 | 103 | describe "determine_host_type/1" do 104 | test "identifies hostname" do 105 | assert UriParser.determine_host_type("example.com") == :hostname 106 | end 107 | 108 | test "identifies IPv4" do 109 | assert UriParser.determine_host_type("192.168.1.1") == :ipv4 110 | end 111 | end 112 | 113 | describe "parse_address/1" do 114 | test "parses user@host" do 115 | assert {:ok, address} = UriParser.parse_address("alice@atlanta.com") 116 | assert address.user == "alice" 117 | assert address.host == "atlanta.com" 118 | assert address.port == nil 119 | end 120 | 121 | test "parses user@host:port" do 122 | assert {:ok, address} = UriParser.parse_address("alice@atlanta.com:5060") 123 | assert address.user == "alice" 124 | assert address.host == "atlanta.com" 125 | assert address.port == 5060 126 | end 127 | 128 | test "parses host only" do 129 | assert {:ok, address} = UriParser.parse_address("atlanta.com") 130 | assert address.user == nil 131 | assert address.host == "atlanta.com" 132 | assert address.port == nil 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/parrot/sip/transport/source.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Transport.Source do 2 | @moduledoc """ 3 | Parrot SIP Stack 4 | SIP message source 5 | """ 6 | 7 | require Logger 8 | 9 | alias Parrot.Sip.Message 10 | alias Parrot.Sip.Headers.Via 11 | 12 | @type source_id :: {:psip_source, module(), options()} 13 | @type options :: term() 14 | 15 | @callback send_response(Message.t(), options()) :: any() 16 | 17 | @doc """ 18 | Creates a source identifier from a module and options. 19 | """ 20 | @spec make_source_id(module(), options()) :: source_id() 21 | def make_source_id(module, args) do 22 | {:psip_source, module, args} 23 | end 24 | 25 | @doc """ 26 | Sends a response through the appropriate source module based on the request's source. 27 | """ 28 | @spec send_response(Message.t(), Message.t()) :: :ok | :error 29 | def send_response(%Message{} = resp, %Message{} = req) do 30 | try do 31 | # Extract source from the request 32 | case req.source do 33 | nil -> 34 | # No source in message, try to extract from Via header 35 | extract_source_from_via(req, resp) 36 | 37 | %Parrot.Sip.Source{} = source -> 38 | # Use the source to send the response 39 | Parrot.Sip.Transport.send_response(resp, source) 40 | 41 | {:psip_source, module, args} -> 42 | # Legacy source format 43 | module.send_response(resp, args) 44 | 45 | _ -> 46 | Logger.error("Unknown source format: #{inspect(req.source)}") 47 | :error 48 | end 49 | rescue 50 | error -> 51 | Logger.error("Failed to send response: #{inspect(error)}") 52 | :error 53 | end 54 | end 55 | 56 | # Extract destination from Via header for responses 57 | defp extract_source_from_via(%Message{} = req, %Message{} = resp) do 58 | case Message.get_header(req, "via") do 59 | nil -> 60 | Logger.error("No Via header in request for response") 61 | :error 62 | 63 | %Via{} = via -> 64 | send_via_response(via, resp) 65 | 66 | [%Via{} = via | _] -> 67 | send_via_response(via, resp) 68 | 69 | _ -> 70 | Logger.error("Invalid Via header format") 71 | :error 72 | end 73 | end 74 | 75 | defp send_via_response(%Via{} = via, %Message{} = resp) do 76 | # Extract host and port from Via header 77 | host = via.host 78 | port = via.port || default_port_for_transport(via.transport) 79 | 80 | # Check for received and rport parameters (NAT handling) 81 | host = Map.get(via.parameters, "received", host) 82 | 83 | port = 84 | case Map.get(via.parameters, "rport") do 85 | nil -> 86 | port 87 | 88 | "" -> 89 | port 90 | 91 | rport_str when is_binary(rport_str) -> 92 | case Integer.parse(rport_str) do 93 | {rport_val, _} -> rport_val 94 | :error -> port 95 | end 96 | end 97 | 98 | # Create a source and send the response 99 | # Convert host to IP tuple if it's an IP address 100 | remote_addr = parse_host_address(host) 101 | 102 | source = %Parrot.Sip.Source{ 103 | # Will be filled by transport layer 104 | local: {nil, nil}, 105 | remote: {remote_addr, port}, 106 | transport: transport_to_atom(via.transport), 107 | source_id: nil 108 | } 109 | 110 | Parrot.Sip.Transport.send_response(resp, source) 111 | end 112 | 113 | defp default_port_for_transport(:udp), do: 5060 114 | defp default_port_for_transport(:tcp), do: 5060 115 | defp default_port_for_transport(:tls), do: 5061 116 | defp default_port_for_transport(:ws), do: 80 117 | defp default_port_for_transport(:wss), do: 443 118 | defp default_port_for_transport(_), do: 5060 119 | 120 | defp transport_to_atom("UDP"), do: :udp 121 | defp transport_to_atom("TCP"), do: :tcp 122 | defp transport_to_atom("TLS"), do: :tls 123 | defp transport_to_atom("WS"), do: :ws 124 | defp transport_to_atom("WSS"), do: :wss 125 | defp transport_to_atom(:udp), do: :udp 126 | defp transport_to_atom(:tcp), do: :tcp 127 | defp transport_to_atom(:tls), do: :tls 128 | defp transport_to_atom(:ws), do: :ws 129 | defp transport_to_atom(:wss), do: :wss 130 | defp transport_to_atom(_), do: :udp 131 | 132 | defp parse_host_address(host) when is_binary(host) do 133 | # Try to parse as IPv4 address 134 | case :inet.parse_ipv4_address(String.to_charlist(host)) do 135 | {:ok, ip} -> 136 | ip 137 | 138 | {:error, _} -> 139 | # Try to parse as IPv6 address 140 | case :inet.parse_ipv6_address(String.to_charlist(host)) do 141 | {:ok, ip} -> 142 | ip 143 | 144 | {:error, _} -> 145 | # It's a hostname, return as is 146 | host 147 | end 148 | end 149 | end 150 | 151 | defp parse_host_address(host), do: host 152 | end 153 | -------------------------------------------------------------------------------- /lib/parrot/sip/headers/allow.ex: -------------------------------------------------------------------------------- 1 | defmodule Parrot.Sip.Headers.Allow do 2 | @moduledoc """ 3 | Module for working with SIP Allow headers as defined in RFC 3261 Section 20.5. 4 | 5 | The Allow header field lists the set of methods supported by the User Agent 6 | generating the message. The Allow header field MUST be present in a 405 7 | (Method Not Allowed) response. 8 | 9 | This module uses `Parrot.Sip.MethodSet` internally for efficient method set operations. 10 | 11 | References: 12 | - RFC 3261 Section 20.5: Allow Header Field 13 | """ 14 | 15 | alias Parrot.Sip.{Method, MethodSet} 16 | 17 | @doc """ 18 | Creates a new Allow header with the specified methods. 19 | 20 | ## Examples 21 | 22 | iex> Parrot.Sip.Headers.Allow.new([:invite, :ack, :bye]) 23 | %Parrot.Sip.MethodSet{} 24 | """ 25 | @spec new([Method.t() | String.t()]) :: MethodSet.t() 26 | def new(methods) when is_list(methods), do: MethodSet.new(methods) 27 | 28 | @doc """ 29 | Creates a standard set of common SIP methods. 30 | 31 | ## Examples 32 | 33 | iex> Parrot.Sip.Headers.Allow.standard() 34 | #Parrot.Sip.MethodSet<[:ack, :bye, :cancel, :invite, :message, :notify, :options, :register, :subscribe]> 35 | """ 36 | @spec standard() :: MethodSet.t() 37 | def standard do 38 | MethodSet.standard_methods() 39 | end 40 | 41 | @doc """ 42 | Parses an Allow header string into a method set. 43 | 44 | ## Examples 45 | 46 | iex> Parrot.Sip.Headers.Allow.parse("INVITE, ACK, BYE") 47 | #Parrot.Sip.MethodSet<[:ack, :bye, :invite]> 48 | 49 | iex> Parrot.Sip.Headers.Allow.parse("") 50 | #Parrot.Sip.MethodSet<[]> 51 | """ 52 | @spec parse(String.t()) :: MethodSet.t() 53 | def parse(""), do: MethodSet.new() 54 | 55 | def parse(string) when is_binary(string) do 56 | MethodSet.from_allow_string(string) 57 | end 58 | 59 | @doc """ 60 | Formats a method set as a string for the Allow header. 61 | 62 | ## Examples 63 | 64 | iex> set = Parrot.Sip.MethodSet.new([:invite, :ack, :bye]) 65 | iex> Parrot.Sip.Headers.Allow.format(set) 66 | "INVITE, ACK, BYE" 67 | 68 | iex> Parrot.Sip.Headers.Allow.format(Parrot.Sip.MethodSet.new()) 69 | "" 70 | """ 71 | @spec format(MethodSet.t()) :: String.t() 72 | def format(%MethodSet{methods: methods}) when map_size(methods) == 0, do: "" 73 | 74 | def format(%MethodSet{} = method_set) do 75 | MethodSet.to_allow_string(method_set) 76 | end 77 | 78 | # For backward compatibility 79 | def format([]), do: "" 80 | 81 | def format(methods) when is_list(methods) do 82 | MethodSet.new(methods) |> format() 83 | end 84 | 85 | @doc """ 86 | Adds a method to the Allow header. 87 | 88 | ## Examples 89 | 90 | iex> set = Parrot.Sip.MethodSet.new([:invite, :ack]) 91 | iex> Parrot.Sip.Headers.Allow.add(set, :bye) 92 | #Parrot.Sip.MethodSet<[:ack, :bye, :invite]> 93 | 94 | iex> set = Parrot.Sip.MethodSet.new([:invite, :ack, :bye]) 95 | iex> Parrot.Sip.Headers.Allow.add(set, :invite) 96 | #Parrot.Sip.MethodSet<[:ack, :bye, :invite]> 97 | """ 98 | @spec add(MethodSet.t(), Method.t() | String.t()) :: MethodSet.t() 99 | def add(%MethodSet{} = method_set, method) do 100 | MethodSet.put(method_set, method) 101 | end 102 | 103 | # For backward compatibility 104 | def add(methods, method) when is_list(methods) do 105 | MethodSet.new(methods) |> add(method) 106 | end 107 | 108 | @doc """ 109 | Removes a method from the Allow header. 110 | 111 | ## Examples 112 | 113 | iex> set = Parrot.Sip.MethodSet.new([:invite, :ack, :bye]) 114 | iex> Parrot.Sip.Headers.Allow.remove(set, :invite) 115 | #Parrot.Sip.MethodSet<[:ack, :bye]> 116 | 117 | iex> set = Parrot.Sip.MethodSet.new([:invite, :ack]) 118 | iex> Parrot.Sip.Headers.Allow.remove(set, :bye) 119 | #Parrot.Sip.MethodSet<[:ack, :invite]> 120 | """ 121 | @spec remove(MethodSet.t(), Method.t() | String.t()) :: MethodSet.t() 122 | def remove(%MethodSet{} = method_set, method) do 123 | MethodSet.delete(method_set, method) 124 | end 125 | 126 | # For backward compatibility 127 | def remove(methods, method) when is_list(methods) do 128 | MethodSet.new(methods) |> remove(method) 129 | end 130 | 131 | @doc """ 132 | Checks if a specific method is allowed. 133 | 134 | ## Examples 135 | 136 | iex> set = Parrot.Sip.MethodSet.new([:invite, :ack, :bye]) 137 | iex> Parrot.Sip.Headers.Allow.allows?(set, :invite) 138 | true 139 | 140 | iex> set = Parrot.Sip.MethodSet.new([:invite, :ack]) 141 | iex> Parrot.Sip.Headers.Allow.allows?(set, :bye) 142 | false 143 | """ 144 | @spec allows?(MethodSet.t(), Method.t() | String.t()) :: boolean() 145 | def allows?(%MethodSet{} = method_set, method) do 146 | MethodSet.member?(method_set, method) 147 | end 148 | 149 | # For backward compatibility 150 | def allows?(methods, method) when is_list(methods) do 151 | MethodSet.new(methods) |> allows?(method) 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /examples/parrot_example_uas/README.md: -------------------------------------------------------------------------------- 1 | # Parrot Example UAS 2 | 3 | This example demonstrates a UAS (User Agent Server) application that answers incoming SIP calls and plays audio files. 4 | 5 | ## Features 6 | 7 | - 📞 **Answers incoming SIP calls** (INVITE) 8 | - 🎵 **Plays audio files** when calls connect 9 | - 🔄 **Handles call lifecycle** (INVITE → ACK → BYE) 10 | - 📊 **Media handler callbacks** for custom media processing 11 | - 🎛️ **Configurable audio** (welcome, menu, music files) 12 | 13 | ## Installation 14 | 15 | 1. Navigate to the example directory: 16 | ```bash 17 | cd examples/parrot_example_uas 18 | ``` 19 | 20 | 2. Get dependencies: 21 | ```bash 22 | mix deps.get 23 | ``` 24 | 25 | 3. Compile: 26 | ```bash 27 | mix compile 28 | ``` 29 | 30 | ## Usage 31 | 32 | ### Basic Usage 33 | 34 | Start the UAS in an IEx session: 35 | 36 | ```elixir 37 | iex -S mix 38 | 39 | # Start the UAS on default port 5060 40 | ParrotExampleUas.start() 41 | 42 | # Or start on a custom port 43 | ParrotExampleUas.start(port: 5080) 44 | ``` 45 | 46 | Your SIP server is now running and ready to accept calls! 47 | 48 | ### Testing with SIP Clients 49 | 50 | Connect any SIP client (Linphone, Zoiper, MicroSIP, etc.) and call: 51 | - URI: `sip:service@:5060` 52 | - No authentication required 53 | 54 | When you call, the UAS will: 55 | 1. Answer automatically (200 OK) 56 | 2. Play a welcome message 57 | 3. Accept BYE to end the call 58 | 59 | ### Testing with parrot_example_uac 60 | 61 | For the best experience, test with the UAC example: 62 | 63 | #### Terminal 1 - Start the UAS: 64 | ```bash 65 | cd examples/parrot_example_uas 66 | iex -S mix 67 | iex> ParrotExampleUas.start() 68 | ``` 69 | 70 | #### Terminal 2 - Start the UAC and make a call: 71 | ```bash 72 | cd examples/parrot_example_uac 73 | iex -S mix 74 | iex> ParrotExampleUac.start() 75 | iex> ParrotExampleUac.call("sip:service@127.0.0.1:5060") 76 | ``` 77 | 78 | ## Configuration 79 | 80 | ### Audio Files 81 | 82 | The example uses audio files from the parrot_platform priv directory. To use custom audio: 83 | 84 | ```elixir 85 | audio_config = %{ 86 | welcome_file: "/path/to/welcome.wav", 87 | menu_file: "/path/to/menu.wav", 88 | music_file: "/path/to/music.wav", 89 | goodbye_file: "/path/to/goodbye.wav" 90 | } 91 | ``` 92 | 93 | Audio files must be: 94 | - WAV format 95 | - 8000 Hz sample rate 96 | - Mono channel 97 | - PCM encoding 98 | 99 | ### Logging 100 | 101 | Control logging verbosity: 102 | 103 | ```elixir 104 | handler = Parrot.Sip.Handler.new( 105 | Parrot.Sip.HandlerAdapter.Core, 106 | {__MODULE__, %{calls: %{}}}, 107 | log_level: :info, # :debug, :info, :warning, :error 108 | sip_trace: true # Show full SIP messages 109 | ) 110 | ``` 111 | 112 | ## Architecture 113 | 114 | The example implements: 115 | - `Parrot.UasHandler` for SIP protocol handling 116 | - `Parrot.MediaHandler` for media session callbacks 117 | - Transaction state callbacks for INVITE processing 118 | - Media playback using Membrane Framework 119 | 120 | ### Handler Callbacks 121 | 122 | #### UasHandler Callbacks 123 | - `handle_invite/2` - Process incoming calls 124 | - `handle_ack/2` - Start media when ACK received 125 | - `handle_bye/2` - Clean up when call ends 126 | - `handle_options/2` - Report capabilities 127 | - `handle_cancel/2` - Cancel pending calls 128 | 129 | #### MediaHandler Callbacks 130 | - `handle_session_start/3` - Media session initialized 131 | - `handle_stream_start/3` - Audio stream started 132 | - `handle_play_complete/2` - Audio file finished playing 133 | - `handle_codec_negotiation/3` - Select audio codec 134 | - `handle_stream_error/3` - Handle media errors 135 | 136 | ## Extending the Example 137 | 138 | This example can be extended to: 139 | - Add authentication/registration 140 | - Implement call routing 141 | - Add DTMF detection 142 | - Support video calls 143 | - Record conversations 144 | - Connect to external systems 145 | 146 | ## Troubleshooting 147 | 148 | ### Port Already in Use 149 | 150 | If you get "address already in use": 151 | ```bash 152 | # Find process using port 5060 153 | lsof -i :5060 154 | 155 | # Kill the process 156 | kill -9 157 | ``` 158 | 159 | ### No Audio 160 | 161 | If calls connect but no audio plays: 162 | - Check audio file paths exist 163 | - Verify audio format (8000 Hz, mono, WAV) 164 | - Check RTP port range (16384-32768) in firewall 165 | 166 | ### SIP Client Issues 167 | 168 | Common client configuration: 169 | - Username: anything (e.g., "test") 170 | - Domain: your server IP 171 | - Port: 5060 (or custom port) 172 | - Transport: UDP 173 | - No authentication/password 174 | 175 | ## Code Structure 176 | 177 | ``` 178 | lib/ 179 | └── parrot_example_uas.ex 180 | ├── SIP Handlers # handle_invite, handle_bye, etc. 181 | ├── Transaction States # INVITE state machine callbacks 182 | ├── Media Handlers # Audio playback control 183 | └── Helper Functions # SDP processing, media setup 184 | ``` 185 | 186 | ## License 187 | 188 | This example is part of the Parrot Platform and is licensed under the Apache License 2.0. -------------------------------------------------------------------------------- /guides/rfc-compliance.md: -------------------------------------------------------------------------------- 1 | # RFC Compliance Reference 2 | 3 | This guide provides a comprehensive listing of all RFC standards referenced and implemented in the Parrot Framework codebase. The framework implements core SIP functionality according to these standards (or, at least, does its very best to). 4 | 5 | ## Core SIP RFCs 6 | 7 | ### RFC 3261 - SIP: Session Initiation Protocol 8 | The fundamental SIP protocol specification. Parrot implements: 9 | - Basic SIP message structure and parsing 10 | - Transaction state machines (INVITE and non-INVITE) 11 | - Dialog management 12 | - Transport layer (UDP) 13 | - Request and response handling 14 | 15 | ### RFC 3263 - Session Initiation Protocol (SIP): Locating SIP Servers 16 | DNS resolution for SIP servers: 17 | - SRV record lookups 18 | - A/AAAA record fallback 19 | - Transport selection 20 | 21 | ### RFC 3581 - An Extension to the Session Initiation Protocol (SIP) for Symmetric Response Routing 22 | Symmetric response routing support: 23 | - rport parameter handling 24 | - NAT traversal improvements 25 | 26 | ### RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax 27 | URI parsing and validation for SIP URIs. 28 | 29 | ### RFC 4566 - SDP: Session Description Protocol 30 | SDP parsing and generation for media negotiation: 31 | - Media descriptions 32 | - Codec negotiation 33 | - Connection information 34 | 35 | ### RFC 5234 - Augmented BNF for Syntax Specifications: ABNF 36 | Used for parsing SIP message syntax according to ABNF rules. 37 | 38 | ## SIP Extensions 39 | 40 | ### RFC 3262 - Reliability of Provisional Responses 41 | Support for reliable provisional responses (100rel). 42 | 43 | ### RFC 3264 - An Offer/Answer Model with the Session Description Protocol (SDP) 44 | SDP offer/answer negotiation model. 45 | 46 | ### RFC 3265 - Session Initiation Protocol (SIP)-Specific Event Notification 47 | SUBSCRIBE/NOTIFY event framework. 48 | 49 | ### RFC 3311 - The Session Initiation Protocol (SIP) UPDATE Method 50 | UPDATE method support for mid-dialog modifications. 51 | 52 | ### RFC 3428 - Session Initiation Protocol (SIP) Extension for Instant Messaging 53 | MESSAGE method for instant messaging. 54 | 55 | ### RFC 3515 - The Session Initiation Protocol (SIP) REFER Method 56 | REFER method for call transfer. 57 | 58 | ### RFC 3840 - Indicating User Agent Capabilities in SIP 59 | Feature tags and capability negotiation. 60 | 61 | ### RFC 3903 - Session Initiation Protocol (SIP) Extension for Event State Publication 62 | PUBLISH method for event state publication. 63 | 64 | ### RFC 4028 - Session Timers in the Session Initiation Protocol (SIP) 65 | Session timer support for detecting failed sessions. 66 | 67 | ### RFC 6026 - Correct Transaction Handling for 2xx Responses to INVITE 68 | Proper handling of multiple 2xx responses. 69 | 70 | ### RFC 6665 - SIP-Specific Event Notification (Updates RFC 3265) 71 | Updated event notification framework. 72 | 73 | ## Media and RTP RFCs 74 | 75 | ### RFC 3550 - RTP: A Transport Protocol for Real-Time Applications 76 | RTP packet handling and stream management. 77 | 78 | ### RFC 3551 - RTP Profile for Audio and Video Conferences 79 | Standard audio/video payload types and formats. 80 | 81 | ### RFC 3711 - The Secure Real-time Transport Protocol (SRTP) 82 | Secure RTP support (future implementation). 83 | 84 | ### RFC 4733 - RTP Payload for DTMF Digits, Telephony Tones, and Telephony Signals 85 | DTMF over RTP (telephone-event). 86 | 87 | ### RFC 5109 - RTP Payload Format for Generic Forward Error Correction 88 | FEC support for RTP streams. 89 | 90 | ### RFC 5389 - Session Traversal Utilities for NAT (STUN) 91 | STUN support for NAT traversal. 92 | 93 | ### RFC 5766 - Traversal Using Relays around NAT (TURN) 94 | TURN relay support. 95 | 96 | ### RFC 8445 - Interactive Connectivity Establishment (ICE) 97 | ICE for NAT traversal. 98 | 99 | ## Codec Standards 100 | 101 | ### ITU-T G.711 - Pulse Code Modulation (PCM) 102 | G.711 μ-law (PCMU) and A-law (PCMA) audio codecs. 103 | 104 | ### ITU-T G.729 - Coding of speech at 8 kbit/s 105 | G.729 codec support (requires licensing). 106 | 107 | ### RFC 3952 - Real-time Transport Protocol (RTP) Payload Format for Internet Low Bit Rate Codec (iLBC) 108 | iLBC codec support. 109 | 110 | ### RFC 5577 - RTP Payload Format for ITU-T Recommendation G.722.1 111 | G.722.1 wideband codec. 112 | 113 | ## Implementation Status 114 | 115 | The framework currently implements: 116 | - ✅ Core SIP protocol (RFC 3261) 117 | - ✅ Basic SDP support (RFC 4566) 118 | - ✅ RTP handling (RFC 3550) 119 | - ✅ G.711 codecs 120 | - ✅ DNS resolution (RFC 3263) 121 | - ✅ Symmetric response routing (RFC 3581) 122 | - ⚠️ Partial SUBSCRIBE/NOTIFY (RFC 3265) 123 | - ⚠️ Partial session timers (RFC 4028) 124 | - ❌ SRTP (RFC 3711) - planned 125 | - ❌ ICE (RFC 8445) - planned 126 | 127 | ## Usage in Code 128 | 129 | References to these RFCs can be found throughout the codebase in comments explaining implementation decisions. For example: 130 | 131 | ```elixir 132 | # As per RFC 3261 Section 17.2.1, server transactions are created 133 | # when a request is received 134 | ``` 135 | 136 | When implementing new features or debugging issues, refer to the relevant RFC sections for authoritative guidance on correct behavior. 137 | --------------------------------------------------------------------------------