Skip to content

Add support for receiving DTMF tones #215

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 81 additions & 70 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,74 +14,85 @@ jobs:
name: CI on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}
strategy:
matrix:
otp: ['27']
elixir: ['1.17']
otp: ["27"]
elixir: ["1.17"]
steps:
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
otp-version: ${{matrix.otp}}
elixir-version: ${{matrix.elixir}}

- name: Checkout the code
uses: actions/checkout@v4

- name: Cache dependencies
uses: actions/cache@v4
with:
path: deps
key: ${{ runner.os }}-mix-deps-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-deps-

- name: Cache compiled build
uses: actions/cache@v4
with:
path: _build
key: ${{ runner.os }}-mix-build-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-build-
${{ runner.os }}-mix-

- name: Cache dialyzer artifacts
uses: actions/cache@v4
with:
path: _dialyzer
key: ${{ runner.os }}-dialyzer-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-dialyzer-

- name: Install dependencies
run: mix deps.get

- name: Compile without warnings
id: compile
run: mix compile --warnings-as-errors

- name: Check formatting
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: mix format --check-formatted

- name: Check with credo
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: mix credo

- name: Check with dialyzer
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: mix dialyzer

- name: Check docs
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: mix docs 2>&1 | (! grep -q "warning:")

- name: Run tests and check test coverage
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
id: test
run: mix coveralls.json

- name: Upload test coverage results to Codecov
if: ${{ !cancelled() && steps.test.outcome == 'success' }}
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true,
token: ${{ secrets.CODECOV_TOKEN }}
- name: "Interfaces"
run: |
echo "Listing Network Interfaces:"
ip link show

- name: Check if Docker is installed and running on Linux
run: |
echo "Checking Docker..."
docker -v
systemctl status docker.service --no-pager

- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
otp-version: ${{matrix.otp}}
elixir-version: ${{matrix.elixir}}

- name: Checkout the code
uses: actions/checkout@v4

- name: Cache dependencies
uses: actions/cache@v4
with:
path: deps
key: ${{ runner.os }}-mix-deps-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-deps-

- name: Cache compiled build
uses: actions/cache@v4
with:
path: _build
key: ${{ runner.os }}-mix-build-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-build-
${{ runner.os }}-mix-

- name: Cache dialyzer artifacts
uses: actions/cache@v4
with:
path: _dialyzer
key: ${{ runner.os }}-dialyzer-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-dialyzer-

- name: Install dependencies
run: mix deps.get

- name: Compile without warnings
id: compile
run: mix compile --warnings-as-errors

- name: Check formatting
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: mix format --check-formatted

- name: Check with credo
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: mix credo

- name: Check with dialyzer
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: mix dialyzer

- name: Check docs
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: mix docs 2>&1 | (! grep -q "warning:")

- name: Run tests and check test coverage
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
id: test
run: mix coveralls.json

- name: Upload test coverage results to Codecov
if: ${{ !cancelled() && steps.test.outcome == 'success' }}
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true,
token: ${{ secrets.CODECOV_TOKEN }}
4 changes: 4 additions & 0 deletions examples/dtmf/.formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
23 changes: 23 additions & 0 deletions examples/dtmf/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# The directory Mix will write compiled artifacts to.
/_build/

# If you run "mix test --cover", coverage assets end up here.
/cover/

# The directory Mix downloads your dependencies sources to.
/deps/

# Where third-party dependencies like ExDoc output generated docs.
/doc/

# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump

# Also ignore archive artifacts (built via "mix archive.build").
*.ez

# Ignore package tarball (built via "mix hex.build").
dtmf-*.tar

# Temporary files, for example, from tests.
/tmp/
21 changes: 21 additions & 0 deletions examples/dtmf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Dtmf

**TODO: Add description**

## Installation

