Powered by AppSignal & Oban Pro

Real-time chatbot app with Membrane

demo.livemd

Real-time chatbot app with Membrane

Mix.install(
  [
    {:kino_termite, "~> 0.1.1"},
    {:membrane_transcoder_plugin, "~> 0.3.0"},
    {:membrane_raw_audio_parser_plugin, "~> 0.5.0"},
    {:membrane_webrtc_plugin, "~> 0.26.3"},
    {:membrane_gemini_demo, path: __DIR__}
  ],
  lockfile: :membrane_gemini_demo
)

case System.fetch_env("LB_GEMINI_API_KEY") do
  {:ok, api_key} ->
    Application.put_env(:gemini_ex, :api_key, api_key)

  :error ->
    raise """
        A key for the Gemini Live API is required to run this example.
        Please set `GEMINI_API_KEY` in your livebook's SECRETS section.
    """
end

Hi!

Welcome to this livebook demo, where we'll set up a Membrane pipeline powering a very simple chatbot app.

For the UI, we've used Termite with the KinoTermite adapter that allows embedding an xterm.js terminal directly in this livebook.

Enjoy!

The pipeline

This is a pretty straightforward Membrane pipeline for our chatbot app. The central element is Membrane.Gemini.Bin, which is an endpoint streaming our inputs to and responses from the Gemini Live API.

We use WebRTC for audio and its built-in echo cancellation, so we can talk with Gemini without it hearing feedback from itself.

The Demo.TuiSink elements are used to send audio samples to the Termite process to render the waveforms in real time, while also handling events from Membrane.Gemini.Bin.

defmodule Membrane.LLM.Demo.Pipeline do
  use Membrane.Pipeline

  import Membrane.ChildrenSpec
  require Membrane.Pad

  alias Membrane.LLM.Demo
  alias Membrane.RawAudio

  @chunk_duration Membrane.Time.milliseconds(40)
  @gemini_input_format %RawAudio{sample_format: :s16le, channels: 1, sample_rate: 16_000}

  @spec submit_text(pipeline :: pid(), String.t()) :: {:text, String.t()}
  def submit_text(pipeline, text), do: send(pipeline, {:text, text})

  @spec reset_session(pipeline :: pid()) :: :reset_session
  def reset_session(pipeline), do: send(pipeline, :reset_session)

  @spec toggle_mute(pipeline :: pid()) :: :toggle_mute
  def toggle_mute(pipeline), do: send(pipeline, :toggle_mute)

  @spec set_signaling(pipeline :: pid(), WebRTC.Signaling.t(), WebRTC.Signaling.t()) ::
          {:signaling, WebRTC.Signaling.t(), WebRTC.Signaling.t()}
  def set_signaling(pipeline, source_signaling, sink_signaling),
    do: send(pipeline, {:signaling, source_signaling, sink_signaling})

  @impl true
  def handle_init(_ctx, opts) do
    app = Keyword.fetch!(opts, :app)
    %{source: source_signaling, sink: sink_signaling} = Keyword.fetch!(opts, :signaling)

    spec = [
      child(:webrtc_source, %Membrane.WebRTC.Source{signaling: source_signaling})
      |> via_out(Pad.ref(:output, :main_audio), options: [kind: :audio])
      |> child(:input_transcoder, %Membrane.Transcoder{output_stream_format: @gemini_input_format})
      |> child(:input_resampler, %Membrane.FFmpeg.SWResample.Converter{
        output_stream_format: @gemini_input_format
      })
      |> child(:mute_filter, Demo.MuteFilter)
      |> child(:mic_pts_normalizer, %Membrane.RawAudioParser{
        overwrite_pts?: true,
        chunk_duration: @chunk_duration
      })
      |> child(:mic_tee, Membrane.Tee)
      |> via_out(Pad.ref(:output, :main))
      |> via_in(:audio_input)
      |> child(:gemini, Membrane.Gemini.Bin)
      |> child(:gemini_pts_normalizer, %Membrane.RawAudioParser{
        overwrite_pts?: true,
        chunk_duration: @chunk_duration
      })
      |> child(:gemini_tee, Membrane.Tee)
      |> via_out(Pad.ref(:output, :main))
      |> child(:output_transcoder, %Membrane.Transcoder{output_stream_format: Membrane.Opus})
      |> via_in(Pad.ref(:input, :main_audio), options: [kind: :audio])
      |> child(:webrtc_sink, %Membrane.WebRTC.Sink{
        signaling: sink_signaling,
        tracks: [:audio]
      }),
      child(:text_source, Demo.TextSource)
      |> via_in(:text_input)
      |> get_child(:gemini),
      get_child(:mic_tee)
      |> via_out(Pad.ref(:output, :tui))
      |> child(:mic_tui_sink, %Demo.TuiSink{
        origin: :client,
        app: app
      }),
      get_child(:gemini_tee)
      |> via_out(Pad.ref(:output, :tui))
      |> child(:gemini_realtimer, Membrane.Realtimer)
      |> child(:gemini_tui_sink, %Demo.TuiSink{
        origin: :server,
        app: app
      })
    ]

    {[spec: spec], %{app: app}}
  end

  @impl true
  def handle_playing(_ctx, %{app: app} = state) do
    Demo.App.signal_pipeline_playing(app)
    {[], state}
  end

  @impl true
  def handle_info({:text, _text} = msg, _ctx, state),
    do: {[notify_child: {:text_source, msg}], state}

  @impl true
  def handle_info(:reset_session, _ctx, state),
    do: {[notify_child: {:gemini, :reset_session}], state}

  @impl true
  def handle_info(:toggle_mute, _ctx, state),
    do: {[notify_child: {:mute_filter, :toggle_mute}], state}
