Skip to content

Commit fc58df5

Browse files
committed
wip add support for dtmf
1 parent af0ab87 commit fc58df5

File tree

22 files changed

+611
-33
lines changed

22 files changed

+611
-33
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,4 @@
1-
name: CI
2-
3-
on: push
4-
5-
env:
6-
MIX_ENV: test
7-
8-
permissions:
9-
contents: read
10-
11-
jobs:
12-
test:
13-
runs-on: ubuntu-latest
14-
name: CI on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}
1+
name: CI on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}
152
strategy:
163
matrix:
174
otp: ['27']
@@ -33,7 +20,6 @@ jobs:
3320
key: ${{ runner.os }}-mix-deps-${{ hashFiles('**/mix.lock') }}
3421
restore-keys: |
3522
${{ runner.os }}-mix-deps-
36-
3723
- name: Cache compiled build
3824
uses: actions/cache@v4
3925
with:
@@ -42,15 +28,13 @@ jobs:
4228
restore-keys: |
4329
${{ runner.os }}-mix-build-
4430
${{ runner.os }}-mix-
45-
4631
- name: Cache dialyzer artifacts
4732
uses: actions/cache@v4
4833
with:
4934
path: _dialyzer
5035
key: ${{ runner.os }}-dialyzer-${{ hashFiles('**/mix.lock') }}
5136
restore-keys: |
5237
${{ runner.os }}-dialyzer-
53-
5438
- name: Install dependencies
5539
run: mix deps.get
5640

@@ -84,4 +68,4 @@ jobs:
8468
uses: codecov/codecov-action@v4
8569
with:
8670
fail_ci_if_error: true,
87-
token: ${{ secrets.CODECOV_TOKEN }}
71+
token: ${{ secrets.CODECOV_TOKEN }}

examples/dtmf/.formatter.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Used by "mix format"
2+
[
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4+
]

examples/dtmf/.gitignore

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
dtmf-*.tar
21+
22+
# Temporary files, for example, from tests.
23+
/tmp/

examples/dtmf/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Dtmf
2+
3+
**TODO: Add description**
4+
5+
## Installation
6+
7+
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
8+
by adding `dtmf` to your list of dependencies in `mix.exs`:
9+
10+
```elixir
11+
def deps do
12+
[
13+
{:dtmf, "~> 0.1.0"}
14+
]
15+
end
16+
```
17+
18+
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
19+
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
20+
be found at <https://hexdocs.pm/dtmf>.
21+

examples/dtmf/config/config.exs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Config
2+
3+
config :logger, level: :info
4+
5+
# normally you take these from env variables in `config/runtime.exs`
6+
config :dtmf,
7+
ip: {127, 0, 0, 1},
8+
port: 8829

