├── resources ├── .keep ├── flow.png ├── simulflow.png ├── silero_vad.onnx ├── test-voice.wav ├── silero_vad_half.onnx ├── silero_vad_16k_op15.onnx └── clj-kondo.exports │ └── com.shipclojure │ └── simulflow │ ├── config.edn │ └── hooks │ ├── defframe.clj │ └── defalias.clj ├── doc ├── implementation │ ├── decisions.org │ ├── README.org │ └── drafts.org └── intro.md ├── bin ├── kaocha └── chat ├── dev ├── user.clj ├── malli.clj └── simulflow │ └── dev.clj ├── .clj-kondo └── config.edn ├── tests.edn ├── .dir-locals.el ├── src └── simulflow │ ├── vad │ ├── factory.clj │ └── core.clj │ ├── secrets.clj │ ├── transport │ ├── protocols.clj │ ├── codecs.clj │ ├── text_in.clj │ └── text_out.clj │ ├── onnx.clj │ ├── async.clj │ ├── command.clj │ ├── filters │ └── mute.clj │ ├── processors │ ├── google.clj │ ├── openai.clj │ ├── activity_monitor.clj │ ├── audio_resampler.clj │ ├── system_frame_router.clj │ ├── groq.clj │ ├── deepgram.clj │ └── elevenlabs.clj │ ├── transport.clj │ ├── utils │ ├── request.clj │ └── openai.clj │ └── scenario_manager.clj ├── cljfmt.edn ├── bb.edn ├── .gitignore ├── examples ├── deps.edn └── src │ └── simulflow_examples │ ├── gemini.clj │ ├── local_w_groq.clj │ ├── scenario_example.clj │ ├── local_with_mute_filter.clj │ ├── scenario_example_system_router_mute_filter.clj │ ├── local_w_interruption_support.clj │ ├── local.clj │ └── text_chat.clj ├── .github └── workflows │ └── tests.yaml ├── test └── simulflow │ ├── utils │ └── core_test.clj │ └── util │ └── core_test.clj ├── pom.xml ├── deps.edn ├── TODO.org_archive └── LICENSE /resources/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/implementation/decisions.org: -------------------------------------------------------------------------------- 1 | #+title: Decisions 2 | -------------------------------------------------------------------------------- /bin/kaocha: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | clojure -M:test "$@" 3 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require 3 | [simulflow.dev])) 4 | -------------------------------------------------------------------------------- /resources/flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipclojure/simulflow/HEAD/resources/flow.png -------------------------------------------------------------------------------- /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:config-paths ["../resources/clj-kondo.exports/com.shipclojure/simulflow"]} 2 | -------------------------------------------------------------------------------- /resources/simulflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipclojure/simulflow/HEAD/resources/simulflow.png -------------------------------------------------------------------------------- /resources/silero_vad.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipclojure/simulflow/HEAD/resources/silero_vad.onnx -------------------------------------------------------------------------------- /resources/test-voice.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipclojure/simulflow/HEAD/resources/test-voice.wav -------------------------------------------------------------------------------- /resources/silero_vad_half.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipclojure/simulflow/HEAD/resources/silero_vad_half.onnx -------------------------------------------------------------------------------- /resources/silero_vad_16k_op15.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipclojure/simulflow/HEAD/resources/silero_vad_16k_op15.onnx -------------------------------------------------------------------------------- /doc/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction to simulflow 2 | 3 | TODO: write [great documentation](http://jacobian.org/writing/what-to-write/) 4 | -------------------------------------------------------------------------------- /tests.edn: -------------------------------------------------------------------------------- 1 | #kaocha/v1 2 | {:plugins [:noyoda.plugin/swap-actual-and-expected] 3 | :tests [{:id :unit 4 | :source-paths ["src"] 5 | :test-paths ["test"] 6 | :ns-patterns ["-test$"]}]} 7 | -------------------------------------------------------------------------------- /doc/implementation/README.org: -------------------------------------------------------------------------------- 1 | #+title: This directory contains implementation docs & decisions 2 | 3 | Follow this document if you want to understand why things are the way they are 4 | and how it got to this point. 5 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((nil . ((cider-preferred-build-tool . clojure-cli) 2 | (cider-clojure-cli-aliases . ":dev:test:with-examples:clj-reload") 3 | (cider-clojure-cli-parameters . "--port 8000")))) 4 | -------------------------------------------------------------------------------- /src/simulflow/vad/factory.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.vad.factory 2 | "Standard vad processors supported by simulflow" 3 | (:require 4 | [simulflow.vad.silero :as silero])) 5 | 6 | (def factory 7 | {:vad.analyser/silero silero/create-silero-vad}) 8 | -------------------------------------------------------------------------------- /resources/clj-kondo.exports/com.shipclojure/simulflow/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {uncomplicate.commons.core/with-release clojure.core/let 2 | simulflow.async/vthread-loop clojure.core/loop} 3 | :hooks {:analyze-call {simulflow.frame/defframe hooks.defframe/defframe}}} 4 | -------------------------------------------------------------------------------- /src/simulflow/secrets.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.secrets 2 | (:require 3 | [clojure.edn :as edn] 4 | [clojure.java.io :as io])) 5 | 6 | (defn- secret-map 7 | [] 8 | (edn/read-string (slurp (io/resource "secrets.edn")))) 9 | 10 | (defn secret 11 | [path] 12 | (get-in (secret-map) path)) 13 | -------------------------------------------------------------------------------- /src/simulflow/transport/protocols.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.transport.protocols) 2 | 3 | (defprotocol FrameSerializer 4 | (serialize-frame [this frame] "Encode a frame to a specific format")) 5 | 6 | (defprotocol FrameDeserializer 7 | (deserialize-frame [this raw-data] "Decode some raw data into a simulflow frame")) 8 | -------------------------------------------------------------------------------- /cljfmt.edn: -------------------------------------------------------------------------------- 1 | ;; `cljfmt` is not used directly with simulflow. I recommend you use 2 | ;; `standard-clojure`: https://github.com/oakmac/standard-clojure-style-js 3 | 4 | ;; This config is here because AI tools like `clojure-mcp` format code so it is 5 | ;; good for the AI formatting to be as compliant as possible with our standard 6 | ;; formatting. 7 | {:indents {#re ".*" [[:inner 0]]} 8 | :remove-surrounding-whitespace? false 9 | :remove-trailing-whitespace? false 10 | :remove-consecutive-blank-lines? false} 11 | -------------------------------------------------------------------------------- /dev/malli.clj: -------------------------------------------------------------------------------- 1 | (ns malli 2 | (:require 3 | [clojure.java.io :as io] 4 | [malli.dev :as malli-dev])) 5 | 6 | (defn export-types [] 7 | ;; collect schemas and start instrumentation 8 | (malli-dev/start!) 9 | 10 | ;; create export file 11 | (let [export-file (io/file "resources/clj-kondo.exports/com.shipclojure/simulflow-types/config.edn")] 12 | 13 | ;; make parents if not exist 14 | (io/make-parents export-file) 15 | 16 | ;; copy the configs 17 | (io/copy 18 | (io/file ".clj-kondo/metosin/malli-types-clj/config.edn") 19 | export-file)) 20 | 21 | ;; clear the cache and stop instrumentation 22 | (malli-dev/stop!)) 23 | 24 | (comment 25 | 26 | (export-types) 27 | 28 | ,) 29 | -------------------------------------------------------------------------------- /src/simulflow/onnx.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.onnx 2 | "Namespace with various utils for the Onnx framework " 3 | (:import 4 | (ai.onnxruntime OrtEnvironment OrtSession) 5 | (java.util Map))) 6 | 7 | (defn inspect-model [^String model-path] 8 | (let [env (OrtEnvironment/getEnvironment) 9 | session ^OrtSession (.createSession env model-path)] 10 | (println "Input names and shapes:") 11 | (doseq [input ^Map (.getInputInfo session)] 12 | (println " " (.getKey input) "->" (.getValue input))) 13 | (println "Output names and shapes:") 14 | (doseq [output (.getOutputInfo session)] 15 | (println " " (.getKey output) "->" (.getValue output))) 16 | (.close session))) 17 | 18 | (comment 19 | (inspect-model "resources/silero_vad.onnx")) 20 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:min-bb-version "1.2.174" 2 | :paths ["src" "test"] 3 | :deps 4 | {current/deps {:local/root "."}} 5 | 6 | :tasks 7 | {test {:doc "Run all tests" 8 | :task (shell "bin/kaocha")} 9 | jar {:doc "Build jar with latest version" 10 | :task (clojure "-T:build jar")} 11 | pom {:doc "Build pom.xml based on deps.edn" 12 | :task (clojure "-X:deps mvn-pom")} 13 | deploy {:doc "Deploy the latest release" 14 | :task (clojure "-X:deploy")} 15 | format:check {:doc "Check code formatting with standard-clj" 16 | :task (shell "standard-clj" "check" "src" "test" "examples/src")} 17 | format:project {:doc "Run standard-clojure formatting on the entire codebase" 18 | :task (shell "standard-clj" "fix" "src" "test" "examples/src")}}} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .calva/output-window/ 2 | .classpath 3 | .cpcache 4 | .DS_Store 5 | .eastwood 6 | .factorypath 7 | .hg/ 8 | .hgignore 9 | .java-version 10 | .lein-* 11 | .lsp/.cache 12 | .lsp/sqlite.db 13 | .nrepl-history 14 | .nrepl-port 15 | .portal 16 | .project 17 | .rebel_readline_history 18 | .settings 19 | .socket-repl-port 20 | .sw* 21 | .vscode 22 | *.class 23 | *.jar 24 | *.swp 25 | *~ 26 | /checkouts 27 | /classes 28 | /target 29 | /resources/secrets.edn 30 | /core/resources/secrets.edn 31 | /core/.lsp/ 32 | /examples/.calva/ 33 | .idea/ 34 | *.iml 35 | 36 | 37 | # From https://github.com/metabase/metabase/blob/6127271a8964c37a2bbef358ad60eaeb5b3fccb3/.gitignore#L95 38 | **/.clj-kondo/.cache 39 | 40 | # clj-kondo: ignore all except our defined config 41 | .clj-kondo/* 42 | !.clj-kondo/README.md 43 | !.clj-kondo/config.edn 44 | !.clj-kondo/config/ 45 | !.clj-kondo/src/ 46 | !.clj-kondo/test/ 47 | !.clj-kondo/macros/ 48 | /.mcp.json 49 | /.clojure-mcp/ 50 | 51 | # Local documentation files - preserved locally but not tracked 52 | PROJECT_SUMMARY.md 53 | CLAUDE.md 54 | LLM_CODE_STYLE.md 55 | -------------------------------------------------------------------------------- /examples/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 | org.clojure/data.xml {:mvn/version "0.0.8"} 4 | ring/ring-jetty-adapter {:mvn/version "1.13.0"} 5 | com.shipclojure/simulflow {:local/root "../"} 6 | metosin/reitit {:mvn/version "0.7.2"} 7 | hato/hato {:mvn/version "1.0.0"}} 8 | :aliases {:cider-clj {:extra-deps {cider/cider-nrepl {:mvn/version "0.51.1"}} 9 | :main-opts ["-m" "nrepl.cmdline" "--middleware" "[cider.nrepl/cider-middleware]"]} 10 | :dev {:extra-deps {djblue/portal {:mvn/version "0.58.5"} 11 | criterium/criterium {:mvn/version "0.4.6"} 12 | clj-kondo/clj-kondo {:mvn/version "2024.11.14"}}} 13 | :test {:extra-paths ["test"] 14 | :extra-deps {org.clojure/test.check {:mvn/version "1.1.1"} 15 | midje/midje {:mvn/version "1.10.10"} 16 | io.github.cognitect-labs/test-runner 17 | {:git/tag "v0.5.1" :git/sha "dfb30dd"}}}}} 18 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | # https://practical.li/clojure/continuous-integration/github-actions/ 2 | name: Tests build 3 | on: [push, pull_request] 4 | jobs: 5 | clojure: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | 12 | - name: Prepare java 13 | uses: actions/setup-java@v2 14 | with: 15 | distribution: 'temurin' 16 | java-version: '21' 17 | 18 | - name: Install clojure tools 19 | uses: DeLaGuardo/setup-clojure@4.0 20 | with: 21 | cli: 'latest' # Clojure CLI based on tools.deps 22 | 23 | - name: Cache clojure dependencies 24 | uses: actions/cache@v3 25 | with: 26 | path: | 27 | ~/.m2/repository 28 | ~/.gitlibs 29 | ~/.deps.clj 30 | # List all files containing dependencies: 31 | key: cljdeps-${{ hashFiles('core/deps.edn') }} 32 | # key: cljdeps-${{ hashFiles('deps.edn', 'bb.edn') }} 33 | # key: cljdeps-${{ hashFiles('project.clj') }} 34 | # key: cljdeps-${{ hashFiles('build.boot') }} 35 | restore-keys: cljdeps- 36 | 37 | - name: Run Clj Unit tests 38 | run: bin/kaocha 39 | -------------------------------------------------------------------------------- /src/simulflow/transport/codecs.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.transport.codecs 2 | (:require 3 | [simulflow.frame :as frame] 4 | [simulflow.transport.protocols :as p] 5 | [simulflow.utils.audio :as audio] 6 | [simulflow.utils.core :as u])) 7 | 8 | (defn deserialize-twilio-data 9 | "Convert twilio message to pipeline frame" 10 | [json-data] 11 | (case (:event json-data) 12 | ;; TODO more cases 13 | "media" (frame/audio-input-raw (-> json-data 14 | :media 15 | :payload 16 | u/decode-base64 17 | audio/ulaw8k->pcm16k)) 18 | nil)) 19 | 20 | (defn make-twilio-serializer [stream-sid & {:keys [convert-audio?] 21 | :or {convert-audio? false}}] 22 | (reify 23 | p/FrameSerializer 24 | (serialize-frame [_ frame] 25 | ;; Convert pipeline frame to Twilio-specific format 26 | (if (frame/audio-output-raw? frame) 27 | (let [{:keys [sample-rate audio]} (:frame/data frame)] 28 | (u/json-str {:event "media" 29 | :streamSid stream-sid 30 | :media {:payload (u/encode-base64 (if convert-audio? 31 | (audio/pcm->ulaw8k audio sample-rate) 32 | audio))}})) 33 | frame)))) 34 | -------------------------------------------------------------------------------- /examples/src/simulflow_examples/gemini.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow-examples.gemini 2 | (:require 3 | [clojure.core.async :as a] 4 | [clojure.core.async.flow :as flow] 5 | [simulflow-examples.local :as local] 6 | [simulflow.async :refer [vthread-loop]] 7 | [simulflow.processors.google :as google] 8 | [simulflow.secrets :refer [secret]] 9 | [simulflow.vad.silero :as silero] 10 | [taoensso.telemere :as t])) 11 | 12 | (comment 13 | (def local-flow-gemini 14 | (local/make-local-flow {:extra-procs {:llm {:proc google/google-llm-process 15 | :args {:llm/model :gemini-2.0-flash 16 | :google/api-key (secret [:google :api-key])}}} 17 | :vad-analyser (silero/create-silero-vad)})) 18 | 19 | (defonce flow-started? (atom false)) 20 | 21 | ;; Start local ai flow - starts paused 22 | (let [{:keys [report-chan error-chan]} (flow/start local-flow-gemini)] 23 | (reset! flow-started? true) 24 | ;; Resume local ai -> you can now speak with the AI 25 | (flow/resume local-flow-gemini) 26 | (vthread-loop [] 27 | (when @flow-started? 28 | (when-let [[msg c] (a/alts!! [report-chan error-chan])] 29 | (when (map? msg) 30 | (t/log! {:level :debug :id (if (= c error-chan) :error :report)} msg)) 31 | (recur))))) 32 | 33 | ;; Stop the conversation 34 | (do 35 | (flow/stop local-flow-gemini) 36 | (reset! flow-started? false)) 37 | ,) 38 | -------------------------------------------------------------------------------- /src/simulflow/vad/core.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.vad.core) 2 | 3 | (defprotocol VADAnalyzer 4 | "The standard protocol for Voice Activity Detection Analizers." 5 | (analyze-audio [this audio-buffer] 6 | "Analize audio and give back the vad state associated with the current audio-buffer. 7 | Args: 8 | this - the VADAnalizer reification 9 | audio-buffer - byte array representing 16kHz PCM mono audio 10 | 11 | Returns: 12 | `:vad.state/speaking` `:vad.state/starting` `:vad.state/quiet` `:vad.state/stopping`") 13 | 14 | (voice-confidence [this audio-buffer] 15 | "Calculates voice activity confidence for the given audio buffer. 16 | Args: 17 | this - the VADAnalizer reification 18 | audio-buffer - byte array representing 16kHz PCM mono audio 19 | 20 | Returns: 21 | Voice confidence score between 0.0 and 1.0") 22 | 23 | (cleanup [this] 24 | "Cleans up any resources created either on instantiation or running other 25 | vad analysis functions related to this analyser.")) 26 | 27 | (def default-params 28 | "Default parameters for VAD. 29 | :vad/min-confidence - minimum confidence threshold for voice detection 30 | :vad/min-speech-duration-ms - duration to wait before confirming voice start 31 | :vad/min-silence-duration-ms - duration to wait before confirming voice end" 32 | {:vad/min-confidence 0.7 33 | :vad/min-speech-duration-ms 200 34 | :vad/min-silence-duration-ms 800}) 35 | 36 | (defn transition? 37 | [vad-state] 38 | (or (= vad-state :vad.state/stopping) 39 | (= vad-state :vad.state/starting))) 40 | -------------------------------------------------------------------------------- /test/simulflow/utils/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.utils.core-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [simulflow.utils.core :as u])) 5 | 6 | (deftest content-type-test 7 | (testing "extracts Content-Type with proper casing" 8 | (is (= "application/json" 9 | (u/content-type {"Content-Type" "application/json"})))) 10 | (testing "extracts content-type with lowercase" 11 | (is (= "text/html" 12 | (u/content-type {"content-type" "text/html"})))) 13 | (testing "extracts :content-type as keyword" 14 | (is (= "application/xml" 15 | (u/content-type {:content-type "application/xml"})))) 16 | (testing "prioritizes Content-Type over content-type" 17 | (is (= "application/json" 18 | (u/content-type {"Content-Type" "application/json" 19 | "content-type" "text/html"})))) 20 | (testing "prioritizes Content-Type over :content-type" 21 | (is (= "application/json" 22 | (u/content-type {"Content-Type" "application/json" 23 | :content-type "text/html"})))) 24 | (testing "prioritizes content-type over :content-type" 25 | (is (= "text/html" 26 | (u/content-type {"content-type" "text/html" 27 | :content-type "application/xml"})))) 28 | (testing "returns nil for empty headers" 29 | (is (nil? (u/content-type {})))) 30 | (testing "returns nil for nil headers" 31 | (is (nil? (u/content-type nil)))) 32 | (testing "returns nil when no content-type variants found" 33 | (is (nil? (u/content-type {"Authorization" "Bearer token"}))))) 34 | -------------------------------------------------------------------------------- /dev/simulflow/dev.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.dev 2 | (:require 3 | [clj-reload.core :as reload] 4 | [clojure.java.io :as io] 5 | [malli.dev :as malli-dev] 6 | [taoensso.telemere :as t])) 7 | 8 | (t/set-min-level! :debug) 9 | 10 | (defn enable-reflection-warnings! 11 | "Enable reflection warnings for all loaded namespaces and set as default for new ones." 12 | [] 13 | ;; Set default for new namespaces 14 | (alter-var-root #'*warn-on-reflection* (constantly true)) 15 | 16 | ;; Enable for all currently loaded namespaces 17 | (doseq [ns-sym (all-ns)] 18 | (binding [*ns* ns-sym] 19 | (set! *warn-on-reflection* true))) 20 | 21 | (println "Reflection warnings enabled for all namespaces")) 22 | 23 | ;; Enable reflection warnings by default in dev 24 | (enable-reflection-warnings!) 25 | 26 | (defmacro jit [sym] 27 | `(requiring-resolve '~sym)) 28 | 29 | (defonce initiated-clj-reload? (atom false)) 30 | 31 | (defn reset 32 | [] 33 | (when-not @initiated-clj-reload? 34 | (reload/init {:dirs ["src" "dev" "test" "../examples/src"]}) 35 | (reset! initiated-clj-reload? true)) 36 | (reload/reload)) 37 | 38 | (defn export-types [] 39 | ;; collect schemas and start instrumentation 40 | ((jit malli-dev/start!)) 41 | 42 | ;; create export file 43 | (def export-file 44 | (io/file "resources/clj-kondo.exports/com.shipclojure/simulflow_types/config.edn")) 45 | 46 | ;; make parents if not exist 47 | (io/make-parents export-file) 48 | 49 | ;; copy the configs 50 | (io/copy 51 | (io/file ".clj-kondo/metosin/malli-types-clj/config.edn") 52 | export-file) 53 | 54 | ;; clear the cache and stop instrumentation 55 | ((jit malli-dev/stop!))) 56 | 57 | (comment ;; s-: 58 | 59 | (reset) 60 | ,) 61 | 62 | (comment 63 | (export-types) 64 | ,) 65 | -------------------------------------------------------------------------------- /src/simulflow/async.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.async 2 | (:require 3 | [clojure.core.async :refer [alts!!]] 4 | [clojure.core.async.flow :as flow]) 5 | (:import 6 | (java.util.concurrent Executors))) 7 | 8 | (def virtual-threads-supported? 9 | (try 10 | (Class/forName "java.lang.Thread$Builder$OfVirtual") 11 | true 12 | (catch ClassNotFoundException _ 13 | false))) 14 | 15 | (defonce ^:private vthread-executor 16 | (when virtual-threads-supported? 17 | (Executors/newVirtualThreadPerTaskExecutor))) 18 | 19 | (defn vfuturize 20 | "Like flow/futurize but uses virtual threads when available (Java 21+), 21 | otherwise falls back to the specified executor type (default :mixed)" 22 | ([f & {:keys [exec] 23 | :or {exec :mixed}}] 24 | (if virtual-threads-supported? 25 | (flow/futurize f :exec vthread-executor) 26 | (flow/futurize f :exec exec)))) 27 | 28 | (defmacro vthread 29 | "Executes body in a virtual thread (when available) or falls back to a regular 30 | thread pool. Returns immediately to the calling thread. 31 | 32 | Similar to core.async/thread but leverages virtual threads on Java 21+. 33 | 34 | Example: 35 | (vthread 36 | (let [result (http/get \"https://example.com\")] 37 | (process-result result)))" 38 | [& body] 39 | `((vfuturize (fn [] ~@body)))) 40 | 41 | (defmacro vthread-loop 42 | "Like (vthread (loop ...)). Executes the body in a virtual thread with a loop. 43 | 44 | Example: 45 | (vthread-loop [count 0] 46 | (when (< count 10) 47 | (process-item count) 48 | (recur (inc count))))" 49 | [bindings & body] 50 | `(vthread (loop ~bindings ~@body))) 51 | 52 | (defn drain-channel! 53 | "Drains all pending messages from a channel without blocking. 54 | Returns the number of messages drained." 55 | [ch] 56 | (loop [count 0] 57 | (let [[msg _port] (alts!! [ch] :default ::none)] 58 | (if (= msg ::none) 59 | count 60 | (recur (inc count)))))) 61 | -------------------------------------------------------------------------------- /resources/clj-kondo.exports/com.shipclojure/simulflow/hooks/defframe.clj: -------------------------------------------------------------------------------- 1 | (ns hooks.defframe 2 | (:require 3 | [clj-kondo.hooks-api :as api])) 4 | 5 | (defn defframe [{:keys [node]}] 6 | (let [[name docstring frame-type] (rest (:children node)) 7 | frame-name (api/token-node (symbol (str name))) 8 | pred-name (api/token-node (symbol (str name "?")))] 9 | {:node (api/list-node 10 | [(api/token-node 'do) 11 | ;; Multi-arity function definition 12 | (api/list-node 13 | [(api/token-node 'defn) frame-name docstring 14 | ;; Zero arity (frame name) 15 | (api/list-node 16 | [(api/vector-node []) 17 | (api/list-node 18 | [(api/token-node 'simulflow.frame/create-frame) 19 | frame-type])]) 20 | ;; Single arity: (frame-name data) 21 | (api/list-node 22 | [(api/vector-node [(api/token-node 'data)]) 23 | (api/list-node 24 | [(api/token-node 'simulflow.frame/create-frame) 25 | frame-type 26 | (api/token-node 'data)])]) 27 | ;; Two arity: (frame-name data opts) 28 | (api/list-node 29 | [(api/vector-node [(api/token-node 'data) (api/token-node 'opts)]) 30 | (api/list-node 31 | [(api/token-node 'simulflow.frame/create-frame) 32 | frame-type 33 | (api/token-node 'data) 34 | (api/token-node 'opts)])])]) 35 | ;; Predicate function 36 | (api/list-node 37 | [(api/token-node 'defn) pred-name (str "Checks if frame is of type " frame-name) 38 | (api/vector-node [(api/token-node 'frame)]) 39 | (api/list-node 40 | [(api/token-node 'instance?) 41 | frame-name 42 | (api/token-node 'frame)])])])})) 43 | -------------------------------------------------------------------------------- /examples/src/simulflow_examples/local_w_groq.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow-examples.local-w-groq 2 | (:require 3 | [clojure.core.async :as a] 4 | [clojure.core.async.flow :as flow] 5 | [simulflow-examples.local :as local] 6 | [simulflow.processors.groq :as groq] 7 | [simulflow.secrets :refer [secret]] 8 | [taoensso.telemere :as t])) 9 | 10 | (comment 11 | 12 | (def local-flow-groq (local/make-local-flow {:extra-procs {:llm {:proc groq/groq-llm-process 13 | :args {:llm/model "llama-3.3-70b-versatile" 14 | :groq/api-key (secret [:groq :api-key])}}} 15 | :llm-context {:messages 16 | [{:role "system" 17 | :content "You are a voice agent operating via phone. Be 18 | concise in your answers. The input you receive comes from a 19 | speech-to-text (transcription) system that isn't always 20 | efficient and may send unclear text. Ask for 21 | clarification when you're unsure what the person said."}] 22 | :tools []}})) 23 | 24 | (defonce flow-started? (atom false)) 25 | 26 | ;; Start local ai flow - starts paused 27 | (let [{:keys [report-chan error-chan]} (flow/start local-flow-groq)] 28 | (reset! flow-started? true) 29 | ;; Resume local ai -> you can now speak with the AI 30 | (flow/resume local-flow-groq) 31 | (a/thread 32 | (loop [] 33 | (when @flow-started? 34 | (when-let [[msg c] (a/alts!! [report-chan error-chan])] 35 | (when (map? msg) 36 | (t/log! {:level :debug :id (if (= c error-chan) :error :report)} msg)) 37 | (recur)))))) 38 | 39 | ;; Stop the conversation 40 | (do 41 | (flow/stop local-flow-groq) 42 | (reset! flow-started? false)) 43 | 44 | ,) 45 | -------------------------------------------------------------------------------- /test/simulflow/util/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.util.core-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [simulflow.utils.core :refer [ends-with-sentence?]])) 5 | 6 | (deftest ends-with-sentence?-test 7 | (testing "Basic sentence endings" 8 | (is (ends-with-sentence? "This is a sentence.")) 9 | (is (ends-with-sentence? "Is this a question?")) 10 | (is (ends-with-sentence? "What an exclamation!")) 11 | (is (ends-with-sentence? "First part; second part")) 12 | (is (ends-with-sentence? "One thing: another thing"))) 13 | 14 | (testing "Asian punctuation marks" 15 | (is (ends-with-sentence? "这是一个句子。")) 16 | (is (ends-with-sentence? "这是问题吗?")) 17 | (is (ends-with-sentence? "多么令人兴奋!")) 18 | (is (ends-with-sentence? "第一部分:")) 19 | (is (ends-with-sentence? "第一个;"))) 20 | 21 | (testing "Should not match abbreviations with periods" 22 | (is (not (ends-with-sentence? "U.S.A."))) 23 | (is (ends-with-sentence? "etc."))) 24 | 25 | (testing "Should not match after numbers" 26 | (is (not (ends-with-sentence? "1."))) 27 | (is (not (ends-with-sentence? "Chapter 2."))) 28 | (is (not (ends-with-sentence? "Section 3.2.")))) 29 | 30 | (testing "Should not match time markers" 31 | (is (not (ends-with-sentence? "3 a."))) 32 | (is (not (ends-with-sentence? "11 p."))) 33 | (is (ends-with-sentence? "9 a.m.")) 34 | (is (ends-with-sentence? "10 p.m."))) 35 | 36 | (testing "Should not match after titles" 37 | (is (not (ends-with-sentence? "Mr."))) 38 | (is (not (ends-with-sentence? "Mrs."))) 39 | (is (not (ends-with-sentence? "Ms."))) 40 | (is (not (ends-with-sentence? "Dr."))) 41 | (is (not (ends-with-sentence? "Prof.")))) 42 | 43 | (testing "Complex sentences" 44 | (is (ends-with-sentence? "The U.S.A. is a country.")) 45 | (is (ends-with-sentence? "She has a Ph.D. in biology.")) 46 | (is (ends-with-sentence? "Mr. Smith went home."))) 47 | 48 | (testing "Edge cases" 49 | (is (not (ends-with-sentence? ""))) 50 | (is (not (ends-with-sentence? "Mrs"))) 51 | (is (ends-with-sentence? "Yes!"))) 52 | 53 | (testing "Mixed punctuation" 54 | (is (ends-with-sentence? "Really?!")) 55 | (is (ends-with-sentence? "No way...!")) 56 | (is (ends-with-sentence? "First: then!"))) 57 | 58 | (testing "Whitespace handling" 59 | (is (ends-with-sentence? "End of sentence. ")) 60 | (is (ends-with-sentence? "Question? ")) 61 | (is (not (ends-with-sentence? "Incomplete ")))) 62 | 63 | (testing "Numbers not followed by periods" 64 | (is (ends-with-sentence? "Score: 5")))) 65 | -------------------------------------------------------------------------------- /resources/clj-kondo.exports/com.shipclojure/simulflow/hooks/defalias.clj: -------------------------------------------------------------------------------- 1 | (ns hooks.defalias 2 | (:require 3 | [clj-kondo.hooks-api :as api])) 4 | 5 | (defn defalias [{:keys [node]}] 6 | (let [children (rest (:children node)) 7 | [alias src & _] children] 8 | (cond 9 | ;; Two argument form: (defalias alias-name source-symbol) 10 | (and alias src) 11 | {:node (api/list-node 12 | [(api/token-node 'def) 13 | alias 14 | src])} 15 | 16 | ;; Single argument form: (defalias source-symbol) 17 | alias 18 | (let [src-symbol (:value alias) 19 | alias-name (api/token-node (symbol (name src-symbol)))] 20 | {:node (api/list-node 21 | [(api/token-node 'def) 22 | alias-name 23 | alias])}) 24 | 25 | ;; Fallback 26 | :else 27 | {:node (api/list-node [(api/token-node 'do)])}))) 28 | 29 | (defn defaliases [{:keys [node]}] 30 | (let [children (rest (:children node)) 31 | ;; Generate a def for each alias clause 32 | defs (mapv (fn [clause] 33 | (cond 34 | ;; Simple symbol form: (defaliases some.ns/symbol) 35 | (api/token-node? clause) 36 | (let [src-symbol (:value clause) 37 | alias-name (api/token-node (symbol (name src-symbol)))] 38 | (api/list-node 39 | [(api/token-node 'def) 40 | alias-name 41 | clause])) 42 | 43 | ;; Map form: {:alias my-name :src some.ns/symbol} 44 | (api/map-node? clause) 45 | (let [map-children (:children clause) 46 | ;; Parse key-value pairs 47 | pairs (partition 2 map-children) 48 | alias-pair (first (filter #(= :alias (:value (first %))) pairs)) 49 | src-pair (first (filter #(= :src (:value (first %))) pairs)) 50 | alias-name (when alias-pair (second alias-pair)) 51 | src-name (when src-pair (second src-pair))] 52 | (if (and alias-name src-name) 53 | (api/list-node 54 | [(api/token-node 'def) 55 | alias-name 56 | src-name]) 57 | ;; Fallback if we can't parse the map 58 | (api/list-node [(api/token-node 'do)]))) 59 | 60 | :else 61 | ;; Unknown form, just emit a do 62 | (api/list-node [(api/token-node 'do)]))) 63 | children)] 64 | {:node (api/list-node 65 | (into [(api/token-node 'do)] 66 | defs))})) 67 | -------------------------------------------------------------------------------- /bin/chat: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Simulflow Text Chat Demo 4 | # Interactive text-based chat with AI using simulflow 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | BLUE='\033[0;34m' 12 | YELLOW='\033[1;33m' 13 | NC='\033[0m' # No Color 14 | 15 | # Function to print colored output 16 | print_color() { 17 | echo -e "${1}${2}${NC}" 18 | } 19 | 20 | # Check if we're in the right directory 21 | if [ ! -f "deps.edn" ]; then 22 | print_color $RED "❌ Error: This script must be run from the simulflow project root directory" 23 | exit 1 24 | fi 25 | 26 | # Check if secrets.edn exists 27 | if [ ! -f "resources/secrets.edn" ]; then 28 | print_color $RED "❌ Error: resources/secrets.edn not found" 29 | print_color $YELLOW "Please create resources/secrets.edn with your OpenAI API key:" 30 | echo '{:openai {:new-api-sk "your-openai-api-key-here"}}' 31 | exit 1 32 | fi 33 | 34 | # Parse command line arguments 35 | ARGS="" 36 | HELP=false 37 | 38 | while [[ $# -gt 0 ]]; do 39 | case $1 in 40 | --debug) 41 | ARGS="$ARGS --debug" 42 | shift 43 | ;; 44 | --scenario) 45 | ARGS="$ARGS --scenario" 46 | shift 47 | ;; 48 | --help|-h) 49 | HELP=true 50 | shift 51 | ;; 52 | *) 53 | print_color $RED "❌ Unknown option: $1" 54 | HELP=true 55 | shift 56 | ;; 57 | esac 58 | done 59 | 60 | # Show help 61 | if [ "$HELP" = true ]; then 62 | echo "Simulflow Text Chat Demo" 63 | echo "" 64 | echo "Usage: $0 [OPTIONS]" 65 | echo "" 66 | echo "Options:" 67 | echo " --debug Enable debug mode (verbose logging)" 68 | echo " --scenario Use a scenario (structured conversation)" 69 | echo " --help, -h Show this help message" 70 | echo "" 71 | echo "Examples:" 72 | echo " $0 # Start normal open-ended chat" 73 | echo " $0 --debug # Start with debug logging" 74 | echo " $0 --scenario # Start with a scenario" 75 | echo " $0 --debug --scenario # Debug mode with scenario" 76 | echo "" 77 | echo "Once running:" 78 | echo " • Type messages and press Enter" 79 | echo " • Input is blocked while assistant responds" 80 | echo " • Ask to 'quit chat' to exit gracefully (normal chat)" 81 | echo " • Scenario will end automatically when complete" 82 | echo " • Use Ctrl+C to force exit" 83 | exit 0 84 | fi 85 | 86 | # Show startup banner 87 | print_color $GREEN "🚀 Starting Simulflow Text Chat Demo..." 88 | print_color $BLUE "📁 Project: $(pwd)" 89 | 90 | if [[ "$ARGS" == *"--debug"* ]]; then 91 | print_color $YELLOW "🐛 Debug mode enabled" 92 | fi 93 | 94 | if [[ "$ARGS" == *"--scenario"* ]]; then 95 | print_color $YELLOW "🎭 Scenario mode enabled" 96 | fi 97 | 98 | print_color $GREEN "🔧 Loading Clojure dependencies..." 99 | 100 | # Run the chat demo 101 | print_color $GREEN "🤖 Launching interactive chat..." 102 | echo "" 103 | 104 | exec clj -M:with-examples -m simulflow-examples.text-chat $ARGS 105 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | com.shipclojure 5 | jar 6 | simulflow 7 | shipclojure/simulflow 8 | A Clojure framework for building real-time voice-enabled AI applications. simulflow handles the orchestration of speech recognition, audio processing, and AI service integration with the elegance of functional programming. 9 | https://github.com/shipclojure/simulflow 10 | 11 | 12 | Eclipse Public License 13 | http://www.eclipse.org/legal/epl-v10.html 14 | 15 | 16 | 17 | 18 | Ovi Stoica 19 | 20 | 21 | 22 | https://github.com/shipclojure/simulflow 23 | scm:git:git://github.com/shipclojure/simulflow.git 24 | scm:git:ssh://git@github.com/shipclojure/simulflow.git 25 | v0.1.8-alpha 26 | 27 | v0.1.8-alpha 28 | 29 | src 30 | 31 | 32 | 33 | clojars 34 | https://repo.clojars.org/ 35 | 36 | 37 | 38 | 39 | clojars 40 | Clojars repository 41 | https://clojars.org/repo 42 | 43 | 44 | 45 | 46 | org.clojure 47 | clojure 48 | 1.12.0 49 | 50 | 51 | hato 52 | hato 53 | 1.1.0-SNAPSHOT 54 | 55 | 56 | com.microsoft.onnxruntime 57 | onnxruntime 58 | 1.22.0 59 | 60 | 61 | org.uncomplicate 62 | clojure-sound 63 | 0.3.0 64 | 65 | 66 | metosin 67 | malli 68 | 0.19.1 69 | 70 | 71 | metosin 72 | jsonista 73 | 0.3.13 74 | 75 | 76 | org.clojure 77 | core.async 78 | 1.9.808-alpha1 79 | 80 | 81 | com.taoensso 82 | telemere 83 | 1.0.1 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /src/simulflow/command.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.command 2 | (:require 3 | [clojure.string :as str] 4 | [hato.client :as http] 5 | [simulflow.utils.core :refer [content-type json-str without-nils]] 6 | [simulflow.utils.request :refer [sse-request]])) 7 | 8 | ;;; Command System Utilities 9 | ;;; These utilities support the Data Centric Async Processor Pattern 10 | ;;; by processing commands generated by transform functions 11 | 12 | (defn make-command 13 | "Create a command data structure for side effects" 14 | ([kind data] 15 | {:command/kind kind 16 | :command/data data}) 17 | ([kind data id] 18 | {:command/kind kind 19 | :command/data data 20 | :command/id id})) 21 | 22 | (defn sse-request-command 23 | "Create an SSE request command for streaming API calls" 24 | ([params] 25 | (make-command :command/sse-request (without-nils params))) 26 | ([url method body & {:keys [headers timeout-ms buffer-size id]}] 27 | (make-command :command/sse-request 28 | (without-nils {:url url 29 | :method method 30 | :body body 31 | :headers headers 32 | :timeout-ms timeout-ms 33 | :buffer-size buffer-size}) 34 | id))) 35 | 36 | (defn http-request-command 37 | "Create an HTTP request command for regular API calls" 38 | [url method body & {:keys [headers timeout-ms id]}] 39 | (make-command :command/http-request 40 | (without-nils {:url url 41 | :method method 42 | :body body 43 | :headers headers 44 | :timeout-ms timeout-ms}) 45 | id)) 46 | 47 | ;;; Command Handlers 48 | ;;; These functions process commands by executing the side effects 49 | 50 | (defmulti handle-command 51 | "Execute a command by dispatching on its kind" 52 | (fn [command] (:command/kind command))) 53 | 54 | (defmethod handle-command :command/sse-request 55 | [command] 56 | (let [{:keys [url method body headers timeout-ms buffer-size]} (:command/data command) 57 | ;; JSON stringify the body if it's a map/collection and content-type is JSON 58 | final-body (if (and (or (map? body) (coll? body)) 59 | (some-> (content-type headers) 60 | (str/includes? "application/json"))) 61 | (json-str body) 62 | body)] 63 | (:body (sse-request {:request (without-nils {:url url 64 | :method method 65 | :body final-body 66 | :headers headers 67 | :timeout-ms timeout-ms}) 68 | :params (without-nils {:buffer-size buffer-size 69 | :stream/close? true})})))) 70 | 71 | (defmethod handle-command :command/http-request 72 | [command] 73 | (let [{:keys [url method body headers timeout-ms]} (:command/data command)] 74 | (http/request (without-nils {:url url 75 | :method method 76 | :body body 77 | :headers headers 78 | :timeout timeout-ms})))) 79 | 80 | (defmethod handle-command :default 81 | [command] 82 | (throw (ex-info "Unknown command kind" {:command command}))) 83 | -------------------------------------------------------------------------------- /src/simulflow/filters/mute.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.filters.mute 2 | "This filter handles muting the user input based on specific strategies: 3 | - A function call is in progress 4 | - Bot's first speech (introductions) 5 | - Bot speech (don't process user speech while bot is speaking) 6 | " 7 | (:require 8 | [clojure.core.async.flow :as flow] 9 | [clojure.set :as set] 10 | [simulflow.frame :as frame] 11 | [simulflow.schema :as schema])) 12 | 13 | (def MuteStrategy 14 | [:enum 15 | ;; Mute user during introduction 16 | :mute.strategy/first-speech 17 | ;; Mute user during bot tool calls 18 | :mute.strategy/tool-call 19 | ;; Mute user during all bot speech 20 | :mute.strategy/bot-speech]) 21 | 22 | (def MuteFilterConfig 23 | [:map 24 | [:mute/strategies 25 | {:description "Collection of strategies used to trigger muting the user input."} 26 | [:set MuteStrategy]]]) 27 | 28 | (def describe 29 | {:ins {:in "Channel for normal frames" 30 | :sys-in "Channel for system frames"} 31 | :outs {:sys-out "Channel for mute-start/stop frames"} 32 | :params (schema/->describe-parameters MuteFilterConfig)}) 33 | 34 | (defn init 35 | [params] 36 | (let [parsed-params (schema/parse-with-defaults MuteFilterConfig params)] 37 | parsed-params)) 38 | 39 | (defn transform 40 | [{:keys [mute/strategies ::muted?] :as state} _ msg] 41 | (cond 42 | ;; Function call strategy 43 | (and (frame/llm-tool-call-request? msg) 44 | (strategies :mute.strategy/tool-call) 45 | (not muted?)) 46 | [(assoc state ::muted? true) (frame/send (frame/mute-input-start))] 47 | 48 | (and (frame/llm-tool-call-result? msg) 49 | (strategies :mute.strategy/tool-call) 50 | muted?) 51 | [(assoc state ::muted? false) (frame/send (frame/mute-input-stop))] 52 | 53 | ;; bot speech & first-speech strategies 54 | (and (frame/bot-speech-start? msg) 55 | (seq (set/intersection strategies #{:mute.strategy/first-speech :mute.strategy/bot-speech})) 56 | (not muted?)) 57 | (let [emit-mute-first-speech? (and (strategies :mute.strategy/first-speech) 58 | (not (true? (::first-speech-started? state)))) 59 | emit-mute-bot-speech? (strategies :mute.strategy/bot-speech) 60 | emit-mute? (or emit-mute-first-speech? emit-mute-bot-speech?) 61 | 62 | ns (cond-> state 63 | emit-mute-first-speech? (assoc ::first-speech-started? true) 64 | emit-mute? (assoc ::muted? true))] 65 | [ns (when emit-mute? (frame/send (frame/mute-input-start)))]) 66 | 67 | (and (frame/bot-speech-stop? msg) 68 | (seq (set/intersection strategies #{:mute.strategy/first-speech :mute.strategy/bot-speech})) 69 | muted?) 70 | (let [emit-unmute-first-speech? (and (strategies :mute.strategy/first-speech) 71 | (true? (::first-speech-started? state)) 72 | (not (true? (::first-speech-ended? state)))) 73 | emit-unmute-bot-speech? (strategies :mute.strategy/bot-speech) 74 | emit-unmute? (or emit-unmute-first-speech? emit-unmute-bot-speech?) 75 | 76 | ns (cond-> state 77 | emit-unmute-first-speech? (assoc ::first-speech-ended? true) 78 | emit-unmute? (assoc ::muted? false))] 79 | [ns (when emit-unmute? (frame/send (frame/mute-input-stop)))]) 80 | 81 | :else 82 | [state])) 83 | 84 | (defn processor-fn 85 | ([] describe) 86 | ([params] (init params)) 87 | ([state _transition] state) 88 | ([state in msg] 89 | (transform state in msg))) 90 | 91 | (def process (flow/process processor-fn)) 92 | -------------------------------------------------------------------------------- /src/simulflow/transport/text_in.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.transport.text-in 2 | (:refer-clojure :exclude [send]) 3 | (:require 4 | [clojure.core.async :as a] 5 | [clojure.core.async.flow :as flow] 6 | [clojure.string :as str] 7 | [simulflow.async :refer [vthread-loop]] 8 | [simulflow.frame :as frame] 9 | [taoensso.telemere :as t]) 10 | (:import 11 | (java.io BufferedReader InputStreamReader))) 12 | 13 | (defn text-input-transform 14 | "Transform function handles stdin input and LLM response lifecycle for blocking" 15 | [state input-port frame] 16 | (let [{:keys [blocked?]} state] 17 | (cond 18 | ;; Handle LLM response lifecycle via :sys-in for blocking 19 | (and (= input-port :sys-in) (frame/llm-full-response-start? frame)) 20 | [(assoc state :blocked? true) {}] 21 | (and (= input-port :sys-in) (frame/llm-full-response-end? frame)) 22 | [(assoc state :blocked? false) {}] 23 | ;; Handle text input from stdin - transform decides whether to process 24 | (and (= input-port ::stdin) (string? frame)) 25 | (if blocked? 26 | (do 27 | (t/log! {:level :debug :id :text-input} "Ignoring input while LLM is responding") 28 | [state {}]) 29 | (let [user-text (str/trim frame)] 30 | (if (not-empty user-text) 31 | ;; Emit the standard speech sequence: start -> transcription -> stop 32 | [state (frame/send 33 | (frame/user-speech-start true) 34 | (frame/transcription user-text) 35 | (frame/user-speech-stop true))] 36 | [state {}]))) 37 | :else [state {}]))) 38 | 39 | (defn text-input-init! 40 | "Initialize text input processor with stdin reading loop (no prompt handling)" 41 | [params] 42 | (let [stdin-ch (a/chan 1024) 43 | running? (atom true) 44 | ;; Create buffered reader for stdin 45 | reader (BufferedReader. (InputStreamReader. System/in)) 46 | ;; Close function to be called on stop 47 | close-fn (fn [] 48 | (reset! running? false) 49 | (try (.close reader) (catch Exception _)) 50 | (a/close! stdin-ch))] 51 | ;; Start stdin reading loop in virtual thread (no prompts) 52 | (vthread-loop [] 53 | (when @running? 54 | (try 55 | ;; Block on stdin read - this is safe in virtual threads 56 | (when-let [line (.readLine reader)] 57 | (when @running? 58 | (when-not (a/offer! stdin-ch line) 59 | (t/log! :warn "stdin channel full, dropping input")))) 60 | (catch Exception e 61 | (if @running? 62 | (do 63 | (t/log! {:level :error :id :text-input :error e} "Error reading from stdin") 64 | (Thread/sleep 100)) 65 | ;; Normal shutdown, don't log error 66 | nil))) 67 | (recur))) 68 | ;; Set up state with channels 69 | {::flow/in-ports {::stdin stdin-ch} 70 | ::close close-fn 71 | :blocked? false})) 72 | 73 | (defn text-input-processor 74 | "Multi-arity text input processor following simulflow patterns" 75 | ([] 76 | ;; Describe processor 77 | {:ins {:in "Text input frames" 78 | :sys-in "System frames (LLM response lifecycle)"} 79 | :outs {:out "User speech frames (transcription sequence)" 80 | :sys-out "System frames"} 81 | :params {}}) 82 | ([config] 83 | ;; Init processor 84 | (text-input-init! config)) 85 | ([state transition] 86 | ;; Handle transitions 87 | (case transition 88 | ::flow/stop (when-let [close-fn (::close state)] 89 | (close-fn)) 90 | state)) 91 | ([state input-port frame] 92 | ;; Transform function 93 | (text-input-transform state input-port frame))) 94 | 95 | ;; Export for use in flows 96 | (def text-input-process 97 | (flow/process text-input-processor)) 98 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 | org.uncomplicate/clojure-sound {:mvn/version "0.3.0"} 4 | com.taoensso/telemere {:mvn/version "1.0.1"} 5 | metosin/malli {:mvn/version "0.19.1"} 6 | org.clojure/core.async {:mvn/version "1.9.808-alpha1"} 7 | metosin/jsonista {:mvn/version "0.3.13"} 8 | com.microsoft.onnxruntime/onnxruntime {:mvn/version "1.22.0"} 9 | hato/hato {:mvn/version "1.1.0-SNAPSHOT"}} 10 | 11 | :mvn/repos {"central" {:url "https://repo1.maven.org/maven2/"} 12 | "clojars" {:url "https://repo.clojars.org/"}} 13 | 14 | :pom {:group-id "com.shipclojure" 15 | :artifact-id "simulflow" 16 | :version "0.1.8-alpha" 17 | :name "simulflow" 18 | :description "A Clojure framework for building real-time voice-enabled AI applications" 19 | :url "https://github.com/shipclojure/simulflow" 20 | :scm {:url "https://github.com/shipclojure/simulflow" 21 | :tag "v0.1.8-alpha" 22 | :connection "scm:git:git://github.com/shipclojure/simulflow.git" 23 | :dev-connection "scm:git:ssh://git@github.com/shipclojure/simulflow.git"} 24 | :licenses [{:name "Eclipse Public License" 25 | :url "http://www.eclipse.org/legal/epl-v10.html"}] 26 | :developers [{:name "Ovi Stoica"}]} 27 | :aliases 28 | {:build {:deps {io.github.clojure/tools.build {:mvn/version "0.9.4"} 29 | slipset/deps-deploy {:mvn/version "0.2.1"}} 30 | :ns-default build} 31 | :run {:main-opts ["-m" "simulflow.transport.local.audio"] 32 | :exec-fn simulflow.transport.local.audio/main} 33 | :dev {:extra-paths ["dev"] 34 | ;; Check schema for each frame instantiation (not recommended in prod 35 | ;; since many schemas are created) 36 | :jvm-opts ["-Dsimulflow.frame.schema-checking=true"] 37 | :extra-deps {djblue/portal {:mvn/version "0.58.5"} 38 | criterium/criterium {:mvn/version "0.4.6"} 39 | clj-kondo/clj-kondo {:mvn/version "2025.06.05"}}} 40 | :cider-clj {:extra-deps {cider/cider-nrepl {:mvn/version "0.52.0"}} 41 | :main-opts ["-m" "nrepl.cmdline" 42 | "--middleware" "[cider.nrepl/cider-middleware]" 43 | "--port" "52158"]} 44 | :test {:extra-paths ["test"] 45 | :main-opts ["-m" "kaocha.runner"] 46 | :extra-deps {org.clojure/tools.namespace {:mvn/version "1.4.4"} 47 | org.clojure/test.check {:mvn/version "1.1.1"} 48 | lambdaisland/kaocha {:mvn/version "1.0.732"} 49 | kaocha-noyoda/kaocha-noyoda {:mvn/version "2019-06-03"}}} 50 | :with-examples {:extra-deps {org.clojure/data.xml {:mvn/version "0.0.8"} 51 | ring/ring-jetty-adapter {:mvn/version "1.14.2"} 52 | ring/ring-core {:mvn/version "1.14.2"} 53 | metosin/reitit {:mvn/version "0.9.1"}} 54 | :extra-paths ["./examples/src"]} 55 | :clj-kondo {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2025.06.05"}} 56 | :main-opts ["-m" "clj-kondo.main"]} 57 | :storm {:classpath-overrides {org.clojure/clojure nil} 58 | :extra-deps {com.github.flow-storm/clojure {:mvn/version "1.12.0-3"} 59 | com.github.flow-storm/flow-storm-dbg {:mvn/version "4.2.0-SNAPSHOT"} 60 | com.github.flow-storm/flow-storm-async-flow-plugin {:mvn/version "1.0.0-SNAPSHOT"}} 61 | :jvm-opts ["-Dclojure.storm.instrumentEnable=true" 62 | "-Dclojure.storm.instrumentOnlyPrefixes=clojure.core.async.flow,simulflow" 63 | "-Dflowstorm.jarEditorCommand=emacsclient -n +<>:0 <>/<>" 64 | "-Dflowstorm.fileEditorCommand=emacsclient -n +<>:0 <>" 65 | 66 | "-Dvisualvm.display.name=VoceFnExample" 67 | "-Djdk.attach.allowAttachSelf" "-XX:+UnlockDiagnosticVMOptions" "-XX:+DebugNonSafepoints"]} 68 | :deploy {:extra-deps {slipset/deps-deploy {:mvn/version "0.2.2"}} 69 | :exec-fn deps-deploy.deps-deploy/deploy 70 | :exec-args {:installer :remote 71 | :sign-releases? false 72 | :artifact "target/com.shipclojure/simulflow-v0.1.8-alpha.jar"}}}} 73 | -------------------------------------------------------------------------------- /src/simulflow/processors/google.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.processors.google 2 | (:require 3 | [clojure.core.async.flow :as flow] 4 | [clojure.string :as str] 5 | [hato.client :as http] 6 | [simulflow.schema :as schema] 7 | [simulflow.secrets :refer [secret]] 8 | [simulflow.utils.core :as u] 9 | [simulflow.utils.openai :as uai])) 10 | 11 | (def google-generative-api-url "https://generativelanguage.googleapis.com/v1beta/openai") 12 | (def google-completions-url (str google-generative-api-url "/chat/completions")) 13 | 14 | (comment 15 | ;; Get list of valid models 16 | (->> (http/request {:url (str google-generative-api-url "/models") 17 | :method :get 18 | :headers {"Authorization" (str "Bearer " (secret [:google :api-key]))}}) 19 | :body 20 | u/parse-if-json 21 | :data 22 | (map :id) 23 | (map #(last (str/split % #"/"))) 24 | set)) 25 | 26 | (def model-schema 27 | (schema/flex-enum 28 | {:description "Google llm model identifier" 29 | :error/message "Must be a valid Google LLM model. Try gemini-2.0-flash"} 30 | [:gemini-embedding-exp :gemini-2.0-flash-thinking-exp :learnlm-1.5-pro-experimental :gemini-1.5-pro :gemini-2.0-flash-lite-001 :gemini-1.5-flash 31 | :gemini-2.0-flash-lite-preview :chat-bison-001 :gemini-exp-1206 :gemini-1.5-pro-002 :text-bison-001 :gemini-2.0-flash-lite-preview-02-05 32 | :gemini-1.5-flash-8b-latest :embedding-001 :gemini-2.0-pro-exp :gemini-1.5-flash-8b-001 :gemini-1.0-pro-vision-latest :gemini-2.5-pro-preview-03-25 33 | :learnlm-2.0-flash-experimental :gemini-1.5-flash-002 :gemma-3-27b-it :text-embedding-004 :gemini-1.5-flash-001-tuning :gemini-2.0-flash-lite 34 | :gemini-2.5-flash-preview-04-17-thinking :gemini-1.5-pro-001 :gemini-pro-vision :gemini-1.5-flash-8b-exp-0924 :gemini-2.0-flash-live-001 35 | :gemini-1.5-flash-8b :gemini-2.0-flash-thinking-exp-1219 :gemini-1.5-flash-latest :gemini-1.5-pro-latest :gemma-3-4b-it :embedding-gecko-001 36 | :gemini-2.0-flash-thinking-exp-01-21 :gemini-2.0-pro-exp-02-05 :veo-2.0-generate-001 :gemini-embedding-exp-03-07 :gemini-2.5-pro-exp-03-25 :gemini-1.5-flash-8b-exp-0827 37 | :gemma-3-12b-it :gemini-2.0-flash :gemini-2.5-flash-preview-04-17 :gemini-2.0-flash-001 :gemini-1.5-flash-001 :gemma-3-1b-it :gemini-2.5-pro-preview-05-06 38 | :imagen-3.0-generate-002 :gemini-2.0-flash-exp])) 39 | 40 | (def GoogleLLMConfigSchema 41 | [:map 42 | {:description "Google LLM configuration"} 43 | [:llm/model {:default :gemini-2.0-flash 44 | :description "Google model used for llm inference"} model-schema] 45 | [:google/api-key {:description "Google API key" 46 | :error/message "Invalid Google Api Key provided"} :string] 47 | [:api/completions-url {:default google-completions-url 48 | :optional true 49 | :description "Different completions url for gemini api"} :string]]) 50 | 51 | (comment 52 | 53 | (:body (uai/normal-chat-completion {:api-key (secret [:google :api-key]) 54 | :model :gemini-2.0-flash 55 | :messages [{:role "system" 56 | :content "You are a voice agent operating via phone. Be 57 | concise in your answers. The input you receive comes from a 58 | speech-to-text (transcription) system that isn't always 59 | efficient and may send unclear text. Ask for 60 | clarification when you're unsure what the person said."} 61 | {:role "user" :content "Do you hear me?"}] 62 | :completions-url google-completions-url}))) 63 | 64 | (def describe 65 | {:ins {:in "Channel for incoming context aggregations"} 66 | :outs {:out "Channel where streaming responses will go"} 67 | :params (schema/->describe-parameters GoogleLLMConfigSchema) 68 | :workload :io}) 69 | 70 | (def init! (partial uai/init-llm-processor! GoogleLLMConfigSchema :gemini)) 71 | 72 | (defn transition 73 | [state transition] 74 | (uai/transition-llm-processor state transition)) 75 | 76 | (defn transform 77 | [state in msg] 78 | (uai/transform-llm-processor state in msg :google/api-key :api/completions-url)) 79 | 80 | (defn google-llm-process-fn 81 | ([] describe) 82 | ([params] (init! params)) 83 | ([state trs] 84 | (transition state trs)) 85 | ([state in msg] (transform state in msg))) 86 | 87 | (def google-llm-process (flow/process google-llm-process-fn)) 88 | -------------------------------------------------------------------------------- /src/simulflow/processors/openai.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.processors.openai 2 | (:require 3 | [clojure.core.async.flow :as flow] 4 | [malli.core :as m] 5 | [simulflow.schema :as schema] 6 | [simulflow.utils.openai :as uai])) 7 | 8 | (def OpenAILLMConfigSchema 9 | [:map 10 | {:description "OpenAI LLM configuration"} 11 | 12 | [:llm/model {:default :gpt-4o-mini} 13 | (schema/flex-enum 14 | {:description "OpenAI model identifier" 15 | :error/message "Must be a valid OpenAI model" 16 | :default "gpt-4o-mini"} 17 | [;; GPT-4o Models (2024-2025) 18 | :gpt-4o 19 | :chatgpt-4o-latest 20 | :gpt-4o-mini 21 | :gpt-4o-audio-preview 22 | :gpt-4o-audio-preview-2024-12-17 23 | :gpt-4o-audio-preview-2024-10-01 24 | :gpt-4o-mini-audio-preview 25 | :gpt-4o-mini-audio-preview-2024-12-17 26 | 27 | ;; GPT-4.1 Models (2025) 28 | :gpt-4.1 29 | :gpt-4.1-mini 30 | :gpt-4.1-nano 31 | :gpt-4.1-2025-04-14 32 | :gpt-4.1-mini-2025-04-14 33 | :gpt-4.1-nano-2025-04-14 34 | 35 | ;; GPT-5 Models (2025) 36 | :gpt-5 37 | :gpt-5-mini 38 | :gpt-5-nano 39 | :gpt-5-chat-latest 40 | :gpt-5-2025-08-07 41 | :gpt-5-mini-2025-08-07 42 | :gpt-5-nano-2025-08-07 43 | 44 | ;; GPT-4 Turbo Models 45 | :gpt-4-turbo 46 | :gpt-4-turbo-2024-04-09 47 | :gpt-4-1106-preview 48 | :gpt-4-0125-preview 49 | :gpt-4-turbo-preview 50 | 51 | ;; GPT-4 Models 52 | :gpt-4 53 | :gpt-4-32k 54 | :gpt-4-vision-preview 55 | 56 | ;; GPT-3.5 Models 57 | :gpt-3.5-turbo 58 | :gpt-3.5-turbo-16k 59 | :gpt-3.5-turbo-1106 60 | :gpt-3.5-turbo-instruct 61 | 62 | ;; O-series Models (2025) 63 | :o3-2025-04-16 64 | :o3-pro 65 | :o4-mini-2025-04-16 66 | ;; Base Models 67 | :babbage-002 68 | :davinci-002])] 69 | [:llm/temperature {:optional true} 70 | [:float 71 | {:description "Sampling temperature (0-2)" 72 | :default 0.7 73 | :min 0.0 74 | :max 2.0}]] 75 | 76 | [:llm/max-tokens {:optional true} 77 | [:int 78 | {:description "Maximum number of tokens to generate" 79 | :min 1}]] 80 | 81 | [:llm/frequency-penalty {:optional true} 82 | [:float 83 | {:description "Frequency penalty (-2.0 to 2.0)" 84 | :min -2.0 85 | :max 2.0}]] 86 | 87 | [:llm/presence-penalty {:optional true} 88 | [:float 89 | {:description "Presence penalty (-2.0 to 2.0)" 90 | :min -2.0 91 | :max 2.0}]] 92 | 93 | [:llm/top-p {:optional true} 94 | [:float 95 | {:description "Nucleus sampling threshold" 96 | :min 0.0 97 | :max 1.0}]] 98 | 99 | [:llm/seed {:optional true} 100 | [:int 101 | {:description "Random seed for deterministic sampling"}]] 102 | 103 | [:llm/max-completion-tokens {:optional true} 104 | [:int 105 | {:description "Maximum tokens in completion" 106 | :min 1}]] 107 | 108 | [:llm/extra {:optional true} 109 | [:map 110 | {:description "Additional model parameters"}]] 111 | 112 | [:openai/api-key 113 | [:string 114 | {:description "OpenAI API key" 115 | :secret true ;; Marks this as sensitive data 116 | :min 40 ;; OpenAI API keys are typically longer 117 | :error/message "Invalid OpenAI API key format"}]] 118 | 119 | [:api/completions-url {:optional true 120 | :default uai/openai-completions-url} :string]]) 121 | 122 | ;; Example validation: 123 | (comment 124 | (require 125 | '[malli.error :as me]) 126 | ;; Valid config 127 | (m/validate OpenAILLMConfigSchema 128 | {:openai/api-key "sk-12312312312312312312312312312312312312312313..."}) 129 | ;; => true 130 | 131 | ;; Invalid model 132 | (-> OpenAILLMConfigSchema 133 | (m/explain {:llm/model "invalid-model" 134 | :openai/api-key "sk-..."}) 135 | me/humanize)) 136 | 137 | (def describe 138 | {:ins {:in "Channel for incoming context aggregations" 139 | :sys-in "Channel for incoming system messages"} 140 | :outs {:out "Channel where streaming responses will go"} 141 | :params (schema/->describe-parameters OpenAILLMConfigSchema) 142 | :workload :io}) 143 | 144 | (def init! (partial uai/init-llm-processor! OpenAILLMConfigSchema :openai)) 145 | 146 | (defn transition 147 | [state transition] 148 | (uai/transition-llm-processor state transition)) 149 | 150 | (defn transform 151 | [state in msg] 152 | (uai/transform-llm-processor state in msg :openai/api-key :api/completions-url)) 153 | 154 | (defn openai-llm-fn 155 | "Multi-arity processor function for OpenAI LLM" 156 | ([] describe) 157 | ([params] (init! params)) 158 | ([state trs] (transition state trs)) 159 | ([state input-port frame] (transform state input-port frame))) 160 | 161 | (def openai-llm-process 162 | "OpenAI LLM processor using separate function approach" 163 | (flow/process openai-llm-fn)) 164 | -------------------------------------------------------------------------------- /src/simulflow/processors/activity_monitor.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.processors.activity-monitor 2 | "Process to monitor activity on the call. Used to ping user when no activity is detected." 3 | (:require 4 | [clojure.core.async :as a] 5 | [clojure.core.async.flow :as flow] 6 | [simulflow.async :refer [vthread-loop]] 7 | [simulflow.frame :as frame] 8 | [simulflow.schema :as schema] 9 | [taoensso.telemere :as t])) 10 | 11 | (def ActivityMonitorConfigSchema 12 | [:map 13 | [::timeout-ms 14 | {:default 5000 15 | :optional true 16 | :description "Timeout in ms before sending inactivity message. Default 5000ms"} 17 | :int] 18 | [::end-phrase 19 | {:default "Goodbye!" 20 | :optional true 21 | :description "Message for bot to say in order to end the conversation"} 22 | :string] 23 | [::max-pings 24 | {:default 3 25 | :optional true 26 | :description "Maximum number of inactivity pings before ending the conversation"} 27 | :int] 28 | [::ping-phrases 29 | {:default #{"Are you still there?"} 30 | :optional true 31 | :description "Collection (set or vector) with messages to send when inactivity is detected."} 32 | [:or 33 | [:set :string] 34 | [:vector :string]]]]) 35 | 36 | ;; When a frame is received 37 | ;; 1. If that frame is VAD for either bot or user 38 | ;; 1.1 If user speaking frame, set user speaking true and send frame to timer process to reset activity timer 39 | ;; 1.2 If user stopped speaking, set user speaking false and send frame to timer process to reset activity timer 40 | ;; 2.1 If bot speaking frame, set bot speaking true and send frame to timer process to reset activity timer 41 | ;; 2.2 If bot stopped speaking, set bot speaking false and send frame to timer process to reset activity timer 42 | ;; 3. If the timeout for activity has passed and no VAD frame came AND nobody is speaking 43 | ;; 3.1 Increment the activity ping message count 44 | ;; 3.2 If the activity ping message count is bigger or equal to max pings send end conversation message 45 | ;; 4.1 If the timeout for activity has passed and no VAD frame came, but the user is speaking, don't send inactivity ping 46 | ;; 4.2 If the timeout for activity has passed and no VAD frame came, but the bot is speaking, don't send inactivity ping 47 | 48 | (defn speaking? 49 | [state] 50 | (or (::bot-speaking? state) 51 | (::user-speaking? state))) 52 | 53 | (defn transform 54 | [state in msg] 55 | (if (= :sys-in in) 56 | (cond 57 | (frame/user-speech-start? msg) 58 | [(assoc state 59 | ::user-speaking? true 60 | ::ping-count 0) {:timer-process-in [msg]}] 61 | 62 | (frame/user-speech-stop? msg) 63 | [(assoc state ::user-speaking? false) {:timer-process-in [msg]}] 64 | 65 | (frame/bot-speech-start? msg) 66 | [(assoc state ::bot-speaking? true) {:timer-process-in [msg]}] 67 | 68 | (frame/bot-speech-stop? msg) 69 | [(assoc state ::bot-speaking? false) {:timer-process-in [msg]}] 70 | 71 | :else [state]) 72 | (let [ping-count (::ping-count state 0) 73 | max-pings (::max-pings state 0) 74 | ping-phrases-raw (::ping-phrases state #{"Are you still there?"}) 75 | ping-phrases (if (seq ping-phrases-raw) ping-phrases-raw #{"Are you still there?"}) 76 | end-phrase (::end-phrase state "Goodbye!") 77 | now (:now state)] 78 | (cond 79 | (and (= in :timer-process-out) 80 | (::timeout? msg) 81 | (< (inc ping-count) max-pings) 82 | (not (speaking? state))) 83 | [(update-in state [::ping-count] (fnil inc 0)) {:out [(frame/speak-frame (rand-nth (vec ping-phrases)) {:timestamp now})]}] 84 | 85 | (and (= in :timer-process-out) 86 | (::timeout? msg) 87 | (= (inc ping-count) max-pings) 88 | (not (speaking? state))) 89 | [(assoc state ::ping-count 0) {:out [(frame/speak-frame end-phrase {:timestamp now})]}] 90 | 91 | :else [state])))) 92 | 93 | (def describe 94 | {:ins {:sys-in "Channel for system messages"} 95 | :outs {:out "Channel for inactivity prompts"} 96 | :params (schema/->describe-parameters ActivityMonitorConfigSchema)}) 97 | 98 | (defn init! [params] 99 | (let [{::keys [timeout-ms] :as parsed} (schema/parse-with-defaults ActivityMonitorConfigSchema params) 100 | timer-in-ch (a/chan 1024) 101 | timer-out-ch (a/chan 1024)] 102 | (vthread-loop [] 103 | (let [timeout-ch (a/timeout timeout-ms) 104 | [_v c] (a/alts!! [timer-in-ch timeout-ch])] 105 | (when (= c timeout-ch) 106 | (t/log! {:msg "Activity timeout activated!" 107 | :id :activity-monitor 108 | :level :debug}) 109 | (a/>!! timer-out-ch {::timeout? true})) 110 | (recur))) 111 | 112 | (merge {::flow/out-ports {:timer-process-in timer-in-ch} 113 | ::flow/in-ports {:timer-process-out timer-out-ch}} 114 | parsed))) 115 | 116 | (defn transition [{::flow/keys [out-ports in-ports] :as state} transition] 117 | (when (= transition ::flow/stop) 118 | (doseq [port (concat (vals out-ports) (vals in-ports))] 119 | (a/close! port))) 120 | state) 121 | 122 | (defn processor-fn 123 | ([] describe) 124 | ([params] (init! params)) 125 | ([state trs] (transition state trs)) 126 | ([state in msg] (transform state in msg))) 127 | 128 | (def process (flow/process processor-fn)) 129 | -------------------------------------------------------------------------------- /doc/implementation/drafts.org: -------------------------------------------------------------------------------- 1 | #+title: Drafts 2 | #+description: The place where ideas battle it out to become decisions 3 | #+startup: indent 4 | 5 | * Audio out transport 6 | 7 | There are two current modes of audio out transport: 8 | - =speakers= - just write the audio to a audio source line 9 | 10 | - =core.async channel= - put audio frames on a channel to be handled by the 11 | outside world. Even =speakers= could be handled this way, we ship it as a 12 | commodity for people. 13 | 14 | 15 | ** Responsabilities of the audio out 16 | 17 | 18 | *** 1. Buffer audio frames to maintain realtime sending 19 | 20 | This is important because we support bot interruption, therefore when the user 21 | speaks, we need to be able to stop the flow of frames. We cannot rely on the 22 | output "device" to handle that when we send an interrupt signal 23 | 24 | *** 2. Send bot speech events 25 | 26 | The audio out sends bot speech events when the bot started/stopped speaking. 27 | These are important for judging if the bot should be interrupted at certain 28 | times or to understand if there is activity on the call - used for [[file:~/workspace/simulflow/src/simulflow/processors/activity_monitor.clj::(ns simulflow.processors.activity-monitor][activity 29 | monitoring]] among others 30 | 31 | *** 3. Accept system frames that contain serializer and serialize output frames when a serializer exists 32 | This is something that most of the time can be handled on the other side of the 33 | core async channel to limit the responsability of the simulflow core but there 34 | might be cases where this is required here. 35 | 36 | *** 4. Handle interuptions 37 | Discard all new audio frames if it is in an interrupted state and clear out the 38 | audio playback queue if it receives an interrupt-start frame 39 | 40 | ** Current problems 41 | 42 | 1. The logic was created more for =speakers-out= but it is better positioned for 43 | normal =realtime async out=. At the core of it, =speakers-out= is just a 44 | different =init!= that starts a audio source line. The code should be 45 | refactored. 46 | 47 | * Audio in transport 48 | 49 | ** DONE Provide a transport in processor that just takes a in channel and receives in frames on it (might be there already) 50 | CLOSED: [2025-09-03 Wed 09:46] 51 | 52 | * Interruptions - Make the pipeline interruptible either through VAD or Smart Turn detecton 53 | 54 | ** How pipecat handles interruptions 55 | 56 | In pipecat, transport contains both [[file:~/workspace/pipecat/src/pipecat/transports/base_input.py::class BaseInputTransport(FrameProcessor):][in]] & out. When audio comes in, each chunk is 57 | sent to a VAD analizer like Silero and optionally a Smart turn detection model. 58 | Based on these analyzers the BaseTransportInput keeps a =speaking= flag. If 59 | there is a smart turn detector, it's logic for wether the user is 60 | speaking/stopped speaking will take precedence, otherwise the VAD will be the 61 | source of truth. 62 | 63 | When the VAD state changes, (ex: speaking -> not speaking), the pipeline emits 64 | =VADUserStartedSpeakingFrame=/=VADUserStoppedSpeakingFrame=. This happens 65 | regardless in order to have good observation on the pipeline. 66 | 67 | The transport input has a role into starting interruptions *if* there isn't a 68 | interruption strategy created. If one or more *interruption* strategies exists, 69 | the triggering of the interruption logic is defered to use UserContextAggregator 70 | that has access to the current transcriptions coming in. 71 | 72 | *** Interruption flow 73 | 74 | 1. When either the BaseTransportInput or the UserContextAggregator deems an 75 | interruption should start, they emit a frame to do so. BaseTransportInput emits 76 | a =StartInterruptionFrame= and UserContextAggregator emits a 77 | =BotInterruptionFrame= which is sent to the BaseTransportInput who upon 78 | receiving this frame, emits a =StartInterruptionFrame=. This frame does the 79 | following: 80 | - Tells the LLM to cancel in-flight requests and current streaming tokens 81 | - Tells TTS processors to cancel in-flight reqeusts and clear their accumulators 82 | - Tells TransportOut to stop sending AudioOut frames and clear the current 83 | playback queue 84 | - (Possibly) tells BotContextAggregator to drop current sentence assembled or 85 | just cut it short as the user only heard a part of it. 86 | 87 | 2. The pipeline is now in a interrupted state (relevant for TransportOut because 88 | it drops any new AudioOut frames until it receives a =StopInterruptionFrame=) 89 | 90 | 3. When user is deemed to have stopped speaking (by either VAD or Turn taking 91 | model) a =UserStoppedSpeaking= frame is sent. Which will trigger a 92 | =StopInterruptionFrame= if the pipeline supports interuption. This frame will 93 | either be sent by the TransportBaseInput or the UserContextAggregator, based 94 | on the existence of Interruption strategies. 95 | 96 | Important mention here: The LLM, TTS don't keep a =speaking= flag in this 97 | period as they don't care about this state. They only drop their current 98 | activity when a =StartInterruptionFrame= is received but don't handle a 99 | =StopInterruptionFrame= at all. 100 | 101 | ** Interruption flow responsabilities 102 | 103 | 1. VAD on each new chunk 104 | 2. (optional) Turn Detect on each new chunk 105 | 3. Start interruption 106 | 4. Stop interruption 107 | 5. Interruption based on strategies 108 | 109 | Basically the VAD and turn detections can be protocols not processors 110 | -------------------------------------------------------------------------------- /src/simulflow/processors/audio_resampler.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.processors.audio-resampler 2 | "Audio resampling processor for converting between different sample rates and encodings. 3 | 4 | Transforms audio-input-raw frames from one format to another (e.g., 8kHz μ-law to 16kHz PCM). 5 | Useful for adapting audio from transport layers to transcription requirements." 6 | (:require 7 | [clojure.core.async.flow :as flow] 8 | [simulflow.frame :as frame] 9 | [simulflow.schema :as schema] 10 | [simulflow.utils.audio :as audio] 11 | [taoensso.telemere :as t])) 12 | 13 | ;; Configuration Schema 14 | (def AudioResamplerConfigSchema 15 | [:map 16 | [:audio-resample/source-sample-rate 17 | {:default 8000 18 | :optional true 19 | :description "Input sample rate in Hz"} 20 | schema/SampleRate] 21 | [:audio-resample/target-sample-rate 22 | {:default 16000 23 | :optional true 24 | :description "Output sample rate in Hz"} 25 | schema/SampleRate] 26 | [:audio-resample/source-encoding 27 | {:default :ulaw 28 | :optional true 29 | :description "Input encoding format"} 30 | schema/AudioEncoding] 31 | [:audio-resample/target-encoding 32 | {:default :pcm-signed 33 | :optional true 34 | :description "Output encoding format"} 35 | schema/AudioEncoding] 36 | [:audio-resample/channels 37 | {:default 1 38 | :optional true 39 | :description "Number of audio channels"} 40 | schema/AudioChannels] 41 | [:audio-resample/sample-size-bits 42 | {:default 16 43 | :optional true 44 | :description "Bit depth of samples"} 45 | schema/SampleSizeBits] 46 | [:audio-resample/buffer-size 47 | {:default 1024 48 | :optional true 49 | :description "Audio buffer size in bytes"} 50 | schema/BufferSize] 51 | [:audio-resample/endian 52 | {:default :little-endian 53 | :optional true 54 | :description "Audio byte order (endianness)"} 55 | schema/AudioEndian]]) 56 | 57 | (defn audio-frame? 58 | [frame] 59 | (or 60 | (frame/audio-input-raw? frame) 61 | (frame/audio-output-raw? frame))) 62 | 63 | (defn transform 64 | "Transform function that resamples audio-input-raw frames" 65 | [state input-port frame] 66 | (if (and (= input-port :in) 67 | (audio-frame? frame)) 68 | (let [{:audio-resample/keys [source-sample-rate target-sample-rate 69 | source-encoding target-encoding 70 | channels sample-size-bits buffer-size endian]} state 71 | 72 | audio-data ^bytes (:frame/data frame) 73 | 74 | source-config {:sample-rate source-sample-rate 75 | :encoding source-encoding 76 | :channels channels 77 | :sample-size-bits (if (= source-encoding :ulaw) 8 sample-size-bits) 78 | :buffer-size buffer-size 79 | :endian endian} 80 | 81 | target-config {:sample-rate target-sample-rate 82 | :encoding target-encoding 83 | :channels channels 84 | :sample-size-bits sample-size-bits 85 | :buffer-size buffer-size 86 | :endian endian} 87 | 88 | resampled-data (audio/resample-audio-data audio-data source-config target-config) 89 | 90 | output-frame (assoc frame :frame/data resampled-data)] 91 | 92 | (t/log! {:level :debug 93 | :id :audio-resampler 94 | :msg (format "Resampled audio: %d bytes -> %d bytes (%dkHz %s -> %dkHz %s)" 95 | (alength audio-data) (alength resampled-data) 96 | (/ source-sample-rate 1000) source-encoding 97 | (/ target-sample-rate 1000) target-encoding) 98 | :sample 0.05}) 99 | 100 | [state {:out [output-frame]}]) 101 | 102 | ;; Pass through non-audio frames unchanged 103 | [state {:out [frame]}])) 104 | 105 | (def describe 106 | {:ins {:in "Channel for audio-input-raw frames to be resampled"} 107 | :outs {:out "Channel for resampled audio-input-raw frames"} 108 | :params (schema/->describe-parameters AudioResamplerConfigSchema)}) 109 | 110 | (defn init! 111 | "Initialize the audio resampler with configuration" 112 | [params] 113 | (let [config (schema/parse-with-defaults AudioResamplerConfigSchema params)] 114 | (t/log! {:level :info :id :audio-resampler :data config} 115 | (format "Initialized audio resampler: %dkHz %s -> %dkHz %s" 116 | (/ (:audio-resample/source-sample-rate config) 1000) 117 | (:audio-resample/source-encoding config) 118 | (/ (:audio-resample/target-sample-rate config) 1000) 119 | (:audio-resample/target-encoding config))) 120 | config)) 121 | 122 | (defn transition 123 | "Handle processor lifecycle transitions" 124 | [state trs] 125 | (when (= trs ::flow/stop) 126 | (t/log! {:level :info :id :audio-resampler} "Audio resampler stopped")) 127 | state) 128 | 129 | ;; Multi-arity Processor Function 130 | 131 | (defn audio-resampler-fn 132 | "Multi-arity function following simulflow processor pattern" 133 | ([] describe) 134 | ([params] (init! params)) 135 | ([state trs] (transition state trs)) 136 | ([state input-port frame] (transform state input-port frame))) 137 | 138 | ;; Flow Process 139 | 140 | (def process 141 | "Audio resampling processor for flow integration" 142 | (flow/process audio-resampler-fn)) 143 | -------------------------------------------------------------------------------- /src/simulflow/transport/text_out.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.transport.text-out 2 | (:refer-clojure :exclude [send]) 3 | (:require 4 | [clojure.core.async :as a] 5 | [clojure.core.async.flow :as flow] 6 | [simulflow.async :refer [vthread-loop]] 7 | [simulflow.frame :as frame] 8 | [taoensso.telemere :as t])) 9 | 10 | (def ^:private default-config 11 | {:text-out/response-prefix "Assistant: " 12 | :text-out/response-suffix "" 13 | :text-out/show-thinking false 14 | :text-out/user-prompt "You: " 15 | :text-out/manage-prompts true}) 16 | 17 | (defn text-output-transform 18 | "Pure transform function - handles text output logic and emits commands" 19 | [state _input-port frame] 20 | (let [{:text-out/keys [response-prefix response-suffix show-thinking 21 | user-prompt]} state] 22 | (cond 23 | ;; LLM response start - show prefix, clean formatting 24 | (frame/llm-full-response-start? frame) 25 | [(assoc state :response-active? true) 26 | {::command [{:command/kind :command/print 27 | :command/data response-prefix}]}] 28 | ;; LLM response end - show suffix and prepare for next input 29 | (frame/llm-full-response-end? frame) 30 | [(assoc state :response-active? false) 31 | (cond-> {::command [{:command/kind :command/print 32 | :command/data response-suffix} 33 | {:command/kind :command/println 34 | :command/data ""} ; newline 35 | {:command/kind :command/print 36 | :command/data user-prompt}]})] 37 | ;; Stream LLM text chunks 38 | (frame/llm-text-chunk? frame) 39 | [state {::command [{:command/kind :command/print 40 | :command/data (:frame/data frame)}]}] 41 | 42 | (frame/speak-frame? frame) 43 | [state {::command [{:command/kind :command/println 44 | :command/data ""} 45 | {:command/kind :command/print 46 | :command/data response-prefix} 47 | {:command/kind :command/println 48 | :command/data (:frame/data frame)} 49 | {:command/kind :command/print 50 | :command/data user-prompt}]}] 51 | ;; Handle tool calls quietly unless in debug mode 52 | (frame/llm-tool-call-chunk? frame) 53 | [state (if show-thinking 54 | {::command [{:command/kind :command/debug 55 | :command/data (str "Tool call: " (:frame/data frame))}]} 56 | {})] 57 | :else [state {}]))) 58 | 59 | (defn text-output-init! 60 | "Initialize text output processor with command handling loop" 61 | [params] 62 | (let [config (merge default-config params) 63 | {:text-out/keys [user-prompt manage-prompts]} config 64 | command-ch (a/chan 32) 65 | running? (atom true) 66 | ;; Close function to be called on stop 67 | close-fn (fn [] 68 | (reset! running? false) 69 | (a/close! command-ch))] 70 | ;; Start command handling loop in virtual thread 71 | (vthread-loop [] 72 | (when @running? 73 | (try 74 | (when-let [command (a/ audio-size chunk-actual-size) 42 | (let [new-audio-size (- audio-size chunk-actual-size) 43 | remaining-audio (byte-array new-audio-size)] 44 | (System/arraycopy audio chunk-actual-size remaining-audio 0 new-audio-size) 45 | (recur remaining-audio (conj chunks chunk))) 46 | ;; No more chunks to process, return final result 47 | (conj chunks chunk)))))) 48 | 49 | (defn audio-splitter-config 50 | "Validate and apply defaults to audio splitter configuration." 51 | [config] 52 | (schema/parse-with-defaults AudioSplitterConfig config)) 53 | 54 | (defn audio-splitter-fn 55 | "Audio splitter processor function with multi-arity support." 56 | ([] {:ins {:in "Channel for raw audio frames"} 57 | :outs {:out "Channel for audio frames split by chunk size"} 58 | :params (schema/->describe-parameters AudioSplitterConfig)}) 59 | ([config] 60 | (audio-splitter-config config)) 61 | ([state _] 62 | ;; Transition function must return the state 63 | state) 64 | ([state _ frame] 65 | (cond 66 | (frame/audio-output-raw? frame) 67 | (let [{:audio.out/keys [duration-ms sample-size-bits]} state 68 | {:keys [audio sample-rate]} (:frame/data frame) 69 | ;; Calculate chunk size based on frame's sample rate and configured duration 70 | chunk-size (audio/audio-chunk-size {:sample-rate sample-rate 71 | :channels 1 ;; All generated audio (AI speech) is mono 72 | :sample-size-bits sample-size-bits 73 | :duration-ms duration-ms})] 74 | (t/log! {:id :audio-splitter 75 | :msg "Received audio output. Splitting into chunks" 76 | :level :debug 77 | :data {:sample-rate sample-rate 78 | :chunk-size chunk-size}}) 79 | (if-let [chunks (split-audio-into-chunks audio chunk-size)] 80 | ;; Create new frames preserving the sample rate from original frame 81 | [state {:out (mapv (fn [chunk-audio] 82 | (frame/audio-output-raw {:audio chunk-audio 83 | :sample-rate sample-rate})) 84 | chunks)}] 85 | [state])) 86 | :else [state]))) 87 | 88 | (def audio-splitter 89 | "Takes in audio-output-raw frames and splits them up into :audio.out/duration-ms 90 | chunks. Chunks are split to achieve realtime streaming." 91 | (flow/process audio-splitter-fn)) 92 | 93 | ;; Backward compatibility 94 | (defaliases 95 | {:src out/realtime-out-describe} 96 | {:src out/realtime-out-init!} 97 | {:src out/realtime-out-transition} 98 | {:alias realtime-out-transform :src out/base-realtime-out-transform} 99 | {:src out/realtime-out-fn} 100 | {:src out/realtime-out-processor} 101 | {:src out/realtime-speakers-out-describe} 102 | {:src out/realtime-speakers-out-init!} 103 | {:alias realtime-speakers-out-transition :src out/realtime-out-transition} 104 | {:alias realtime-speakers-out-transform :src out/base-realtime-out-transform} 105 | {:src out/realtime-speakers-out-fn} 106 | {:src out/realtime-speakers-out-processor} 107 | 108 | ;; twilio in 109 | {:src in/twilio-transport-in} 110 | {:src in/twilio-transport-in-describe} 111 | {:src in/twilio-transport-in-init!} 112 | {:src in/twilio-transport-in-transform} 113 | {:src in/twilio-transport-in-fn} 114 | 115 | ;; mic transport in 116 | {:src in/microphone-transport-in} 117 | {:src in/mic-transport-in-describe} 118 | {:src in/mic-transport-in-init!} 119 | {:src in/mic-transport-in-transform} 120 | {:src in/mic-transport-in-fn} 121 | 122 | ;; async-transform-in 123 | {:src in/async-transport-in-describe} 124 | {:src in/async-transport-in-init!} 125 | {:src in/async-transport-in-fn}) 126 | -------------------------------------------------------------------------------- /src/simulflow/processors/system_frame_router.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.processors.system-frame-router 2 | "Simple system frame router that collects frames on :sys-in and broadcasts on :sys-out. 3 | 4 | This processor acts as a fan-out hub for system frames within a single flow, 5 | eliminating the need for N×(N-1) connections between system frame producers and consumers." 6 | (:require 7 | [clojure.core.async.flow :as flow] 8 | [clojure.datafy :as datafy] 9 | [simulflow.frame :as frame] 10 | [taoensso.telemere :as t])) 11 | 12 | (defn system-frame-router-fn 13 | "Creates a system frame router processor. 14 | 15 | This processor: 16 | 1. Receives all frames on its :sys-in port 17 | 2. Forwards system frames to its :sys-out port 18 | 3. Drops non-system frames with optional logging 19 | 20 | Usage in flow: 21 | :system-router {:proc system-frame-router} 22 | 23 | Connections: 24 | [[:producer :sys-out] [:system-router :sys-in]] 25 | [[:system-router :sys-out] [:consumer1 :sys-in]] 26 | [[:system-router :sys-out] [:consumer2 :sys-in]] 27 | [[:system-router :sys-out] [:consumer3 :sys-in]]" 28 | 29 | ([] 30 | ;; 0-arity: describe the processor 31 | {:ins {:sys-in "System frames from producers"} 32 | :outs {:sys-out "System frames broadcasted to consumers"} 33 | :params {:log-dropped? "Log when non-system frames are dropped"}}) 34 | 35 | ([{:keys [log-dropped?] :or {log-dropped? false}}] 36 | ;; 1-arity: init processor state 37 | {:log-dropped? log-dropped?}) 38 | 39 | ([state _transition] 40 | ;; 2-arity: handle state transitions (not used) 41 | state) 42 | 43 | ([state input-port frame] 44 | ;; 3-arity: transform function - main routing logic 45 | (cond 46 | (and (= input-port :sys-in) (some? frame)) 47 | (if (frame/system-frame? frame) 48 | ;; Forward system 49 | [state {:sys-out [frame]}] 50 | ;; Drop non-system frames 51 | (do 52 | (when (:log-dropped? state) 53 | (t/log! {:level :debug :id :system-frame-router :frame-type (:frame/type frame)} 54 | "Dropped non-system frame")) 55 | [state])) 56 | 57 | :else 58 | ;; Unknown input port or nil frame - no output 59 | [state {}]))) 60 | 61 | (def system-frame-router 62 | "Convenience function that wraps the system-frame-router in a flow/process. 63 | 64 | This is the standard way to create the processor for use in flows." 65 | (flow/process system-frame-router-fn)) 66 | 67 | (defn contains-system-router? 68 | "Returns true if the flow-config contains the system-frame-router-process" 69 | [flow-config] 70 | (reduce-kv (fn [_ _ v] 71 | (if (= (:proc v) system-frame-router) 72 | (reduced true) 73 | false)) 74 | false 75 | flow-config)) 76 | 77 | (defn generate-system-router-connections 78 | "Given a flow config's processor map, analyzes each processor's :proc with clojure.datafy/datafy 79 | to check if it has :sys-in and :sys-out channels in its :desc. Returns a vector 80 | of connection pairs that should be added to connect processors to the system-router. 81 | 82 | Assumes the system-router processor is named :system-router in the flow config. 83 | 84 | Returns connections in the format: 85 | [;; System frame producers -> router 86 | [[:producer-with-sys-out :sys-out] [:system-router :sys-in]] 87 | ;; Router -> system frame consumers 88 | [[:system-router :sys-out] [:consumer-with-sys-in :sys-in]]]" 89 | [flow-config-processors] 90 | (let [processor-analyses (for [[proc-name {:keys [proc]}] flow-config-processors 91 | :when (and proc (not= proc-name :system-router))] 92 | (try 93 | (let [desc (-> proc datafy/datafy :desc)] 94 | {:name proc-name 95 | :has-sys-in? (contains? (:ins desc) :sys-in) 96 | :has-sys-out? (contains? (:outs desc) :sys-out)}) 97 | (catch Exception e 98 | {:name proc-name 99 | :has-sys-in? false 100 | :has-sys-out? false 101 | :error (.getMessage e)}))) 102 | 103 | sys-out-producers (filter :has-sys-out? processor-analyses) 104 | sys-in-consumers (filter :has-sys-in? processor-analyses) 105 | 106 | producer-connections (map (fn [{:keys [name]}] 107 | [[name :sys-out] [:system-router :sys-in]]) 108 | sys-out-producers) 109 | 110 | consumer-connections (map (fn [{:keys [name]}] 111 | [[:system-router :sys-out] [name :sys-in]]) 112 | sys-in-consumers)] 113 | 114 | (vec (concat producer-connections consumer-connections)))) 115 | 116 | (comment) 117 | ;; Usage in a flow - eliminates complex system frame connections 118 | 119 | ;; Before (complex N×N connections): 120 | ;; [[:activity-monitor :out] [:context-aggregator :in]] 121 | ;; [[:activity-monitor :out] [:tts :in]] 122 | ;; [[:transport-out :out] [:activity-monitor :in]] 123 | ;; [[:transport-out :out] [:context-aggregator :in]] 124 | 125 | ;; After (simple fan-out via system router): 126 | ;; {:procs {:system-router {:proc system-frame-router-process} 127 | ;; :activity-monitor {:proc activity-monitor-process} 128 | ;; :context-aggregator {:proc context-aggregator-process} 129 | ;; :transport-out {:proc transport-out-process} 130 | ;; :tts {:proc tts-process}} 131 | ;; 132 | ;; :conns [;; System frame producers -> router 133 | ;; [[:activity-monitor :out] [:system-router :sys-in]] 134 | ;; [[:transport-out :out] [:system-router :sys-in]] 135 | ;; 136 | ;; ;; Router -> system frame consumers 137 | ;; [[:system-router :sys-out] [:activity-monitor :sys-in]] 138 | ;; [[:system-router :sys-out] [:context-aggregator :sys-in]] 139 | ;; [[:system-router :sys-out] [:tts :sys-in]]]} 140 | -------------------------------------------------------------------------------- /examples/src/simulflow_examples/scenario_example.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow-examples.scenario-example 2 | (:require 3 | [clojure.core.async :as a] 4 | [clojure.core.async.flow :as flow] 5 | [simulflow-examples.local :as local] 6 | [simulflow.scenario-manager :as sm] 7 | [taoensso.telemere :as t])) 8 | 9 | (def config 10 | {:initial-node :start 11 | :nodes 12 | {:start 13 | ;; Role messages dictate how the AI should behave. Ideally :role-messages 14 | ;; should be present on the :initial-node as they persist for the rest of the conversation 15 | {:run-llm? true ;; Run the AI when this node is set. We don't run inference as we will run pre-defined speech 16 | :role-messages [{:role :system 17 | :content "You are a restaurant reservation assistant for La Maison, an upscale French restaurant. You must ALWAYS use one of the available functions to progress the conversation. This is a phone conversations and your responses will be converted to audio. Avoid outputting special characters and emojis. Be casual and friendly."}] 18 | ;; task-messages signify the current task the AI has. Each node requires a 19 | ;; task 20 | :task-messages [{:role :system 21 | :content "Warmly greet the customer and ask how many people are in their party."}] 22 | :pre-actions [{:type :tts-say 23 | :text "Hello! Welcome to La Maison! How many people are in your party?"}] 24 | :functions [{:type :function 25 | :function 26 | {:name "record_party_size" 27 | :handler (fn [{:keys [size]}] size) 28 | :description "Record the number of people in the party" 29 | :parameters 30 | {:type :object 31 | :properties 32 | {:size {:type :integer 33 | :description "The number of people that will dine." 34 | :minimum 1 35 | :maximum 12}} 36 | :required [:size]} 37 | ;; transition-to dictates the next node the AI will go to 38 | :transition-to :get-time}}]} 39 | :get-time 40 | {:task-messages [{:role :system 41 | :content "Ask what time they'd like to dine. Restaurant is open 5 PM to 10 PM. After they provide a time, confirm it's within operating hours before recording. Use 24-hour format for internal recording (e.g., 17:00 for 5 PM)."}] 42 | :functions [{:type :function 43 | :function {:name "record_time" 44 | :handler (fn [{:keys [time]}] time) 45 | :description "Record the requested time" 46 | :parameters {:type :object 47 | :properties {:time {:type :string 48 | :pattern "^(17|18|19|20|21|22):([0-5][0-9])$" 49 | :description "Reservation time in 24-hour format (17:00-22:00)"}} 50 | :required [:time]} 51 | :transition-to :confirm}}]} 52 | 53 | :confirm 54 | {:task-messages [{:role :system 55 | :content "Confirm the reservation details and end the conversation. Say back to the client the details of the reservation: \"Ok! So a reservation for X people at Y PM. Is this corret?\" "}] 56 | :functions [{:type :function 57 | :function {:name "end" 58 | :description "End the conversation" 59 | :parameters {:type :object, :properties {}} 60 | :transition-to :end}}]} 61 | :end {:task-messages [{:role :system, :content "Thank them and end the conversation."}] 62 | :functions [] 63 | :pre-actions [{:type :end-conversation}]}}}) 64 | 65 | (defn scenario-example 66 | "A scenario is a predefined, highly structured conversation. LLM performance 67 | degrades when it has a big complex prompt to enact, so to ensure a consistent 68 | output use scenarios that transition the LLM into a new scenario node with a clear 69 | instruction for the current node." 70 | ([] 71 | (scenario-example {:initial-node :start})) 72 | ([{:keys [initial-node]}] 73 | (let [flow (local/make-local-flow 74 | {;; Don't add any context because the scenario will handle that 75 | :llm/context {:messages [] 76 | :tools []} 77 | :language :en 78 | 79 | ;; add gateway process for scenario to inject frames 80 | :extra-procs {:scenario {:proc (flow/process #'sm/scenario-in-process)}} 81 | 82 | :extra-conns [[[:scenario :sys-out] [:tts :in]] 83 | [[:scenario :sys-out] [:context-aggregator :sys-in]]]}) 84 | 85 | s (sm/scenario-manager {:flow flow 86 | :flow-in-coord [:scenario :scenario-in] ;; scenario-manager will inject frames through this channel 87 | :scenario-config (assoc config :initial-node initial-node)})] 88 | 89 | {:flow flow 90 | :scenario s}))) 91 | 92 | (comment 93 | 94 | (def call-in-progress? (atom false)) 95 | ;; create flow & scenario 96 | (def s (scenario-example {:initial-node :start})) 97 | 98 | ;; Start local ai flow - starts paused 99 | (let [{:keys [report-chan error-chan]} (flow/start (:flow s))] 100 | ;; start the scenario by setting initial node 101 | (sm/start (:scenario s)) 102 | ;; Resume local ai -> you can now speak with the AI 103 | (flow/resume (:flow s)) 104 | (reset! call-in-progress? true) 105 | ;; Monitor report & error channels 106 | (a/thread 107 | (loop [] 108 | (when @call-in-progress? 109 | (when-let [[msg c] (a/alts!! [report-chan error-chan])] 110 | (when (map? msg) 111 | (t/log! {:level :debug :id (if (= c error-chan) :error :report)} msg)) 112 | (recur)))))) 113 | 114 | ;; Stop the conversation 115 | (do 116 | (flow/stop (:flow s)) 117 | (reset! call-in-progress? false)) 118 | 119 | ,) 120 | -------------------------------------------------------------------------------- /examples/src/simulflow_examples/local_with_mute_filter.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow-examples.local-with-mute-filter 2 | "This example demonstrates muting user input handling​ When the LLM is responding to 3 | the user, if the user says something while the bot is speaking it will not be processed." 4 | (:require 5 | [clojure.core.async :as a] 6 | [clojure.core.async.flow :as flow] 7 | [simulflow.async :refer [vthread-loop]] 8 | [simulflow.filters.mute :as mute-filter] 9 | [simulflow.processors.deepgram :as deepgram] 10 | [simulflow.processors.elevenlabs :as xi] 11 | [simulflow.processors.llm-context-aggregator :as context] 12 | [simulflow.processors.openai :as openai] 13 | [simulflow.processors.system-frame-router :as system-router] 14 | [simulflow.secrets :refer [secret]] 15 | [simulflow.transport :as transport] 16 | [simulflow.transport.in :as transport-in] 17 | [simulflow.transport.out :as transport-out] 18 | [taoensso.telemere :as t])) 19 | 20 | (def llm-context 21 | {:messages 22 | [{:role "system" 23 | :content "You are a voice agent operating via phone. Be 24 | concise in your answers. The input you receive comes from a 25 | speech-to-text (transcription) system that isn't always 26 | efficient and may send unclear text. Ask for 27 | clarification when you're unsure what the person said."}] 28 | :tools 29 | [{:type :function 30 | :function 31 | {:name "get_weather" 32 | :handler (fn [{:keys [town]}] (str "The weather in " town " is 17 degrees celsius")) 33 | :description "Get the current weather of a location" 34 | :parameters {:type :object 35 | :required [:town] 36 | :properties {:town {:type :string 37 | :description "Town for which to retrieve the current weather"}} 38 | :additionalProperties false} 39 | :strict true}}]}) 40 | 41 | (def chunk-duration 20) 42 | 43 | (def flow-processors 44 | {;; Capture audio from microphone and send raw-audio-input frames further in the pipeline 45 | :transport-in {:proc transport-in/microphone-transport-in 46 | :args {:vad/analyser :vad.analyser/silero 47 | :pipeline/supports-interrupt? false}} 48 | ;; raw-audio-input -> transcription frames 49 | :transcriptor {:proc deepgram/deepgram-processor 50 | :args {:transcription/api-key (secret [:deepgram :api-key]) 51 | :transcription/interim-results? true 52 | :transcription/punctuate? false 53 | ;; We use silero for computing VAD 54 | :transcription/vad-events? false 55 | :transcription/smart-format? true 56 | :transcription/model :nova-2 57 | :transcription/utterance-end-ms 1000 58 | :transcription/language :en}} 59 | 60 | ;; user transcription & llm message frames -> llm-context frames 61 | ;; responsible for keeping the full conversation history 62 | :context-aggregator {:proc context/context-aggregator 63 | :args {:llm/context llm-context}} 64 | 65 | ;; Takes llm-context frames and produces new llm-text-chunk & llm-tool-call-chunk frames 66 | :llm {:proc openai/openai-llm-process 67 | :args {:openai/api-key (secret [:openai :new-api-sk]) 68 | :llm/model :gpt-4.1-mini}} 69 | 70 | ;; llm-text-chunk & llm-tool-call-chunk -> llm-context-messages-append frames 71 | :assistant-context-assembler {:proc context/assistant-context-assembler 72 | :args {}} 73 | 74 | ;; llm-text-chunk -> sentence speak frames (faster for text to speech) 75 | :llm-sentence-assembler {:proc context/llm-sentence-assembler} 76 | 77 | ;; speak-frames -> audio-output-raw frames 78 | :tts {:proc xi/elevenlabs-tts-process 79 | :args {:elevenlabs/api-key (secret [:elevenlabs :api-key]) 80 | :elevenlabs/model-id "eleven_flash_v2_5" 81 | :elevenlabs/voice-id (secret [:elevenlabs :voice-id]) 82 | :voice/stability 0.5 83 | :voice/similarity-boost 0.8 84 | :voice/use-speaker-boost? true 85 | :pipeline/language :en}} 86 | 87 | ;; audio-output-raw -> smaller audio-output-raw frames (used for sending audio in realtime) 88 | :audio-splitter {:proc transport/audio-splitter 89 | :args {:audio.out/duration-ms chunk-duration}} 90 | 91 | ;; speakers out 92 | :transport-out {:proc transport-out/realtime-speakers-out-processor 93 | :args {:audio.out/sending-interval chunk-duration 94 | :audio.out/duration-ms chunk-duration}} 95 | 96 | :mute-filter {:proc mute-filter/process 97 | :args {:mute/strategies #{:mute.strategy/bot-speech}}} 98 | ;; Fan out of system frames to processors that need it 99 | :system-router {:proc system-router/system-frame-router}}) 100 | 101 | (def flow-conns (into (system-router/generate-system-router-connections flow-processors) 102 | [[[:transport-in :out] [:transcriptor :in]] 103 | 104 | [[:transcriptor :out] [:context-aggregator :in]] 105 | [[:context-aggregator :out] [:llm :in]] 106 | 107 | ;; Aggregate full context 108 | [[:llm :out] [:assistant-context-assembler :in]] 109 | [[:assistant-context-assembler :out] [:context-aggregator :in]] 110 | 111 | ;; Assemble sentence by sentence for fast speech 112 | [[:llm :out] [:llm-sentence-assembler :in]] 113 | 114 | [[:tts :out] [:audio-splitter :in]] 115 | [[:audio-splitter :out] [:transport-out :in]]])) 116 | 117 | (def flow-config 118 | {:procs flow-processors 119 | :conns flow-conns}) 120 | 121 | (def g (flow/create-flow flow-config)) 122 | 123 | (defonce flow-started? (atom false)) 124 | 125 | (comment 126 | 127 | ;; Start local ai flow - starts paused 128 | (let [{:keys [report-chan error-chan]} (flow/start g)] 129 | (reset! flow-started? true) 130 | ;; Resume local ai -> you can now speak with the AI 131 | (flow/resume g) 132 | (vthread-loop [] 133 | (when @flow-started? 134 | (when-let [[msg c] (a/alts!! [report-chan error-chan])] 135 | (when (map? msg) 136 | (t/log! (cond-> {:level :debug :id (if (= c error-chan) :error :report)} 137 | (= c error-chan) (assoc :error msg)) msg)) 138 | (recur))))) 139 | 140 | ;; Stop the conversation 141 | (do 142 | (flow/stop g) 143 | (reset! flow-started? false)) 144 | 145 | ,) 146 | -------------------------------------------------------------------------------- /examples/src/simulflow_examples/scenario_example_system_router_mute_filter.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow-examples.scenario-example-system-router-mute-filter 2 | (:require 3 | [clojure.core.async :as a] 4 | [clojure.core.async.flow :as flow] 5 | [simulflow-examples.scenario-example :refer [config]] 6 | [simulflow.async :refer [vthread-loop]] 7 | [simulflow.filters.mute :as mute-filter] 8 | [simulflow.processors.deepgram :as deepgram] 9 | [simulflow.processors.elevenlabs :as xi] 10 | [simulflow.processors.llm-context-aggregator :as context] 11 | [simulflow.processors.openai :as openai] 12 | [simulflow.processors.system-frame-router :as system-router] 13 | [simulflow.scenario-manager :as sm] 14 | [simulflow.secrets :refer [secret]] 15 | [simulflow.transport :as transport] 16 | [simulflow.transport.in :as transport-in] 17 | [simulflow.transport.out :as transport-out] 18 | [taoensso.telemere :as t])) 19 | 20 | (def llm-context 21 | {:messages 22 | [{:role "system" 23 | :content "You are a voice agent operating via phone. Be 24 | concise in your answers. The input you receive comes from a 25 | speech-to-text (transcription) system that isn't always 26 | efficient and may send unclear text. Ask for 27 | clarification when you're unsure what the person said."}] 28 | :tools 29 | [{:type :function 30 | :function 31 | {:name "get_weather" 32 | :handler (fn [{:keys [town]}] (str "The weather in " town " is 17 degrees celsius")) 33 | :description "Get the current weather of a location" 34 | :parameters {:type :object 35 | :required [:town] 36 | :properties {:town {:type :string 37 | :description "Town for which to retrieve the current weather"}} 38 | :additionalProperties false} 39 | :strict true}}]}) 40 | 41 | (def chunk-duration 20) 42 | 43 | (def flow-processors 44 | {;; Capture audio from microphone and send raw-audio-input frames further in the pipeline 45 | :transport-in {:proc transport-in/microphone-transport-in 46 | :args {:vad/analyser :vad.analyser/silero 47 | :pipeline/supports-interrupt? false}} 48 | ;; raw-audio-input -> transcription frames 49 | :transcriptor {:proc deepgram/deepgram-processor 50 | :args {:transcription/api-key (secret [:deepgram :api-key]) 51 | :transcription/interim-results? true 52 | :transcription/punctuate? false 53 | ;; We use silero for computing VAD 54 | :transcription/vad-events? false 55 | :transcription/smart-format? true 56 | :transcription/model :nova-2 57 | :transcription/utterance-end-ms 1000 58 | :transcription/language :en}} 59 | 60 | ;; user transcription & llm message frames -> llm-context frames 61 | ;; responsible for keeping the full conversation history 62 | :context-aggregator {:proc context/context-aggregator 63 | :args {:llm/context llm-context}} 64 | 65 | ;; Takes llm-context frames and produces new llm-text-chunk & llm-tool-call-chunk frames 66 | :llm {:proc openai/openai-llm-process 67 | :args {:openai/api-key (secret [:openai :new-api-sk]) 68 | :llm/model :gpt-4.1-mini}} 69 | 70 | ;; llm-text-chunk & llm-tool-call-chunk -> llm-context-messages-append frames 71 | :assistant-context-assembler {:proc context/assistant-context-assembler 72 | :args {}} 73 | 74 | ;; llm-text-chunk -> sentence speak frames (faster for text to speech) 75 | :llm-sentence-assembler {:proc context/llm-sentence-assembler} 76 | 77 | ;; speak-frames -> audio-output-raw frames 78 | :tts {:proc xi/elevenlabs-tts-process 79 | :args {:elevenlabs/api-key (secret [:elevenlabs :api-key]) 80 | :elevenlabs/model-id "eleven_flash_v2_5" 81 | :elevenlabs/voice-id (secret [:elevenlabs :voice-id]) 82 | :voice/stability 0.5 83 | :voice/similarity-boost 0.8 84 | :voice/use-speaker-boost? true 85 | :pipeline/language :en}} 86 | 87 | ;; audio-output-raw -> smaller audio-output-raw frames (used for sending audio in realtime) 88 | :audio-splitter {:proc transport/audio-splitter 89 | :args {:audio.out/duration-ms chunk-duration}} 90 | 91 | ;; speakers out 92 | :transport-out {:proc transport-out/realtime-speakers-out-processor 93 | :args {:audio.out/sending-interval chunk-duration 94 | :audio.out/duration-ms chunk-duration}} 95 | 96 | :mute-filter {:proc mute-filter/process 97 | :args {:mute/strategies #{:mute.strategy/bot-speech}}} 98 | 99 | :scenario-in {:proc sm/scenario-in-process} 100 | ;; Fan out of system frames to processors that need it 101 | :system-router {:proc system-router/system-frame-router}}) 102 | 103 | (def flow-conns (into (system-router/generate-system-router-connections flow-processors) 104 | [[[:transport-in :out] [:transcriptor :in]] 105 | 106 | [[:transcriptor :out] [:context-aggregator :in]] 107 | [[:context-aggregator :out] [:llm :in]] 108 | 109 | ;; Aggregate full context 110 | [[:llm :out] [:assistant-context-assembler :in]] 111 | [[:assistant-context-assembler :out] [:context-aggregator :in]] 112 | 113 | ;; Assemble sentence by sentence for fast speech 114 | [[:llm :out] [:llm-sentence-assembler :in]] 115 | 116 | [[:tts :out] [:audio-splitter :in]] 117 | [[:audio-splitter :out] [:transport-out :in]]])) 118 | 119 | (def flow-config 120 | {:procs flow-processors 121 | :conns flow-conns}) 122 | 123 | (def g (flow/create-flow flow-config)) 124 | 125 | (defonce flow-started? (atom false)) 126 | 127 | (def s (sm/scenario-manager {:flow g 128 | :scenario-config config 129 | :flow-in-coord [:scenario-in :scenario-in]})) 130 | 131 | (comment 132 | 133 | ;; Start local ai flow - starts paused 134 | (let [{:keys [report-chan error-chan]} (flow/start g)] 135 | (reset! flow-started? true) 136 | (sm/start s) 137 | ;; Resume local ai -> you can now speak with the AI 138 | (flow/resume g) 139 | (vthread-loop [] 140 | (when @flow-started? 141 | (when-let [[msg c] (a/alts!! [report-chan error-chan])] 142 | (when (map? msg) 143 | (t/log! (cond-> {:level :debug :id (if (= c error-chan) :error :report)} 144 | (= c error-chan) (assoc :error msg)) msg)) 145 | (recur))))) 146 | 147 | ;; Stop the conversation 148 | (do 149 | (flow/stop g) 150 | (reset! flow-started? false)) 151 | 152 | ,) 153 | -------------------------------------------------------------------------------- /examples/src/simulflow_examples/local_w_interruption_support.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow-examples.local-w-interruption-support 2 | "This example demonstrates interruption handling​ When the LLM is responding to 3 | the user, if the user says something, the current playback is interrupted and 4 | processors that have interruption support drop their activity until the user stops speaking." 5 | (:require 6 | [clojure.core.async :as a] 7 | [clojure.core.async.flow :as flow] 8 | [simulflow.async :refer [vthread-loop]] 9 | [simulflow.processors.activity-monitor :as activity-monitor] 10 | [simulflow.processors.deepgram :as deepgram] 11 | [simulflow.processors.elevenlabs :as xi] 12 | [simulflow.processors.llm-context-aggregator :as context] 13 | [simulflow.processors.openai :as openai] 14 | [simulflow.processors.system-frame-router :as system-router] 15 | [simulflow.secrets :refer [secret]] 16 | [simulflow.transport :as transport] 17 | [simulflow.transport.in :as transport-in] 18 | [simulflow.transport.out :as transport-out] 19 | [taoensso.telemere :as t])) 20 | 21 | (def llm-context 22 | {:messages 23 | [{:role "system" 24 | :content "You are a voice agent operating via phone. Be 25 | concise in your answers. The input you receive comes from a 26 | speech-to-text (transcription) system that isn't always 27 | efficient and may send unclear text. Ask for 28 | clarification when you're unsure what the person said."}] 29 | :tools 30 | [{:type :function 31 | :function 32 | {:name "get_weather" 33 | :handler (fn [{:keys [town]}] (str "The weather in " town " is 17 degrees celsius")) 34 | :description "Get the current weather of a location" 35 | :parameters {:type :object 36 | :required [:town] 37 | :properties {:town {:type :string 38 | :description "Town for which to retrieve the current weather"}} 39 | :additionalProperties false} 40 | :strict true}}]}) 41 | 42 | (def chunk-duration 20) 43 | 44 | (def flow-processors 45 | {;; Capture audio from microphone and send raw-audio-input frames further in the pipeline 46 | :transport-in {:proc transport-in/microphone-transport-in 47 | :args {:vad/analyser :vad.analyser/silero 48 | :pipeline/supports-interrupt? true}} 49 | ;; raw-audio-input -> transcription frames 50 | :transcriptor {:proc deepgram/deepgram-processor 51 | :args {:transcription/api-key (secret [:deepgram :api-key]) 52 | :transcription/interim-results? true 53 | :transcription/punctuate? false 54 | ;; We use silero for computing VAD 55 | :transcription/vad-events? false 56 | :transcription/smart-format? true 57 | :transcription/model :nova-2 58 | :transcription/utterance-end-ms 1000 59 | :transcription/language :en}} 60 | 61 | ;; user transcription & llm message frames -> llm-context frames 62 | ;; responsible for keeping the full conversation history 63 | :context-aggregator {:proc context/context-aggregator 64 | :args {:llm/context llm-context}} 65 | 66 | ;; Takes llm-context frames and produces new llm-text-chunk & llm-tool-call-chunk frames 67 | :llm {:proc openai/openai-llm-process 68 | :args {:openai/api-key (secret [:openai :new-api-sk]) 69 | :llm/model :gpt-4.1-mini}} 70 | 71 | ;; llm-text-chunk & llm-tool-call-chunk -> llm-context-messages-append frames 72 | :assistant-context-assembler {:proc context/assistant-context-assembler 73 | :args {}} 74 | 75 | ;; llm-text-chunk -> sentence speak frames (faster for text to speech) 76 | :llm-sentence-assembler {:proc context/llm-sentence-assembler} 77 | 78 | ;; speak-frames -> audio-output-raw frames 79 | :tts {:proc xi/elevenlabs-tts-process 80 | :args {:elevenlabs/api-key (secret [:elevenlabs :api-key]) 81 | :elevenlabs/model-id "eleven_flash_v2_5" 82 | :elevenlabs/voice-id (secret [:elevenlabs :voice-id]) 83 | :voice/stability 0.5 84 | :voice/similarity-boost 0.8 85 | :voice/use-speaker-boost? true 86 | :pipeline/language :en}} 87 | 88 | ;; audio-output-raw -> smaller audio-output-raw frames (used for sending audio in realtime) 89 | :audio-splitter {:proc transport/audio-splitter 90 | :args {:audio.out/duration-ms chunk-duration}} 91 | 92 | ;; speakers out 93 | :transport-out {:proc transport-out/realtime-speakers-out-processor 94 | :args {:audio.out/sending-interval chunk-duration 95 | :audio.out/duration-ms chunk-duration}} 96 | 97 | :activity-monitor {:proc activity-monitor/process 98 | :args {::activity-monitor/timeout-ms 5000}} 99 | :system-router {:proc system-router/system-frame-router}}) 100 | 101 | (def flow-conns (into (system-router/generate-system-router-connections flow-processors) 102 | [[[:transport-in :out] [:transcriptor :in]] 103 | 104 | [[:transcriptor :out] [:context-aggregator :in]] 105 | [[:context-aggregator :out] [:llm :in]] 106 | 107 | ;; Aggregate full context 108 | [[:llm :out] [:assistant-context-assembler :in]] 109 | [[:assistant-context-assembler :out] [:context-aggregator :in]] 110 | 111 | ;; Assemble sentence by sentence for fast speech 112 | [[:llm :out] [:llm-sentence-assembler :in]] 113 | 114 | [[:tts :out] [:audio-splitter :in]] 115 | [[:audio-splitter :out] [:transport-out :in]] 116 | 117 | ;; Activity detection 118 | [[:activity-monitor :out] [:context-aggregator :in]] 119 | [[:activity-monitor :out] [:tts :in]]])) 120 | 121 | (def flow-config 122 | {:procs flow-processors 123 | :conns flow-conns}) 124 | 125 | (def g (flow/create-flow flow-config)) 126 | 127 | (defonce flow-started? (atom false)) 128 | 129 | (comment 130 | 131 | ;; Start local ai flow - starts paused 132 | (let [{:keys [report-chan error-chan]} (flow/start g)] 133 | (reset! flow-started? true) 134 | ;; Resume local ai -> you can now speak with the AI 135 | (flow/resume g) 136 | (vthread-loop [] 137 | (when @flow-started? 138 | (when-let [[msg c] (a/alts!! [report-chan error-chan])] 139 | (when (map? msg) 140 | (t/log! (cond-> {:level :debug :id (if (= c error-chan) :error :report)} 141 | (= c error-chan) (assoc :error msg)) msg)) 142 | (recur))))) 143 | 144 | ;; Stop the conversation 145 | (do 146 | (flow/stop g) 147 | (reset! flow-started? false)) 148 | 149 | ,) 150 | -------------------------------------------------------------------------------- /src/simulflow/utils/request.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.utils.request 2 | "Taken from https://github.com/wkok/openai-clojure/blob/main/src/wkok/openai_clojure/sse.clj." 3 | {:no-doc true} 4 | (:require 5 | [clojure.core.async :as a] 6 | [clojure.string :as string] 7 | [hato.client :as http] 8 | [hato.middleware :as hm] 9 | [simulflow.async :refer [vthread vthread-loop]] 10 | [simulflow.utils.core :as u] 11 | [taoensso.telemere :as t]) 12 | (:import 13 | (java.io InputStream))) 14 | 15 | (def event-mask (re-pattern "(?s).+?\n\n")) 16 | 17 | (defn deliver-events 18 | [events {:keys [on-next]}] 19 | (when on-next 20 | (vthread-loop [] 21 | (when-let [event (a/!! events %)) es)) 89 | (recur (drop (apply + (map #(count (.getBytes ^String %)) es)) 90 | next-byte-coll)) 91 | 92 | ;; Output stream closed, exiting read-loop 93 | (do 94 | (t/log! {:level :trace :msg "SSE output stream closed during event sending"}) 95 | nil))) 96 | 97 | (do 98 | (t/log! {:level :trace :msg "SSE no events found in data chunk"}) 99 | (recur next-byte-coll))))))) 100 | (finally 101 | (when close? 102 | (a/close! events)) 103 | (.close event-stream)))) 104 | 105 | events)) 106 | 107 | (defn sse-request 108 | "Process streamed results. 109 | If on-next callback provided, then read from channel and call the callback. 110 | Returns a response with the core.async channel as the body" 111 | [{:keys [params] :as ctx}] 112 | (let [events (sse-events ctx)] 113 | (deliver-events events params) 114 | {:status 200 115 | :body events})) 116 | 117 | (defn wrap-trace 118 | "Middleware that allows the user to supply a trace function that 119 | will receive the raw request & response as arguments. 120 | See: https://github.com/gnarroway/hato?tab=readme-ov-file#custom-middleware" 121 | [trace] 122 | (fn [client] 123 | (fn 124 | ([req] 125 | (let [resp (client req)] 126 | (trace req resp) 127 | resp)) 128 | ([req respond raise] 129 | (client req 130 | #(respond (do (trace req %) 131 | %)) 132 | raise))))) 133 | 134 | (defn with-trace-middleware 135 | "The default list of middleware hato uses for wrapping requests but 136 | with added wrap-trace in the correct position to allow tracing of error messages." 137 | [trace] 138 | [hm/wrap-request-timing 139 | 140 | hm/wrap-query-params 141 | hm/wrap-basic-auth 142 | hm/wrap-oauth 143 | hm/wrap-user-info 144 | hm/wrap-url 145 | 146 | hm/wrap-decompression 147 | hm/wrap-output-coercion 148 | 149 | (wrap-trace trace) 150 | 151 | hm/wrap-exceptions 152 | hm/wrap-accept 153 | hm/wrap-accept-encoding 154 | hm/wrap-multipart 155 | 156 | hm/wrap-content-type 157 | hm/wrap-form-params 158 | hm/wrap-nested-params 159 | hm/wrap-method]) 160 | 161 | (comment 162 | (require '[simulflow.secrets :refer [secret]]) 163 | 164 | (http/request {:url "https://api.openai.com/v1/chat/completions" 165 | :headers {"Authorization" (str "Bearer " (secret [:openai :new-api-sk])) 166 | "Content-Type" "application/json"} 167 | 168 | :method :post 169 | :body (u/json-str {:messages [{:role "system" :content "You are a helpful assistant"} 170 | {:role "user" :content "What is the capital of France?"}] 171 | :model "gpt-4o-mini"})}) 172 | 173 | (def res (sse-request 174 | {:request {:url "https://api.openai.com/v1/chat/completions" 175 | :headers {"Authorization" (str "Bearer " (secret [:openai :new-api-sk])) 176 | "Content-Type" "application/json"} 177 | 178 | :method :post 179 | :body (u/json-str {:messages [{:role "system" :content "You are a helpful assistant"} 180 | {:role "user" :content "What is the capital of France?"}] 181 | :stream true 182 | :model "gpt-4o-mini"})} 183 | :params {:stream/close? true}})) 184 | 185 | res 186 | 187 | (def ch (:body res)) 188 | 189 | (a/go-loop [] 190 | (println "Taking from result") 191 | (when-let [res (a/ transcription frames 67 | :transcriptor {:proc deepgram/deepgram-processor 68 | :args {:transcription/api-key (secret [:deepgram :api-key]) 69 | :transcription/interim-results? true 70 | :transcription/punctuate? false 71 | :transcription/vad-events? false 72 | :transcription/smart-format? true 73 | :transcription/model :nova-2 74 | :transcription/utterance-end-ms 1000 75 | :transcription/language language}} 76 | 77 | ;; user transcription & llm message frames -> llm-context frames 78 | ;; responsible for keeping the full conversation history 79 | :context-aggregator {:proc context/context-aggregator 80 | :args {:llm/context llm-context 81 | :aggregator/debug? debug?}} 82 | 83 | ;; Takes llm-context frames and produces new llm-text-chunk & llm-tool-call-chunk frames 84 | :llm {:proc openai/openai-llm-process 85 | :args {:openai/api-key (secret [:openai :new-api-sk]) 86 | :llm/model :gpt-4.1-mini}} 87 | 88 | ;; llm-text-chunk & llm-tool-call-chunk -> llm-context-messages-append frames 89 | :assistant-context-assembler {:proc context/assistant-context-assembler 90 | :args {:debug? debug?}} 91 | 92 | ;; llm-text-chunk -> sentence speak frames (faster for text to speech) 93 | :llm-sentence-assembler {:proc context/llm-sentence-assembler} 94 | 95 | ;; speak-frames -> audio-output-raw frames 96 | :tts {:proc xi/elevenlabs-tts-process 97 | :args {:elevenlabs/api-key (secret [:elevenlabs :api-key]) 98 | :elevenlabs/model-id "eleven_flash_v2_5" 99 | :elevenlabs/voice-id (secret [:elevenlabs :voice-id]) 100 | :voice/stability 0.5 101 | :voice/similarity-boost 0.8 102 | :voice/use-speaker-boost? true 103 | :pipeline/language language}} 104 | 105 | ;; audio-output-raw -> smaller audio-output-raw frames (used for sending audio in realtime) 106 | :audio-splitter {:proc transport/audio-splitter 107 | :args {:audio.out/duration-ms chunk-duration-ms}} 108 | 109 | ;; speakers out 110 | :transport-out {:proc transport-out/realtime-speakers-out-processor 111 | :args {:audio.out/sending-interval chunk-duration-ms 112 | :audio.out/duration-ms chunk-duration-ms}} 113 | 114 | :activity-monitor {:proc activity-monitor/process 115 | :args {::activity-monitor/timeout-ms 5000}}} 116 | extra-procs) 117 | :conns (concat 118 | [[[:transport-in :out] [:transcriptor :in]] 119 | 120 | [[:transcriptor :out] [:context-aggregator :in]] 121 | [[:transport-in :sys-out] [:context-aggregator :sys-in]] 122 | [[:context-aggregator :out] [:llm :in]] 123 | 124 | ;; Aggregate full context 125 | [[:llm :out] [:assistant-context-assembler :in]] 126 | [[:assistant-context-assembler :out] [:context-aggregator :in]] 127 | 128 | ;; Assemble sentence by sentence for fast speech 129 | [[:llm :out] [:llm-sentence-assembler :in]] 130 | [[:llm-sentence-assembler :sys-out] [:tts :sys-in]] 131 | 132 | [[:tts :out] [:audio-splitter :in]] 133 | [[:audio-splitter :out] [:transport-out :in]] 134 | 135 | ;; Activity detection 136 | [[:transport-out :sys-out] [:activity-monitor :sys-in]] 137 | [[:transport-in :sys-out] [:activity-monitor :sys-in]] 138 | [[:transcriptor :sys-out] [:activity-monitor :sys-in]] 139 | [[:activity-monitor :out] [:context-aggregator :in]] 140 | [[:activity-monitor :out] [:tts :in]]] 141 | extra-conns)}))) 142 | 143 | (comment 144 | 145 | (def local-ai (make-local-flow)) 146 | 147 | (defonce flow-started? (atom false)) 148 | 149 | ;; Start local ai flow - starts paused 150 | (let [{:keys [report-chan error-chan]} (flow/start local-ai)] 151 | (reset! flow-started? true) 152 | ;; Resume local ai -> you can now speak with the AI 153 | (flow/resume local-ai) 154 | (vthread-loop [] 155 | (when @flow-started? 156 | (when-let [[msg c] (a/alts!! [report-chan error-chan])] 157 | (when (map? msg) 158 | (t/log! (cond-> {:level :debug :id (if (= c error-chan) :error :report)} 159 | (= c error-chan) (assoc :error msg)) msg)) 160 | (recur))))) 161 | 162 | ;; Stop the conversation 163 | (do 164 | (flow/stop local-ai) 165 | (reset! flow-started? false)) 166 | 167 | ,) 168 | -------------------------------------------------------------------------------- /src/simulflow/processors/groq.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.processors.groq 2 | (:require 3 | [clojure.core.async :as a] 4 | [clojure.core.async.flow :as flow] 5 | [hato.client :as http] 6 | [malli.core :as m] 7 | [malli.transform :as mt] 8 | [simulflow.frame :as frame] 9 | [simulflow.schema :as schema] 10 | [simulflow.utils.core :as u] 11 | [simulflow.utils.request :as request] 12 | [taoensso.telemere :as t])) 13 | 14 | (def groq-api-url "https://api.groq.com/openai/v1") 15 | (def groq-completions-url (str groq-api-url "/chat/completions")) 16 | 17 | (defn stream-groq-chat-completion 18 | [{:keys [api-key messages tools model]}] 19 | (:body (request/sse-request {:request {:url groq-completions-url 20 | :headers {"Authorization" (str "Bearer " api-key) 21 | "Content-Type" "application/json"} 22 | 23 | :method :post 24 | :body (u/json-str (cond-> {:messages messages 25 | :stream true 26 | :model model} 27 | (pos? (count tools)) (assoc :tools tools)))} 28 | :params {:stream/close? true}}))) 29 | 30 | (defn normal-chat-completion 31 | [{:keys [api-key messages tools model]}] 32 | (http/request {:url groq-completions-url 33 | :headers {"Authorization" (str "Bearer " api-key) 34 | "Content-Type" "application/json"} 35 | 36 | :throw-on-error? false 37 | :method :post 38 | :body (u/json-str (cond-> {:messages messages 39 | :stream true 40 | :model model} 41 | (pos? (count tools)) (assoc :tools tools)))})) 42 | 43 | (comment 44 | 45 | (map u/token-content (a/> (http/get (str groq-api-url "/models") 54 | {:headers {"Authorization" (str "Bearer " (secret [:groq :api-key])) 55 | "Content-Type" "application/json"}}) 56 | :body 57 | u/parse-if-json 58 | :data 59 | (map :id))) 60 | 61 | (def delta (comp :delta first :choices)) 62 | 63 | (def GroqLLMConfigSchema 64 | [:map 65 | {:closed true 66 | :description "Groq LLM configuration"} 67 | 68 | [:llm/model 69 | (schema/flex-enum 70 | {:description "Groq model identifier" 71 | :error/message "Must be a valid Groq model" 72 | :default "llama-3.3-70b-versatile"} 73 | ["llama-3.2-3b-preview" 74 | "llama-3.1-8b-instant" 75 | "llama-3.3-70b-versatile" 76 | "llama-3.2-11b-vision-preview" 77 | "whisper-large-v3-turbo" 78 | "llama-3.1-70b-versatile" 79 | "llama3-8b-8192" 80 | "llama3-70b-8192" 81 | "llama-guard-3-8b" 82 | "whisper-large-v3" 83 | "llama-3.2-1b-preview" 84 | "mixtral-8x7b-32768" 85 | "gemma2-9b-it" 86 | "llama-3.2-90b-vision-preview" 87 | "llama-3.3-70b-specdec" 88 | "distil-whisper-large-v3-en"])] 89 | 90 | [:groq/api-key 91 | [:string 92 | {:description "Groq API key" 93 | :secret true ;; Marks this as sensitive data 94 | :min 40 ;; Groq API keys are typically longer 95 | :error/message "Invalid Groq API key format"}]]]) 96 | 97 | (defn flow-do-completion! 98 | "Handle completion requests for Groq LLM models" 99 | [state out-c context] 100 | (let [{:llm/keys [model] :groq/keys [api-key]} state] 101 | ;; Start request only when the last message in the context is by the user 102 | 103 | (a/>!! out-c (frame/llm-full-response-start true)) 104 | (let [stream-ch (try (stream-groq-chat-completion (merge {:model model 105 | :api-key api-key 106 | :messages (:messages context) 107 | :tools (mapv u/->tool-fn (:tools context))})) 108 | (catch Exception e 109 | (t/log! {:level :error :id :groq} ["Stream completion error" e])))] 110 | 111 | (a/go-loop [] 112 | (when-let [chunk (a/! out-c (frame/llm-full-response-end true)) 116 | (do 117 | (if-let [tool-call (first (:tool_calls d))] 118 | (do 119 | (t/log! {:level :debug :id :groq} ["Sending tool call" tool-call]) 120 | (a/>! out-c (frame/llm-tool-call-chunk tool-call))) 121 | (when-let [c (:content d)] 122 | (a/>! out-c (frame/llm-text-chunk c)))) 123 | (recur))))))))) 124 | 125 | (defn tool-result-adapter 126 | "Transform tool results to the groq format" 127 | [{:keys [result tool-id fname]}] 128 | {:role :tool 129 | :name fname 130 | :content (if (string? result) result (u/json-str result)) 131 | :tool_call_id tool-id}) 132 | 133 | (def groq-llm-process 134 | (flow/process 135 | (flow/map->step 136 | {:describe (fn [] {:ins {:in "Channel for incoming context aggregations"} 137 | :outs {:out "Channel where streaming responses will go"} 138 | :params {:llm/model "Groq model used" 139 | :groq/api-key "Groq Api key" 140 | :llm/temperature "Optional temperature parameter for the llm inference" 141 | :llm/max-tokens "Optional max tokens to generate" 142 | :llm/presence-penalty "Optional (-2.0 to 2.0)" 143 | :llm/top-p "Optional nucleus sampling threshold" 144 | :llm/seed "Optional seed used for deterministic sampling" 145 | :llm/max-completion-tokens "Optional Max tokens in completion"} 146 | :workload :io}) 147 | 148 | :transition (fn [{::flow/keys [in-ports out-ports] :as state} transition] 149 | (when (= transition ::flow/stop) 150 | (doseq [port (concat (vals in-ports) (vals out-ports))] 151 | (a/close! port))) 152 | state) 153 | :init (fn [params] 154 | (let [state (m/decode GroqLLMConfigSchema params mt/default-value-transformer) 155 | llm-write (a/chan 100) 156 | llm-read (a/chan 1024) 157 | write-to-llm #(loop [] 158 | (if-let [frame (a/ m :channel :alternatives first :transcript)) 43 | 44 | (defn final-transcript? 45 | [m] 46 | (:is_final m)) 47 | 48 | (defn interim-transcript? 49 | [m] 50 | (let [trsc (transcript m)] 51 | (and (not (final-transcript? m)) 52 | (string? trsc) 53 | (not= "" trsc)))) 54 | 55 | (defn speech-started-event? 56 | [m] 57 | (= (:type m) "SpeechStarted")) 58 | 59 | (defn utterance-end-event? 60 | [m] 61 | (= (:type m) "UtteranceEnd")) 62 | 63 | (def close-connection-payload (u/json-str {:type "CloseStream"})) 64 | 65 | (def keep-alive-payload (u/json-str {:type "KeepAlive"})) 66 | 67 | (defn deepgram-event->frames 68 | [event & {:keys [send-interrupt? supports-interrupt?]}] 69 | (let [trsc (transcript event)] 70 | (cond 71 | (speech-started-event? event) 72 | [(frame/user-speech-start true)] 73 | 74 | (utterance-end-event? event) 75 | (if supports-interrupt? 76 | [(frame/user-speech-stop true) (frame/control-interrupt-stop true)] 77 | [(frame/user-speech-stop true)]) 78 | 79 | (final-transcript? event) 80 | [(frame/transcription trsc)] 81 | 82 | (interim-transcript? event) 83 | (if (and supports-interrupt? send-interrupt?) 84 | ;; Deepgram sends a lot of speech start events, many of which are false 85 | ;; positives. They have a disrupting effect on the pipeline, so instead 86 | ;; of sending interrupt-start frames on speech-start events, we send it 87 | ;; on the first interim transcription event AFTER a speech start - this 88 | ;; tends to be a better indicator of speech start 89 | [(frame/transcription-interim trsc) (frame/control-interrupt-start true)] 90 | [(frame/transcription-interim trsc)])))) 91 | 92 | (def DeepgramConfigSchema 93 | [:map 94 | [:transcription/api-key :string] 95 | [:transcription/model {:default :nova-2-general} 96 | (flex-enum (into [:nova-2] (map #(str "nova-2-" %) #{"general" "meeting" "phonecall" "voicemail" "finance" "conversationalai" "video" "medical" "drivethru" "automotive" "atc"})))] 97 | [:transcription/interim-results? {:default false 98 | :optional true} :boolean] 99 | [:transcription/smart-format? {:default true 100 | :optional true} :boolean] 101 | [:transcription/profanity-filter? {:default true 102 | :optional true} :boolean] 103 | [:transcription/supports-interrupt? {:optional true 104 | :default false} :boolean] 105 | [:transcription/vad-events? {:default false 106 | :optional true} :boolean] 107 | [:transcription/utterance-end-ms {:optional true} :int] 108 | [:transcription/language {:default :en} schema/Language] 109 | [:transcription/punctuate? {:default false} :boolean]]) 110 | 111 | (def DeepgramConfig 112 | [:and 113 | DeepgramConfigSchema 114 | ;; if smart-format is true, no need for punctuate 115 | [:fn {:error/message "When :transcription/utterance-end-ms is provided, :transcription/interim-results? must be true. More details here: 116 | https://developers.deepgram.com/docs/understanding-end-of-speech-detection#using-utteranceend"} 117 | (fn [{:transcription/keys [utterance-end-ms interim-results?]}] 118 | (if (some? utterance-end-ms) 119 | interim-results? 120 | true))] 121 | [:fn {:error/message "When :transcription/smart-format? is true, :transcription/punctuate? must be false. More details here: https://developers.deepgram.com/docs/smart-format#enable-feature"} 122 | (fn [{:transcription/keys [smart-format? punctuate?]}] 123 | (not (and smart-format? punctuate?)))]]) 124 | 125 | (def describe 126 | {:ins {:sys-in "Channel for system messages that take priority" 127 | :in "Channel for audio input frames (from transport-in)"} 128 | :outs {:sys-out "Channel for system messages that have priority" 129 | :out "Channel on which transcription frames are put"} 130 | :params (schema/->describe-parameters DeepgramConfigSchema) 131 | :workload :io}) 132 | 133 | (defn init! 134 | [args] 135 | ;; Validate configuration 136 | (let [validated-args (schema/parse-with-defaults DeepgramConfig args) 137 | websocket-url (make-websocket-url validated-args) 138 | ws-read-chan (a/chan 1024) 139 | ws-write-chan (a/chan 1024) 140 | alive? (atom true) 141 | conn-config {:headers {"Authorization" (str "Token " (:transcription/api-key validated-args))} 142 | :on-open (fn [_] 143 | (t/log! {:level :info :id :deepgram} "Websocket connection open")) 144 | :on-message (fn [_ws ^HeapCharBuffer data _last?] 145 | (a/put! ws-read-chan (str data))) 146 | :on-error (fn [_ e] 147 | (t/log! {:level :error :id :deepgram} ["Websocket error" e])) 148 | :on-close (fn [_ws code reason] 149 | (reset! alive? false) 150 | (t/log! {:level :info :id :deepgram} ["Websocket connection closed" "Code:" code "Reason:" reason]))} 151 | 152 | _ (t/log! {:level :info :id :deepgram} ["Connecting to websocket" websocket-url]) 153 | ws-conn @(ws/websocket websocket-url conn-config)] 154 | 155 | ;; Audio message processing loop 156 | (vthread-loop [] 157 | (when @alive? 158 | (when-let [msg (a/frames m)] 196 | [state (apply frame/send frames)]) 197 | (cond 198 | (frame/audio-input-raw? msg) [state {:ws-write [msg]}] 199 | :else [state]))) 200 | 201 | (defn processor-fn 202 | ([] describe) 203 | ([params] (init! params)) 204 | ([state trs] (transition state trs)) 205 | ([state in-name msg] (transform state in-name msg))) 206 | 207 | (def deepgram-processor (flow/process processor-fn)) 208 | -------------------------------------------------------------------------------- /src/simulflow/utils/openai.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.utils.openai 2 | "Common logic for openai format requests. Many LLM providers use openai format 3 | for their APIs. This NS keeps common logic for those providers." 4 | (:require 5 | [clojure.core.async :as a] 6 | [clojure.core.async.flow :as flow] 7 | [hato.client :as http] 8 | [simulflow.async :refer [vthread-loop]] 9 | [simulflow.command :as command] 10 | [simulflow.frame :as frame] 11 | [simulflow.schema :as schema] 12 | [simulflow.utils.core :as u] 13 | [simulflow.utils.request :as request] 14 | [taoensso.telemere :as t]) 15 | (:import 16 | (clojure.lang ExceptionInfo))) 17 | 18 | (def response-chunk-delta 19 | "Retrieve the delta part of a streaming completion response" 20 | (comp :delta first :choices)) 21 | 22 | (defn handle-completion-request! 23 | "Handle completion requests for OpenAI LLM models" 24 | [in-c out-c] 25 | (vthread-loop [] 26 | (when-let [chunk (a/!! out-c (frame/llm-full-response-end true)) 30 | (do 31 | (if-let [tool-call (first (:tool_calls d))] 32 | (a/>!! out-c (frame/llm-tool-call-chunk tool-call)) 33 | ;; normal text completion 34 | (when-let [c (:content d)] 35 | (a/>!! out-c (frame/llm-text-chunk c)))) 36 | (recur))))))) 37 | 38 | (def openai-completions-url "https://api.openai.com/v1/chat/completions") 39 | 40 | (defn stream-chat-completion 41 | [{:keys [api-key messages tools model response-format completions-url] 42 | :or {model "gpt-4o-mini" 43 | completions-url openai-completions-url}}] 44 | (:body (request/sse-request {:request {:url completions-url 45 | :headers {"Authorization" (str "Bearer " api-key) 46 | "Content-Type" "application/json"} 47 | 48 | :method :post 49 | :body (u/json-str (cond-> {:messages messages 50 | :stream true 51 | :response_format response-format 52 | :model model} 53 | (pos? (count tools)) (assoc :tools tools)))} 54 | :params {:stream/close? true}}))) 55 | 56 | (defn normal-chat-completion 57 | [{:keys [api-key messages tools model response-format stream completions-url] 58 | :or {model "gpt-4o-mini" 59 | completions-url openai-completions-url 60 | stream false}}] 61 | (http/request {:url completions-url 62 | :headers {"Authorization" (str "Bearer " api-key) 63 | "Content-Type" "application/json"} 64 | 65 | :throw-on-error? false 66 | :method :post 67 | :body (u/json-str (cond-> {:messages messages 68 | :stream stream 69 | :response_format response-format 70 | :model model} 71 | (pos? (count tools)) (assoc :tools tools)))})) 72 | 73 | ;; Common processor functions 74 | (defn vthread-pipe-response-with-interrupt 75 | "Common function to handle streaming response from LLM" 76 | [{:keys [in-ch out-ch interrupt-ch on-end] :as data}] 77 | (t/log! {:msg "Piping out result" :data data :level :trace :id :llm-processor}) 78 | (vthread-loop [] 79 | (let [[val port] (a/alts!! [interrupt-ch in-ch] :priority true)] 80 | (t/log! {:level :trace 81 | :msg "Piping current val" 82 | :data {:val val 83 | :chan (if (= port in-ch) :in-ch :interrupt-ch)} 84 | :id :llm-processor}) 85 | 86 | (if (and (= port in-ch) val) 87 | (do 88 | (a/>!! out-ch val) 89 | (recur)) 90 | ;; No more data or interruption, call on-end and exit 91 | (when (fn? on-end) 92 | (on-end)))))) 93 | 94 | (defn init-llm-processor! 95 | "Common initialization function for OpenAI-compatible LLM processors" 96 | [schema log-id params] 97 | (let [parsed-config (schema/parse-with-defaults schema params) 98 | llm-write (a/chan 100) 99 | llm-read (a/chan 1024) 100 | interrupt-ch (a/chan 10) 101 | request-in-progress? (atom false)] 102 | (vthread-loop [] 103 | (when-let [command (a/!! interrupt-ch command)) 123 | 124 | nil) 125 | (catch ExceptionInfo e 126 | (t/log! {:level :error :id log-id :error e} "Error processing command") 127 | (when (= (:command/kind command) :command/sse-request) 128 | (let [data (ex-data e) 129 | body (slurp (:body data))] 130 | (t/log! {:level :error :id log-id :data {:body body}} "Error details"))))) 131 | (recur))) 132 | 133 | (merge parsed-config 134 | {::flow/in-ports {::llm-read llm-read} 135 | ::flow/out-ports {::llm-write llm-write} 136 | ::interrupt-ch interrupt-ch}))) 137 | 138 | (defn transition-llm-processor 139 | "Common transition function for LLM processors" 140 | [{::flow/keys [in-ports out-ports] :as state} transition] 141 | (when (= transition ::flow/stop) 142 | (doseq [port (concat (vals in-ports) (vals out-ports))] 143 | (a/close! port)) 144 | (when-let [c (::interrupt-ch state)] 145 | (a/close! c))) 146 | state) 147 | 148 | (defn transform-handle-llm-response 149 | "Common function to handle the streaming response from the LLM" 150 | [state msg] 151 | (let [d (response-chunk-delta msg) 152 | tool-call (first (:tool_calls d)) 153 | c (:content d)] 154 | (cond 155 | (= msg :done) 156 | [state (frame/send (frame/llm-full-response-end true))] 157 | 158 | tool-call 159 | [state (frame/send (frame/llm-tool-call-chunk tool-call))] 160 | 161 | c 162 | [state (frame/send (frame/llm-text-chunk c))] 163 | 164 | :else [state]))) 165 | 166 | (defn transform-llm-context 167 | "Common function to transform LLM context into SSE request" 168 | [state context-frame api-key-key completions-url-key] 169 | (let [context-data (:frame/data context-frame) 170 | {:llm/keys [model]} state 171 | api-key (get state api-key-key) 172 | completions-url (get state completions-url-key) 173 | tools (mapv u/->tool-fn (:tools context-data)) 174 | request-body (cond-> {:messages (:messages context-data) 175 | :stream true 176 | :model model} 177 | (pos? (count tools)) (assoc :tools tools))] 178 | [state {::llm-write [(command/sse-request-command {:url completions-url 179 | :method :post 180 | :body request-body 181 | :headers {"Authorization" (str "Bearer " api-key) 182 | "Content-Type" "application/json"}})] 183 | :out [(frame/llm-full-response-start true)]}])) 184 | 185 | (defn transform-llm-processor 186 | "Common transform function for LLM processors" 187 | [state in msg api-key-key completions-url-key] 188 | (cond 189 | (= in ::llm-read) 190 | (transform-handle-llm-response state msg) 191 | 192 | (frame/llm-context? msg) 193 | (transform-llm-context state msg api-key-key completions-url-key) 194 | 195 | (frame/control-interrupt-start? msg) 196 | [state {:llm-write [{:command/kind :command/interrupt-request}]}] 197 | 198 | :else 199 | [state {}])) 200 | -------------------------------------------------------------------------------- /examples/src/simulflow_examples/text_chat.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow-examples.text-chat 2 | {:clj-reload/no-unload true} 3 | (:require 4 | [clojure.core.async :as a] 5 | [clojure.core.async.flow :as flow] 6 | [simulflow.processors.llm-context-aggregator :as context] 7 | [simulflow.processors.openai :as openai] 8 | [simulflow.scenario-manager :as sm] 9 | [simulflow.secrets :refer [secret]] 10 | [simulflow.transport.text-in :as text-in] 11 | [simulflow.transport.text-out :as text-out] 12 | [simulflow.utils.core :as u] 13 | [taoensso.telemere :as t])) 14 | 15 | ;; Set log level to reduce noise during chat 16 | (t/set-min-level! :warn) 17 | 18 | (defn text-chat-flow-config 19 | "Text-based chat flow using stdin/stdout instead of voice I/O. 20 | 21 | This example demonstrates how to interact with simulflow through text: 22 | - User types messages instead of speaking 23 | - LLM responses are streamed to console as they're generated 24 | - Input is blocked while LLM is responding 25 | - Clean prompt management handled by text-output processor 26 | - Uses existing frame types: user-speech-start/transcription/user-speech-stop sequence 27 | 28 | For a convenient CLI version, use: bin/chat 29 | " 30 | ([] (text-chat-flow-config {})) 31 | ([{:keys [llm/context debug? extra-procs extra-conns] 32 | :or {context {:messages 33 | [{:role "system" 34 | :content "You are a helpful AI assistant. Be concise and conversational. 35 | You are communicating through text chat."}] 36 | :tools 37 | [{:type :function 38 | :function 39 | {:name "get_weather" 40 | :handler (fn [{:keys [town]}] 41 | (str "The weather in " town " is 17 degrees celsius")) 42 | :description "Get the current weather of a location" 43 | :parameters {:type :object 44 | :required [:town] 45 | :properties {:town {:type :string 46 | :description "Town for which to retrieve the current weather"}} 47 | :additionalProperties false} 48 | :strict true}} 49 | {:type :function 50 | :function 51 | {:name "quit_chat" 52 | :handler (fn [_] 53 | (println "\nGoodbye!") 54 | (System/exit 0)) 55 | :description "Quit the chat session" 56 | :parameters {:type :object 57 | :properties {} 58 | :additionalProperties false} 59 | :strict true}}]} 60 | debug? false}}] 61 | 62 | {:procs 63 | (u/deep-merge 64 | {;; Read from stdin and emit user-speech-start/transcription/user-speech-stop sequence 65 | :text-input {:proc text-in/text-input-process 66 | :args {}} 67 | ;; Handle conversation context and user speech aggregation 68 | :context-aggregator {:proc context/context-aggregator 69 | :args {:llm/context context 70 | :aggregator/debug? debug?}} 71 | ;; Generate LLM responses with streaming 72 | :llm {:proc openai/openai-llm-process 73 | :args {:openai/api-key (secret [:openai :api-key]) 74 | :llm/model :gpt-4.1-mini}} 75 | ;; Handle assistant message assembly for context 76 | :assistant-context-assembler {:proc context/assistant-context-assembler 77 | :args {:debug? debug?}} 78 | ;; Stream LLM output and manage prompts 79 | :text-output {:proc text-out/text-output-process 80 | :args {:text-out/response-prefix "Assistant: " 81 | :text-out/response-suffix "" 82 | :text-out/show-thinking debug? 83 | :text-out/user-prompt "You: " 84 | :text-out/manage-prompts true}}} 85 | extra-procs) 86 | :conns (concat 87 | [;; Main conversation flow 88 | [[:text-input :out] [:context-aggregator :in]] 89 | [[:context-aggregator :out] [:llm :in]] 90 | ;; Stream LLM responses to output for clean formatting 91 | [[:llm :out] [:text-output :in]] 92 | ;; Assemble assistant context for conversation history 93 | [[:llm :out] [:assistant-context-assembler :in]] 94 | [[:assistant-context-assembler :out] [:context-aggregator :in]] 95 | ;; System frame routing for input blocking/unblocking 96 | ;; LLM emits llm-full-response-start/end frames to :out, which are system frames 97 | [[:llm :out] [:text-input :sys-in]] 98 | ;; System frames for lifecycle management 99 | [[:text-input :sys-out] [:context-aggregator :sys-in]]] 100 | extra-conns)})) 101 | 102 | (defn scenario-example 103 | "A scenario is a predefined, highly structured conversation. LLM performance 104 | degrades when it has a big complex prompt to enact, so to ensure a consistent 105 | output use scenarios that transition the LLM into a new scenario node with a clear 106 | instruction for the current node." 107 | [{:keys [initial-node scenario-config]}] 108 | (let [flow (flow/create-flow 109 | (text-chat-flow-config 110 | {;; Don't add any context because the scenario will handle that 111 | :llm/context {:messages [] 112 | :tools []} 113 | :language :en 114 | 115 | ;; add gateway process for scenario to inject frames 116 | :extra-procs {:scenario {:proc (flow/process #'sm/scenario-in-process)}} 117 | 118 | :extra-conns [[[:scenario :sys-out] [:text-output :in]] 119 | [[:scenario :sys-out] [:context-aggregator :sys-in]]]})) 120 | 121 | s (when scenario-config 122 | (sm/scenario-manager 123 | {:flow flow 124 | :flow-in-coord [:scenario :scenario-in] ;; scenario-manager will inject frames through this channel 125 | :scenario-config (cond-> scenario-config 126 | initial-node (assoc :initial-node initial-node))}))] 127 | 128 | {:flow flow 129 | :scenario s})) 130 | 131 | (defn start-text-chat! 132 | "Start a text-based chat session with the simulflow agent. 133 | 134 | Usage: 135 | (start-text-chat!) ; Regular chat 136 | (start-text-chat! {:scenario? true}) ; Scenario example 137 | 138 | Then type messages and press Enter. The assistant will respond with streaming text. 139 | Input is blocked while the assistant is responding. 140 | 141 | Type messages with 'quit' to exit via the quit_chat function. 142 | 143 | CLI Alternative: Use bin/chat for a convenient command-line interface." 144 | ([] (start-text-chat! {})) 145 | ([{:keys [scenario?] :as config}] 146 | (println "🤖 Starting Simulflow Text Chat...") 147 | (println "💡 Type your messages and press Enter") 148 | (println "⏸️ Input is blocked while assistant responds") 149 | (println "🚪 Ask to 'quit chat' to exit") 150 | (println "🔗 CLI version available at: bin/chat") 151 | (println (apply str (repeat 50 "="))) 152 | ;; Create flow based on scenario choice 153 | (let [{:keys [flow scenario]} (if scenario? 154 | (scenario-example {:scenario-config scenario-example/config}) 155 | {:flow (flow/create-flow (text-chat-flow-config config)) :scenario nil}) 156 | {:keys [report-chan error-chan]} (flow/start flow)] 157 | ;; Start error/report monitoring in background 158 | (future 159 | (loop [] 160 | (when-let [[msg c] (a/alts!! [report-chan error-chan] :default nil)] 161 | (when msg 162 | (when (= c error-chan) 163 | (println "\n❌ Error:" msg)) 164 | (recur))))) 165 | ;; Resume the flow to begin processing 166 | (flow/resume flow) 167 | (when scenario (sm/start scenario)) 168 | ;; Return flow for manual control if needed 169 | flow))) 170 | 171 | (defn -main 172 | "Main entry point for text chat demo" 173 | [& args] 174 | (let [debug? (some #(= % "--debug") args) 175 | config (cond-> {} 176 | debug? (assoc :debug? true) 177 | (some #(= % "--scenario") args) (assoc :scenario? true))] 178 | (when debug? 179 | (t/set-min-level! :debug)) 180 | (start-text-chat! config))) 181 | 182 | (comment 183 | ;; Interactive usage examples: 184 | ;; 1. Basic usage 185 | (def chat (start-text-chat!)) 186 | ;; Now type in the console: 187 | ;; You: Hello, how are you? 188 | ;; Assistant: Hello! I'm doing well, thank you for asking... 189 | ;; 2. With debug mode 190 | (def debug-chat (start-text-chat! {:debug? true})) 191 | ;; 3. With custom system message 192 | (def expert-chat (start-text-chat! 193 | {:llm-context 194 | {:messages 195 | [{:role "system" 196 | :content "You are a technical expert in Clojure programming. Be detailed and precise."}]}})) 197 | ;; 4. Stop the chat manually (or just ask to quit) 198 | (flow/stop chat)) 199 | ;; 5. Test the weather function 200 | ;; You: What's the weather like in Paris? 201 | ;; Assistant: I'll check the weather in Paris for you. 202 | ;; [Tool call executed] 203 | ;; The weather in Paris is 17 degrees celsius 204 | ;; 6. CLI usage (alternative to REPL) 205 | ;; From terminal: bin/chat 206 | ;; Or with debug: bin/chat --debug 207 | -------------------------------------------------------------------------------- /TODO.org_archive: -------------------------------------------------------------------------------- 1 | # -*- mode: org -*- 2 | 3 | 4 | Archived entries from file /Users/ovistoica/workspace/simulflow/TODO.org 5 | 6 | 7 | * DONE Add support for configuration change. 8 | CLOSED: [2025-01-28 Tue 09:12] 9 | :PROPERTIES: 10 | :ARCHIVE_TIME: 2025-01-28 Tue 09:12 11 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 12 | :ARCHIVE_CATEGORY: TODO 13 | :ARCHIVE_TODO: DONE 14 | :END: 15 | Usecase: 16 | We have an initial prompt and tools to use. We want to change it based on the custom parameters that are inputted throught the twilio websocket. 17 | Example: On the twilio websocket, we can give custom parameters like script-name, overrides like user name, etc. 18 | 19 | We can use the config-change frame to do this. And every processor takes what it cares about from it. However, you add very specific functionality to the twilio-in transport. So, what you need to do is add a custom-params->config argument. 20 | 21 | #+begin_src clojure 22 | :transport-in {:proc transport/twilio-transport-in 23 | :args {:transport/in-ch in 24 | :twilio/handle-event (fn [event] 25 | {:out {:llm/context ".." 26 | :llm/registered-tools [...]}})} 27 | #+end_src 28 | 29 | * DONE add core.async.flow support 30 | CLOSED: [2025-01-28 Tue 08:51] 31 | :PROPERTIES: 32 | :ARCHIVE_TIME: 2025-01-28 Tue 09:12 33 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 34 | :ARCHIVE_CATEGORY: TODO 35 | :ARCHIVE_TODO: DONE 36 | :END: 37 | :LOGBOOK: 38 | CLOCK: [2025-01-25 Sat 16:35]--[2025-01-25 Sat 17:00] => 0:25 39 | CLOCK: [2025-01-25 Sat 15:18]--[2025-01-25 Sat 15:43] => 0:25 40 | CLOCK: [2025-01-25 Sat 11:14]--[2025-01-25 Sat 11:39] => 0:25 41 | CLOCK: [2025-01-25 Sat 09:50]--[2025-01-25 Sat 10:15] => 0:25 42 | :END: 43 | 44 | * DONE Research a way to add clj-kondo schema type hints for frames in the macro 45 | CLOSED: [2025-01-20 Lun 07:43] 46 | :PROPERTIES: 47 | :ARCHIVE_TIME: 2025-01-28 Tue 09:13 48 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 49 | :ARCHIVE_CATEGORY: TODO 50 | :ARCHIVE_TODO: DONE 51 | :END: 52 | #+begin_src clojure 53 | (defframe my-cool-frame 54 | "This is a cool frame" 55 | {:type :frame.cool/hello 56 | :schema [:map 57 | [:messages LLMContextMessages] 58 | [:tools LLMTools]]}) 59 | #+end_src 60 | 61 | * DONE Add tools calls support :mvp: 62 | CLOSED: [2025-01-28 Tue 09:13] DEADLINE: <2025-01-17 Fri> 63 | :PROPERTIES: 64 | :ARCHIVE_TIME: 2025-01-28 Tue 09:13 65 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 66 | :ARCHIVE_CATEGORY: TODO 67 | :ARCHIVE_TODO: DONE 68 | :END: 69 | :LOGBOOK: 70 | CLOCK: [2025-01-23 Thu 08:23]--[2025-01-23 Thu 08:48] => 0:25 71 | CLOCK: [2025-01-20 Lun 07:26]--[2025-01-20 Lun 07:51] => 0:25 72 | CLOCK: [2025-01-19 Dum 07:56]--[2025-01-19 Dum 08:21] => 0:25 73 | CLOCK: [2025-01-18 Sat 06:36]--[2025-01-18 Sat 06:41] => 0:05 74 | CLOCK: [2025-01-16 Thu 19:19]--[2025-01-16 Thu 19:44] => 0:25 75 | CLOCK: [2025-01-15 Wed 08:53]--[2025-01-15 Wed 09:18] => 0:25 76 | CLOCK: [2025-01-15 Wed 08:16]--[2025-01-15 Wed 08:41] => 0:25 77 | CLOCK: [2025-01-15 Wed 06:30]--[2025-01-15 Wed 06:55] => 0:25 78 | CLOCK: [2025-01-14 Tue 07:09]--[2025-01-14 Tue 07:34] => 0:25 79 | CLOCK: [2025-01-14 Tue 06:25]--[2025-01-14 Tue 06:50] => 0:25 80 | :END: 81 | 82 | * DONE Create buffered output transport that sends chunks of 20ms at a 10ms interval 83 | CLOSED: [2025-01-10 Vin 13:46] 84 | :PROPERTIES: 85 | :ARCHIVE_TIME: 2025-01-28 Tue 09:13 86 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 87 | :ARCHIVE_OLPATH: Add pipeline interruptions 88 | :ARCHIVE_CATEGORY: TODO 89 | :ARCHIVE_TODO: DONE 90 | :ARCHIVE_ITAGS: mvp 91 | :END: 92 | :LOGBOOK: 93 | CLOCK: [2025-01-09 Thu 15:51]--[2025-01-09 Thu 16:16] => 0:25 94 | CLOCK: [2025-01-09 Thu 15:19]--[2025-01-09 Thu 15:44] => 0:25 95 | CLOCK: [2025-01-09 Thu 14:45]--[2025-01-09 Thu 15:10] => 0:25 96 | CLOCK: [2025-01-09 Thu 13:58]--[2025-01-09 Thu 14:23] => 0:25 97 | CLOCK: [2025-01-09 Thu 08:29]--[2025-01-09 Thu 08:54] => 0:25 98 | CLOCK: [2025-01-09 Thu 07:46]--[2025-01-09 Thu 08:11] => 0:25 99 | CLOCK: [2025-01-09 Thu 07:00]--[2025-01-09 Thu 07:25] => 0:25 100 | CLOCK: [2025-01-09 Thu 06:29]--[2025-01-09 Thu 06:54] => 0:25 101 | CLOCK: [2025-01-08 Wed 10:45]--[2025-01-08 Wed 11:10] => 0:25 102 | CLOCK: [2025-01-08 Wed 08:29]--[2025-01-08 Wed 08:54] => 0:25 103 | :END: 104 | 105 | * DONE Handle Start/Stop interruption frames in LLM and TTS and other assemblers 106 | CLOSED: [2025-01-13 Mon 07:53] 107 | :PROPERTIES: 108 | :ARCHIVE_TIME: 2025-01-28 Tue 09:13 109 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 110 | :ARCHIVE_OLPATH: Add pipeline interruptions 111 | :ARCHIVE_CATEGORY: TODO 112 | :ARCHIVE_TODO: DONE 113 | :ARCHIVE_ITAGS: mvp 114 | :END: 115 | :LOGBOOK: 116 | CLOCK: [2025-01-10 Vin 16:29]--[2025-01-10 Vin 16:54] => 0:25 117 | CLOCK: [2025-01-10 Vin 14:15]--[2025-01-10 Vin 14:41] => 0:26 118 | CLOCK: [2025-01-10 Vin 13:46]--[2025-01-10 Vin 14:11] => 0:25 119 | CLOCK: [2025-01-08 Wed 07:01]--[2025-01-08 Wed 07:26] => 0:25 120 | CLOCK: [2025-01-07 Tue 07:17]--[2025-01-07 Tue 07:42] => 0:25 121 | CLOCK: [2025-01-07 Tue 06:20]--[2025-01-07 Tue 06:45] => 0:25 122 | CLOCK: [2025-01-06 Mon 17:07]--[2025-01-06 Mon 17:40] => 0:33 123 | CLOCK: [2025-01-06 Mon 16:36]--[2025-01-06 Mon 17:01] => 0:25 124 | :END: 125 | 126 | * DONE Add assembler that takes in interim transcripts based on VAD 127 | CLOSED: [2025-01-06 Mon 16:35] 128 | :PROPERTIES: 129 | :ARCHIVE_TIME: 2025-01-28 Tue 09:13 130 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 131 | :ARCHIVE_OLPATH: Add pipeline interruptions 132 | :ARCHIVE_CATEGORY: TODO 133 | :ARCHIVE_TODO: DONE 134 | :ARCHIVE_ITAGS: mvp 135 | :END: 136 | :LOGBOOK: 137 | CLOCK: [2025-01-06 Mon 12:28]--[2025-01-06 Mon 12:53] => 0:25 138 | CLOCK: [2025-01-06 Mon 07:37]--[2025-01-06 Mon 08:02] => 0:25 139 | CLOCK: [2025-01-05 Sun 09:21]--[2025-01-05 Sun 09:46] => 0:25 140 | CLOCK: [2025-01-05 Sun 08:18]--[2025-01-05 Sun 08:43] => 0:25 141 | CLOCK: [2025-01-04 Sat 15:22]--[2025-01-04 Sat 15:47] => 0:25 142 | CLOCK: [2025-01-04 Sat 11:04]--[2025-01-04 Sat 11:29] => 0:25 143 | CLOCK: [2025-01-04 Sat 07:14]--[2025-01-04 Sat 07:39] => 0:25 144 | :END: 145 | 146 | * DONE Add VAD events from deepgram 147 | CLOSED: [2025-01-03 Fri 19:41] 148 | :PROPERTIES: 149 | :ARCHIVE_TIME: 2025-01-28 Tue 09:13 150 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 151 | :ARCHIVE_CATEGORY: TODO 152 | :ARCHIVE_TODO: DONE 153 | :END: 154 | :LOGBOOK: 155 | CLOCK: [2025-01-03 Fri 16:25]--[2025-01-03 Fri 16:50] => 0:25 156 | CLOCK: [2025-01-03 Fri 15:36]--[2025-01-03 Fri 16:01] => 0:25 157 | CLOCK: [2025-01-03 Fri 11:01]--[2025-01-03 Fri 11:26] => 0:25 158 | :END: 159 | 160 | * DONE Add schema validation with defaults 161 | CLOSED: [2025-01-03 Fri 11:01] 162 | :PROPERTIES: 163 | :ARCHIVE_TIME: 2025-01-28 Tue 09:13 164 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 165 | :ARCHIVE_CATEGORY: TODO 166 | :ARCHIVE_TODO: DONE 167 | :END: 168 | :LOGBOOK: 169 | CLOCK: [2025-01-03 Fri 07:51]--[2025-01-03 Fri 08:16] => 0:25 170 | CLOCK: [2025-01-03 Fri 07:06]--[2025-01-03 Fri 07:31] => 0:25 171 | CLOCK: [2025-01-03 Fri 06:35]--[2025-01-03 Fri 07:00] => 0:25 172 | :END: 173 | 174 | * DONE Change tool_call declaration to include the handler, to enable changing available tools on the fly 175 | CLOSED: [2025-02-02 Sun 07:31] 176 | :PROPERTIES: 177 | :ARCHIVE_TIME: 2025-02-02 Sun 07:31 178 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 179 | :ARCHIVE_OLPATH: Implement diagram flows into vice-fn 180 | :ARCHIVE_CATEGORY: TODO 181 | :ARCHIVE_TODO: DONE 182 | :END: 183 | If the function :handler returns a channel, the tool-caller will block until a result is put on the channel, optionally with a timeout 184 | #+begin_src clojure 185 | :functions [{:type :function 186 | :function 187 | {:name "record_party_size" 188 | :handler (fn [{:keys [size]}] ...) 189 | :description "Record the number of people in the party" 190 | :parameters 191 | {:type :object 192 | :properties 193 | {:size {:type :integer 194 | :minimum 1 195 | :maximum 12}} 196 | :required [:size]} 197 | :transition-to :get-time}}] 198 | #+end_src 199 | 200 | After this, basically we can just emit a =frame/llm-context= and that will update the current context. However the scenario manager needs to 201 | 202 | * DONE Fix end the call function 203 | CLOSED: [2025-02-05 Wed 08:34] 204 | :PROPERTIES: 205 | :ARCHIVE_TIME: 2025-02-05 Wed 08:34 206 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 207 | :ARCHIVE_CATEGORY: TODO 208 | :ARCHIVE_TODO: DONE 209 | :END: 210 | :LOGBOOK: 211 | CLOCK: [2025-01-29 Wed 18:03]--[2025-01-29 Wed 18:10] => 0:07 212 | :END: 213 | 214 | * Differences between pipecat and simulflow 215 | :PROPERTIES: 216 | :ARCHIVE_TIME: 2025-08-23 Sat 09:58 217 | :ARCHIVE_FILE: ~/workspace/simulflow/TODO.org 218 | :ARCHIVE_OLPATH: Add pipeline interruptions 219 | :ARCHIVE_CATEGORY: TODO 220 | :ARCHIVE_ITAGS: mvp 221 | :END: 222 | 1. (I think) simulflow TTS processors whould keep a =:pipeline/interrupted?= 223 | state because when the processor receives a =speak-frame=, it sends it on the 224 | websocket connection to the actual TTS provider that may send one or more 225 | events back that need to be accumulated to construct the full audio 226 | eequivalent of the text from the =speak-frame=. Therefore we keep the 227 | =pipeline/interrupted?= flag so when new data is received on the websocket 228 | the processor drops them. 229 | 2. We need a way to clear the "playback queue". Currently the playback queue is 230 | represented by the [[file:src/simulflow/transport/out.clj::audio-write-ch (a/chan 1024)\]][audio-write-channel]] defined. There is a [[file:src/simulflow/async.clj::(defn drain-channel!][drain-channel!]] 231 | function which will work but we need to introduce two channels to communicate 232 | with the [[file:src/simulflow/transport/out.clj::(vthread-loop \[\]][process running in a vthread]] that sends audio to out. One for 233 | commands to drain audio, and one on which to take audio from (the current 234 | existing one) 235 | 3. Pipecat uses a bidirectional queue system between processors: 236 | Transport in <-> Transcriptor <-> Context Aggregator <-> LLM <-> TTS <-> a 237 | -------------------------------------------------------------------------------- /src/simulflow/processors/elevenlabs.clj: -------------------------------------------------------------------------------- 1 | (ns simulflow.processors.elevenlabs 2 | (:require 3 | [clojure.core.async :as a] 4 | [clojure.core.async.flow :as flow] 5 | [hato.websocket :as ws] 6 | [simulflow.async :refer [vthread-loop]] 7 | [simulflow.frame :as frame] 8 | [simulflow.schema :as schema] 9 | [simulflow.utils.core :as u] 10 | [taoensso.telemere :as t]) 11 | (:import 12 | (java.nio HeapCharBuffer))) 13 | 14 | (def ^:private xi-tts-websocket-url "wss://api.elevenlabs.io/v1/text-to-speech/%s/stream-input") 15 | 16 | (def elevenlabs-encoding 17 | "Mapping from clojure sound encoding to elevenlabs format" 18 | {:ulaw :ulaw 19 | :mp3 :mp3 20 | :pcm-signed :pcm 21 | :pcm-unsigned :pcm 22 | :pcm-float :pcm}) 23 | 24 | (defn encoding->elevenlabs 25 | [format sample-rate] 26 | (keyword (str (name (elevenlabs-encoding format)) "_" sample-rate))) 27 | 28 | (defn make-elevenlabs-ws-url 29 | [args] 30 | (let [{:audio.out/keys [encoding sample-rate] 31 | :pipeline/keys [language] 32 | :elevenlabs/keys [model-id voice-id] 33 | :or {model-id "eleven_flash_v2_5" 34 | sample-rate 24000}} 35 | args] 36 | (assert voice-id "Voice ID is required") 37 | (u/append-search-params (format xi-tts-websocket-url voice-id) 38 | {:model_id model-id 39 | :language_code language 40 | :output_format (encoding->elevenlabs encoding sample-rate)}))) 41 | 42 | (defn begin-stream-message 43 | [{:voice/keys [stability similarity-boost use-speaker-boost?] 44 | :elevenlabs/keys [api-key] 45 | :or {stability 0.5 46 | similarity-boost 0.8 47 | use-speaker-boost? true}}] 48 | (u/json-str {:text " " 49 | :voice_settings {:stability stability 50 | :similarity_boost similarity-boost 51 | :use_speaker_boost use-speaker-boost?} 52 | :xi_api_key api-key})) 53 | 54 | (def close-stream-message 55 | (u/json-str {:text ""})) 56 | 57 | (def keep-alive-message 58 | "Sent to keep the connection alive" 59 | (u/json-str {:text " "})) 60 | 61 | (defn text-message 62 | [text] 63 | (u/json-str {:text (str text " ") 64 | :flush true})) 65 | 66 | (def ElevenLabsTTSConfig 67 | "Configuration for Elevenlabs TextToSpeech service" 68 | [:map 69 | [:elevenlabs/api-key 70 | [:string 71 | {:min 32 ;; ElevenLabs API keys are typically long 72 | :secret true ;; Marks this as sensitive data 73 | :description "ElevenLabs API key"}]] 74 | [:pipeline/language {:default :en} schema/Language] 75 | [:elevenlabs/model-id {:default :eleven_flash_v2_5 76 | :description "ElevenLabs model identifier"} 77 | (schema/flex-enum 78 | [:eleven_multilingual_v2 :eleven_turbo_v2_5 :eleven_turbo_v2 :eleven_monolingual_v1 79 | :eleven_multilingual_v1 :eleven_multilingual_sts_v2 :eleven_flash_v2 :eleven_flash_v2_5 :eleven_english_sts_v2])] 80 | [:elevenlabs/voice-id 81 | [:string 82 | {:min 20 ;; ElevenLabs voice IDs are fixed length 83 | :max 20 84 | :description "ElevenLabs voice identifier"}]] 85 | [:voice/stability {:default 0.5 86 | :description "Voice stability factor (0.0 to 1.0)"} 87 | [:double 88 | {:min 0.0 89 | :max 1.0}]] 90 | [:voice/similarity-boost {:default 0.8 91 | :description "Voice similarity boost factor (0.0 to 1.0)"} 92 | [:double 93 | {:min 0.0 94 | :max 1.0}]] 95 | [:voice/use-speaker-boost? {:default true 96 | :description "Whether to enable speaker boost enhancement"} 97 | :boolean] 98 | [:audio.out/encoding {:default :pcm-signed 99 | :description "The encoding for the generated audio. By default uses PCM but can be changed for others for example ulaw to use in telephony context"} schema/AudioEncoding] 100 | [:audio.out/sample-rate {:default 24000 101 | :description "The sample rate at which elevenlabs will generate audio"} 102 | [:enum 8000 16000 22050 24000 44100]]]) 103 | 104 | (defn accumulate-json-response 105 | "Pure function to accumulate JSON response fragments. 106 | Returns [new-accumulator parsed-json-or-nil]" 107 | [current-accumulator new-fragment] 108 | (let [combined (str current-accumulator new-fragment) 109 | parsed (u/parse-if-json combined)] 110 | (if (map? parsed) 111 | ["" parsed] ; Successfully parsed, reset accumulator 112 | [combined nil]))) ; Still accumulating 113 | 114 | (defn process-speak-frame 115 | "Pure function to process speak frame into WebSocket message. 116 | Returns WebSocket message string." 117 | [speak-frame] 118 | (text-message (:frame/data speak-frame))) 119 | 120 | (defn process-websocket-message 121 | "Pure function to process WebSocket message and update accumulator state. 122 | Returns [new-state output-map]" 123 | [state message timestamp] 124 | (let [current-accumulator (::accumulator state) 125 | [new-accumulator parsed-json] (accumulate-json-response current-accumulator message)] 126 | (if parsed-json 127 | ;; JSON parsing complete 128 | (let [new-state (assoc state ::accumulator new-accumulator) 129 | frames (when-let [audio (:audio parsed-json)] 130 | [(frame/audio-output-raw {:audio (u/decode-base64 audio) 131 | :sample-rate (:audio.out/sample-rate state)} 132 | {:timestamp timestamp}) 133 | (frame/xi-audio-out parsed-json {:timestamp timestamp})])] 134 | [new-state (apply frame/send frames)]) 135 | ;; Still accumulating 136 | [(assoc state ::accumulator new-accumulator) {}]))) 137 | 138 | ;; ============================================================================ 139 | ;; WebSocket Configuration 140 | ;; ============================================================================ 141 | 142 | (defn create-websocket-config 143 | "Pure function to create WebSocket configuration map" 144 | [args ws-read ws-write] 145 | (let [configuration-msg (begin-stream-message args)] 146 | {:on-open (fn [ws] 147 | (t/log! {:level :debug :id :elevenlabs} ["Websocket connection open. Sending configuration message" configuration-msg]) 148 | (ws/send! ws configuration-msg)) 149 | :on-message (fn [_ws ^HeapCharBuffer data _last?] 150 | (a/put! ws-read (str data))) 151 | :on-error (fn [_ e] 152 | (t/log! {:level :error :id :elevenlabs} ["Websocket error" (ex-message e)])) 153 | :on-close (fn [_ws code reason] 154 | (t/log! {:level :debug :id :elevenlabs} ["Websocket connection closed" "Code:" code "Reason:" reason]))})) 155 | 156 | (def elevenlabs-tts-describe 157 | {:ins {:sys-in "Channel for system messages that take priority" 158 | :in "Channel for audio input frames (from transport-in) "} 159 | :outs {:sys-out "Channel for system messages that have priority" 160 | :out "Channel on which transcription frames are put"} 161 | :params (schema/->describe-parameters ElevenLabsTTSConfig) 162 | :workload :io}) 163 | 164 | (defn elevenlabs-tts-init! [params] 165 | (let [args (schema/parse-with-defaults ElevenLabsTTSConfig params) 166 | url (make-elevenlabs-ws-url args) 167 | ws-read (a/chan 100) 168 | ws-write (a/chan 100) 169 | alive? (atom true) 170 | conf (assoc (create-websocket-config args ws-read ws-write) 171 | :on-close (fn [_ws code reason] 172 | (reset! alive? false) 173 | (t/log! {:level :debug :id :elevenlabs} ["Websocket connection closed" "Code:" code "Reason:" reason]))) 174 | _ (t/log! {:level :debug :id :elevenlabs :data url} "Connecting to TTS websocket") 175 | ws-conn @(ws/websocket url conf)] 176 | (vthread-loop [] 177 | (when @alive? 178 | (when-let [msg (a/ :cat [:any] :any]]]]) 27 | 28 | (def ScenarioConfig 29 | [:and [:map {:closed true} 30 | [:initial-node :keyword] 31 | [:nodes [:map-of 32 | :keyword 33 | [:map {:closed true} 34 | [:run-llm? {:optional true} :boolean] 35 | [:role-messages {:optional true} [:vector schema/LLMSystemMessage]] 36 | [:task-messages [:vector schema/LLMSystemMessage]] 37 | [:functions [:vector [:or 38 | schema/LLMFunctionToolDefinitionWithHandling 39 | schema/LLMTransitionToolDefinition]]] 40 | [:pre-actions {:optional true 41 | :description "Actions to be invoked when the node is selected."} [:vector ScenarioAction]] 42 | [:post-actions {:optional true 43 | :description "Actions to be invoked when the node will be replaced."} [:vector ScenarioAction]]]]]] 44 | [:fn {:error/message "Initial node not defined"} 45 | (fn [sc] 46 | (boolean (get-in sc [:nodes (:initial-node sc)])))] 47 | [:fn {:error/fn (fn [{:keys [value]} _] 48 | (let [nodes (set (keys (:nodes value))) 49 | transitions (->> value 50 | :nodes 51 | vals 52 | (mapcat :functions) 53 | (keep (fn [f] (get-in f [:function :transition-to]))) 54 | (remove #(or (nil? %) 55 | (fn? %)))) 56 | invalid-transition (first (remove nodes transitions))] 57 | (when invalid-transition 58 | (format "Unreachable node: %s" invalid-transition))))} 59 | (fn [{:keys [nodes]}] 60 | (let [defined-nodes (set (keys nodes)) 61 | transitions (->> nodes 62 | vals 63 | (mapcat :functions) 64 | (keep (fn [f] (get-in f [:function :transition-to]))) 65 | (remove #(or (nil? %) 66 | (fn? %))))] 67 | (every? defined-nodes transitions)))]]) 68 | 69 | (defprotocol Scenario 70 | (start [s] "Start the scenario") 71 | (set-node [s node] "Moves to the current node of the conversation") 72 | (current-node [s] "Get current node")) 73 | 74 | (defn transition-fn 75 | "Transform a function declaration into a transition function. A transition 76 | function calls the original function handler, and then transitions the 77 | scenario to the :transition-to node from f 78 | 79 | scenario - scenario that will be transitioned 80 | tool - transition tool declaration. See `schema/LLMTransitionToolDefinition` 81 | " 82 | [scenario tool] 83 | (let [fndef (:function tool) 84 | handler (:handler fndef) 85 | 86 | next-node-or-fn (:transition-to fndef) 87 | cb (when next-node-or-fn 88 | (if (fn? next-node-or-fn) 89 | (fn [args] 90 | (let [next-node (next-node-or-fn args)] 91 | (set-node scenario next-node))) 92 | (fn [_] (set-node scenario next-node-or-fn))))] 93 | (cond-> tool 94 | true (update-in [:function] dissoc :transition-to) 95 | cb (assoc-in [:function :transition-cb] cb) 96 | (nil? handler) (assoc-in [:function :handler] (fn [_] {:status :success}))))) 97 | 98 | (defn scenario-manager 99 | [{:keys [scenario-config flow flow-in-coord]}] 100 | (when-let [errors (me/humanize (m/explain ScenarioConfig scenario-config))] 101 | (throw (ex-info "Invalid scenario config" {:errors errors}))) 102 | 103 | (let [current-node (atom nil) 104 | nodes (:nodes scenario-config) 105 | initialized? (atom false) 106 | tts-action? #(contains? #{:tts-say "tts-say"} (:type %)) 107 | end-action? #(contains? #{:end-conversation "end-conversation"} (:type %)) 108 | handle-action (fn [a] 109 | (cond 110 | (tts-action? a) 111 | (flow/inject flow flow-in-coord (if (coll? (:text a)) 112 | (mapv #(frame/speak-frame %) (:text a)) 113 | [(frame/speak-frame (:text a))])) 114 | (end-action? a) (flow/stop flow) 115 | :else ((:handler a))))] 116 | (reify Scenario 117 | (current-node [_] @current-node) 118 | (set-node [this node-id] 119 | (assert (get-in scenario-config [:nodes node-id]) (str "Invalid node: " node-id)) 120 | (t/log! :info ["SCENARIO" "NEW NODE" node-id]) 121 | (let [node (get nodes node-id) 122 | tools (mapv (partial transition-fn this) (:functions node)) 123 | context (vec (->> (concat (:role-messages node) (:task-messages node)) 124 | (remove nil?))) 125 | ;; post actions from previous node 126 | post-actions (when @current-node (get-in nodes [@current-node :post-actions])) 127 | pre-actions (:pre-actions node)] 128 | 129 | (try 130 | (when (seq post-actions) (doseq [a post-actions] 131 | (handle-action a))) 132 | 133 | (reset! current-node node-id) 134 | (flow/inject 135 | flow 136 | flow-in-coord 137 | [(frame/scenario-context-update {:messages context 138 | :tools tools 139 | :properties {:run-llm? (get node :run-llm? true)}})]) 140 | (when (seq pre-actions) (doseq [a pre-actions] 141 | (handle-action a))) 142 | 143 | (catch Exception e 144 | (t/log! :error e))))) 145 | 146 | (start [s] 147 | (when-not @initialized? 148 | (reset! initialized? true) 149 | (set-node s (:initial-node scenario-config))))))) 150 | 151 | (defn scenario-in-fn 152 | "Process that acts as a input for the scenario manager into the flow. This 153 | process will direct specific frames to specific outs. Example: speak-frame 154 | will be directed to :speak-out channel (should be connected to a text to 155 | speech process)" 156 | ([] {:ins {:scenario-in "Channel on which the scenario will put frames."} 157 | :outs {:sys-out "Channel where system frames will be put (follows app convention)"}}) 158 | ([_] nil) 159 | ([state _] state) 160 | ([_ in frame] 161 | (t/log! {:id :scenario-in :data frame :msg "GOT NEW FRAME" :level :debug}) 162 | (when (= in :scenario-in) 163 | [nil (frame/send frame)]))) 164 | 165 | (def scenario-in-process (flow/process scenario-in-fn)) 166 | 167 | (comment 168 | (scenario-manager 169 | {:flow (flow/create-flow {:procs {} 170 | :conns []}) 171 | :scenario 172 | {:initial-node :start 173 | :nodes 174 | {:start 175 | {:role-messages [{:role :system 176 | :content "You are a restaurant reservation assistant for La Maison, an upscale French restaurant. You must ALWAYS use one of the available functions to progress the conversation. This is a phone conversations and your responses will be converted to audio. Avoid outputting special characters and emojis. Be casual and friendly."}] 177 | :task-messages [{:role :system 178 | :content "Warmly greet the customer and ask how many people are in their party."}] 179 | :functions [{:type :function 180 | :function 181 | {:name "record_party_size" 182 | :handler (fn [{:keys [size]}] size) 183 | :description "Record the number of people in the party" 184 | :parameters 185 | {:type :object 186 | :properties 187 | {:size {:type :integer 188 | :minimum 1 189 | :maximum 12}} 190 | :required [:size]} 191 | :transition-to :get-time}}]} 192 | :get-time 193 | {:task-messages [{:role :system 194 | :content "Ask what time they'd like to dine. Restaurant is open 5 PM to 10 PM. After they provide a time, confirm it's within operating hours before recording. Use 24-hour format for internal recording (e.g., 17:00 for 5 PM)."}] 195 | :functions [{:type :function 196 | :function {:name "record_time" 197 | :handler (fn [{:keys [time]}] time) 198 | :description "Record the requested time" 199 | :parameters {:type :object 200 | :properties {:time {:type :string 201 | :pattern "^(17|18|19|20|21|22):([0-5][0-9])$" 202 | :description "Reservation time in 24-hour format (17:00-22:00)"}} 203 | :required [:time]} 204 | :transition_to "confirm"}}]}}}})) 205 | 206 | (comment 207 | (me/humanize (m/explain schema/LLMTransitionToolDefinition {:type :function 208 | :function 209 | {:name "record_party_size" 210 | :handler (fn [] :1) 211 | :description "Record the number of people in the party" 212 | :parameters 213 | {:type :object 214 | :properties 215 | {:size {:type :integer 216 | :min 1 217 | :max 12 218 | :description "The people that want to dine"}} 219 | :required [:size]} 220 | :transition-to :get-time}}))) 221 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC 2 | LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM 3 | CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 4 | 5 | 1. DEFINITIONS 6 | 7 | "Contribution" means: 8 | 9 | a) in the case of the initial Contributor, the initial code and 10 | documentation distributed under this Agreement, and 11 | 12 | b) in the case of each subsequent Contributor: 13 | 14 | i) changes to the Program, and 15 | 16 | ii) additions to the Program; 17 | 18 | where such changes and/or additions to the Program originate from and are 19 | distributed by that particular Contributor. A Contribution 'originates' from 20 | a Contributor if it was added to the Program by such Contributor itself or 21 | anyone acting on such Contributor's behalf. Contributions do not include 22 | additions to the Program which: (i) are separate modules of software 23 | distributed in conjunction with the Program under their own license 24 | agreement, and (ii) are not derivative works of the Program. 25 | 26 | "Contributor" means any person or entity that distributes the Program. 27 | 28 | "Licensed Patents" mean patent claims licensable by a Contributor which are 29 | necessarily infringed by the use or sale of its Contribution alone or when 30 | combined with the Program. 31 | 32 | "Program" means the Contributions distributed in accordance with this 33 | Agreement. 34 | 35 | "Recipient" means anyone who receives the Program under this Agreement, 36 | including all Contributors. 37 | 38 | 2. GRANT OF RIGHTS 39 | 40 | a) Subject to the terms of this Agreement, each Contributor hereby grants 41 | Recipient a non-exclusive, worldwide, royalty-free copyright license to 42 | reproduce, prepare derivative works of, publicly display, publicly perform, 43 | distribute and sublicense the Contribution of such Contributor, if any, and 44 | such derivative works, in source code and object code form. 45 | 46 | b) Subject to the terms of this Agreement, each Contributor hereby grants 47 | Recipient a non-exclusive, worldwide, royalty-free patent license under 48 | Licensed Patents to make, use, sell, offer to sell, import and otherwise 49 | transfer the Contribution of such Contributor, if any, in source code and 50 | object code form. This patent license shall apply to the combination of the 51 | Contribution and the Program if, at the time the Contribution is added by the 52 | Contributor, such addition of the Contribution causes such combination to be 53 | covered by the Licensed Patents. The patent license shall not apply to any 54 | other combinations which include the Contribution. No hardware per se is 55 | licensed hereunder. 56 | 57 | c) Recipient understands that although each Contributor grants the licenses 58 | to its Contributions set forth herein, no assurances are provided by any 59 | Contributor that the Program does not infringe the patent or other 60 | intellectual property rights of any other entity. Each Contributor disclaims 61 | any liability to Recipient for claims brought by any other entity based on 62 | infringement of intellectual property rights or otherwise. As a condition to 63 | exercising the rights and licenses granted hereunder, each Recipient hereby 64 | assumes sole responsibility to secure any other intellectual property rights 65 | needed, if any. For example, if a third party patent license is required to 66 | allow Recipient to distribute the Program, it is Recipient's responsibility 67 | to acquire that license before distributing the Program. 68 | 69 | d) Each Contributor represents that to its knowledge it has sufficient 70 | copyright rights in its Contribution, if any, to grant the copyright license 71 | set forth in this Agreement. 72 | 73 | 3. REQUIREMENTS 74 | 75 | A Contributor may choose to distribute the Program in object code form under 76 | its own license agreement, provided that: 77 | 78 | a) it complies with the terms and conditions of this Agreement; and 79 | 80 | b) its license agreement: 81 | 82 | i) effectively disclaims on behalf of all Contributors all warranties and 83 | conditions, express and implied, including warranties or conditions of title 84 | and non-infringement, and implied warranties or conditions of merchantability 85 | and fitness for a particular purpose; 86 | 87 | ii) effectively excludes on behalf of all Contributors all liability for 88 | damages, including direct, indirect, special, incidental and consequential 89 | damages, such as lost profits; 90 | 91 | iii) states that any provisions which differ from this Agreement are offered 92 | by that Contributor alone and not by any other party; and 93 | 94 | iv) states that source code for the Program is available from such 95 | Contributor, and informs licensees how to obtain it in a reasonable manner on 96 | or through a medium customarily used for software exchange. 97 | 98 | When the Program is made available in source code form: 99 | 100 | a) it must be made available under this Agreement; and 101 | 102 | b) a copy of this Agreement must be included with each copy of the Program. 103 | 104 | Contributors may not remove or alter any copyright notices contained within 105 | the Program. 106 | 107 | Each Contributor must identify itself as the originator of its Contribution, 108 | if any, in a manner that reasonably allows subsequent Recipients to identify 109 | the originator of the Contribution. 110 | 111 | 4. COMMERCIAL DISTRIBUTION 112 | 113 | Commercial distributors of software may accept certain responsibilities with 114 | respect to end users, business partners and the like. While this license is 115 | intended to facilitate the commercial use of the Program, the Contributor who 116 | includes the Program in a commercial product offering should do so in a 117 | manner which does not create potential liability for other Contributors. 118 | Therefore, if a Contributor includes the Program in a commercial product 119 | offering, such Contributor ("Commercial Contributor") hereby agrees to defend 120 | and indemnify every other Contributor ("Indemnified Contributor") against any 121 | losses, damages and costs (collectively "Losses") arising from claims, 122 | lawsuits and other legal actions brought by a third party against the 123 | Indemnified Contributor to the extent caused by the acts or omissions of such 124 | Commercial Contributor in connection with its distribution of the Program in 125 | a commercial product offering. The obligations in this section do not apply 126 | to any claims or Losses relating to any actual or alleged intellectual 127 | property infringement. In order to qualify, an Indemnified Contributor must: 128 | a) promptly notify the Commercial Contributor in writing of such claim, and 129 | b) allow the Commercial Contributor to control, and cooperate with the 130 | Commercial Contributor in, the defense and any related settlement 131 | negotiations. The Indemnified Contributor may participate in any such claim 132 | at its own expense. 133 | 134 | For example, a Contributor might include the Program in a commercial product 135 | offering, Product X. That Contributor is then a Commercial Contributor. If 136 | that Commercial Contributor then makes performance claims, or offers 137 | warranties related to Product X, those performance claims and warranties are 138 | such Commercial Contributor's responsibility alone. Under this section, the 139 | Commercial Contributor would have to defend claims against the other 140 | Contributors related to those performance claims and warranties, and if a 141 | court requires any other Contributor to pay any damages as a result, the 142 | Commercial Contributor must pay those damages. 143 | 144 | 5. NO WARRANTY 145 | 146 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON 147 | AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER 148 | EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR 149 | CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A 150 | PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the 151 | appropriateness of using and distributing the Program and assumes all risks 152 | associated with its exercise of rights under this Agreement , including but 153 | not limited to the risks and costs of program errors, compliance with 154 | applicable laws, damage to or loss of data, programs or equipment, and 155 | unavailability or interruption of operations. 156 | 157 | 6. DISCLAIMER OF LIABILITY 158 | 159 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY 160 | CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, 161 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION 162 | LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 163 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 164 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 165 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY 166 | OF SUCH DAMAGES. 167 | 168 | 7. GENERAL 169 | 170 | If any provision of this Agreement is invalid or unenforceable under 171 | applicable law, it shall not affect the validity or enforceability of the 172 | remainder of the terms of this Agreement, and without further action by the 173 | parties hereto, such provision shall be reformed to the minimum extent 174 | necessary to make such provision valid and enforceable. 175 | 176 | If Recipient institutes patent litigation against any entity (including a 177 | cross-claim or counterclaim in a lawsuit) alleging that the Program itself 178 | (excluding combinations of the Program with other software or hardware) 179 | infringes such Recipient's patent(s), then such Recipient's rights granted 180 | under Section 2(b) shall terminate as of the date such litigation is filed. 181 | 182 | All Recipient's rights under this Agreement shall terminate if it fails to 183 | comply with any of the material terms or conditions of this Agreement and 184 | does not cure such failure in a reasonable period of time after becoming 185 | aware of such noncompliance. If all Recipient's rights under this Agreement 186 | terminate, Recipient agrees to cease use and distribution of the Program as 187 | soon as reasonably practicable. However, Recipient's obligations under this 188 | Agreement and any licenses granted by Recipient relating to the Program shall 189 | continue and survive. 190 | 191 | Everyone is permitted to copy and distribute copies of this Agreement, but in 192 | order to avoid inconsistency the Agreement is copyrighted and may only be 193 | modified in the following manner. The Agreement Steward reserves the right to 194 | publish new versions (including revisions) of this Agreement from time to 195 | time. No one other than the Agreement Steward has the right to modify this 196 | Agreement. The Eclipse Foundation is the initial Agreement Steward. The 197 | Eclipse Foundation may assign the responsibility to serve as the Agreement 198 | Steward to a suitable separate entity. Each new version of the Agreement will 199 | be given a distinguishing version number. The Program (including 200 | Contributions) may always be distributed subject to the version of the 201 | Agreement under which it was received. In addition, after a new version of 202 | the Agreement is published, Contributor may elect to distribute the Program 203 | (including its Contributions) under the new version. Except as expressly 204 | stated in Sections 2(a) and 2(b) above, Recipient receives no rights or 205 | licenses to the intellectual property of any Contributor under this 206 | Agreement, whether expressly, by implication, estoppel or otherwise. All 207 | rights in the Program not expressly granted under this Agreement are 208 | reserved. 209 | 210 | This Agreement is governed by the laws of the State of New York and the 211 | intellectual property laws of the United States of America. No party to this 212 | Agreement will bring a legal action under this Agreement more than one year 213 | after the cause of action arose. Each party waives its rights to a jury trial 214 | in any resulting litigation. 215 | --------------------------------------------------------------------------------