If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `dtmf` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:dtmf, "~> 0.1.0"}
]
end
```

Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/dtmf>.

8 changes: 8 additions & 0 deletions examples/dtmf/config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Config

config :logger, level: :info

# normally you take these from env variables in `config/runtime.exs`
config :dtmf,
ip: {127, 0, 0, 1},
port: 8829
15 changes: 15 additions & 0 deletions examples/dtmf/lib/dtmf.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule Dtmf do
use Application

@ip Application.compile_env!(:dtmf, :ip)
@port Application.compile_env!(:dtmf, :port)

@impl true
def start(_type, _args) do
children = [
{Bandit, plug: __MODULE__.Router, ip: @ip, port: @port}
]

Supervisor.start_link(children, strategy: :one_for_one)
end
end
136 changes: 136 additions & 0 deletions examples/dtmf/lib/dtmf/peer_handler.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
defmodule Dtmf.PeerHandler do
require Logger

alias ExWebRTC.{
ICECandidate,
MediaStreamTrack,
PeerConnection,
RTPCodecParameters,
SessionDescription
}

@behaviour WebSock

@ice_servers [
%{urls: "stun:stun.l.google.com:19302"}
]

@audio_codecs [
%RTPCodecParameters{
payload_type: 111,
mime_type: "audio/opus",
clock_rate: 48_000,
channels: 2
},
%RTPCodecParameters{
payload_type: 112,
mime_type: "audio/telephone-event",
clock_rate: 8000,
channels: 1
}
]

@impl true
def init(_) do
{:ok, pc} =
PeerConnection.start_link(
ice_servers: @ice_servers,
video_codecs: [],
audio_codecs: @audio_codecs
)

state = %{
peer_connection: pc,
in_audio_track_id: nil
}

{:ok, state}
end

@impl true
def handle_in({msg, [opcode: :text]}, state) do
msg
|> Jason.decode!()
|> handle_ws_msg(state)
end

@impl true
def handle_info({:ex_webrtc, _from, msg}, state) do
handle_webrtc_msg(msg, state)
end

@impl true
def handle_info({:EXIT, pc, reason}, %{peer_connection: pc} = state) do
# Bandit traps exits under the hood so our PeerConnection.start_link
# won't automatically bring this process down.
Logger.info("Peer connection process exited, reason: #{inspect(reason)}")
{:stop, {:shutdown, :pc_closed}, state}
end

@impl true
def terminate(reason, _state) do
Logger.info("WebSocket connection was terminated, reason: #{inspect(reason)}")
end

defp handle_ws_msg(%{"type" => "offer", "data" => data}, state) do
Logger.info("Received SDP offer:\n#{data["sdp"]}")

offer = SessionDescription.from_json(data)
:ok = PeerConnection.set_remote_description(state.peer_connection, offer)

{:ok, answer} = PeerConnection.create_answer(state.peer_connection)
:ok = PeerConnection.set_local_description(state.peer_connection, answer)

answer_json = SessionDescription.to_json(answer)

msg =
%{"type" => "answer", "data" => answer_json}
|> Jason.encode!()

Logger.info("Sent SDP answer:\n#{answer_json["sdp"]}")

{:push, {:text, msg}, state}
end

defp handle_ws_msg(%{"type" => "ice", "data" => data}, state) do
Logger.info("Received ICE candidate: #{data["candidate"]}")

candidate = ICECandidate.from_json(data)
:ok = PeerConnection.add_ice_candidate(state.peer_connection, candidate)
{:ok, state}
end

defp handle_webrtc_msg({:connection_state_change, conn_state}, state) do
Logger.info("Connection state changed: #{conn_state}")

if conn_state == :failed do
{:stop, {:shutdown, :pc_failed}, state}
else
{:ok, state}
end
end

defp handle_webrtc_msg({:ice_candidate, candidate}, state) do
candidate_json = ICECandidate.to_json(candidate)

msg =
%{"type" => "ice", "data" => candidate_json}
|> Jason.encode!()

Logger.info("Sent ICE candidate: #{candidate_json["candidate"]}")

{:push, {:text, msg}, state}
end

defp handle_webrtc_msg({:track, %MediaStreamTrack{kind: :audio, id: id}}, state) do
state = %{state | in_audio_track_id: id}
{:ok, state}
end

defp handle_webrtc_msg({:rtp, id, nil, packet}, %{in_audio_track_id: id} = state) do
dbg(packet)
{:ok, state}
end

defp handle_webrtc_msg(_msg, state), do: {:ok, state}
end
15 changes: 15 additions & 0 deletions examples/dtmf/lib/dtmf/router.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule Dtmf.Router do
use Plug.Router

plug(Plug.Static, at: "/", from: :dtmf)
plug(:match)
plug(:dispatch)

get "/ws" do
WebSockAdapter.upgrade(conn, Dtmf.PeerHandler, %{}, [])
end

match _ do
send_resp(conn, 404, "not found")
end
end
Loading