examples/dtmf/lib/dtmf.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
defmodule Dtmf do
2+
use Application
3+
4+
@ip Application.compile_env!(:dtmf, :ip)
5+
@port Application.compile_env!(:dtmf, :port)
6+
7+
@impl true
8+
def start(_type, _args) do
9+
children = [
10+
{Bandit, plug: __MODULE__.Router, ip: @ip, port: @port}
11+
]
12+
13+
Supervisor.start_link(children, strategy: :one_for_one)
14+
end
15+
end
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
defmodule Dtmf.PeerHandler do
2+
require Logger
3+
4+
alias ExWebRTC.{
5+
ICECandidate,
6+
MediaStreamTrack,
7+
PeerConnection,
8+
RTPCodecParameters,
9+
RTP.Depayloader,
10+
RTP.JitterBuffer,
11+
SessionDescription
12+
}
13+
14+
@behaviour WebSock
15+
16+
@ice_servers [
17+
%{urls: "stun:stun.l.google.com:19302"}
18+
]
19+
20+
@audio_codecs [
21+
%RTPCodecParameters{
22+
payload_type: 111,
23+
mime_type: "audio/opus",
24+
clock_rate: 48_000,
25+
channels: 2
26+
},
27+
%RTPCodecParameters{
28+
payload_type: 126,
29+
mime_type: "audio/telephone-event",
30+
clock_rate: 8000,
31+
channels: 1
32+
}
33+
]
34+
35+
@impl true
36+
def init(_) do
37+
{:ok, pc} =
38+
PeerConnection.start_link(
39+
ice_servers: @ice_servers,
40+
video_codecs: [],
41+
audio_codecs: @audio_codecs
42+
)
43+
44+
state = %{
45+
peer_connection: pc,
46+
in_audio_track_id: nil,
47+
# The flow of this example is as follows:
48+
# we first feed rtp packets into jitter buffer to
49+
# wait for retransmissions and fix ordering.
50+
# Once ordering and gaps are fixed, we feed packets
51+
# to the depayloader, which detects DTMF events.
52+
# Note that depayloader takes all RTP packets (both Opus and DTMF),
53+
# but ignores those that are not DTMF ones.
54+
# This is to avoid demuxing packets by the user.
55+
jitter_buffer: nil,
56+
depayloader: nil
57+
}
58+
59+
{:ok, state}
60+
end
61+
62+
@impl true
63+
def handle_in({msg, [opcode: :text]}, state) do
64+
msg
65+
|> Jason.decode!()
66+
|> handle_ws_msg(state)
67+
end
68+
69+
@impl true
70+
def handle_info({:ex_webrtc, _from, msg}, state) do
71+
handle_webrtc_msg(msg, state)
72+
end
73+
74+
@impl true
75+
def handle_info(:jitter_buffer_timeout, state) do
76+
state.jitter_buffer
77+
|> JitterBuffer.handle_timeout()
78+
|> handle_jitter_buffer_result(state)
79+
end
80+
81+
@impl true
82+
def handle_info({:EXIT, pc, reason}, %{peer_connection: pc} = state) do
83+
# Bandit traps exits under the hood so our PeerConnection.start_link
84+
# won't automatically bring this process down.
85+
Logger.info("Peer connection process exited, reason: #{inspect(reason)}")
86+
{:stop, {:shutdown, :pc_closed}, state}
87+
end
88+
89+
@impl true
90+
def terminate(reason, _state) do
91+
Logger.info("WebSocket connection was terminated, reason: #{inspect(reason)}")
92+
end
93+
94+
defp handle_ws_msg(%{"type" => "offer", "data" => data}, state) do
95+
Logger.info("Received SDP offer:\n#{data["sdp"]}")
96+
97+
offer = SessionDescription.from_json(data)
98+
:ok = PeerConnection.set_remote_description(state.peer_connection, offer)
99+
100+
{:ok, answer} = PeerConnection.create_answer(state.peer_connection)
101+
:ok = PeerConnection.set_local_description(state.peer_connection, answer)
102+
103+
answer_json = SessionDescription.to_json(answer)
104+
105+
msg =
106+
%{"type" => "answer", "data" => answer_json}
107+
|> Jason.encode!()
108+
109+
Logger.info("Sent SDP answer:\n#{answer_json["sdp"]}")
110+
111+
{:push, {:text, msg}, state}
112+
end
113+
114+
defp handle_ws_msg(%{"type" => "ice", "data" => data}, state) do
115+
Logger.info("Received ICE candidate: #{data["candidate"]}")
116+
117+
candidate = ICECandidate.from_json(data)
118+
:ok = PeerConnection.add_ice_candidate(state.peer_connection, candidate)
119+
{:ok, state}
120+
end
121+
122+
defp handle_webrtc_msg({:connection_state_change, conn_state}, state) do
123+
Logger.info("Connection state changed: #{conn_state}")
124+
125+
if conn_state == :failed do
126+
{:stop, {:shutdown, :pc_failed}, state}
127+
else
128+
{:ok, state}
129+
end
130+
end
131+
132+
defp handle_webrtc_msg({:ice_candidate, candidate}, state) do
133+
candidate_json = ICECandidate.to_json(candidate)
134+
135+
msg =
136+
%{"type" => "ice", "data" => candidate_json}
137+
|> Jason.encode!()
138+
139+
Logger.info("Sent ICE candidate: #{candidate_json["candidate"]}")
140+
141+
{:push, {:text, msg}, state}
142+
end
143+
144+
defp handle_webrtc_msg({:track, %MediaStreamTrack{kind: :audio, id: id}}, state) do
145+
# Find dtmf codec. Its config (payload type) might have changed during negotiation.
146+
tr =
147+
state.peer_connection
148+
|> PeerConnection.get_transceivers()
149+
|> Enum.find(fn tr -> tr.receiver.track.id == id end)
150+
151+
codec = Enum.find(tr.codecs, fn codec -> codec.mime_type == "audio/telephone-event" end)
152+
153+
if codec == nil do
154+
raise "DTMF for the track has not been negotiated."
155+
end
156+
157+
jitter_buffer = JitterBuffer.new()
158+
{:ok, depayloader} = Depayloader.new(codec)
159+
160+
state = %{
161+
state
162+
| in_audio_track_id: id,
163+
jitter_buffer: jitter_buffer,
164+
depayloader: depayloader
165+
}
166+
167+
{:ok, state}
168+
end
169+
170+
defp handle_webrtc_msg({:rtp, id, nil, packet}, %{in_audio_track_id: id} = state) do
171+
state.jitter_buffer
172+
|> JitterBuffer.insert(packet)
173+
|> handle_jitter_buffer_result(state)
174+
end
175+
176+
defp handle_webrtc_msg(_msg, state), do: {:ok, state}
177+
178+
defp handle_jitter_buffer_result({packets, timeout, jitter_buffer}, state) do
179+
state = %{state | jitter_buffer: jitter_buffer}
180+
181+
if timeout != nil do
182+
Process.send_after(self(), :jitter_buffer_timeout, timeout)
183+
end
184+
185+
state =
186+
Enum.reduce(packets, state, fn packet, state ->
187+
case Depayloader.depayload(state.depayloader, packet) do
188+
{nil, depayloader} ->
189+
%{state | depayloader: depayloader}
190+
191+
{event, depayloader} ->
192+
Logger.info("Received DTMF event: #{event.event}")
193+
%{state | depayloader: depayloader}
194+
end
195+
end)
196+
197+
{:ok, state}
198+
end
199+
end

