├── 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 | --------------------------------------------------------------------------------