end

The WebRTC bridge widget

Membrane.LLM.Demo.WebRTCBridge is a Kino.JS.Live widget that hosts both peer connections in one iframe (so the browser's echo canceller sees mic capture and <audio> playback on the same page) and relays signalling between the JS and the two Membrane.WebRTC.Signaling channels.

defmodule Membrane.LLM.Demo.WebRTCBridge do
  use Kino.JS, assets_path: Path.join(__DIR__, "lib/assets/webrtc_bridge")
  use Kino.JS.Live

  alias Membrane.WebRTC.Signaling

  @spec subscribe(Kino.JS.Live.t(), pid()) :: :ok
  def subscribe(bridge, pid \\ self()) do
    Kino.JS.Live.cast(bridge, {:subscribe, pid})
  end

  @spec new(Signaling.t(), Signaling.t()) :: Kino.JS.Live.t()
  def new(source_signaling, sink_signaling) do
    Kino.JS.Live.new(__MODULE__, %{source: source_signaling, sink: sink_signaling})
  end

  @impl true
  def init(%{source: source, sink: sink}, ctx) do
    Signaling.register_peer(source, message_format: :json_data)
    Signaling.register_peer(sink, message_format: :json_data)

    {:ok,
     assign(ctx,
       source: source,
       sink: sink,
       source_queue: [],
       sink_queue: [],
       drained?: false,
       subscribers: MapSet.new(),
       peer_states: %{}
     )}
  end

  @impl true
  def handle_connect(ctx) do
    payload = %{
      source_queue: Enum.reverse(ctx.assigns.source_queue),
      sink_queue: Enum.reverse(ctx.assigns.sink_queue)
    }

    {:ok, payload, assign(ctx, source_queue: [], sink_queue: [], drained?: true)}
  end

  @impl true
  def handle_event("signal_source", message, ctx) do
    Signaling.signal(ctx.assigns.source, message)
    {:noreply, ctx}
  end

  def handle_event("signal_sink", message, ctx) do
    Signaling.signal(ctx.assigns.sink, message)
    {:noreply, ctx}
  end

  def handle_event("peer_state", %{"peer" => peer, "state" => state}, ctx) do
    ctx = update(ctx, :peer_states, &Map.put(&1, peer, state))

    for pid <- ctx.assigns.subscribers, do: send(pid, {:webrtc_bridge, peer, state})

    {:noreply, ctx}
  end

  @impl true
  def handle_cast({:subscribe, pid}, ctx) do
    for {peer, state} <- ctx.assigns.peer_states, do: send(pid, {:webrtc_bridge, peer, state})

    {:noreply, update(ctx, :subscribers, &MapSet.put(&1, pid))}
  end

  @impl true
  def handle_info({:membrane_webrtc_signaling, pid, message, _meta}, ctx) do
    {channel, queue_key} =
      cond do
        pid == ctx.assigns.source.pid -> {"source_signal", :source_queue}
        pid == ctx.assigns.sink.pid -> {"sink_signal", :sink_queue}
      end

    if ctx.assigns.drained? do
      broadcast_event(ctx, channel, message)
      {:noreply, ctx}
    else
      {:noreply, update(ctx, queue_key, &[message | &1])}
    end
  end
end
defmodule Membrane.LLM.Demo.AwaitBridge do
  def await_connected(bridge) do
    Membrane.LLM.Demo.WebRTCBridge.subscribe(bridge)
    do_await_bridge(%{source?: false, sink?: false})
  end

  defp do_await_bridge(%{source?: true, sink?: true}), do: :ok

  defp do_await_bridge(state) do
    receive do
      {:webrtc_bridge, "source", "connected"} -> %{state | source?: true}
      {:webrtc_bridge, "sink", "connected"} -> %{state | sink?: true}
      _other -> state
    after
      30_000 -> raise "Timed out waiting for WebRTC bridge"
    end
    |> do_await_bridge()
  end
end

Start the pipeline + TUI

Now that we've defined the modules for the pipeline and WebRTC I/O for this livebook, we use the generic Membrane.LLM.Demo.App module for the TUI (there's a native variant in native.exs!).

alias Membrane.LLM.Demo

Logger.configure(level: :warning)

source_signaling = Membrane.WebRTC.Signaling.new()
sink_signaling = Membrane.WebRTC.Signaling.new()

bridge = Demo.WebRTCBridge.new(source_signaling, sink_signaling)

bridge

Click Connect above and run the cell below to wait for the chatbot TUI to show up. You can speak with Gemini or send text messages using the chat prompt. Available chat commands:

/mute — mute / unmute the input device
/debug — toggle the debug event log
/clear — reset the conversation
{:ok, app_pid} =
  Kino.start_child(
    Supervisor.child_spec(
      {Demo.App,
       pipeline_mod: Demo.Pipeline,
       terminal_factory: fn -> Termite.Terminal.start(adapter: KinoTermite.Adapter, rows: 50) end},
      restart: :temporary
    )
  )

{:ok, _supervisor, _pipeline} =
  Kino.start_child(%{
    id: Demo.Pipeline,
    start:
      {Membrane.Pipeline, :start_link,
       [
         Demo.Pipeline,
         [app: app_pid, signaling: %{source: source_signaling, sink: sink_signaling}]
       ]},
    restart: :temporary
  })

Demo.AwaitBridge.await_connected(bridge)

terminal = Demo.App.get_terminal(app_pid)
KinoTermite.output(terminal)