examples/dtmf/lib/dtmf/router.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
defmodule Dtmf.Router do
2+
use Plug.Router
3+
4+
plug(Plug.Static, at: "/", from: :dtmf)
5+
plug(:match)
6+
plug(:dispatch)
7+
8+
get "/ws" do
9+
WebSockAdapter.upgrade(conn, Dtmf.PeerHandler, %{}, [])
10+
end
11+
12+
match _ do
13+
send_resp(conn, 404, "not found")
14+
end
15+
end

examples/dtmf/mix.exs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
defmodule Dtmf.MixProject do
2+
use Mix.Project
3+
4+
def project do
5+
[
6+
app: :dtmf,
7+
version: "0.1.0",
8+
elixir: "~> 1.18",
9+
start_permanent: Mix.env() == :prod,
10+
deps: deps()
11+
]
12+
end
13+
14+
# Run "mix help compile.app" to learn about applications.
15+
def application do
16+
[
17+
extra_applications: [:logger],
18+
mod: {Dtmf, []}
19+
]
20+
end
21+
22+
# Run "mix help deps" to learn about dependencies.
23+
defp deps do
24+
[
25+
{:plug, "~> 1.15.0"},
26+
{:bandit, "~> 1.2.0"},
27+
{:websock_adapter, "~> 0.5.0"},
28+
{:jason, "~> 1.4.0"},
29+
{:ex_webrtc, path: "../../."}
30+
]
31+
end
32+
end

0 commit comments

Comments
 (0)