├── test
├── test_helper.exs
├── echo_web
│ └── controllers
│ │ └── error_json_test.exs
└── support
│ └── conn_case.ex
├── rel
└── overlays
│ └── bin
│ ├── server.bat
│ └── server
├── priv
├── models
│ └── silero_vad.onnx
└── gettext
│ ├── en
│ └── LC_MESSAGES
│ │ └── errors.po
│ └── errors.pot
├── .formatter.exs
├── lib
├── echo
│ ├── speech_to_text
│ │ ├── provider.ex
│ │ └── bumblebee.ex
│ ├── text_generation
│ │ ├── provider.ex
│ │ ├── openai.ex
│ │ └── bumblebee.ex
│ ├── speech_to_text.ex
│ ├── text_generation.ex
│ ├── text_to_speech.ex
│ ├── application.ex
│ ├── vad.ex
│ └── client
│ │ └── eleven_labs
│ │ └── web_socket.ex
├── echo.ex
├── echo_web
│ ├── router.ex
│ ├── socket
│ │ ├── serializer.ex
│ │ └── conversation.ex
│ ├── gettext.ex
│ ├── endpoint.ex
│ └── telemetry.ex
└── echo_web.ex
├── config
├── prod.exs
├── test.exs
├── config.exs
├── dev.exs
└── runtime.exs
├── .env.example
├── .gitignore
├── mix.exs
├── Dockerfile
├── README.md
├── LICENSE
└── mix.lock
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server.bat:
--------------------------------------------------------------------------------
1 | set PHX_SERVER=true
2 | call "%~dp0\echo" start
3 |
--------------------------------------------------------------------------------
/priv/models/silero_vad.onnx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seanmor5/echo/HEAD/priv/models/silero_vad.onnx
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:phoenix],
3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -eu
3 |
4 | cd -P -- "$(dirname -- "$0")"
5 | PHX_SERVER=true exec ./echo start
6 |
--------------------------------------------------------------------------------
/lib/echo/speech_to_text/provider.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.SpeechToText.Provider do
2 | @callback transcribe(audio :: binary()) :: binary()
3 | end
4 |
--------------------------------------------------------------------------------
/lib/echo/text_generation/provider.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.TextGeneration.Provider do
2 | @callback chat_completion(messages :: list()) :: Stream.t()
3 | end
4 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Do not print debug messages in production
4 | config :logger, level: :info
5 |
6 | # Runtime production configuration, including reading
7 | # of environment variables, is done on config/runtime.exs.
8 |
--------------------------------------------------------------------------------
/lib/echo.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo do
2 | @moduledoc """
3 | Echo keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/lib/echo_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule EchoWeb.Router do
2 | use EchoWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | plug :protect_from_forgery
8 | plug :put_secure_browser_headers
9 | end
10 |
11 | scope "/", EchoWeb do
12 | pipe_through :browser
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/echo/speech_to_text.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.SpeechToText do
2 | @doc """
3 | Generic TTS Module.
4 | """
5 |
6 | def transcribe(audio) do
7 | provider().transcribe(audio)
8 | end
9 |
10 | defp provider, do: env(:provider)
11 |
12 | defp env(key), do: :echo |> Application.fetch_env!(__MODULE__) |> Keyword.fetch!(key)
13 | end
14 |
--------------------------------------------------------------------------------
/lib/echo/text_generation.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.TextGeneration do
2 | @moduledoc """
3 | Generic Text Generation module.
4 | """
5 |
6 | def chat_completion(messages) do
7 | provider().chat_completion(messages)
8 | end
9 |
10 | defp provider, do: env(:provider)
11 |
12 | defp env(key), do: :echo |> Application.fetch_env!(__MODULE__) |> Keyword.fetch!(key)
13 | end
14 |
--------------------------------------------------------------------------------
/lib/echo_web/socket/serializer.ex:
--------------------------------------------------------------------------------
1 | defmodule EchoWeb.Socket.Serializer do
2 | @behaviour Phoenix.Socket.Serializer
3 |
4 | def decode!(iodata, _options) do
5 | %Phoenix.Socket.Message{payload: IO.iodata_to_binary(iodata)}
6 | end
7 |
8 | def encode!(%{payload: data}), do: {:socket_push, :binary, data}
9 |
10 | def fastlane!(%{payload: data}), do: {:socket_push, :binary, data}
11 | end
12 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here has no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # STT Settings
2 | SPEECH_TO_TEXT_PROVIDER="bumblebee"
3 | SPEECH_TO_TEXT_MODEL="distil-whisper/distil-medium.en"
4 |
5 | # LLM Settings
6 | TEXT_GENERATION_PROVIDER="openai"
7 | OPENAI_API_KEY=
8 |
9 | # TTS Settings
10 | TEXT_TO_SPEECH_PROVIDER="eleven_labs"
11 |
12 | ELEVEN_LABS_API_KEY=
13 | ELEVEN_LABS_MODEL_ID=
14 | ELEVEN_LABS_VOICE_ID=
15 | ELEVEN_LABS_OPTIMIZE_STREAMING_LATENCY=
16 | ELEVEN_LABS_OUTPUT_FORMAT=
--------------------------------------------------------------------------------
/test/echo_web/controllers/error_json_test.exs:
--------------------------------------------------------------------------------
1 | defmodule EchoWeb.ErrorJSONTest do
2 | use EchoWeb.ConnCase, async: true
3 |
4 | test "renders 404" do
5 | assert EchoWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
6 | end
7 |
8 | test "renders 500" do
9 | assert EchoWeb.ErrorJSON.render("500.json", %{}) ==
10 | %{errors: %{detail: "Internal Server Error"}}
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :echo, EchoWeb.Endpoint,
6 | http: [ip: {127, 0, 0, 1}, port: 4002],
7 | secret_key_base: "MTR+T29AbArhJeW33OZ+9l9OZ9UmZpSHDO3NqVE00TE+CbrBgBITy7A+0tw4X5kl",
8 | server: false
9 |
10 | # Print only warnings and errors during test
11 | config :logger, level: :warning
12 |
13 | # Initialize plugs at runtime for faster test compilation
14 | config :phoenix, :plug_init_mode, :runtime
15 |
--------------------------------------------------------------------------------
/lib/echo/text_generation/openai.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.TextGeneration.OpenAI do
2 | @behaviour Echo.TextGeneration.Provider
3 |
4 | @impl true
5 | def chat_completion(messages) do
6 | opts = Keyword.merge([messages: messages], config())
7 |
8 | OpenAI.chat_completion(opts)
9 | |> Stream.map(&get_in(&1, ["choices", Access.at(0), "delta", "content"]))
10 | |> Stream.reject(&is_nil/1)
11 | end
12 |
13 | defp config do
14 | [
15 | model: env(:model),
16 | max_tokens: env(:max_tokens),
17 | stream: true
18 | ]
19 | end
20 |
21 | defp env(key), do: :echo |> Application.fetch_env!(__MODULE__) |> Keyword.fetch!(key)
22 | end
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
3 | # The directory Mix will write compiled artifacts to.
4 | /_build/
5 |
6 | # If you run "mix test --cover", coverage assets end up here.
7 | /cover/
8 |
9 | # The directory Mix downloads your dependencies sources to.
10 | /deps/
11 |
12 | # Where 3rd-party dependencies like ExDoc output generated docs.
13 | /doc/
14 |
15 | # Ignore .fetch files in case you like to edit your project deps locally.
16 | /.fetch
17 |
18 | # If the VM crashes, it generates a dump, let's ignore it too.
19 | erl_crash.dump
20 |
21 | # Also ignore archive artifacts (built via "mix archive.build").
22 | *.ez
23 |
24 | # Temporary files, for example, from tests.
25 | /tmp/
26 |
27 | # Ignore package tarball (built via "mix hex.build").
28 | echo-*.tar
29 |
30 |
--------------------------------------------------------------------------------
/lib/echo_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule EchoWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import EchoWeb.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :echo
24 | end
25 |
--------------------------------------------------------------------------------
/lib/echo_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule EchoWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :echo
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_echo_key",
10 | signing_salt: "ygE5htLL",
11 | same_site: "Lax"
12 | ]
13 |
14 | socket "/conversation", EchoWeb.Socket.Conversation,
15 | websocket: [
16 | serializer: EchoWeb.Socket.Serializer,
17 | connect_info: [:peer_data]
18 | ],
19 | longpoll: false
20 |
21 | plug Plug.RequestId
22 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
23 |
24 | plug Plug.Parsers,
25 | parsers: [:urlencoded, :multipart, :json],
26 | pass: ["*/*"],
27 | json_decoder: Phoenix.json_library()
28 |
29 | plug Plug.MethodOverride
30 | plug Plug.Head
31 | plug Plug.Session, @session_options
32 | plug EchoWeb.Router
33 | end
34 |
--------------------------------------------------------------------------------
/lib/echo/speech_to_text/bumblebee.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.SpeechToText.Bumblebee do
2 | @behaviour Echo.SpeechToText.Provider
3 |
4 | @impl true
5 | def transcribe(audio) do
6 | output = Nx.Serving.batched_run(Echo.SpeechToText, audio)
7 | output.chunks |> Enum.map_join(& &1.text) |> String.trim()
8 | end
9 |
10 | def serving() do
11 | repo = {:hf, env(:repo)}
12 |
13 | {:ok, model_info} =
14 | Bumblebee.load_model(repo,
15 | type: Axon.MixedPrecision.create_policy(params: {:f, 16}, compute: {:f, 16})
16 | )
17 |
18 | {:ok, featurizer} = Bumblebee.load_featurizer(repo)
19 | {:ok, tokenizer} = Bumblebee.load_tokenizer(repo)
20 | {:ok, generation_config} = Bumblebee.load_generation_config(repo)
21 |
22 | Bumblebee.Audio.speech_to_text_whisper(model_info, featurizer, tokenizer, generation_config,
23 | task: nil,
24 | compile: [batch_size: 1],
25 | defn_options: [compiler: EXLA]
26 | )
27 | end
28 |
29 | defp env(key), do: :echo |> Application.fetch_env!(__MODULE__) |> Keyword.fetch!(key)
30 | end
31 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :echo,
11 | generators: [timestamp_type: :utc_datetime]
12 |
13 | # Configures the endpoint
14 | config :echo, EchoWeb.Endpoint,
15 | url: [host: "localhost"],
16 | adapter: Bandit.PhoenixAdapter,
17 | render_errors: [
18 | formats: [json: EchoWeb.ErrorJSON],
19 | layout: false
20 | ],
21 | pubsub_server: Echo.PubSub,
22 | live_view: [signing_salt: "iu0J3zJG"]
23 |
24 | # Configures Elixir's Logger
25 | config :logger, :console,
26 | format: "$time $metadata[$level] $message\n",
27 | metadata: [:request_id]
28 |
29 | # Use Jason for JSON parsing in Phoenix
30 | config :phoenix, :json_library, Jason
31 |
32 | # Import environment specific config. This must remain at the bottom
33 | # of this file so it overrides the configuration defined above.
34 | import_config "#{config_env()}.exs"
35 |
--------------------------------------------------------------------------------
/lib/echo/text_to_speech.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.TextToSpeech do
2 | @moduledoc """
3 | Generic TTS module.
4 | """
5 | alias Echo.Client.ElevenLabs
6 |
7 | @separators [".", ",", "?", "!", ";", ":", "—", "-", "(", ")", "[", "]", "}", " "]
8 |
9 | @doc """
10 | Consumes an Enumerable (such as a stream) of text
11 | into speech, applying `fun` to each audio element.
12 |
13 | Returns the spoken text contained within `enumerable`.
14 | """
15 | def stream(enumerable, pid) do
16 | result =
17 | enumerable
18 | |> group_tokens()
19 | |> Stream.map(fn text ->
20 | text = IO.iodata_to_binary(text)
21 | ElevenLabs.WebSocket.send(pid, text)
22 | text
23 | end)
24 | |> Enum.join()
25 |
26 | ElevenLabs.WebSocket.flush(pid)
27 |
28 | result
29 | end
30 |
31 | defp group_tokens(stream) do
32 | Stream.transform(stream, {[], []}, fn item, {current_chunk, _acc} ->
33 | updated_chunk = [current_chunk, item]
34 |
35 | if String.ends_with?(item, @separators) do
36 | {[updated_chunk], {[], []}}
37 | else
38 | {[], {updated_chunk, []}}
39 | end
40 | end)
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule EchoWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use EchoWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # The default endpoint for testing
23 | @endpoint EchoWeb.Endpoint
24 |
25 | use EchoWeb, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import EchoWeb.ConnCase
31 | end
32 | end
33 |
34 | setup _tags do
35 | {:ok, conn: Phoenix.ConnTest.build_conn()}
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/echo/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.Application do
2 | use Application
3 |
4 | @impl true
5 | def start(_type, _args) do
6 | children = [EchoWeb.Telemetry] ++ servings() ++ [Echo.VAD, EchoWeb.Endpoint]
7 |
8 | # See https://hexdocs.pm/elixir/Supervisor.html
9 | # for other strategies and supported options
10 | opts = [strategy: :one_for_one, name: Echo.Supervisor]
11 | Supervisor.start_link(children, opts)
12 | end
13 |
14 | defp servings() do
15 | stt_serving =
16 | {Nx.Serving,
17 | name: Echo.SpeechToText,
18 | serving: Echo.SpeechToText.Bumblebee.serving(),
19 | batch_size: 1,
20 | batch_timeout: 10}
21 |
22 | if Application.fetch_env!(:echo, Echo.TextGeneration)[:provider] == "bumblebee" do
23 | [
24 | stt_serving,
25 | {Nx.Serving,
26 | name: Echo.TextGeneration,
27 | serving: Echo.TextGeneration.Bumblebee.serving(),
28 | batch_size: 1,
29 | batch_timeout: 10}
30 | ]
31 | else
32 | [stt_serving]
33 | end
34 | end
35 |
36 | # Tell Phoenix to update the endpoint configuration
37 | # whenever the application is updated.
38 | @impl true
39 | def config_change(changed, _new, removed) do
40 | EchoWeb.Endpoint.config_change(changed, removed)
41 | :ok
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/echo/vad.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.VAD do
2 | @moduledoc """
3 | Voice-activity detection based on Silero-VAD ONNX model.
4 |
5 | Ideally, we would use Nx.Serving here, but unfortunately it does
6 | not currently support custom batch dimensions.
7 | """
8 | @sample_rate 16_000
9 |
10 | @threshold 0.5
11 |
12 | use GenServer
13 |
14 | ## Client
15 |
16 | def start_link(_opts) do
17 | GenServer.start_link(__MODULE__, :unused_state, name: __MODULE__)
18 | end
19 |
20 | def predict(audio) do
21 | GenServer.call(__MODULE__, {:predict, audio})
22 | end
23 |
24 | ## Server
25 |
26 | @impl true
27 | def init(_opts) do
28 | model = Ortex.load(Path.join([:code.priv_dir(:echo), "models", "silero_vad.onnx"]))
29 |
30 | {:ok,
31 | %{
32 | model: model,
33 | last: 0.0,
34 | h: Nx.broadcast(0.0, {2, 1, 64}),
35 | c: Nx.broadcast(0.0, {2, 1, 64})
36 | }}
37 | end
38 |
39 | @impl true
40 | def handle_call({:predict, audio}, _from, %{model: model, h: h, c: c} = state) do
41 | {prob, h, c} = do_predict(model, h, c, audio)
42 | prob = prob |> Nx.squeeze() |> Nx.to_number()
43 | {:reply, prob > @threshold, %{state | h: h, c: c}}
44 | end
45 |
46 | defp do_predict(model, h, c, audio) do
47 | input = Nx.from_binary(audio, :f32) |> Nx.new_axis(0)
48 | sr = Nx.tensor(@sample_rate)
49 | Ortex.run(model, {input, sr, h, c})
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/echo/text_generation/bumblebee.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.TextGeneration.Bumblebee do
2 | @behaviour Echo.TextGeneration.Provider
3 |
4 | @impl true
5 | def chat_completion(messages) do
6 | prompt = apply_chat_template(messages)
7 |
8 | Nx.Serving.batched_run(Echo.TextGeneration, prompt)
9 | end
10 |
11 | def serving() do
12 | repo = {:hf, env(:repo)}
13 |
14 | {:ok, model} = Bumblebee.load_model(repo, type: :bf16)
15 | {:ok, tokenizer} = Bumblebee.load_tokenizer(repo)
16 | {:ok, generation_config} = Bumblebee.load_generation_config(repo)
17 |
18 | generation_config = config(generation_config)
19 |
20 | Bumblebee.Text.generation(model, tokenizer, generation_config,
21 | defn_options: [compiler: EXLA],
22 | compile: [batch_size: 1, sequence_length: env(:max_sequence_length)],
23 | stream: true
24 | )
25 | end
26 |
27 | defp apply_chat_template(messages) do
28 | content =
29 | Enum.map_join(messages, "", fn
30 | %{role: "user", content: content} -> user_message(content)
31 | %{role: "assistant", content: content} -> assistant_message(content)
32 | end)
33 |
34 | "#{content}"
35 | end
36 |
37 | defp user_message(content), do: "[INST]#{content}[/INST]"
38 | defp assistant_message(content), do: "#{String.replace(content, "", "")}"
39 |
40 | defp config(generation_config) do
41 | Bumblebee.configure(generation_config, %{
42 | max_new_tokens: env(:max_tokens),
43 | type: :multinomial_sampling,
44 | top_k: 4
45 | })
46 | end
47 |
48 | defp env(key), do: :echo |> Application.fetch_env!(__MODULE__) |> Keyword.fetch!(key)
49 | end
50 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Echo.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :echo,
7 | version: "0.1.0",
8 | elixir: "~> 1.15",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | start_permanent: Mix.env() == :prod,
11 | aliases: aliases(),
12 | deps: deps()
13 | ]
14 | end
15 |
16 | # Configuration for the OTP application.
17 | #
18 | # Type `mix help compile.app` for more information.
19 | def application do
20 | [
21 | mod: {Echo.Application, []},
22 | extra_applications: [:logger, :runtime_tools]
23 | ]
24 | end
25 |
26 | # Specifies which paths to compile per environment.
27 | defp elixirc_paths(:test), do: ["lib", "test/support"]
28 | defp elixirc_paths(_), do: ["lib"]
29 |
30 | # Specifies your project dependencies.
31 | #
32 | # Type `mix help deps` for examples and options.
33 | defp deps do
34 | [
35 | {:phoenix, "~> 1.7.10"},
36 | {:telemetry_metrics, "~> 0.6"},
37 | {:telemetry_poller, "~> 1.0"},
38 | {:gettext, "~> 0.20"},
39 | {:jason, "~> 1.2"},
40 | {:bandit, ">= 0.0.0"},
41 | {:websockex, "~> 0.4.3"},
42 | {:openai, "~> 0.6.1"},
43 | {:nx, "~> 0.7"},
44 | {:exla, "~> 0.7"},
45 | {:bumblebee, "~> 0.5"},
46 | {:ortex, "~> 0.1.9"},
47 | {:msgpax, "~> 2.0"}
48 | ]
49 | end
50 |
51 | # Aliases are shortcuts or tasks specific to the current project.
52 | # For example, to install project dependencies and perform other setup tasks, run:
53 | #
54 | # $ mix setup
55 | #
56 | # See the documentation for `Mix` for more info on aliases.
57 | defp aliases do
58 | [
59 | setup: ["deps.get"]
60 | ]
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/lib/echo_web.ex:
--------------------------------------------------------------------------------
1 | defmodule EchoWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use EchoWeb, :controller
9 | use EchoWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | end
30 | end
31 |
32 | def channel do
33 | quote do
34 | use Phoenix.Channel
35 | end
36 | end
37 |
38 | def controller do
39 | quote do
40 | use Phoenix.Controller,
41 | formats: [:html, :json],
42 | layouts: [html: EchoWeb.Layouts]
43 |
44 | import Plug.Conn
45 | import EchoWeb.Gettext
46 |
47 | unquote(verified_routes())
48 | end
49 | end
50 |
51 | def verified_routes do
52 | quote do
53 | use Phoenix.VerifiedRoutes,
54 | endpoint: EchoWeb.Endpoint,
55 | router: EchoWeb.Router,
56 | statics: EchoWeb.static_paths()
57 | end
58 | end
59 |
60 | @doc """
61 | When used, dispatch to the appropriate controller/view/etc.
62 | """
63 | defmacro __using__(which) when is_atom(which) do
64 | apply(__MODULE__, which, [])
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we can use it
8 | # to bundle .js and .css sources.
9 | config :echo, EchoWeb.Endpoint,
10 | # Binding to loopback ipv4 address prevents access from other machines.
11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
12 | http: [ip: {127, 0, 0, 1}, port: System.get_env("PORT") || 4000],
13 | check_origin: false,
14 | code_reloader: true,
15 | debug_errors: true,
16 | secret_key_base: "bZASo6x7wVzLM5zbVkLwJH63aYd9Cdimcb/5WI2XpPwWlOM47dvPnNNbR/7M1gw9",
17 | watchers: []
18 |
19 | # ## SSL Support
20 | #
21 | # In order to use HTTPS in development, a self-signed
22 | # certificate can be generated by running the following
23 | # Mix task:
24 | #
25 | # mix phx.gen.cert
26 | #
27 | # Run `mix help phx.gen.cert` for more information.
28 | #
29 | # The `http:` config above can be replaced with:
30 | #
31 | # https: [
32 | # port: 4001,
33 | # cipher_suite: :strong,
34 | # keyfile: "priv/cert/selfsigned_key.pem",
35 | # certfile: "priv/cert/selfsigned.pem"
36 | # ],
37 | #
38 | # If desired, both `http:` and `https:` keys can be
39 | # configured to run both http and https servers on
40 | # different ports.
41 |
42 | # Enable dev routes for dashboard and mailbox
43 | config :echo, dev_routes: true
44 |
45 | # Do not include metadata nor timestamps in development logs
46 | config :logger, :console, format: "[$level] $message\n"
47 |
48 | # Set a higher stacktrace during development. Avoid configuring such
49 | # in production as building large stacktraces may be expensive.
50 | config :phoenix, :stacktrace_depth, 20
51 |
52 | # Initialize plugs at runtime for faster development compilation
53 | config :phoenix, :plug_init_mode, :runtime
54 |
--------------------------------------------------------------------------------
/lib/echo/client/eleven_labs/web_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule Echo.Client.ElevenLabs.WebSocket do
2 | use WebSockex
3 |
4 | require Logger
5 |
6 | ## Client
7 |
8 | def start_link(broadcast_fun, token) do
9 | headers = [{"xi-api-key", env(:api_key)}]
10 |
11 | params = %{
12 | model_id: env(:model_id),
13 | optimize_streaming_latency: env(:optimize_streaming_latency),
14 | output_format: env(:output_format)
15 | }
16 |
17 | url =
18 | URI.new!("wss://api.elevenlabs.io")
19 | |> URI.append_path("/v1/text-to-speech/#{env(:voice_id)}/stream-input")
20 | |> URI.append_query(URI.encode_query(params))
21 | |> URI.to_string()
22 |
23 | WebSockex.start_link(url, __MODULE__, %{fun: broadcast_fun, token: token},
24 | extra_headers: headers
25 | )
26 | end
27 |
28 | def open_stream(pid) do
29 | msg = Jason.encode!(%{text: " "})
30 | WebSockex.send_frame(pid, {:text, msg})
31 |
32 | pid
33 | end
34 |
35 | def close_stream(pid) do
36 | msg = Jason.encode!(%{text: ""})
37 | WebSockex.send_frame(pid, {:text, msg})
38 | end
39 |
40 | def send(pid, text) do
41 | msg = Jason.encode!(%{text: "#{text} ", try_trigger_generation: true})
42 | WebSockex.send_frame(pid, {:text, msg})
43 | end
44 |
45 | def flush(pid) do
46 | msg = Jason.encode!(%{text: " ", try_trigger_generation: true, flush: true})
47 | WebSockex.send_frame(pid, {:text, msg})
48 | end
49 |
50 | def update_token(pid, token) do
51 | WebSockex.cast(pid, {:update_token, {:binary, token}})
52 | end
53 |
54 | ## Server
55 |
56 | def handle_cast({:update_token, {:binary, token}}, state) do
57 | {:ok, %{state | token: token}}
58 | end
59 |
60 | def handle_frame({:text, msg}, %{fun: broadcast_fun, token: token} = state) do
61 | case Jason.decode!(msg) do
62 | %{"audio" => audio} when is_binary(audio) ->
63 | raw = Base.decode64!(audio)
64 | broadcast_fun.(token <> raw)
65 |
66 | error ->
67 | Logger.error("Something went wrong: #{inspect(error)}")
68 | :ok
69 | end
70 |
71 | {:ok, state}
72 | end
73 |
74 | defp env(key), do: :echo |> Application.fetch_env!(__MODULE__) |> Keyword.fetch!(key)
75 | end
76 |
--------------------------------------------------------------------------------
/lib/echo_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule EchoWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.start.system_time",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.endpoint.stop.duration",
29 | unit: {:native, :millisecond}
30 | ),
31 | summary("phoenix.router_dispatch.start.system_time",
32 | tags: [:route],
33 | unit: {:native, :millisecond}
34 | ),
35 | summary("phoenix.router_dispatch.exception.duration",
36 | tags: [:route],
37 | unit: {:native, :millisecond}
38 | ),
39 | summary("phoenix.router_dispatch.stop.duration",
40 | tags: [:route],
41 | unit: {:native, :millisecond}
42 | ),
43 | summary("phoenix.socket_connected.duration",
44 | unit: {:native, :millisecond}
45 | ),
46 | summary("phoenix.channel_joined.duration",
47 | unit: {:native, :millisecond}
48 | ),
49 | summary("phoenix.channel_handled_in.duration",
50 | tags: [:event],
51 | unit: {:native, :millisecond}
52 | ),
53 |
54 | # VM Metrics
55 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
56 | summary("vm.total_run_queue_lengths.total"),
57 | summary("vm.total_run_queue_lengths.cpu"),
58 | summary("vm.total_run_queue_lengths.io")
59 | ]
60 | end
61 |
62 | defp periodic_measurements do
63 | [
64 | # A module, function and arguments to be invoked periodically.
65 | # This function must call :telemetry.execute/3 and a metric must be added above.
66 | # {EchoWeb, :count_users, []}
67 | ]
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG ELIXIR_VERSION=1.15.0
2 | ARG OTP_VERSION=26.0.2
3 | ARG UBUNTU_VERSION=jammy-20230126
4 | ARG CUDA_VERSION=12.2.2
5 |
6 | FROM rust as rust
7 |
8 | FROM hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-ubuntu-${UBUNTU_VERSION} as builder
9 | COPY --from=rust /usr/local/cargo /usr/local/cargo
10 | ENV PATH=$PATH:/usr/local/cargo/bin
11 | # install build dependencies
12 | RUN apt-get update -y && apt-get install -y build-essential git wget \
13 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
14 |
15 | # prepare build dir
16 | WORKDIR /app
17 |
18 | # install hex + rebar
19 | RUN mix local.hex --force && \
20 | mix local.rebar --force
21 |
22 | # set build ENV
23 | ENV MIX_ENV="prod"
24 | ENV BUMBLEBEE_CACHE_DIR="/app/.bumblebee"
25 |
26 | # install mix dependencies
27 | COPY mix.exs mix.lock ./
28 | RUN mix deps.get --only $MIX_ENV
29 | RUN mkdir config
30 |
31 | # copy compile-time config files before we compile dependencies
32 | # to ensure any relevant config change will trigger the dependencies
33 | # to be re-compiled.
34 | COPY config/config.exs config/${MIX_ENV}.exs config/
35 | RUN rustup default stable && mix deps.compile
36 |
37 | COPY priv priv
38 |
39 | COPY lib lib
40 |
41 | # Compile the release
42 | RUN mix compile
43 |
44 | # Changes to config/runtime.exs don't require recompiling the code
45 | COPY config/runtime.exs config/
46 |
47 | COPY rel rel
48 | RUN mix release
49 |
50 | # start a new build stage so that the final image will only contain
51 | # the compiled release and other runtime necessities
52 | FROM nvidia/cuda:${CUDA_VERSION}-cudnn8-runtime-ubuntu22.04
53 |
54 | RUN apt-get update -y && \
55 | apt-get install -y \
56 | libstdc++6 \
57 | openssl \
58 | libncurses5 \
59 | locales \
60 | ca-certificates && \
61 | apt-get clean && \
62 | rm -f /var/lib/apt/lists/*_*
63 |
64 | # Set the locale
65 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
66 |
67 | ENV LANG en_US.UTF-8
68 | ENV LANGUAGE en_US:en
69 | ENV LC_ALL en_US.UTF-8
70 |
71 | EXPOSE 4000
72 |
73 | WORKDIR "/app"
74 | RUN chown nobody /app
75 |
76 | # set runner ENV
77 | ENV MIX_ENV="prod"
78 | ENV BUMBLEBEE_CACHE_DIR="/app/.bumblebee"
79 | ENV XLA_TARGET="cuda120"
80 |
81 | # Only copy the final release from the build stage
82 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/echo ./
83 |
84 | USER nobody
85 |
86 | CMD ["/app/bin/server"]
87 |
--------------------------------------------------------------------------------
/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # config/runtime.exs is executed for all environments, including
4 | # during releases. It is executed after compilation and before the
5 | # system starts, so it is typically used to load production configuration
6 | # and secrets from environment variables or elsewhere. Do not define
7 | # any compile-time configuration in here, as it won't be applied.
8 | # The block below contains prod specific runtime configuration.
9 |
10 | # ## Using releases
11 | #
12 | # If you use `mix release`, you need to explicitly enable the server
13 | # by passing the PHX_SERVER=true when you start it:
14 | #
15 | # PHX_SERVER=true bin/echo start
16 | #
17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
18 | # script that automatically sets the env var above.
19 |
20 | # LLM
21 | provider = System.get_env("TEXT_GENERATION_PROVIDER") || "openai"
22 |
23 | case provider do
24 | "openai" ->
25 | openai_api_key = System.fetch_env!("OPENAI_API_KEY")
26 | openai_model = System.fetch_env!("TEXT_GENERATION_MODEL")
27 |
28 | openai_max_tokens =
29 | System.get_env("TEXT_GENERATION_MAX_NEW_TOKENS", "400") |> String.to_integer()
30 |
31 | config :openai,
32 | api_key: openai_api_key,
33 | http_options: [recv_timeout: :infinity, async: :once]
34 |
35 | config :echo, Echo.TextGeneration, provider: Echo.TextGeneration.OpenAI
36 |
37 | config :echo, Echo.TextGeneration.OpenAI,
38 | model: openai_model,
39 | max_tokens: openai_max_tokens
40 |
41 | "generic" ->
42 | generic_api_url = System.fetch_env!("TEXT_GENERATION_API_URL")
43 | generic_model = System.fetch_env!("TEXT_GENERATION_MODEL")
44 |
45 | generic_max_tokens =
46 | System.get_env("TEXT_GENERATION_MAX_NEW_TOKENS", "400") |> String.to_integer()
47 |
48 | config :echo, Echo.TextGeneration, provider: Echo.TextGeneration.OpenAI
49 |
50 | config :openai,
51 | api_url: generic_api_url,
52 | http_options: [recv_timeout: :infinity, async: :once]
53 |
54 | config :echo, Echo.TextGeneration.OpenAI,
55 | model: generic_model,
56 | max_tokens: generic_max_tokens
57 |
58 | "bumblebee" ->
59 | bb_text_generation_model = System.fetch_env!("TEXT_GENERATION_MODEL")
60 |
61 | bb_max_new_tokens =
62 | System.get_env("TEXT_GENERATION_MAX_NEW_TOKENS", "400") |> String.to_integer()
63 |
64 | bb_max_sequence_length =
65 | System.get_env("TEXT_GENERATION_MAX_SEQUENCE_LENGTH", "2048") |> String.to_integer()
66 |
67 | config :echo, Echo.TextGeneration, provider: Echo.TextGeneration.Bumblebee
68 |
69 | config :echo, Echo.TextGeneration.Bumblebee,
70 | repo: bb_text_generation_model,
71 | max_new_tokens: bb_max_new_tokens,
72 | max_sequence_length: bb_max_sequence_length
73 | end
74 |
75 | # Speech-to-Text
76 | stt_model_repo = System.fetch_env!("SPEECH_TO_TEXT_MODEL")
77 |
78 | config :echo, Echo.SpeechToText.Bumblebee, repo: stt_model_repo
79 |
80 | # Text-to-Speech
81 | eleven_labs_api_key = System.fetch_env!("ELEVEN_LABS_API_KEY")
82 | eleven_labs_voice_id = System.get_env("ELEVEN_LABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM")
83 | eleven_labs_model_id = System.get_env("ELEVEN_LABS_MODEL_ID", "eleven_turbo_v2")
84 |
85 | eleven_labs_optimize_streaming_latency =
86 | System.get_env("ELEVEN_LABS_OPTIMIZE_STREAMING_LATENCY", "2") |> String.to_integer()
87 |
88 | eleven_labs_output_format = System.get_env("ELEVEN_LABS_OUTPUT_FORMAT", "mp3_22050_32")
89 |
90 | config :echo, Echo.Client.ElevenLabs.WebSocket,
91 | api_key: eleven_labs_api_key,
92 | voice_id: eleven_labs_voice_id,
93 | model_id: eleven_labs_model_id,
94 | optimize_streaming_latency: eleven_labs_optimize_streaming_latency,
95 | output_format: eleven_labs_output_format
96 |
97 | # Regular Config
98 |
99 | config :nx, default_backend: EXLA.Backend
100 |
101 | if System.get_env("PHX_SERVER") do
102 | config :echo, EchoWeb.Endpoint, server: true
103 | end
104 |
105 | if config_env() == :prod do
106 | # The secret key base is used to sign/encrypt cookies and other secrets.
107 | # A default value is used in config/dev.exs and config/test.exs but you
108 | # want to use a different value for prod and you most likely don't want
109 | # to check this value into version control, so we use an environment
110 | # variable instead.
111 | secret_key_base =
112 | System.get_env("SECRET_KEY_BASE") ||
113 | raise """
114 | environment variable SECRET_KEY_BASE is missing.
115 | You can generate one by calling: mix phx.gen.secret
116 | """
117 |
118 | port = String.to_integer(System.get_env("PORT") || "4000")
119 |
120 | config :echo, EchoWeb.Endpoint,
121 | http: [ip: {0, 0, 0, 0}, port: port],
122 | server: true,
123 | code_reloader: false,
124 | check_origin: false,
125 | secret_key_base: secret_key_base
126 |
127 | # ## SSL Support
128 | #
129 | # To get SSL working, you will need to add the `https` key
130 | # to your endpoint configuration:
131 | #
132 | # config :echo, EchoWeb.Endpoint,
133 | # https: [
134 | # ...,
135 | # port: 443,
136 | # cipher_suite: :strong,
137 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
138 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
139 | # ]
140 | #
141 | # The `cipher_suite` is set to `:strong` to support only the
142 | # latest and more secure SSL ciphers. This means old browsers
143 | # and clients may not be supported. You can set it to
144 | # `:compatible` for wider support.
145 | #
146 | # `:keyfile` and `:certfile` expect an absolute path to the key
147 | # and cert in disk or a relative path inside priv, for example
148 | # "priv/ssl/server.key". For all supported SSL configuration
149 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
150 | #
151 | # We also recommend setting `force_ssl` in your endpoint, ensuring
152 | # no data is ever sent via http, always redirecting to https:
153 | #
154 | # config :echo, EchoWeb.Endpoint,
155 | # force_ssl: [hsts: true]
156 | #
157 | # Check `Plug.SSL` for all available options in `force_ssl`.
158 | end
159 |
--------------------------------------------------------------------------------
/lib/echo_web/socket/conversation.ex:
--------------------------------------------------------------------------------
1 | defmodule EchoWeb.Socket.Conversation do
2 | @moduledoc """
3 | Implements a WebSocket API for a conversational agent.
4 |
5 | Possible states:
6 |
7 | - `:closed` - doing nothing
8 | - `:waiting` - waiting for voice activity
9 | - `:listening` - listening to voice activity
10 | - `:transcribing` - actively transcribing a message
11 | - `:replying` - pushing audio
12 |
13 | We open in a closed state, until we receive a message to kick off
14 | the conversation from the user. That message can contain conversation
15 | parameters to include the prompt, and other settings.
16 | """
17 | alias Echo.Client.ElevenLabs.WebSocket
18 |
19 | require Logger
20 |
21 | @behaviour Phoenix.Socket.Transport
22 |
23 | @impl true
24 | def child_spec(_opts) do
25 | # We won't spawn any process, so let's ignore the child spec
26 | :ignore
27 | end
28 |
29 | @impl true
30 | def connect(_connect_opts) do
31 | # Callback to retrieve relevant data from the connection.
32 | # The map contains options, params, transport and endpoint keys.
33 | {:ok, %{}}
34 | end
35 |
36 | @impl true
37 | def init(_state) do
38 | {:ok,
39 | %{
40 | mode: :closed,
41 | last_audio_buffer: "",
42 | accumulated_audio_buffer: "",
43 | transcription_pid: nil,
44 | reply_pid: nil,
45 | tts_pid: nil,
46 | chat: []
47 | }}
48 | end
49 |
50 | @impl true
51 | def handle_in({msg, _opts}, state) do
52 | decoded = Msgpax.unpack!(msg)
53 | handle_message(decoded, state)
54 | end
55 |
56 | @impl true
57 | def handle_info({ref, transcription}, state) when ref == state.transcription_pid.ref do
58 | chat = state.chat ++ [%{role: "user", content: transcription}]
59 | state = reply(%{state | chat: chat})
60 | {:ok, %{state | transcription_pid: nil}}
61 | end
62 |
63 | def handle_info({ref, response}, state) when ref == state.reply_pid.ref do
64 | chat = state.chat ++ [%{role: "assistant", content: response}]
65 | {:ok, %{state | reply_pid: nil, chat: chat}}
66 | end
67 |
68 | def handle_info({:token, token}, state) do
69 | message = Msgpax.pack!(%{type: "token", token: token})
70 | {:push, {:binary, message}, state}
71 | end
72 |
73 | def handle_info({:audio, data}, state) do
74 | message = Msgpax.pack!(%{type: "audio", audio: Msgpax.Bin.new(data)})
75 | {:push, {:binary, message}, state}
76 | end
77 |
78 | def handle_info(_, state) do
79 | Logger.info("Ignored message")
80 | {:ok, state}
81 | end
82 |
83 | @impl true
84 | def terminate(_reason, state) do
85 | WebSocket.close_stream(state.tts_pid)
86 | end
87 |
88 | ## Helpers
89 |
90 | defp handle_message(%{"type" => "open", "prompt" => prompt}, %{mode: :closed} = state) do
91 | chat = [%{role: "system", content: prompt}, %{role: "user", content: "Hello!"}]
92 | target = self()
93 |
94 | # Start TTS pid and sync tokens
95 | token = tts_token()
96 |
97 | {:ok, tts_pid} =
98 | WebSocket.start_link(
99 | fn audio ->
100 | send(target, {:audio, audio})
101 | end,
102 | token
103 | )
104 |
105 | tts_pid = WebSocket.open_stream(tts_pid)
106 |
107 | # Update state, and start conversation
108 | state = reply(%{state | tts_pid: tts_pid, chat: chat})
109 |
110 | # Push token to client
111 | message = Msgpax.pack!(%{type: "token", token: token})
112 |
113 | {:push, {:binary, message}, state}
114 | end
115 |
116 | defp handle_message(%{"type" => "open"}, state) do
117 | Logger.info("Received open message in already-open state. Ignoring...")
118 | {:ok, state}
119 | end
120 |
121 | defp handle_message(%{"type" => "close"}, state) do
122 | Logger.info("Received close message. Closing connection...")
123 | {:stop, :normal, state}
124 | end
125 |
126 | defp handle_message(%{"type" => "audio", "audio" => data}, %{mode: mode} = state) do
127 | voice_detected? = Echo.VAD.predict(data)
128 |
129 | case {voice_detected?, mode} do
130 | {true, :waiting} ->
131 | # if we are waiting or listening and detect voice activity,
132 | # then we enter a listening state and start accumulating
133 | # incoming audio to transcribe
134 | state = %{
135 | state
136 | | last_audio_buffer: data,
137 | accumulated_audio_buffer: state.last_audio_buffer <> data,
138 | mode: :listening
139 | }
140 |
141 | {:ok, state}
142 |
143 | {true, :listening} ->
144 | # if we are listening and detect voice activity,
145 | # then we continue listening and accumulating
146 | state = %{
147 | state
148 | | last_audio_buffer: data,
149 | accumulated_audio_buffer: state.accumulated_audio_buffer <> data,
150 | mode: :listening
151 | }
152 |
153 | {:ok, state}
154 |
155 | {true, :replying} ->
156 | # if we detect voice activity while we are replying,
157 | # then we need to push an interrupt to the client to
158 | # stop speaking
159 | state = %{
160 | state
161 | | last_audio_buffer: data,
162 | accumulated_audio_buffer: state.last_audio_buffer <> data,
163 | mode: :listening
164 | }
165 |
166 | # any interrupt needs to cycle the tts token, so we avoid
167 | # a race condition of sending dead audio to the audio queue
168 | token = tts_token()
169 | WebSocket.update_token(state.tts_pid, token)
170 | message = Msgpax.pack!(%{type: "interrupt", token: token})
171 | {:push, {:binary, message}, state}
172 |
173 | {true, :transcribing} ->
174 | # if we detect voice activity while we are transcribing,
175 | # then I'm honestly not sure what to do except maybe just
176 | # accumulate transcription pids and concat all of the transcriptions
177 | # together, for now this is ignored
178 | # TODO:
179 | {:ok, state}
180 |
181 | {false, :listening} ->
182 | # if we are listening and do not detect voice activity,
183 | # then we clear the buffers and trigger transcription
184 | state = transcribe(data, state)
185 | {:ok, state}
186 |
187 | {false, mode} when mode in [:waiting, :replying, :transcribing] ->
188 | # if we are waiting, replying, or transcribing and do not
189 | # detect voice activity, then we do nothing
190 | state = %{state | last_audio_buffer: data}
191 | {:ok, state}
192 |
193 | {_, :closed} ->
194 | # just ignore anything in the closed state, we shouldn't even
195 | # be pushing audio in this state
196 | {:ok, state}
197 | end
198 | end
199 |
200 | defp handle_message(%{"type" => "state", "state" => "waiting"}, state) do
201 | {:ok, %{state | mode: :waiting}}
202 | end
203 |
204 | defp transcribe(data, %{accumulated_audio_buffer: buffer} = state) do
205 | final_buffer = buffer <> data
206 | transcription_pid = start_transcription(final_buffer)
207 |
208 | %{
209 | state
210 | | transcription_pid: transcription_pid,
211 | last_audio_buffer: data,
212 | accumulated_audio_buffer: "",
213 | mode: :transcribing
214 | }
215 | end
216 |
217 | defp reply(%{chat: chat, tts_pid: tts_pid} = state) do
218 | response =
219 | Echo.TextGeneration.chat_completion(
220 | model: "gpt-3.5-turbo",
221 | messages: chat,
222 | max_tokens: 400,
223 | stream: true
224 | )
225 |
226 | reply_pid = start_speaking(response, tts_pid)
227 | %{state | reply_pid: reply_pid, mode: :replying}
228 | end
229 |
230 | defp start_speaking(response, tts_pid) do
231 | Task.async(fn ->
232 | Echo.TextToSpeech.stream(response, tts_pid)
233 | end)
234 | end
235 |
236 | defp start_transcription(buffer) do
237 | Task.async(fn ->
238 | buffer
239 | |> Nx.from_binary(:f32)
240 | |> Echo.SpeechToText.transcribe()
241 | end)
242 | end
243 |
244 | defp tts_token() do
245 | for _ <- 1..8, into: "", do: <>
246 | end
247 | end
248 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Echo
2 |
3 | > Give your agents a voice with Echo.
4 |
5 | Echo is a WebSocket server for conversational agents with low latency and support for interrupts. You can read the [origin blog post here](https://seanmoriarity.com/2024/02/25/implementing-natural-conversational-agents-with-elixir/).
6 |
7 | ## Usage
8 |
9 | ### Development Server
10 |
11 | Echo is an [Elixir Phoenix](https://www.phoenixframework.org/) application. You can run the server by installing Elixir and Phoenix, setting the [required environment variables](#configuration) and running:
12 |
13 | ```sh
14 | mix phx.server
15 | ```
16 |
17 | This will start a server running on port 4000 with a single endpoint: `ws://localhost:4000/conversation`. I highly recommend you run on a GPU-enabled machine and set `XLA_ENV=cuda120`. I can get very low latency, but only by running transcription on a GPU.
18 |
19 | ### Docker Deployment
20 |
21 | Alternatively, you can build a Docker image from the included `Dockerfile`
22 |
23 | #### Build
24 |
25 | Using the format:
26 |
27 | ```sh
28 | docker build -t TAG_NAME DOCKERFILE_PATH
29 | ```
30 |
31 | Assuming you clone this repo and `cd` into the root of the repo (where the Dockerfile is):
32 |
33 | ```sh
34 | docker build -t echo .
35 | ```
36 |
37 | This will build a Docker image with the tag `echo`, but you can rename the tag or choose to not include any.
38 |
39 | You can also pass any of the following as a `--build-arg`:
40 |
41 | * `ELIXIR_VERSION` (defaults `1.15.0`)
42 | * `OTP_VERSION` (defaults `26.0.2`)
43 | * `DEBIAN_VERSION` (defaults `bullseye-20230612-slim`)
44 | * `BUILDER_IMAGE` (defaults `hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}`)
45 | * `RUNNER_IMAGE` (defaults `debian:${DEBIAN_VERSION}`)
46 |
47 | You should not need to adjust the default values, but if you need to consult the Dockerfile itself for
48 | information on their usage.
49 |
50 | #### Run
51 |
52 | Then to run the server do:
53 |
54 | ```sh
55 | docker run \
56 | -e SECRET_KEY_BASE=$(mix phx.gen.secret) \
57 | -e OPENAI_API_KEY=YOUR_API_KEY \
58 | -e TEXT_GENERATION_MODEL=gpt-3.5-turbo \
59 | -e SPEECH_TO_TEXT_MODEL=distil-whisper/distil-medium.en \
60 | -e ELEVEN_LABS_API_KEY=YOUR_OTHER_API_KEY \
61 | -p 4001:4000 echo
62 | ```
63 |
64 | The Dockerfile builds the server as a production release, so you'll need to include the `SECRET_KEY_BASE` string.
65 | `mix phx.gen.secret` will generate a new secret for you, but you can use any secrey you desire.
66 |
67 | `-p 4001:4000` binds your host's port 4001 to the containers port 4000. If you want to bind to a different port on
68 | your host machine just change the first number, like `-p PORT:4000`.
69 |
70 | You can also pass any other environment variables accepted by the server, as described in detail below.
71 |
72 | ### Interacting with the WebSocket Server
73 |
74 | > WARNING: This API is **extremely** alpha. I know it's kind of confusing, sorry.
75 |
76 | The WebSocket server uses [MessagePack](https://msgpack.org/index.html) for serialization. Each message object contains a `type` field representing the type of message to send, and then some additional data. These are the types of messages you can send to the client:
77 |
78 | * `{type: "open", prompt: "System prompt for agent"}`
79 | * `{type: "audio", audio: ArrayBuffer}`
80 | * `{type: "state", state: "waiting"}`
81 |
82 | After connecting, you should send an "open" message to the server with the system prompt for the agent you'd like to interact with. The server will immediately start pushing audio. The types of messages the server sends to the client are:
83 |
84 | * `{type: "audio", audio: ArrayBuffer}`
85 | * `{type: "token", token: String}`
86 | * `{type: "interrupt", token: String}`
87 |
88 | Each audio event sends data as a MessagePack binary which will get decoded as a UInt8Array. The first 8 bytes are a sequencing token for interrupts. You should use the sequencing token to avoid race conditions in the event your agent is interrupted in the middle of streaming audio from ElevenLabs. The rest of the audio is a buffer matching the format provided in your configuration. For example, I recommend using `pcm_44100` which will output 16-bit PCM audio data. Then you can convert the incoming audio data like:
89 |
90 | ```js
91 | const data = decoded.audio;
92 |
93 | const tokenBytes = data.slice(0, 8);
94 | const textDecoder = new TextDecoder("utf-8");
95 | const token = textDecoder.decode(tokenBytes);
96 |
97 | const audio = new Int16Array(data.buffer, data.byteOffset + 8, (data.byteLength - 8) / Int16Array.BYTES_PER_ELEMENT);
98 | this.enqueueAudioData({ token, audio });
99 | ```
100 |
101 | Assuming you're using a queue to sequence incoming audio events.
102 |
103 | `interrupt` events tell you that your model has been interrupted while speaking, and you should stop playback. Each interrupt event creates a new sequencing token to avoid race conditions. `token` events update the sequencing token for audio playback.
104 |
105 | You should send data to the server in the same endianness as the server. You should also send it as FP32 PCM data. I recommend streaming to the server in chunks between 30ms and 250ms. You *must* sample audio at 16_000 Hz. The VAD model requires audio sampled at 16_000 hurts to work well. It does not support samples smaller than 30ms. Here's an example which pushes every 128ms:
106 |
107 | ```js
108 | const audioOptions = {
109 | sampleRate: SAMPLING_RATE,
110 | echoCancellation: true,
111 | noiseSuppression: true,
112 | autoGainControl: true,
113 | channelCount: 1,
114 | };
115 |
116 | navigator.mediaDevices.getUserMedia({ audio: audioOptions }).then((stream) => {
117 | const source = this.microphoneContext.createMediaStreamSource(stream);
118 | this.processor = this.microphoneContext.createScriptProcessor(2048, 1, 1);
119 |
120 | this.processor.onaudioprocess = (e) => {
121 | const pcmFloat32Data = this.convertEndianness32(
122 | e.inputBuffer.getChannelData(0),
123 | this.getEndianness(),
124 | this.el.dataset.endianness
125 | );
126 |
127 | const message = { type: "audio", audio: pcmFloat32Data };
128 |
129 | this.socket.send(encoder.encode(message), { type: "application/octet-stream" });
130 | };
131 |
132 | source.connect(this.processor);
133 | this.processor.connect(this.microphoneContext.destination);
134 | });
135 | ```
136 |
137 | ## Configuration {#configuration}
138 |
139 | Echo aims to be as configurable as possible. Right now you can configure each piece to varying extents.
140 |
141 | ### LLM Configuration
142 |
143 | Echo supports configurable LLMs through environment variables. First, you must set a provider with `TEXT_GENERATION_PROVIDER`. There are 3 possible providers:
144 |
145 | * `bumblebee` - Use a [Bumblebee](https://github.com/elixir-nx/bumblebee) supported model. This will run the model under an [Nx Serving](https://hexdocs.pm/nx/Nx.Serving.html) and has the benefit of no HTTP Request overhead. Models will be compiled with [XLA](https://github.com/openxla/xla).
146 | * `openai` - Use an OpenAI model such as GPT-3.5 or GPT-4.
147 | * `generic` - Use a generic model provider with an OpenAI compatible `/v1/chat/completions` endpoint.
148 |
149 | Each provider has it's own specific configuration options. Some options are shared between providers.
150 |
151 | #### Bumblebee Configuration
152 |
153 | * `TEXT_GENERATION_MODEL` (required) - The HuggingFace model repo of the LLM you would like to use. The model configuration in the given repository must support text generation.
154 | * `TEXT_GENERATION_MAX_SEQUENCE_LENGTH` (optional, default: 2048) - Maximum total number of tokens for a given conversation.
155 | * `TEXT_GENERATION_MAX_NEW_TOKENS` (optional, default: 400) - Maximum new tokens to generate on each conversation turn.
156 |
157 | #### OpenAI Configuration
158 |
159 | * `OPENAI_API_KEY` (required) - Your OpenAI API key.
160 | * `TEXT_GENERATION_MODEL` (required) - The OpenAI model to use.
161 | * `TEXT_GENERATION_MAX_NEW_TOKENS` (optional, default: 400) - Maximum new tokens to generate on each conversation turn.
162 |
163 | #### Generic Configuration
164 |
165 | * `TEXT_GENERATION_API_URL` (required) - The OpenAI-compatible chat completions endpoint to use.
166 | * `TEXT_GENERATION_MODEL` (required) - The generic model to use.
167 | * `TEXT_GENERATION_API_KEY` (optional) - API key, if necessary, for the OpenAI-compatible endpoint.
168 | * `TEXT_GENERATION_MAX_NEW_TOKENS` (optional, default: 400) - Maximum new tokens to generate on each conversation turn.
169 |
170 | ### Speech-to-Text Configuration
171 |
172 | Currently, the only supported speech-to-text provider is Bumblebee. You can customize the speech-to-text model with:
173 |
174 | * `SPEECH_TO_TEXT_MODEL` (required) - The HuggingFace model repo of the STT model you would like to use. `distil-whisper/distil-medium.en` is a good default for most configurations.
175 |
176 | ### Text-to-Speech Configuration
177 |
178 | Currently, the only supported text-to-speech provider is ElevenLabs. There are a number of configuration options to customize the experience with ElevenLabs.
179 |
180 | #### ElevenLabs Configuration
181 |
182 | * `ELEVEN_LABS_API_KEY` (required) - Your ElevenLabs API key.
183 | * `ELEVEN_LABS_MODEL_ID` (optional, default: "eleven_turbo_v2") - The ElevenLabs model ID to use. In most cases you should just leave the default.
184 | * `ELEVEN_LABS_OPTIMIZE_STREAMING_LATENCY` (optional, default: 2) - The ElevenLabs optimization setting. Higher numbers result in better latency but will struggle with some pronounciations.
185 | * `ELEVEN_LABS_OUTPUT_FORMAT` (optional, default: mp3_22050_32) - The ElevenLabs audio output format. Adjusting this can impact latency due to decoding requirements.
186 |
187 | ## Examples
188 |
189 | * [Phoenix LiveView Example](https://github.com/seanmor5/echo_example)
190 |
191 | ## Acknowledgements
192 |
193 | Thank you to [Andres Alejos](https://twitter.com/ac_alejos) for help setting up the VAD model and [Paulo Valente](https://twitter.com/polvalente) for some teachings on audio processing.
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "axon": {:hex, :axon, "0.6.1", "1d042fdba1c1b4413a3d65800524feebd1bc8ed218f8cdefe7a97510c3f427f3", [:mix], [{:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:kino_vega_lite, "~> 0.1.7", [hex: :kino_vega_lite, repo: "hexpm", optional: true]}, {:nx, "~> 0.6.0 or ~> 0.7.0", [hex: :nx, repo: "hexpm", optional: false]}, {:polaris, "~> 0.1", [hex: :polaris, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "d6b0ae2f0dd284f6bf702edcab71e790d6c01ca502dd06c4070836554f5a48e1"},
3 | "bandit": {:hex, :bandit, "1.2.3", "a98d664a96fec23b68e776062296d76a94b4459795b38209f4ae89cb4225709c", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e29150245a9b5f56944434e5240966e75c917dad248f689ab589b32187a81af"},
4 | "bumblebee": {:hex, :bumblebee, "0.5.2", "93bd37ea55ed0128f596be536b1330214022d6bc99e4e14b4cfde770ba8d8b9f", [:mix], [{:axon, "~> 0.6.1", [hex: :axon, repo: "hexpm", optional: false]}, {:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.7.0", [hex: :nx, repo: "hexpm", optional: false]}, {:nx_image, "~> 0.1.0", [hex: :nx_image, repo: "hexpm", optional: false]}, {:nx_signal, "~> 0.2.0", [hex: :nx_signal, repo: "hexpm", optional: false]}, {:progress_bar, "~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: false]}, {:safetensors, "~> 0.1.3", [hex: :safetensors, repo: "hexpm", optional: false]}, {:tokenizers, "~> 0.4", [hex: :tokenizers, repo: "hexpm", optional: false]}, {:unpickler, "~> 0.1.0", [hex: :unpickler, repo: "hexpm", optional: false]}, {:unzip, "~> 0.10.0", [hex: :unzip, repo: "hexpm", optional: false]}], "hexpm", "3798aff7b4ae961ec8f137b9e31dd95f9e2b66cfa1df2f60e852669b472add18"},
5 | "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"},
6 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
7 | "complex": {:hex, :complex, "0.5.0", "af2d2331ff6170b61bb738695e481b27a66780e18763e066ee2cd863d0b1dd92", [:mix], [], "hexpm", "2683bd3c184466cfb94fad74cbfddfaa94b860e27ad4ca1bffe3bff169d91ef1"},
8 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
9 | "elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"},
10 | "exla": {:hex, :exla, "0.7.0", "27fac40a580f0d3816fe3bf35c50dfc2f99597d26ac7e2aca4a3c62b89bb427f", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nx, "~> 0.7.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.6.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "d3bfc622deb52cec95efc9d76063891afc7cd33e38eddbb01f3385c53e043c40"},
11 | "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
12 | "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
13 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
14 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
15 | "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"},
16 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
17 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
19 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
20 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
21 | "msgpax": {:hex, :msgpax, "2.4.0", "4647575c87cb0c43b93266438242c21f71f196cafa268f45f91498541148c15d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "ca933891b0e7075701a17507c61642bf6e0407bb244040d5d0a58597a06369d2"},
22 | "nx": {:hex, :nx, "0.7.0", "cec684cada356e9d268af01daa758882f7372aa952716dbe0369c657abb9e762", [:mix], [{:complex, "~> 0.5", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68edaa48a5841495ecab0dd4cf7b11b2fc0ad809754ae7f82d9c4090b91acf55"},
23 | "nx_image": {:hex, :nx_image, "0.1.2", "0c6e3453c1dc30fc80c723a54861204304cebc8a89ed3b806b972c73ee5d119d", [:mix], [{:nx, "~> 0.4", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "9161863c42405ddccb6dbbbeae078ad23e30201509cc804b3b3a7c9e98764b81"},
24 | "nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"},
25 | "openai": {:hex, :openai, "0.6.1", "ad86b5b253969fe6d59896d295b1a573cbe44d586fd00bfa8cf3f440d800b4d6", [:mix], [{:httpoison, "~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "aea82953ea82fcbf91d0474125943becf5d8318af53081ed722a0f26d4346353"},
26 | "ortex": {:hex, :ortex, "0.1.9", "a9b14552ef6058961a3e300f973a51887328a13c2ffa6f2cad1b0785f9c7e73c", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29.0", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "5201b9aa8e22a86f3a04e819266bfd1c5a8194f0c51f917c1d0cffe8bdbb76d8"},
27 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
28 | "phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"},
29 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
30 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
31 | "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"},
32 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"},
33 | "polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"},
34 | "progress_bar": {:hex, :progress_bar, "3.0.0", "f54ff038c2ac540cfbb4c2bfe97c75e7116ead044f3c2b10c9f212452194b5cd", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "6981c2b25ab24aecc91a2dc46623658e1399c21a2ae24db986b90d678530f2b7"},
35 | "rustler": {:hex, :rustler, "0.29.1", "880f20ae3027bd7945def6cea767f5257bc926f33ff50c0d5d5a5315883c084d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "109497d701861bfcd26eb8f5801fe327a8eef304f56a5b63ef61151ff44ac9b6"},
36 | "rustler_precompiled": {:hex, :rustler_precompiled, "0.7.1", "ecadf02cc59a0eccbaed6c1937303a5827fbcf60010c541595e6d3747d3d0f9f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "b9e4657b99a1483ea31502e1d58c464bedebe9028808eda45c3a429af4550c66"},
37 | "safetensors": {:hex, :safetensors, "0.1.3", "7ff3c22391e213289c713898481d492c9c28a49ab1d0705b72630fb8360426b2", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fe50b53ea59fde4e723dd1a2e31cfdc6013e69343afac84c6be86d6d7c562c14"},
38 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
39 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
40 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
41 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
42 | "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"},
43 | "tokenizers": {:hex, :tokenizers, "0.4.0", "140283ca74a971391ddbd83cd8cbdb9bd03736f37a1b6989b82d245a95e1eb97", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "ef1a9824f5a893cd3b831c0e5b3d72caa250d2ec462035cc6afef6933b13a82e"},
44 | "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
45 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
46 | "unpickler": {:hex, :unpickler, "0.1.0", "c2262c0819e6985b761e7107546cef96a485f401816be5304a65fdd200d5bd6a", [:mix], [], "hexpm", "e2b3f61e62406187ac52afead8a63bfb4e49394028993f3c4c42712743cab79e"},
47 | "unzip": {:hex, :unzip, "0.10.0", "374e0059e48e982076f3fd22cd4817ab11016c1bae3f09421511901ddda95c5c", [:mix], [], "hexpm", "101c06b0fa97a858a83beb618f4bc20370624f73ab3954f756d9b52194056de6"},
48 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
49 | "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"},
50 | "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"},
51 | "xla": {:hex, :xla, "0.6.0", "67bb7695efa4a23b06211dc212de6a72af1ad5a9e17325e05e0a87e4c241feb8", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "dd074daf942312c6da87c7ed61b62fb1a075bced157f1cc4d47af2d7c9f44fb7"},
52 | }
53 |
--------------------------------------------------------------------------------