├── 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 |
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 |
66 |
67 |
68 | ;tag=[pid]SIPpTag01[call_number]
72 | To: [$remote_from]
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 |
--------------------------------------------------------------------------------
/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 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
--------------------------------------------------------------------------------