package openai import ( "context" "crypto/rand" "encoding/base64" "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "math" "os" "strconv" "sync" "time" "net/http" "github.com/go-audio/audio" "github.com/gorilla/websocket" "github.com/labstack/echo/v4" "github.com/mudler/LocalAI/core/application" "github.com/mudler/LocalAI/core/backend" "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/http/auth" mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp" "github.com/mudler/LocalAI/core/http/endpoints/openai/respcoord" "github.com/mudler/LocalAI/core/http/endpoints/openai/turncoord" "github.com/mudler/LocalAI/core/http/endpoints/openai/types" "github.com/mudler/LocalAI/core/schema" "github.com/mudler/LocalAI/core/templates" laudio "github.com/mudler/LocalAI/pkg/audio" "github.com/mudler/LocalAI/pkg/functions" "github.com/mudler/LocalAI/pkg/grpc" "github.com/mudler/LocalAI/pkg/grpc/proto" model "github.com/mudler/LocalAI/pkg/model" "github.com/mudler/LocalAI/pkg/reasoning" "github.com/mudler/LocalAI/pkg/sound" "github.com/mudler/LocalAI/pkg/utils" "github.com/mudler/xlog" ) const ( // XXX: Presently it seems all ASR/VAD backends use 16Khz. If a backend uses 24Khz then it will likely still work, but have reduced performance localSampleRate = 16000 defaultRemoteSampleRate = 24000 // Maximum audio buffer size in bytes (100MB) to prevent memory exhaustion maxAudioBufferSize = 100 * 1024 * 1024 // Maximum WebSocket message size in bytes (10MB) to prevent DoS attacks maxWebSocketMessageSize = 10 * 1024 * 1024 defaultInstructions = "You are a helpful voice assistant. " + "Your responses will be spoken aloud using text-to-speech, so keep them concise and conversational. " + "Do not use markdown formatting, bullet points, numbered lists, code blocks, or special characters. " + "Speak naturally as you would in a phone conversation. " + "Avoid parenthetical asides, URLs, and anything that cannot be clearly vocalized." ) // resolveOutputModalities returns the effective output modalities for a // response: response-level overrides session-level, and the OpenAI Realtime // spec default is ["audio"] when neither is set. func resolveOutputModalities(session, response []types.Modality) []types.Modality { if len(response) > 0 { return response } if len(session) > 0 { return session } return []types.Modality{types.ModalityAudio} } // modalitiesContainAudio reports whether the resolved modalities include audio // output. func modalitiesContainAudio(m []types.Modality) bool { for _, x := range m { if x == types.ModalityAudio { return true } } return false } // A model can be "emulated" that is: transcribe audio to text -> feed text to the LLM -> generate audio as result // If the model support instead audio-to-audio, we will use the specific gRPC calls instead // Session represents a single WebSocket connection and its state type Session struct { ID string TranscriptionOnly bool // The pipeline or any-to-any model name (full realtime mode) Model string // The voice may be a TTS model name or a parameter passed to a TTS model Voice string TurnDetection *types.TurnDetectionUnion // "server_vad", "semantic_vad" or "none" InputAudioTranscription *types.AudioTranscription // SoundDetectionEnabled is set when pipeline.sound_detection names a // sound-event-classification model. When true, each committed utterance is // also run through ModelInterface.SoundDetection and the scored tags are // emitted as a conversation.item.sound_detection event. SoundDetectionTopK // and SoundDetectionThreshold are the knobs passed to that call (defaults: // top_k=5, threshold=0). SoundDetectionEnabled bool SoundDetectionTopK int SoundDetectionThreshold float32 // SoundDetectionWindowMs / SoundDetectionHopMs, when both > 0, enable // server-side windowing for a sound-only session: the server classifies the // last WindowMs of streamed audio every HopMs (no client commits needed). SoundDetectionWindowMs int SoundDetectionHopMs int Tools []types.ToolUnion ToolChoice *types.ToolChoiceUnion Conversations map[string]*Conversation InputAudioBuffer []byte AudioBufferLock sync.Mutex OpusFrames [][]byte OpusFramesLock sync.Mutex Instructions string DefaultConversationID string ModelInterface Model // The pipeline model config or the config for an any-to-any model ModelConfig *config.ModelConfig InputSampleRate int OutputSampleRate int MaxOutputTokens types.IntOrInf // OutputModalities mirrors the OpenAI Realtime spec field of the same // name. Empty means "use the spec default" (audio). ["text"] suppresses // TTS so the client receives only response.output_text.* events. OutputModalities []types.Modality // MaxHistoryItems caps the number of MessageItems passed to the LLM each // turn (0 = unlimited). Small models — especially the LFM2.5-Audio 1.5B // served via the liquid-audio backend — degrade quickly past a handful // of turns. Counted from the tail; FunctionCall + FunctionCallOutput // pairs are kept together so we never feed an orphaned tool result. MaxHistoryItems int // Compaction settings resolved from pipeline.compaction (see resolveCompaction). CompactionEnabled bool CompactionTrigger int SummaryModel string MaxSummaryTokens int // summarizerFactory lazily builds the model used for compaction summaries // when summary_model is configured; nil means reuse the pipeline LLM. summarizerFactory func() (Model, error) summarizerOnce sync.Once summarizerCached Model // AssistantExecutor is non-nil when the session opted into the in-process // LocalAI Assistant tool surface. Tool calls whose name matches this // executor's catalog are run inproc and their output is fed back to the // model server-side; the client never sees a function_call_arguments // event for those. Mirrors the chat handler's metadata.localai_assistant // path. AssistantExecutor mcpTools.ToolExecutor // AssistantTools is the cached ToolUnion slice we injected at session // creation. Re-applied after every client session.update so a // client-driven tool refresh (e.g. toggling a client MCP server) doesn't // silently strip Manage Mode's tools. AssistantTools []types.ToolUnion // voiceGate is non-nil when pipeline.voice_recognition is configured. It // authorizes each committed utterance's speaker before the LLM runs. voiceGate *voiceGate // gateMu guards the when:first verification state below. gateMu sync.Mutex voiceVerified bool // respSink is the explicit response-coordination state machine (respcoord, // machine M3). It replaces the legacy startResponse/cancelActiveResponse // pair and its dual-writer activeResponse* fields: every start/cancel/finish // decision is serialized through respcoord.Coordinator, guaranteeing at most // one live response. See realtime_respcoord.go. respSink *responseSink } func (s *Session) FromClient(session *types.SessionUnion) { } func (s *Session) ToServer() types.SessionUnion { if s.TranscriptionOnly { return types.SessionUnion{ Transcription: &types.TranscriptionSession{ ID: s.ID, Object: "realtime.transcription_session", Audio: &types.TranscriptionSessionAudio{ Input: &types.SessionAudioInput{ Transcription: s.InputAudioTranscription, }, }, }, } } else { return types.SessionUnion{ Realtime: &types.RealtimeSession{ ID: s.ID, Object: "realtime.session", Model: s.Model, Instructions: s.Instructions, Tools: s.Tools, ToolChoice: s.ToolChoice, MaxOutputTokens: s.MaxOutputTokens, OutputModalities: s.OutputModalities, Audio: &types.RealtimeSessionAudio{ Input: &types.SessionAudioInput{ TurnDetection: s.TurnDetection, Transcription: s.InputAudioTranscription, }, Output: &types.SessionAudioOutput{ Voice: types.Voice(s.Voice), }, }, }, } } } // Conversation represents a conversation with a list of items type Conversation struct { ID string Items []*types.MessageItemUnion Lock sync.Mutex // Memory is the rolling summary of items already evicted by compaction. It // is kept out of Items (so trimRealtimeItems never drops it) and rendered // as a system message right after the session instructions. Memory string // compaction is the explicit single-flight compaction coordinator (M4): at // most one background summarize+evict runs per conversation at a time. It // replaces the legacy `compacting atomic.Bool`. See realtime_compactcoord.go. compaction *compactionSink } func (c *Conversation) ToServer() types.Conversation { return types.Conversation{ ID: c.ID, Object: "realtime.conversation", } } // Map to store sessions (in-memory) var sessions = make(map[string]*Session) var sessionLock sync.Mutex type Model interface { VAD(ctx context.Context, request *schema.VADRequest) (*schema.VADResponse, error) Transcribe(ctx context.Context, audio, language string, translate bool, diarize bool, prompt string) (*schema.TranscriptionResult, error) Predict(ctx context.Context, messages schema.Messages, images, videos, audios []string, tokenCallback func(string, backend.TokenUsage) bool, tools []types.ToolUnion, toolChoice *types.ToolChoiceUnion, logprobs *int, topLogprobs *int, logitBias map[string]float64) (func() (backend.LLMResponse, error), error) TTS(ctx context.Context, text, voice, language string) (string, *proto.Result, error) // TTSStream synthesizes speech incrementally, invoking onAudio with raw PCM // chunks (and the backend sample rate) as they are produced. TTSStream(ctx context.Context, text, voice, language string, onAudio func(pcm []byte, sampleRate int) error) error // TranscribeStream transcribes audio incrementally, invoking onDelta for each // transcript text fragment and returning the final aggregated result. TranscribeStream(ctx context.Context, audio, language string, translate, diarize bool, prompt string, onDelta func(text string)) (*schema.TranscriptionResult, error) // SoundDetection classifies a committed audio window into scored AudioSet // sound-event tags. topK caps the number of returned tags (0 = backend // default), threshold drops tags below the given score (0 = keep all). SoundDetection(ctx context.Context, audio string, topK int, threshold float32) (*schema.SoundClassificationResult, error) // TranscribeLive opens a live (bidirectional) transcription session on the // pipeline's transcription backend, used by semantic_vad turn detection; // onEvent fires from a background goroutine for every delta/EOU/final // event. Backends without live support fail with an error satisfying // grpcerrors.IsLiveTranscriptionUnsupported. TranscribeLive(ctx context.Context, language string, onEvent func(backend.LiveTranscriptionEvent)) (backend.LiveTranscriptionSession, error) PredictConfig() *config.ModelConfig // Warmup eagerly loads the pipeline's sub-model backends into memory so the // first realtime turn doesn't pay each backend's cold-start load cost. Loads // run concurrently; Warmup blocks until they all finish and returns a joined // error naming every stage that failed to load (nil if all succeeded), so a // caller can surface model-load failures at session start instead of mid-call. Warmup(ctx context.Context) error } var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true // Allow all origins }, } // TODO: Implement ephemeral keys to allow these endpoints to be used func RealtimeSessions(application *application.Application) echo.HandlerFunc { return func(c echo.Context) error { return c.NoContent(501) } } func RealtimeTranscriptionSession(application *application.Application) echo.HandlerFunc { return func(c echo.Context) error { return c.NoContent(501) } } // RealtimeSessionOptions bundles per-session knobs decoded from the WS query // string (or the WebRTC handshake body). Mirrors what chat.go pulls off // `metadata.localai_assistant` — admin-only opt-in to the in-process // management tool surface. type RealtimeSessionOptions struct { LocalAIAssistant bool // AuthEnabled mirrors chat.go's requireAssistantAccess gate. We resolve // admin role at handshake time (where the echo.Context has the auth // cookie/Bearer) and drop the result here so runRealtimeSession can // decide without holding onto the request. IsAdmin bool } func Realtime(application *application.Application) echo.HandlerFunc { return func(c echo.Context) error { ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil) if err != nil { return err } defer ws.Close() // Set maximum message size to prevent DoS attacks ws.SetReadLimit(maxWebSocketMessageSize) // Extract query parameters from Echo context before passing to websocket handler model := c.QueryParam("model") assistantFlag, _ := strconv.ParseBool(c.QueryParam("localai_assistant")) opts := RealtimeSessionOptions{ LocalAIAssistant: assistantFlag, IsAdmin: isCurrentUserAdmin(c, application), } registerRealtime(application, model, opts)(ws) return nil } } // isCurrentUserAdmin replicates the chat-side admin check at the realtime // handshake. When auth is disabled, every caller is treated as admin (same // as chat's requireAssistantAccess). func isCurrentUserAdmin(c echo.Context, application *application.Application) bool { if application == nil || application.ApplicationConfig() == nil || !application.ApplicationConfig().Auth.Enabled { return true } user := auth.GetUser(c) return user != nil && user.Role == auth.RoleAdmin } func registerRealtime(application *application.Application, model string, opts RealtimeSessionOptions) func(c *websocket.Conn) { return func(conn *websocket.Conn) { t := NewWebSocketTransport(conn) evaluator := application.TemplatesEvaluator() xlog.Debug("Realtime WebSocket connection established", "address", conn.RemoteAddr().String(), "model", model) runRealtimeSession(application, t, model, evaluator, opts) } } // defaultMaxHistoryItems picks a sensible default cap for the session. // Small any-to-any audio models degrade quickly past a handful of turns; // legacy pipelines composing larger LLMs keep the historical "unlimited" // default and rely on the LLM's own context window. func defaultMaxHistoryItems(cfg *config.ModelConfig) int { if cfg != nil && cfg.HasUsecases(config.FLAG_REALTIME_AUDIO) { return 6 } return 0 } // resolveMaxHistoryItems honors an explicit pipeline.max_history_items when set, // otherwise falls back to the per-model-type default. This lets a composed // pipeline (VAD+STT+LLM+TTS) cap its history so a long-running session doesn't // grow until the LLM's context window fills. func resolveMaxHistoryItems(cfg *config.ModelConfig) int { if cfg != nil && cfg.Pipeline.MaxHistoryItems != nil { return *cfg.Pipeline.MaxHistoryItems } return defaultMaxHistoryItems(cfg) } // trimRealtimeItems returns the tail of items capped at maxItems (0 = no cap). // Walks backwards keeping function_call + function_call_output pairs together // so we never feed the LLM an orphaned tool result that references a call it // can't see. func trimRealtimeItems(items []*types.MessageItemUnion, maxItems int) []*types.MessageItemUnion { if maxItems <= 0 || len(items) <= maxItems { return items } // Find the cut point starting from len-maxItems and pull it left until // we're not in the middle of a tool-call pair. cut := len(items) - maxItems for cut > 0 && items[cut] != nil && items[cut].FunctionCallOutput != nil { cut-- } return items[cut:] } // prepareRealtimeConfig validates a model config for use in a realtime session // and fills in pipeline slots for self-contained any-to-any models. It returns // an error code + message pair suitable for sendError; the bool indicates // whether the caller should proceed. Extracted from runRealtimeSession so the // gate logic can be exercised in unit tests without a full Application. func prepareRealtimeConfig(cfg *config.ModelConfig) (errCode, errMsg string, ok bool) { if cfg == nil { return "invalid_model", "Model is not a pipeline model", false } // Self-contained any-to-any models (e.g. liquid-audio) own the whole // loop in one engine — surface them by populating empty pipeline slots // with the model's own name so newModel can resolve a config for each // role. The user can still pin individual slots (e.g. Pipeline.VAD = // silero-vad) and those wins. if cfg.HasUsecases(config.FLAG_REALTIME_AUDIO) { if cfg.Pipeline.VAD == "" { cfg.Pipeline.VAD = cfg.Name } if cfg.Pipeline.Transcription == "" { cfg.Pipeline.Transcription = cfg.Name } if cfg.Pipeline.LLM == "" { cfg.Pipeline.LLM = cfg.Name } if cfg.Pipeline.TTS == "" { cfg.Pipeline.TTS = cfg.Name } return "", "", true } if cfg.Pipeline.VAD == "" && cfg.Pipeline.Transcription == "" && cfg.Pipeline.TTS == "" && cfg.Pipeline.LLM == "" && cfg.Pipeline.SoundDetection == "" { return "invalid_model", "Model is not a pipeline model", false } return "", "", true } // runRealtimeSession runs the main event loop for a realtime session. // It is transport-agnostic and works with both WebSocket and WebRTC. func runRealtimeSession(application *application.Application, t Transport, model string, evaluator *templates.Evaluator, opts RealtimeSessionOptions) { cl := application.ModelConfigLoader() cfg, err := cl.LoadModelConfigFileByNameDefaultOptions(model, application.ApplicationConfig()) if err != nil { xlog.Error("failed to load model config", "error", err) sendError(t, "model_load_error", "Failed to load model config", "", "") return } if code, msg, ok := prepareRealtimeConfig(cfg); !ok { xlog.Error("model is not a pipeline", "model", model) sendError(t, code, msg, "", "") return } // LocalAI Assistant opt-in: gate on admin (same rule as chat.go's // requireAssistantAccess) and grab the process-wide holder's executor. // We collect tools + system prompt here and merge them into the session // below so they're live from the first response.create. var assistantTools []types.ToolUnion var assistantSystemPrompt string var assistantExecutor mcpTools.ToolExecutor if opts.LocalAIAssistant { if !opts.IsAdmin { sendError(t, "forbidden", "localai_assistant requires admin", "", "") return } appCfg := application.ApplicationConfig() if appCfg != nil && appCfg.DisableLocalAIAssistant { sendError(t, "unavailable", "LocalAI Assistant is disabled on this server", "", "") return } holder := application.LocalAIAssistant() if holder == nil || !holder.HasTools() { sendError(t, "unavailable", "LocalAI Assistant is not available on this server", "", "") return } exec := holder.Executor() fns, discErr := exec.DiscoverTools(context.Background()) if discErr != nil { xlog.Error("realtime: failed to discover LocalAI Assistant tools", "error", discErr) sendError(t, "tool_discovery_failed", "failed to discover assistant tools: "+discErr.Error(), "", "") return } assistantExecutor = exec assistantSystemPrompt = holder.SystemPrompt() assistantTools = make([]types.ToolUnion, 0, len(fns)) for _, fn := range fns { fnCopy := fn assistantTools = append(assistantTools, types.ToolUnion{ Function: &types.ToolFunction{ Name: fnCopy.Name, Description: fnCopy.Description, Parameters: fnCopy.Parameters, }, }) } xlog.Debug("realtime: LocalAI Assistant tools injected", "count", len(fns)) } sttModel := cfg.Pipeline.Transcription // A sound-detection-only pipeline (sound_detection set, no transcription/LLM) // activates on sounds, not speech, so it runs WITHOUT the voice VAD: the // session defaults to turn_detection none and the client drives windowing via // input_audio_buffer.commit. There is no transcription stage in that case. soundOnly := cfg.Pipeline.SoundDetection != "" && cfg.Pipeline.Transcription == "" && cfg.Pipeline.LLM == "" // defaultTurnDetection seeds server_vad by default, or semantic_vad when the // pipeline opts in (turn_detection.type: semantic_vad); clients can still // override per session via session.update. turnDetection := defaultTurnDetection(cfg) inputAudioTranscription := &types.AudioTranscription{Model: sttModel} if soundOnly { turnDetection = nil // turn_detection none: no VAD inputAudioTranscription = nil // no transcription stage } // Compose the system prompt: prepend the assistant prompt when we have // one (it teaches the model the safety rules and tool recipes), then the // session's default voice instructions. Order matches chat.go's // hasSystemMessage check — assistant prompt comes first. instructions := defaultInstructions if assistantSystemPrompt != "" { instructions = assistantSystemPrompt + "\n\n" + defaultInstructions } sessionID := generateSessionID() session := &Session{ ID: sessionID, TranscriptionOnly: false, Model: model, Voice: cfg.TTSConfig.Voice, Instructions: instructions, ModelConfig: cfg, Tools: assistantTools, AssistantTools: assistantTools, AssistantExecutor: assistantExecutor, TurnDetection: turnDetection, InputAudioTranscription: inputAudioTranscription, Conversations: make(map[string]*Conversation), InputSampleRate: defaultRemoteSampleRate, OutputSampleRate: defaultRemoteSampleRate, MaxHistoryItems: resolveMaxHistoryItems(cfg), SoundDetectionEnabled: cfg.Pipeline.SoundDetection != "", SoundDetectionTopK: defaultSoundDetectionTopK, SoundDetectionThreshold: 0, SoundDetectionWindowMs: cfg.Pipeline.SoundDetectionWindowMs, SoundDetectionHopMs: cfg.Pipeline.SoundDetectionHopMs, } session.CompactionEnabled, session.CompactionTrigger, session.MaxSummaryTokens, session.SummaryModel = resolveCompaction(cfg, session.MaxHistoryItems) // Single-writer response coordinator (machine M3). All response starts and // cancels go through this, so the read-loop and VAD goroutine can never race // into two overlapping responses (see realtime_respcoord.go). session.respSink = newResponseSink() // Create a default conversation conversationID := generateConversationID() conversation := &Conversation{ ID: conversationID, Items: []*types.MessageItemUnion{}, } // The compaction coordinator's work closure resolves the summarizer (lazily // loading a configured summary_model) and runs the summarize+evict off the // response path — only when a compaction actually starts. conversation.compaction = newCompactionSink(func(ctx context.Context) { model := session.summarizerModel() if model == nil { return } session.compact(ctx, conversation, model) }) session.Conversations[conversationID] = conversation session.DefaultConversationID = conversationID var m Model if soundOnly { m, err = newSoundDetectionOnlyModel( &cfg.Pipeline, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), ) } else { m, err = newModel( &cfg.Pipeline, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), evaluator, buildRealtimeRoutingContext(application, sessionID), ) } if err != nil { xlog.Error("failed to load model", "error", err) sendError(t, "model_load_error", "Failed to load model", "", "") return } session.ModelInterface = m // The voice gate is built before the warm-up below so its // speaker-recognition model can warm alongside the pipeline stages. if cfg.Pipeline.VoiceGateEnabled() { gate, gerr := newVoiceGate( *cfg.Pipeline.VoiceRecognition, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.VoiceRegistry(), ) if gerr != nil { xlog.Error("failed to initialize voice recognition gate", "error", gerr) sendError(t, "voice_gate_error", gerr.Error(), "", "") return } session.voiceGate = gate xlog.Info("realtime voice recognition gate enabled", "mode", gate.cfg.Mode, "when", gate.cfg.When) } // Warm the pipeline's sub-model backends before announcing the session. // Loads run concurrently but we block here until they all finish, so a model // that fails to load (missing weights, bad backend, OOM) surfaces as an error // at session start rather than stalling — or failing — mid-call on the first // turn (VAD on the first audio chunk, STT at end-of-speech, LLM on the first // reply, TTS on the first spoken output). On success the backends are already // resident, so the first turn pays no cold-start cost. Opt out per pipeline // with `pipeline.disable_warmup: true` to restore lazy load-on-first-use // (errors then surface on first use instead of at session start). if !cfg.Pipeline.DisableWarmup { warmErr := make(chan error, 1) go func() { warmErr <- m.Warmup(context.Background()) }() // The voice-gate model warms concurrently with the pipeline stages: an // enforced gate blocks each utterance on speaker resolution, so its // cold-start would otherwise land on the first turn too. (Compaction's // summary_model stays lazy — it only runs off the response path.) var gateErr error if session.voiceGate != nil { _, gateErr = backend.PreloadStages(context.Background(), application.ModelLoader(), application.ApplicationConfig(), []backend.PreloadStage{ {Role: "voice_recognition", Cfg: session.voiceGate.recCfg}, }) } if err := errors.Join(<-warmErr, gateErr); err != nil { xlog.Error("realtime warmup failed", "model", model, "error", err) sendError(t, "model_load_error", "Failed to load pipeline models: "+err.Error(), "", "") return } } if session.SummaryModel != "" { summaryModelName := session.SummaryModel sid := sessionID session.summarizerFactory = func() (Model, error) { summaryCfg, lerr := application.ModelConfigLoader().LoadModelConfigFileByNameDefaultOptions(summaryModelName, application.ApplicationConfig()) if lerr != nil { return nil, fmt.Errorf("load summary model config %q: %w", summaryModelName, lerr) } return newModel(&summaryCfg.Pipeline, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), evaluator, buildRealtimeRoutingContext(application, sid)) } } // Store the session and notify the transport (for WebRTC audio track handling) sessionLock.Lock() sessions[sessionID] = session sessionLock.Unlock() // For WebRTC, inbound audio arrives as Opus (48kHz) and is decoded+resampled // to localSampleRate in handleIncomingAudioTrack. Set InputSampleRate to // match so handleVAD doesn't needlessly double-resample. if _, ok := t.(*WebRTCTransport); ok { session.InputSampleRate = localSampleRate } if sn, ok := t.(interface{ SetSession(*Session) }); ok { sn.SetSession(session) } sendEvent(t, types.SessionCreatedEvent{ ServerEventBase: types.ServerEventBase{ EventID: "event_TODO", }, Session: session.ToServer(), }) var ( msg []byte wg sync.WaitGroup ) // M1 connection lifecycle. The VAD goroutine's run/stop (and its done channel) // and the once-only teardown are owned by this coordinator, so the channel is // closed exactly once and never resurrected after teardown (Part 2, failure // mode 6; invariants #8, #10). See realtime_conncoord.go and conncoord/. conn := newConnSink(session, sessionID, t, &wg) toggleVAD := func() { conn.setVAD(turnDetectionActive(session.TurnDetection)) } // For WebRTC sessions, start the Opus decode loop before VAD so that // decoded PCM is already flowing when VAD's first tick fires. if wt, ok := t.(*WebRTCTransport); ok { conn.decodeDone = make(chan struct{}) go decodeOpusLoop(session, wt.opusBackend, conn.decodeDone) } toggleVAD() // Server-side sound-detection windowing (option B): for a sound-only session // with window/hop configured, the server classifies the last window of // streamed audio on a timer, so the client only has to stream (no commits). // This runs independent of VAD (sound events are not speech). if soundOnly && session.SoundDetectionWindowMs > 0 && session.SoundDetectionHopMs > 0 { conn.soundWindowDone = make(chan struct{}) soundWindowDone := conn.soundWindowDone wg.Go(func() { handleSoundWindow(session, t, soundWindowDone) }) xlog.Debug("Starting server-side sound-detection windowing", "window_ms", session.SoundDetectionWindowMs, "hop_ms", session.SoundDetectionHopMs) } for { msg, err = t.ReadEvent() if err != nil { xlog.Error("read error", "error", err) break } // Handle diagnostic events that aren't part of the OpenAI protocol var rawType struct { Type string `json:"type"` } if json.Unmarshal(msg, &rawType) == nil && rawType.Type == "test_tone" { if _, ok := t.(*WebSocketTransport); ok { sendError(t, "not_supported", "test_tone is only supported on WebRTC connections", "", "") } else { xlog.Debug("Generating test tone") go sendTestTone(t) } continue } // Parse the incoming message event, err := types.UnmarshalClientEvent(msg) if err != nil { xlog.Error("invalid json", "error", err) sendError(t, "invalid_json", "Invalid JSON format", "", "") continue } switch e := event.(type) { case types.SessionUpdateEvent: xlog.Debug("recv", "message", string(msg)) // Handle transcription session update if e.Session.Transcription != nil { if err := updateTransSession( session, &e.Session, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), ); err != nil { xlog.Error("failed to update session", "error", err) sendError(t, "session_update_error", "Failed to update session", "", "") continue } toggleVAD() sendEvent(t, types.SessionUpdatedEvent{ ServerEventBase: types.ServerEventBase{ EventID: "event_TODO", }, Session: session.ToServer(), }) } // Handle realtime session update if e.Session.Realtime != nil { if err := updateSession( session, &e.Session, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), evaluator, buildRealtimeRoutingContext(application, session.ID), ); err != nil { xlog.Error("failed to update session", "error", err) sendError(t, "session_update_error", "Failed to update session", "", "") continue } toggleVAD() sendEvent(t, types.SessionUpdatedEvent{ ServerEventBase: types.ServerEventBase{ EventID: "event_TODO", }, Session: session.ToServer(), }) } case types.InputAudioBufferAppendEvent: // Handle 'input_audio_buffer.append' if e.Audio == "" { xlog.Error("Audio data is missing in 'input_audio_buffer.append'") sendError(t, "missing_audio_data", "Audio data is missing", "", "") continue } // Decode base64 audio data decodedAudio, err := base64.StdEncoding.DecodeString(e.Audio) if err != nil { xlog.Error("failed to decode audio data", "error", err) sendError(t, "invalid_audio_data", "Failed to decode audio data", "", "") continue } // Check buffer size limits before appending session.AudioBufferLock.Lock() newSize := len(session.InputAudioBuffer) + len(decodedAudio) if newSize > maxAudioBufferSize { session.AudioBufferLock.Unlock() xlog.Error("audio buffer size limit exceeded", "current_size", len(session.InputAudioBuffer), "incoming_size", len(decodedAudio), "limit", maxAudioBufferSize) sendError(t, "buffer_size_exceeded", fmt.Sprintf("Audio buffer size limit exceeded (max %d bytes)", maxAudioBufferSize), "", "") continue } // Append to InputAudioBuffer session.InputAudioBuffer = append(session.InputAudioBuffer, decodedAudio...) session.AudioBufferLock.Unlock() case types.InputAudioBufferCommitEvent: xlog.Debug("recv", "message", string(msg)) sessionLock.Lock() autoTurnDetection := turnDetectionActive(session.TurnDetection) sessionLock.Unlock() // TODO: At the least need to check locking and timer state in the VAD Go routine before allowing this if autoTurnDetection { sendNotImplemented(t, "input_audio_buffer.commit in conjunction with VAD") continue } session.AudioBufferLock.Lock() allAudio := make([]byte, len(session.InputAudioBuffer)) copy(allAudio, session.InputAudioBuffer) session.InputAudioBuffer = nil session.AudioBufferLock.Unlock() sendEvent(t, types.InputAudioBufferCommittedEvent{ ServerEventBase: types.ServerEventBase{}, ItemID: generateItemID(), }) session.respSink.issue(context.Background(), respcoord.SourceClient, func(ctx context.Context) { commitUtterance(ctx, allAudio, session, conversation, t) }) case types.InputAudioBufferClearEvent: xlog.Debug("recv", "message", string(msg)) // Discard a partially-captured utterance so the client can restart // input cleanly without the stale buffer leaking into the next commit. clearInputAudio(session) sendEvent(t, types.InputAudioBufferClearedEvent{ ServerEventBase: types.ServerEventBase{EventID: e.EventID}, }) case types.ConversationItemCreateEvent: xlog.Debug("recv", "message", string(msg)) // Add the item to the conversation item := e.Item // Ensure IDs are present if item.User != nil && item.User.ID == "" { item.User.ID = generateItemID() } if item.Assistant != nil && item.Assistant.ID == "" { item.Assistant.ID = generateItemID() } if item.System != nil && item.System.ID == "" { item.System.ID = generateItemID() } if item.FunctionCall != nil && item.FunctionCall.ID == "" { item.FunctionCall.ID = generateItemID() } if item.FunctionCallOutput != nil && item.FunctionCallOutput.ID == "" { item.FunctionCallOutput.ID = generateItemID() } conversation.Lock.Lock() conversation.Items = append(conversation.Items, &item) conversation.Lock.Unlock() sendEvent(t, types.ConversationItemAddedEvent{ ServerEventBase: types.ServerEventBase{ EventID: e.EventID, }, PreviousItemID: e.PreviousItemID, Item: item, }) case types.ConversationItemDeleteEvent: xlog.Debug("recv", "message", string(msg)) if e.ItemID == "" { sendError(t, "invalid_item_id", "Need item_id, but none specified", "", "event_TODO") continue } conversation.Lock.Lock() updated, ok := deleteItem(conversation.Items, e.ItemID) conversation.Items = updated conversation.Lock.Unlock() if !ok { sendError(t, "invalid_item_id", "Item to delete not found", "", "event_TODO") continue } sendEvent(t, types.ConversationItemDeletedEvent{ ServerEventBase: types.ServerEventBase{EventID: e.EventID}, ItemID: e.ItemID, }) case types.ConversationItemTruncateEvent: xlog.Debug("recv", "message", string(msg)) conversation.Lock.Lock() ok := truncateAssistantText(conversation.Items, e.ItemID, e.ContentIndex) conversation.Lock.Unlock() if !ok { sendError(t, "invalid_item_id", "Item to truncate not found", "", "event_TODO") continue } sendEvent(t, types.ConversationItemTruncatedEvent{ ServerEventBase: types.ServerEventBase{EventID: e.EventID}, ItemID: e.ItemID, ContentIndex: e.ContentIndex, AudioEndMs: e.AudioEndMs, }) case types.ConversationItemRetrieveEvent: xlog.Debug("recv", "message", string(msg)) if e.ItemID == "" { sendError(t, "invalid_item_id", "Need item_id, but none specified", "", "event_TODO") continue } conversation.Lock.Lock() var retrievedItem types.MessageItemUnion for _, item := range conversation.Items { if itemID(item) == e.ItemID { retrievedItem = *item break } } conversation.Lock.Unlock() sendEvent(t, types.ConversationItemRetrievedEvent{ ServerEventBase: types.ServerEventBase{ EventID: "event_TODO", }, Item: retrievedItem, }) case types.ResponseCreateEvent: xlog.Debug("recv", "message", string(msg)) // Handle optional items to add to context if len(e.Response.Input) > 0 { conversation.Lock.Lock() for _, item := range e.Response.Input { // Ensure IDs are present if item.User != nil && item.User.ID == "" { item.User.ID = generateItemID() } if item.Assistant != nil && item.Assistant.ID == "" { item.Assistant.ID = generateItemID() } if item.System != nil && item.System.ID == "" { item.System.ID = generateItemID() } if item.FunctionCall != nil && item.FunctionCall.ID == "" { item.FunctionCall.ID = generateItemID() } if item.FunctionCallOutput != nil && item.FunctionCallOutput.ID == "" { item.FunctionCallOutput.ID = generateItemID() } conversation.Items = append(conversation.Items, &item) } conversation.Lock.Unlock() } resp := e.Response session.respSink.issue(context.Background(), respcoord.SourceClient, func(ctx context.Context) { triggerResponse(ctx, session, conversation, t, &resp) }) case types.ResponseCancelEvent: xlog.Debug("recv", "message", string(msg)) session.respSink.cancel(respcoord.SourceClient) default: xlog.Error("unknown message type") // sendError(t, "unknown_message_type", fmt.Sprintf("Unknown message type: %s", incomingMsg.Type), "", "") } } // Tear down through the connection coordinator (once). It stops any running // VAD goroutine, then the opus-decode and sound-window goroutines, joins them, // cancels the in-flight response and drains all response goroutines, and // finally removes the session — all in dependency order, exactly once. conn.close() } // sendEvent sends a server event via the transport, logging any errors. func sendEvent(t Transport, event types.ServerEvent) { if err := t.SendEvent(event); err != nil { xlog.Error("write error", "error", err) } } // sendError sends an error event to the client. func sendError(t Transport, code, message, param, eventID string) { errorEvent := types.ErrorEvent{ ServerEventBase: types.ServerEventBase{ EventID: eventID, }, Error: types.Error{ Type: "invalid_request_error", Code: code, Message: message, Param: param, EventID: eventID, }, } sendEvent(t, errorEvent) } func sendNotImplemented(t Transport, message string) { sendError(t, "not_implemented", message, "", "event_TODO") } // sendTestTone generates a 1-second 440 Hz sine wave and sends it through // the transport's audio path. This exercises the full Opus encode → RTP → // browser decode pipeline without involving TTS. func sendTestTone(t Transport) { const ( freq = 440.0 sampleRate = 24000 duration = 1 // seconds amplitude = 16000 numSamples = sampleRate * duration ) pcm := make([]byte, numSamples*2) // 16-bit samples = 2 bytes each for i := range numSamples { sample := int16(amplitude * math.Sin(2*math.Pi*freq*float64(i)/sampleRate)) binary.LittleEndian.PutUint16(pcm[i*2:], uint16(sample)) } xlog.Debug("Sending test tone", "samples", numSamples, "sample_rate", sampleRate, "freq", freq) if err := t.SendAudio(context.Background(), pcm, sampleRate); err != nil { xlog.Error("test tone send failed", "error", err) } } func updateTransSession(session *Session, update *types.SessionUnion, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) error { sessionLock.Lock() defer sessionLock.Unlock() // In transcription session update, we look at Transcription field if update.Transcription == nil || update.Transcription.Audio == nil || update.Transcription.Audio.Input == nil { return nil } trUpd := update.Transcription.Audio.Input.Transcription trCur := session.InputAudioTranscription session.TranscriptionOnly = true if trUpd != nil && trUpd.Model != "" && trUpd.Model != trCur.Model { cfg, err := cl.LoadModelConfigFileByNameDefaultOptions(trUpd.Model, appConfig) if err != nil { return err } if cfg == nil || (cfg.Pipeline.VAD == "" || cfg.Pipeline.Transcription == "") { return fmt.Errorf("model is not a valid pipeline model: %s", trUpd.Model) } m, cfg, err := newTranscriptionOnlyModel(&cfg.Pipeline, cl, ml, appConfig) if err != nil { return err } session.ModelInterface = m session.ModelConfig = cfg session.SoundDetectionEnabled = cfg.Pipeline.SoundDetection != "" if session.SoundDetectionTopK <= 0 { session.SoundDetectionTopK = defaultSoundDetectionTopK } } if trUpd != nil { trCur.Language = trUpd.Language trCur.Prompt = trUpd.Prompt } if update.Transcription.Audio.Input.TurnDetectionSet { session.TurnDetection = update.Transcription.Audio.Input.TurnDetection } if update.Transcription.Audio.Input.Format != nil && update.Transcription.Audio.Input.Format.PCM != nil { if update.Transcription.Audio.Input.Format.PCM.Rate > 0 { session.InputSampleRate = update.Transcription.Audio.Input.Format.PCM.Rate } } return nil } func updateSession(session *Session, update *types.SessionUnion, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, evaluator *templates.Evaluator, routing *RealtimeRoutingContext) error { sessionLock.Lock() defer sessionLock.Unlock() if update.Realtime == nil { return nil } session.TranscriptionOnly = false rt := update.Realtime if rt.Model != "" { cfg, err := cl.LoadModelConfigFileByNameDefaultOptions(rt.Model, appConfig) if err != nil { return err } if cfg == nil || (cfg.Pipeline.VAD == "" || cfg.Pipeline.Transcription == "" || cfg.Pipeline.TTS == "" || cfg.Pipeline.LLM == "") { return fmt.Errorf("model is not a valid pipeline model: %s", rt.Model) } if session.InputAudioTranscription == nil { session.InputAudioTranscription = &types.AudioTranscription{} } session.InputAudioTranscription.Model = cfg.Pipeline.Transcription session.Voice = cfg.TTSConfig.Voice session.Model = rt.Model session.ModelConfig = cfg } if rt.Audio != nil && rt.Audio.Output != nil && rt.Audio.Output.Voice != "" { session.Voice = string(rt.Audio.Output.Voice) } if rt.Audio != nil && rt.Audio.Input != nil && rt.Audio.Input.Transcription != nil { trUpd := rt.Audio.Input.Transcription // A language-only update (e.g. a client forcing the STT language) carries // an empty Model. Preserve the pipeline's configured transcription backend // instead of blanking it — otherwise the next utterance transcribes against // an empty model and the backend RPC fails with "unimplemented". if trUpd.Model == "" && session.InputAudioTranscription != nil { trUpd.Model = session.InputAudioTranscription.Model } session.InputAudioTranscription = trUpd if trUpd.Model != "" { session.ModelConfig.Pipeline.Transcription = trUpd.Model } } if rt.Model != "" || (rt.Audio != nil && rt.Audio.Output != nil && rt.Audio.Output.Voice != "") || (rt.Audio != nil && rt.Audio.Input != nil && rt.Audio.Input.Transcription != nil) { m, err := newModel(&session.ModelConfig.Pipeline, cl, ml, appConfig, evaluator, routing) if err != nil { return err } session.ModelInterface = m // A session.update that swaps the model/voice rebuilds the pipeline, so // warm the new backends too (unless opted out) — otherwise the next turn // pays the cold-start load the original session warm-up already avoided. // Unlike session start this stays non-blocking: updateSession runs under // the global sessionLock, so blocking on a multi-second load here would // stall every other session. Load errors are logged (and still surface on // first use); per-stage failures are already warned inside // backend.PreloadStages. if !session.ModelConfig.Pipeline.DisableWarmup { go func() { if err := m.Warmup(context.Background()); err != nil { xlog.Error("realtime warmup failed after session.update", "error", err) } }() } } if rt.Audio != nil && rt.Audio.Input != nil && rt.Audio.Input.TurnDetectionSet { session.TurnDetection = rt.Audio.Input.TurnDetection } if rt.Audio != nil && rt.Audio.Input != nil && rt.Audio.Input.Format != nil && rt.Audio.Input.Format.PCM != nil { if rt.Audio.Input.Format.PCM.Rate > 0 { session.InputSampleRate = rt.Audio.Input.Format.PCM.Rate } } if rt.Audio != nil && rt.Audio.Output != nil && rt.Audio.Output.Format != nil && rt.Audio.Output.Format.PCM != nil { if rt.Audio.Output.Format.PCM.Rate > 0 { session.OutputSampleRate = rt.Audio.Output.Format.PCM.Rate } } if rt.Instructions != "" { session.Instructions = rt.Instructions } if rt.Tools != nil { // Manage Mode tools survive a client-driven session.update — the // alternative is silently dropping them whenever the user toggles // a client MCP server, which would break the modality mid-session. // Names from rt.Tools win on collision (the client is explicit; // we preserve, we don't override). merged := append([]types.ToolUnion(nil), rt.Tools...) seen := make(map[string]struct{}, len(merged)) for _, t := range merged { if t.Function != nil { seen[t.Function.Name] = struct{}{} } } for _, t := range session.AssistantTools { if t.Function == nil { continue } if _, ok := seen[t.Function.Name]; ok { continue } merged = append(merged, t) } session.Tools = merged } if rt.ToolChoice != nil { session.ToolChoice = rt.ToolChoice } if rt.MaxOutputTokens != 0 { session.MaxOutputTokens = rt.MaxOutputTokens } if len(rt.OutputModalities) > 0 { session.OutputModalities = rt.OutputModalities } return nil } // decodeOpusLoop runs a ticker that drains buffered raw Opus frames from the // session, decodes them in a single batched gRPC call, and appends the // resulting PCM to InputAudioBuffer. This gives ~3 gRPC calls/sec instead of // 50 (one per RTP packet) and keeps decode diagnostics once-per-batch. func decodeOpusLoop(session *Session, opusBackend grpc.Backend, done chan struct{}) { ticker := time.NewTicker(300 * time.Millisecond) defer ticker.Stop() for { select { case <-ticker.C: session.OpusFramesLock.Lock() frames := session.OpusFrames session.OpusFrames = nil session.OpusFramesLock.Unlock() if len(frames) == 0 { continue } result, err := opusBackend.AudioDecode(context.Background(), &proto.AudioDecodeRequest{ Frames: frames, Options: map[string]string{ "session_id": session.ID, }, }) if err != nil { xlog.Warn("opus decode batch error", "error", err, "frames", len(frames)) continue } samples := sound.BytesToInt16sLE(result.PcmData) xlog.Debug("opus decode batch", "frames", len(frames), "decoded_samples", len(samples), "sample_rate", result.SampleRate, ) // Resample from 48kHz to session input rate (16kHz) if needed if result.SampleRate != int32(session.InputSampleRate) { samples = sound.ResampleInt16(samples, int(result.SampleRate), session.InputSampleRate) } pcmBytes := sound.Int16toBytesLE(samples) session.AudioBufferLock.Lock() newSize := len(session.InputAudioBuffer) + len(pcmBytes) if newSize <= maxAudioBufferSize { session.InputAudioBuffer = append(session.InputAudioBuffer, pcmBytes...) } session.AudioBufferLock.Unlock() case <-done: return } } } // noSpeechHoldbackSec is how much of the tail of an inspected, segment-free // buffer survives the periodic no-speech clear. It must cover the VAD's // onset-detection latency: a word can already be underway in the newest part // of the window without silero having crossed its threshold yet, and clearing // it cuts the start of the utterance the next tick will detect. const noSpeechHoldbackSec = 0.5 // dropInspectedPrefix removes the head of the audio buffer that a VAD tick // inspected (the first inspected bytes), keeping the newest holdbackBytes of // that window plus everything appended while the tick ran — audio the VAD // never saw. When something is dropped the result is a fresh copy, never a // sub-slice, so later appends can't scribble on memory shared with the old // backing array; when nothing is dropped buf is returned unchanged. func dropInspectedPrefix(buf []byte, inspected, holdbackBytes int) []byte { cut := inspected - holdbackBytes if cut <= 0 { return buf } if cut > len(buf) { cut = len(buf) } return append([]byte(nil), buf[cut:]...) } // handleVAD is a goroutine that listens for audio data from the client, // runs VAD on the audio data, and commits utterances to the conversation. // // With turn_detection.type == "semantic_vad" (sv != nil below) the silero // loop is augmented by a live transcription stream: the buffer's new audio // is fed to the transcription model every tick and its end-of-utterance // token switches the commit threshold between a short post-EOU window and // the long eagerness fallback. The server_vad path is untouched. func handleVAD(session *Session, conv *Conversation, t Transport, done chan struct{}) { vadContext, cancel := context.WithCancel(context.Background()) go func() { <-done cancel() }() silenceThreshold := 0.5 // Default 500ms if session.TurnDetection != nil && session.TurnDetection.ServerVad != nil { silenceThreshold = float64(session.TurnDetection.ServerVad.SilenceDurationMs) / 1000 } lts := newLiveTurnState(session, t) startTime := time.Now() // M2 turn-detection state machine. "Speech started" and "a turn's live ASR // stream is open" are ONE coordinator state (Idle/Speaking), so they cannot // desync the way the legacy speechStarted bool and lts.open() could (Part 2, // failure mode 4). See realtime_turncoord.go and turncoord/. sink := newTurnSink(session, conv, t, lts, vadContext, startTime) // Teardown: end any open turn through the coordinator (DiscardTurn closes the // live stream; no-op if already idle). Replaces the bare lts.discardTurn(). defer func() { if err := sink.coord.Apply(turncoord.Abort{Reason: turncoord.AbortTeardown}); err != nil { xlog.Error("turncoord: abort(teardown) failed", "error", err) } }() ticker := time.NewTicker(300 * time.Millisecond) defer ticker.Stop() for { select { case <-done: return case <-ticker.C: // Semantic mode is re-read each tick: session.update can switch // turn-detection modes (and the retranscribe gate) mid-session. sessionLock.Lock() var sv *types.RealtimeSessionSemanticVad if session.TurnDetection != nil { sv = session.TurnDetection.SemanticVad } retranscribe := sv != nil && session.ModelConfig != nil && session.ModelConfig.Pipeline.TurnDetectionRetranscribe() sessionLock.Unlock() // The turn coordinator's data-heavy effects (OpenTurn/CommitTurn) // need this tick's mode; set it before any Apply below. sink.sv = sv // session.update switched semantic -> server mid-turn: drop the // orphaned live stream. This is NOT a turn abort — the turn continues // under server_vad (a config change must not cut off a mid-utterance // speaker), so the coordinator stays Speaking; only the orphaned live // stream is closed. if sv == nil && lts.open() { lts.discardTurn() } session.AudioBufferLock.Lock() allAudio := make([]byte, len(session.InputAudioBuffer)) copy(allAudio, session.InputAudioBuffer) session.AudioBufferLock.Unlock() aints := sound.BytesToInt16sLE(allAudio) if len(aints) == 0 || len(aints) < int(silenceThreshold*float64(session.InputSampleRate)) { continue } // Resample from InputSampleRate to 16kHz aints = sound.ResampleInt16(aints, session.InputSampleRate, localSampleRate) audioLength := float64(len(aints)) / localSampleRate if sv != nil && lts.open() { lts.feedNewAudio(aints) lts.drainEvents(audioLength) } segments, err := runVAD(vadContext, session, aints) if err != nil { if err.Error() == "unexpected speech end" { xlog.Debug("VAD cancelled") continue } xlog.Error("failed to process audio", "error", err) sendError(t, "processing_error", "Failed to process audio: "+err.Error(), "", "") continue } // NOTE: the no-speech clear and the min-buffer gate above stay on // the short silenceThreshold even in semantic mode — the eagerness // fallback applies only to the end-of-speech commit decision, or a // low eagerness would delay speech_started/barge-in by seconds. if len(segments) == 0 && audioLength > silenceThreshold { // "No segments" is not "no speech": silero (threshold 0.5) // crosses up to a few hundred ms into a soft word onset, so // the newest audio in the inspected window may be the start // of a word the next tick will recognize — and more audio // arrived while this tick ran. Keep both; drop only the // older, confirmed-silent head, or utterance onsets get cut. holdback := int(noSpeechHoldbackSec*float64(session.InputSampleRate)) * 2 session.AudioBufferLock.Lock() session.InputAudioBuffer = dropInspectedPrefix(session.InputAudioBuffer, len(allAudio), holdback) session.AudioBufferLock.Unlock() // No-speech clear: end any open turn (Speaking -> Idle, discarding // the partial). Returning to Idle is the fix for failure mode 4 — // the legacy discardTurn left speechStarted true, suppressing the // next onset. Idle while not speaking is a no-op. if err := sink.coord.Apply(turncoord.Abort{Reason: turncoord.AbortNoSpeech}); err != nil { xlog.Error("turncoord: abort(no_speech) failed", "error", err) } continue } else if len(segments) == 0 { continue } // Speech detected this tick: open the turn (Idle -> Speaking) through // the coordinator. On that transition it opens the turn's live ASR // stream + feeds the buffered prefix (OpenTurn), cancels any in-flight // response (BargeIn, non-blocking — the VAD tick is never stalled), and // emits speech_started. While already Speaking it is a no-op, so "turn // open" and "speech started" can never disagree. The turn id is minted // here and carried by the coordinator through to the committed event. sink.onsetAudio = aints if err := sink.coord.Apply(turncoord.Onset{Turn: turncoord.TurnID(generateItemID())}); err != nil { xlog.Error("turncoord: onset failed", "error", err) } if sv != nil { // Drain again: events produced by THIS tick's feed have // usually arrived by the time runVAD returns, and leaving // them for the next tick adds 300ms to every EOU-triggered // commit. lts.drainEvents(audioLength) } // Segment still in progress when audio ended segEndTime := segments[len(segments)-1].End if segEndTime == 0 { continue } threshold := silenceThreshold eouPending := false if sv != nil { eouPending = lts.eouPending(segments) threshold = lts.thresholdSec(eouPending, sv) } if float32(audioLength)-segEndTime > float32(threshold) { if sv != nil { trigger, eouLag := lts.commitTrigger(eouPending, float64(segEndTime)) xlog.Info("semantic_vad: committing turn", "trigger", trigger, "speech_end_s", segEndTime, "eou_lag_s", eouLag, "silence_s", audioLength-float64(segEndTime), "audio_s", audioLength) } // Retranscribe gate (semantic mode, EOU-triggered commits // only): cross-check the streamed EOU with an offline decode // of the buffered turn before committing. Runs synchronously // on the tick — the engine would serialize a concurrent feed // against it anyway. Timeout-triggered commits skip the gate. var gated *schema.TranscriptionResult if retranscribe && eouPending { batch, gerr := transcribeUtterance(vadContext, sound.Int16toBytesLE(aints), session) switch { case gerr != nil: xlog.Warn("semantic_vad: retranscribe gate failed; committing via the file path", "error", gerr) case !batch.Eou: xlog.Info("semantic_vad: batch decode did not confirm the streamed EOU; continuing to listen", "streamed", lts.previewText(), "batch", batch.Text) // The batch decode rejected the streamed EOU as a false // positive: consume the recorded EOU so the next tick // falls back to the eagerness window instead of // re-triggering on the same token. lts.eouAtSec = 0 continue default: xlog.Info("semantic_vad: batch decode confirmed the streamed EOU", "streamed", lts.previewText(), "batch", batch.Text) gated = batch } } xlog.Debug("Detected end of speech segment") session.AudioBufferLock.Lock() // Keep audio appended while this tick ran — it belongs to // the next turn (in any mode: nil-ing it dropped the onset // of an utterance started right after a commit). session.InputAudioBuffer = dropInspectedPrefix(session.InputAudioBuffer, len(allAudio), 0) session.AudioBufferLock.Unlock() // Commit the turn through the coordinator: it emits speech_stopped // (EmitSpeechStopped) then the committed event, finalizes the live // stream, and issues the response (CommitTurn). The committed item // id is the coordinator's turn id (== the id the live captions // streamed under), so the client replaces the partial text. sink.commitAudio = sound.Int16toBytesLE(aints) sink.commitAudioLength = audioLength sink.commitRetranscribe = retranscribe sink.commitGated = gated // TODO: Remove prefix silence that is over TurnDetectionParams.PrefixPaddingMs if err := sink.coord.Apply(turncoord.Silence{}); err != nil { xlog.Error("turncoord: commit failed", "error", err) } } } } } func commitUtterance(ctx context.Context, utt []byte, session *Session, conv *Conversation, t Transport) { commitUtteranceWithTranscript(ctx, utt, nil, nil, "", session, conv, t) } // commitUtteranceWithTranscript commits one user turn. live carries the // transcript semantic_vad's live stream already produced (its caption deltas // were streamed to the client during the turn, so only the completed event // is emitted here); gated carries the retranscribe gate's batch decode (the // authoritative transcript in that mode). With neither — server_vad, manual // commits, semantic degrade, or a live stream that heard nothing — the audio // is written to a temp WAV and transcribed via the file path as before. // itemID is the turn's conversation item id ("" mints a fresh one); it must // match the id any live deltas were sent under. func commitUtteranceWithTranscript(ctx context.Context, utt []byte, live *liveUtterance, gated *schema.TranscriptionResult, itemID string, session *Session, conv *Conversation, t Transport) { if len(utt) == 0 { return } f, err := os.CreateTemp("", "realtime-audio-chunk-*.wav") if err != nil { xlog.Error("failed to create temp file", "error", err) return } defer f.Close() defer os.Remove(f.Name()) xlog.Debug("Writing to file", "file", f.Name()) hdr := laudio.NewWAVHeader(uint32(len(utt))) if err := hdr.Write(f); err != nil { xlog.Error("Failed to write WAV header", "error", err) return } if _, err := f.Write(utt); err != nil { xlog.Error("Failed to write audio data", "error", err) return } f.Sync() // Start speaker verification concurrently with transcription. This is a // latency optimization only: there is a hard join below before the LLM, so // an unauthorized utterance never reaches generateResponse (no LLM, no // tools, no TTS) regardless of how fast transcription finishes. A rejected // turn wastes only transcription compute, which has no side effects. The // transcript is still emitted to the same peer that sent the audio, which // reveals nothing new to them. // Resolve the speaker when the gate must authorize this turn, or when identity // surfacing/personalization needs a fresh identity. Identity resolution // ignores the when:first short-circuit (that only skips re-authorization). type resolveOutcome struct { res resolution err error } var resolveCh chan resolveOutcome runResolve := false if session.voiceGate != nil && session.InputAudioTranscription != nil { enforce := session.voiceGate.cfg.EnforceGate() gateNeedsAuth := enforce if enforce && session.voiceGate.cfg.When == config.VoiceGateWhenFirst { session.gateMu.Lock() if session.voiceVerified { gateNeedsAuth = false } session.gateMu.Unlock() } if gateNeedsAuth || session.voiceGate.cfg.IdentityEnabled() { runResolve = true resolveCh = make(chan resolveOutcome, 1) wavPath := f.Name() go func() { r, rerr := session.voiceGate.Resolve(ctx, wavPath) resolveCh <- resolveOutcome{res: r, err: rerr} }() } } // TODO: If we have a real any-to-any model then transcription is optional // The turn's live captions (semantic_vad) already streamed under this // itemID; the completed event below reuses it so the client replaces the // partial text. server_vad / manual commits arrive with no itemID, so mint // one here. if itemID == "" { itemID = generateItemID() } var transcript string switch { case gated != nil: // semantic_vad retranscribe gate: the batch decode is authoritative. transcript = gated.Text if err := emitPrecomputedTranscription(t, itemID, nil, transcript); err != nil { sendError(t, "transcription_failed", err.Error(), "", "event_TODO") return } case live != nil && live.Text != "": // The caption deltas already streamed during the turn under this // itemID; the completed event replaces the partial text client-side. transcript = live.Text if err := emitPrecomputedTranscription(t, itemID, nil, transcript); err != nil { sendError(t, "transcription_failed", err.Error(), "", "event_TODO") return } case session.InputAudioTranscription != nil: // emitTranscription streams transcript deltas when // pipeline.streaming.transcription is set, otherwise emits a single // completed event; either way it returns the final transcript text. transcript, err = emitTranscription(ctx, t, session, itemID, f.Name()) if err != nil { // Drain the gate goroutine before returning so its in-flight read of // the temp WAV finishes before the deferred os.Remove fires. if runResolve { <-resolveCh } sendError(t, "transcription_failed", err.Error(), "", "event_TODO") return } case session.SoundDetectionEnabled: // Sound-detection-only session: no transcription and no LLM. The // sound-detection emit below carries the result; there is no any-to-any // path to fall into. Windowing is client-driven (turn_detection none + // input_audio_buffer.commit), so this is not voice-gated. default: // The voice gate runs only on the transcription path above; if an // any-to-any model path is added here, join the gate before responding. sendNotImplemented(t, "any-to-any models") return } // Sound-event detection is additive to transcription: classify the same // committed window and emit its scored AudioSet tags as a separate event. // A failure here is logged but must never abort the turn. if session.SoundDetectionEnabled { if sderr := emitSoundDetection(ctx, t, session, generateItemID(), f.Name()); sderr != nil { xlog.Error("sound detection failed", "error", sderr) } } // Join on the resolution before any side-effecting step. var speaker *types.Speaker if runResolve { out := <-resolveCh enforce := session.voiceGate.cfg.EnforceGate() if out.err != nil { if enforce { // Fail closed: a gate that cannot decide must not let audio through. xlog.Error("voice recognition gate error", "error", out.err) if session.voiceGate.cfg.OnReject == config.VoiceGateRejectEvent { sendError(t, "speaker_not_authorized", "speaker not authorized: verification error", "", "event_TODO") } return } // Non-enforcing: degrade to an unknown speaker and continue. xlog.Warn("voice identity resolve failed; continuing as unknown speaker", "error", out.err) } else { s := out.res.speaker speaker = &s } if enforce { alreadyVerified := false if session.voiceGate.cfg.When == config.VoiceGateWhenFirst { session.gateMu.Lock() alreadyVerified = session.voiceVerified session.gateMu.Unlock() } allowed, reason := false, "verification error" if out.err == nil { allowed, reason = session.voiceGate.authorize(out.res) } proceed, markVerified := session.voiceGate.decide(alreadyVerified, allowed) if !proceed { xlog.Debug("voice recognition gate rejected utterance", "reason", reason) if session.voiceGate.cfg.OnReject == config.VoiceGateRejectEvent { sendError(t, "speaker_not_authorized", "speaker not authorized: "+reason, "", "event_TODO") } return } if markVerified { session.gateMu.Lock() session.voiceVerified = true session.gateMu.Unlock() } xlog.Debug("voice recognition gate authorized utterance", "speaker", out.res.speaker.Name) } } // Generate an LLM response only when there is a transcript to feed it. A // sound-detection-only session (no transcription) has no LLM stage, so it // stops here after emitting the sound-detection event. if session.InputAudioTranscription != nil && !session.TranscriptionOnly { generateResponse(ctx, session, utt, transcript, speaker, conv, t) } } // handleSoundWindow runs server-side windowed sound-event detection (option B): // every HopMs it classifies the last WindowMs of streamed audio and emits a // sound_detection event, so a sound-only client only has to stream audio (no // input_audio_buffer.commit). It keeps the input buffer trimmed to one window // so a long stream stays bounded. Runs until done is closed. This is // independent of VAD: sound events are not speech. func handleSoundWindow(session *Session, t Transport, done chan struct{}) { ticker := time.NewTicker(time.Duration(session.SoundDetectionHopMs) * time.Millisecond) defer ticker.Stop() for { select { case <-done: return case <-ticker.C: classifySoundWindow(session, t) } } } // classifySoundWindow is one windowing tick: it snapshots the most recent // WindowMs of buffered audio (trimming the buffer so a long stream stays // bounded) and, when there is enough, classifies it and emits a sound_detection // event. Extracted from handleSoundWindow so it can be driven synchronously in // tests. func classifySoundWindow(session *Session, t Transport) { const bytesPerSample = 2 // 16-bit mono PCM sr := session.InputSampleRate windowBytes := session.SoundDetectionWindowMs * sr / 1000 * bytesPerSample minBytes := sr / 100 * bytesPerSample // ~10ms before classifying session.AudioBufferLock.Lock() // Keep only the most recent window so a long stream stays bounded. if windowBytes > 0 && len(session.InputAudioBuffer) > windowBytes { trimmed := make([]byte, windowBytes) copy(trimmed, session.InputAudioBuffer[len(session.InputAudioBuffer)-windowBytes:]) session.InputAudioBuffer = trimmed } window := make([]byte, len(session.InputAudioBuffer)) copy(window, session.InputAudioBuffer) session.AudioBufferLock.Unlock() if len(window) < minBytes { return // not enough audio buffered yet } path, err := writeWindowWAV(window, sr) if err != nil { xlog.Error("sound window: failed to write wav", "error", err) return } if sderr := emitSoundDetection(context.Background(), t, session, generateItemID(), path); sderr != nil { xlog.Error("sound window: detection failed", "error", sderr) } if rerr := os.Remove(path); rerr != nil { xlog.Debug("sound window: temp cleanup failed", "error", rerr) } } // writeWindowWAV writes mono 16-bit PCM to a temp WAV at the given sample rate // (the ced classifier reads the declared rate and resamples). Returns the path; // the caller removes it. func writeWindowWAV(pcm []byte, sampleRate int) (string, error) { f, err := os.CreateTemp("", "realtime-sound-window-*.wav") if err != nil { return "", err } defer func() { _ = f.Close() }() hdr := laudio.NewWAVHeaderWithRate(uint32(len(pcm)), uint32(sampleRate)) if err := hdr.Write(f); err != nil { _ = os.Remove(f.Name()) return "", err } if _, err := f.Write(pcm); err != nil { _ = os.Remove(f.Name()) return "", err } _ = f.Sync() return f.Name(), nil } // writeUtteranceWAV persists raw 16 kHz mono PCM to a temp WAV for the // file-based transcription paths. The caller must invoke cleanup. func writeUtteranceWAV(utt []byte) (string, func(), error) { f, err := os.CreateTemp("", "realtime-audio-chunk-*.wav") if err != nil { return "", nil, err } cleanup := func() { _ = f.Close() _ = os.Remove(f.Name()) } xlog.Debug("Writing to file", "file", f.Name()) hdr := laudio.NewWAVHeader(uint32(len(utt))) if err := hdr.Write(f); err != nil { cleanup() return "", nil, err } if _, err := f.Write(utt); err != nil { cleanup() return "", nil, err } _ = f.Sync() return f.Name(), cleanup, nil } // transcribeUtterance runs one offline (unary) decode of the buffered turn — // the semantic_vad retranscribe gate. The result's Eou flag reports whether // the batch decode also ended on the end-of-utterance token. func transcribeUtterance(ctx context.Context, utt []byte, session *Session) (*schema.TranscriptionResult, error) { path, cleanup, err := writeUtteranceWAV(utt) if err != nil { return nil, err } defer cleanup() language, prompt := "", "" if cfg := session.InputAudioTranscription; cfg != nil { language, prompt = cfg.Language, cfg.Prompt } tr, err := session.ModelInterface.Transcribe(ctx, path, language, false, false, prompt) if err != nil { return nil, err } if tr == nil { return nil, fmt.Errorf("transcribe result is nil") } return tr, nil } func runVAD(ctx context.Context, session *Session, adata []int16) ([]schema.VADSegment, error) { soundIntBuffer := &audio.IntBuffer{ Format: &audio.Format{SampleRate: localSampleRate, NumChannels: 1}, SourceBitDepth: 16, Data: sound.ConvertInt16ToInt(adata), } float32Data := soundIntBuffer.AsFloat32Buffer().Data resp, err := session.ModelInterface.VAD(ctx, &schema.VADRequest{ Audio: float32Data, }) if err != nil { return nil, err } // If resp.Segments is empty => no speech return resp.Segments, nil } // speakerNote renders the system-prompt note for the current speaker. Returns // an empty string when there is no name and unknown notes are disabled. func speakerNote(s *types.Speaker, noteUnknown bool) string { if s != nil && s.Matched && s.Name != "" { return "The current speaker is " + s.Name + "." } if noteUnknown { return "The current speaker is unknown." } return "" } // Function to generate a response based on the conversation func generateResponse(ctx context.Context, session *Session, utt []byte, transcript string, speaker *types.Speaker, conv *Conversation, t Transport) { xlog.Debug("Generating realtime response...") // Create user message item item := types.MessageItemUnion{ User: &types.MessageItemUser{ ID: generateItemID(), Status: types.ItemStatusCompleted, Speaker: speaker, Content: []types.MessageContentInput{ { Type: types.MessageContentTypeInputAudio, Audio: base64.StdEncoding.EncodeToString(utt), Transcript: transcript, }, }, }, } conv.Lock.Lock() conv.Items = append(conv.Items, &item) conv.Lock.Unlock() sendEvent(t, types.ConversationItemAddedEvent{ Item: item, }) // Surface the recognized speaker to the client. Skip the event for an // unidentified speaker unless announce_unknown is set. if speaker != nil && session.voiceGate != nil && session.voiceGate.cfg.AnnounceEnabled() { if speaker.Matched || session.voiceGate.cfg.Identity.AnnounceUnknown { sendEvent(t, types.ConversationItemSpeakerEvent{ ItemID: item.User.ID, Speaker: *speaker, }) } } triggerResponse(ctx, session, conv, t, nil) } // maxAssistantToolTurns caps the server-side agentic loop. Mirrors the // chat-page maxToolTurns:10 from useChat.js — the model gets up to this // many consecutive tool round-trips before we return control to the user // without another response cycle. const maxAssistantToolTurns = 10 // responseOutcome is how a response ended, decided by the response body and // read once by triggerResponse to emit the single terminal event. type responseOutcome int const ( outcomeCompleted responseOutcome = iota outcomeCancelled outcomeFailed // an error event was already sent; emit no terminal (legacy behavior) ) // liveResponse accumulates the wire-visible result of ONE response.create across // the whole agentic tool-turn recursion: a single id, the output items as they // complete, the summed token usage, and the final outcome. triggerResponse owns // it; triggerResponseAtTurn / streamLLMResponse / emitToolCallItems fill it in. // This is what makes "exactly one response.done per response.create, with Output // and Usage populated" true — the body no longer emits per-turn terminals. type liveResponse struct { id string output []types.MessageItemUnion usage backend.TokenUsage outcome responseOutcome } func (r *liveResponse) addItem(it types.MessageItemUnion) { r.output = append(r.output, it) } func (r *liveResponse) addUsage(u backend.TokenUsage) { r.usage.Prompt += u.Prompt r.usage.Completion += u.Completion } // responseUsage maps the backend's token counts onto the OpenAI Realtime // response.usage shape. Returns nil when there is nothing to report so the // field is omitted rather than sent as zeros. func responseUsage(u backend.TokenUsage) *types.TokenUsage { if u.Prompt == 0 && u.Completion == 0 { return nil } return &types.TokenUsage{ InputTokens: u.Prompt, OutputTokens: u.Completion, TotalTokens: u.Prompt + u.Completion, } } func triggerResponse(ctx context.Context, session *Session, conv *Conversation, t Transport, overrides *types.ResponseCreateParams) { // One response.created and one response.done per response.create — even when // the server-side tool loop runs several inference turns. The per-turn // terminals the legacy code emitted (one response.done per turn, with empty // Output/Usage) are gone; tool turns are now internal to this single response. r := &liveResponse{id: generateUniqueID()} sendEvent(t, types.ResponseCreatedEvent{ ServerEventBase: types.ServerEventBase{}, Response: types.Response{ ID: r.id, Object: "realtime.response", Status: types.ResponseStatusInProgress, }, }) triggerResponseAtTurn(ctx, session, conv, t, overrides, 0, r) switch r.outcome { case outcomeCancelled: sendEvent(t, types.ResponseDoneEvent{ ServerEventBase: types.ServerEventBase{}, Response: types.Response{ ID: r.id, Object: "realtime.response", Status: types.ResponseStatusCancelled, Output: r.output, }, }) case outcomeFailed: // A specific error event was already sent; emit no terminal (matches the // legacy behavior where failed responses had no response.done). default: sendEvent(t, types.ResponseDoneEvent{ ServerEventBase: types.ServerEventBase{}, Response: types.Response{ ID: r.id, Object: "realtime.response", Status: types.ResponseStatusCompleted, Output: r.output, Usage: responseUsage(r.usage), }, }) } // Fold aged-out turns into the rolling memory off the critical path; the // next turn reaps the smaller buffer. session.maybeCompact(conv) } func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversation, t Transport, overrides *types.ResponseCreateParams, toolTurn int, r *liveResponse) { config := session.ModelInterface.PredictConfig() // Default values tools := session.Tools toolChoice := session.ToolChoice instructions := session.Instructions maxOutputTokens := session.MaxOutputTokens // Overrides if overrides != nil { if overrides.Tools != nil { tools = overrides.Tools } if overrides.ToolChoice != nil { toolChoice = overrides.ToolChoice } if overrides.Instructions != "" { instructions = overrides.Instructions } if overrides.MaxOutputTokens != 0 { maxOutputTokens = overrides.MaxOutputTokens } } // Apply MaxOutputTokens to model config if specified // Save original value to restore after prediction var originalMaxTokens *int if config != nil { originalMaxTokens = config.Maxtokens if maxOutputTokens != 0 && !maxOutputTokens.IsInf() { tokenValue := int(maxOutputTokens) config.Maxtokens = &tokenValue xlog.Debug("Applied max_output_tokens to config", "value", tokenValue) } } // Defer restoration of original value defer func() { if config != nil { config.Maxtokens = originalMaxTokens } }() var conversationHistory schema.Messages conversationHistory = append(conversationHistory, schema.Message{ Role: string(types.MessageRoleSystem), StringContent: instructions, Content: instructions, }) imgIndex := 0 var lastUserSpeaker *types.Speaker personalize := session.voiceGate != nil && session.voiceGate.cfg.PersonalizeEnabled() conv.Lock.Lock() conversationHistory = withMemory(conversationHistory, conv.Memory) items := trimRealtimeItems(conv.Items, session.MaxHistoryItems) for _, item := range items { if item.User != nil { msg := schema.Message{ Role: string(types.MessageRoleUser), } lastUserSpeaker = item.User.Speaker if personalize && session.voiceGate.cfg.Identity.InjectName && item.User.Speaker != nil && item.User.Speaker.Matched && item.User.Speaker.Name != "" { msg.Name = item.User.Speaker.Name } textContent := "" nrOfImgsInMessage := 0 for _, content := range item.User.Content { switch content.Type { case types.MessageContentTypeInputText: textContent += content.Text case types.MessageContentTypeInputAudio: textContent += content.Transcript case types.MessageContentTypeInputImage: img, err := utils.GetContentURIAsBase64(content.ImageURL) if err != nil { xlog.Warn("Failed to process image", "error", err) continue } msg.StringImages = append(msg.StringImages, img) imgIndex++ nrOfImgsInMessage++ } } if nrOfImgsInMessage > 0 && !config.TemplateConfig.UseTokenizerTemplate { templated, err := templates.TemplateMultiModal(config.TemplateConfig.Multimodal, templates.MultiModalOptions{ TotalImages: imgIndex, ImagesInMessage: nrOfImgsInMessage, }, textContent) if err != nil { xlog.Warn("Failed to apply multimodal template", "error", err) templated = textContent } msg.StringContent = templated msg.Content = templated } else { msg.StringContent = textContent msg.Content = textContent } conversationHistory = append(conversationHistory, msg) } else if item.Assistant != nil { for _, content := range item.Assistant.Content { switch content.Type { case types.MessageContentTypeOutputText: conversationHistory = append(conversationHistory, schema.Message{ Role: string(types.MessageRoleAssistant), StringContent: content.Text, Content: content.Text, }) case types.MessageContentTypeOutputAudio: conversationHistory = append(conversationHistory, schema.Message{ Role: string(types.MessageRoleAssistant), StringContent: content.Transcript, Content: content.Transcript, StringAudios: []string{content.Audio}, }) } } } else if item.System != nil { for _, content := range item.System.Content { conversationHistory = append(conversationHistory, schema.Message{ Role: string(types.MessageRoleSystem), StringContent: content.Text, Content: content.Text, }) } } else if item.FunctionCall != nil { conversationHistory = append(conversationHistory, schema.Message{ Role: string(types.MessageRoleAssistant), ToolCalls: []schema.ToolCall{ { ID: item.FunctionCall.CallID, Type: "function", FunctionCall: schema.FunctionCall{ Name: item.FunctionCall.Name, Arguments: item.FunctionCall.Arguments, }, }, }, }) } else if item.FunctionCallOutput != nil { conversationHistory = append(conversationHistory, schema.Message{ Role: "tool", Name: item.FunctionCallOutput.CallID, Content: item.FunctionCallOutput.Output, StringContent: item.FunctionCallOutput.Output, }) } } conv.Lock.Unlock() if personalize && session.voiceGate.cfg.Identity.InjectSystemNote { if note := speakerNote(lastUserSpeaker, session.voiceGate.cfg.Identity.NoteUnknown); note != "" { conversationHistory[0].StringContent += "\n\n" + note conversationHistory[0].Content = conversationHistory[0].StringContent } } var images []string for _, m := range conversationHistory { images = append(images, m.StringImages...) } // response.created/done are emitted once per response.create by triggerResponse; // every turn (including agentic recursion) shares this id. responseID := r.id // Streamed LLM path: when the pipeline opts into LLM streaming, stream the // transcript to the client as it is generated and synthesize the buffered // message once. Tool turns are supported only when the model uses its // tokenizer template: the C++ autoparser then delivers content and tool // calls via ChatDeltas (clearing the text stream), so the spoken transcript // never leaks tool-call tokens. Grammar-based function calling emits the // call as JSON in the token stream, so those turns keep the buffered path. if config != nil && session.ModelConfig != nil && session.ModelConfig.Pipeline.StreamLLM() { canStream := len(tools) == 0 || config.TemplateConfig.UseTokenizerTemplate var respMods []types.Modality if overrides != nil { respMods = overrides.OutputModalities } if canStream && modalitiesContainAudio(resolveOutputModalities(session.OutputModalities, respMods)) { if streamLLMResponse(ctx, session, conv, t, r, conversationHistory, images, config, tools, toolChoice, toolTurn) { return } } } predFunc, err := session.ModelInterface.Predict(ctx, conversationHistory, images, nil, nil, nil, tools, toolChoice, nil, nil, nil) if err != nil { sendError(t, "inference_failed", fmt.Sprintf("backend error: %v", err), "", "") // item.Assistant.ID is unknown here r.outcome = outcomeFailed return } pred, err := predFunc() if err != nil { sendError(t, "prediction_failed", fmt.Sprintf("backend error: %v", err), "", "") r.outcome = outcomeFailed return } r.addUsage(pred.Usage) // Check for cancellation after LLM inference (barge-in may have fired) if ctx.Err() != nil { xlog.Debug("Response cancelled after LLM inference (barge-in)") r.outcome = outcomeCancelled return } xlog.Debug("Function config for parsing", "function_name_key", config.FunctionsConfig.FunctionNameKey, "function_arguments_key", config.FunctionsConfig.FunctionArgumentsKey) xlog.Debug("LLM raw response", "text", pred.Response, "response_length", len(pred.Response), "usage", pred.Usage) // Safely dereference pointer fields for logging maxTokens := "nil" if config.Maxtokens != nil { maxTokens = fmt.Sprintf("%d", *config.Maxtokens) } contextSize := "nil" if config.ContextSize != nil { contextSize = fmt.Sprintf("%d", *config.ContextSize) } xlog.Debug("Model parameters", "max_tokens", maxTokens, "context_size", contextSize, "stopwords", config.StopWords) rawResponse := pred.Response if config.TemplateConfig.ReplyPrefix != "" { rawResponse = config.TemplateConfig.ReplyPrefix + rawResponse } // Detect thinking start token from template for reasoning extraction var template string if config.TemplateConfig.UseTokenizerTemplate { template = config.GetModelTemplate() } else { template = config.TemplateConfig.Chat } thinkingStartToken := reasoning.DetectThinkingStartToken(template, &config.ReasoningConfig) // When the C++ autoparser emitted ChatDeltas with actionable data, // prefer them — the backend clears Reply.Message in that path and // delivers parsed content/reasoning/tool-calls via the delta stream // (see pkg/functions/chat_deltas.go, mirrored from chat.go's non-SSE // handling). Without this, Response is empty and realtime would // synthesize silence for replies that actually produced tokens. var reasoningText, responseWithoutReasoning, textContent, cleanedResponse string var toolCalls []functions.FuncCallResults deltaToolCalls := functions.ToolCallsFromChatDeltas(pred.ChatDeltas) deltaContent := functions.ContentFromChatDeltas(pred.ChatDeltas) deltaReasoning := functions.ReasoningFromChatDeltas(pred.ChatDeltas) if len(deltaToolCalls) > 0 || deltaContent != "" { xlog.Debug("[ChatDeltas] realtime: using C++ autoparser deltas", "tool_calls", len(deltaToolCalls), "content_len", len(deltaContent), "reasoning_len", len(deltaReasoning)) // Issue #9985: when the autoparser only delivered content (no // reasoning_content), it may be running in the "pure content" // PEG fallback (non-jinja path) which leaves // embedded in the content. Run Go-side extraction defensively. // ExtractReasoningWithConfig is a no-op when no tag pair matches, // so it's safe to apply unconditionally in the no-reasoning branch. if deltaReasoning == "" && deltaContent != "" { deltaReasoning, deltaContent = reasoning.ExtractReasoningComplete(deltaContent, thinkingStartToken, spokenReasoningConfig(config.ReasoningConfig)) } reasoningText = deltaReasoning responseWithoutReasoning = deltaContent textContent = deltaContent cleanedResponse = deltaContent toolCalls = deltaToolCalls } else { reasoningText, responseWithoutReasoning = reasoning.ExtractReasoningComplete(rawResponse, thinkingStartToken, spokenReasoningConfig(config.ReasoningConfig)) textContent = functions.ParseTextContent(responseWithoutReasoning, config.FunctionsConfig) cleanedResponse = functions.CleanupLLMResult(responseWithoutReasoning, config.FunctionsConfig) toolCalls = functions.ParseFunctionCall(cleanedResponse, config.FunctionsConfig) } xlog.Debug("LLM Response", "reasoning", reasoningText, "response_without_reasoning", responseWithoutReasoning) xlog.Debug("Function call parsing", "textContent", textContent, "cleanedResponse", cleanedResponse, "toolCallsCount", len(toolCalls)) noActionName := "answer" if config.FunctionsConfig.NoActionFunctionName != "" { noActionName = config.FunctionsConfig.NoActionFunctionName } isNoAction := len(toolCalls) > 0 && toolCalls[0].Name == noActionName var finalSpeech string var finalToolCalls []functions.FuncCallResults if isNoAction { arg := toolCalls[0].Arguments arguments := map[string]any{} if err := json.Unmarshal([]byte(arg), &arguments); err == nil { if m, exists := arguments["message"]; exists { if message, ok := m.(string); ok { finalSpeech = message } else { xlog.Warn("NoAction function message field is not a string", "type", fmt.Sprintf("%T", m)) } } else { xlog.Warn("NoAction function missing 'message' field in arguments") } } else { xlog.Warn("Failed to unmarshal NoAction function arguments", "error", err, "arguments", arg) } if finalSpeech == "" { // Fallback if parsing failed xlog.Warn("NoAction function did not produce speech, using cleaned response as fallback") finalSpeech = cleanedResponse } } else { finalToolCalls = toolCalls xlog.Debug("Setting finalToolCalls", "count", len(finalToolCalls)) if len(toolCalls) > 0 { finalSpeech = textContent } else { finalSpeech = cleanedResponse } } if finalSpeech != "" { // Create the assistant item now that we have content item := types.MessageItemUnion{ Assistant: &types.MessageItemAssistant{ ID: generateItemID(), Status: types.ItemStatusInProgress, Content: []types.MessageContentOutput{ { Type: types.MessageContentTypeOutputAudio, Transcript: finalSpeech, }, }, }, } conv.Lock.Lock() conv.Items = append(conv.Items, &item) conv.Lock.Unlock() sendEvent(t, types.ResponseOutputItemAddedEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, OutputIndex: 0, Item: item, }) sendEvent(t, types.ResponseContentPartAddedEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, ItemID: item.Assistant.ID, OutputIndex: 0, ContentIndex: 0, Part: item.Assistant.Content[0], }) // removeItemFromConv removes the last occurrence of an item with // the given assistant ID from conversation history. removeItemFromConv := func(assistantID string) { conv.Lock.Lock() for i := len(conv.Items) - 1; i >= 0; i-- { if conv.Items[i].Assistant != nil && conv.Items[i].Assistant.ID == assistantID { conv.Items = append(conv.Items[:i], conv.Items[i+1:]...) break } } conv.Lock.Unlock() } // sendCancelledResponse records the cancelled outcome (triggerResponse // emits the single terminal) and cleans up the partial assistant item so // the interrupted reply is not in chat history. sendCancelledResponse := func() { removeItemFromConv(item.Assistant.ID) r.outcome = outcomeCancelled } var audioString string _, isWebRTC := t.(*WebRTCTransport) var respMods []types.Modality if overrides != nil { respMods = overrides.OutputModalities } modalities := resolveOutputModalities(session.OutputModalities, respMods) if modalitiesContainAudio(modalities) { // Check for cancellation before TTS if ctx.Err() != nil { xlog.Debug("Response cancelled before TTS (barge-in)") sendCancelledResponse() return } // Transcript of the spoken reply (the audio's text). sendEvent(t, types.ResponseOutputAudioTranscriptDeltaEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, ItemID: item.Assistant.ID, OutputIndex: 0, ContentIndex: 0, Delta: finalSpeech, }) sendEvent(t, types.ResponseOutputAudioTranscriptDoneEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, ItemID: item.Assistant.ID, OutputIndex: 0, ContentIndex: 0, Transcript: finalSpeech, }) // Synthesize and send the audio. With pipeline.streaming.tts enabled // emitSpeech forwards a response.output_audio.delta per backend PCM // chunk as it's produced; otherwise it sends the whole utterance as a // single delta. The returned PCM is stored (base64) on the item below. pcmAudio, err := emitSpeech(ctx, t, session, responseID, item.Assistant.ID, finalSpeech) if err != nil { if ctx.Err() != nil { xlog.Debug("TTS cancelled (barge-in)") sendCancelledResponse() return } xlog.Error("TTS failed", "error", err) sendError(t, "tts_error", fmt.Sprintf("TTS generation failed: %v", err), "", item.Assistant.ID) r.outcome = outcomeFailed return } if !isWebRTC { audioString = base64.StdEncoding.EncodeToString(pcmAudio) } if !isWebRTC { sendEvent(t, types.ResponseOutputAudioDoneEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, ItemID: item.Assistant.ID, OutputIndex: 0, ContentIndex: 0, }) } } else { // Text-only mode: skip TTS, emit only the text events. sendEvent(t, types.ResponseOutputTextDeltaEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, ItemID: item.Assistant.ID, OutputIndex: 0, ContentIndex: 0, Delta: finalSpeech, }) sendEvent(t, types.ResponseOutputTextDoneEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, ItemID: item.Assistant.ID, OutputIndex: 0, ContentIndex: 0, Text: finalSpeech, }) } sendEvent(t, types.ResponseContentPartDoneEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, ItemID: item.Assistant.ID, OutputIndex: 0, ContentIndex: 0, Part: item.Assistant.Content[0], }) conv.Lock.Lock() item.Assistant.Status = types.ItemStatusCompleted if !isWebRTC { item.Assistant.Content[0].Audio = audioString } conv.Lock.Unlock() sendEvent(t, types.ResponseOutputItemDoneEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, OutputIndex: 0, Item: item, }) r.addItem(item) } // Emit the parsed tool calls and (for server-side assistant tools) the // follow-up turn. Shared with the streamed path so both finalize tool calls // identically. The single terminal is emitted by triggerResponse. emitToolCallItems(ctx, session, conv, t, r, finalToolCalls, finalSpeech != "", toolTurn) } // emitToolCallItems emits the realtime function_call items for the parsed tool // calls, the terminal response.done, and — for server-side LocalAI Assistant // tools — re-triggers a follow-up response so the model can speak the result. // hasContent shifts the tool-call output index past the assistant content item // when the same turn also produced spoken/text content. Two tool paths: // - LocalAI Assistant tools (session.AssistantExecutor.IsTool) run server-side; // we append both the call and its output to conv.Items and re-trigger. The // client only sees observability events. // - All other tools follow the standard OpenAI flow: emit // function_call_arguments.done and wait for the client to send // conversation.item.create back. func emitToolCallItems(ctx context.Context, session *Session, conv *Conversation, t Transport, r *liveResponse, toolCalls []functions.FuncCallResults, hasContent bool, toolTurn int) { responseID := r.id xlog.Debug("About to handle tool calls", "finalToolCallsCount", len(toolCalls)) executedAssistantTool := false for i, tc := range toolCalls { toolCallID := generateItemID() callID := "call_" + generateUniqueID() // OpenAI uses call_xyz // Create FunctionCall Item fcItem := types.MessageItemUnion{ FunctionCall: &types.MessageItemFunctionCall{ ID: toolCallID, CallID: callID, Name: tc.Name, Arguments: tc.Arguments, Status: types.ItemStatusCompleted, }, } conv.Lock.Lock() conv.Items = append(conv.Items, &fcItem) conv.Lock.Unlock() outputIndex := i if hasContent { outputIndex++ } sendEvent(t, types.ResponseOutputItemAddedEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, OutputIndex: outputIndex, Item: fcItem, }) serverSide := session.AssistantExecutor != nil && session.AssistantExecutor.IsTool(tc.Name) if serverSide { output, execErr := session.AssistantExecutor.ExecuteTool(ctx, tc.Name, tc.Arguments) if execErr != nil { output = "Error: " + execErr.Error() xlog.Error("realtime: assistant tool execution failed", "tool", tc.Name, "error", execErr) } foItem := types.MessageItemUnion{ FunctionCallOutput: &types.MessageItemFunctionCallOutput{ ID: generateItemID(), CallID: callID, Output: output, Status: types.ItemStatusCompleted, }, } conv.Lock.Lock() conv.Items = append(conv.Items, &foItem) conv.Lock.Unlock() // Close the call out and emit the output as its own paired // added/done — the OpenAI spec pairs every item-done with a // preceding item-added, so we re-pair here for the output. // The UI renders the transcript entry on item.done for both // shapes (FunctionCall + FunctionCallOutput). sendEvent(t, types.ResponseOutputItemDoneEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, OutputIndex: outputIndex, Item: fcItem, }) r.addItem(fcItem) sendEvent(t, types.ResponseOutputItemAddedEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, OutputIndex: outputIndex, Item: foItem, }) sendEvent(t, types.ResponseOutputItemDoneEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, OutputIndex: outputIndex, Item: foItem, }) r.addItem(foItem) executedAssistantTool = true continue } sendEvent(t, types.ResponseFunctionCallArgumentsDeltaEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, ItemID: toolCallID, OutputIndex: outputIndex, CallID: callID, Delta: tc.Arguments, }) sendEvent(t, types.ResponseFunctionCallArgumentsDoneEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, ItemID: toolCallID, OutputIndex: outputIndex, CallID: callID, Arguments: tc.Arguments, Name: tc.Name, }) sendEvent(t, types.ResponseOutputItemDoneEvent{ ServerEventBase: types.ServerEventBase{}, ResponseID: responseID, OutputIndex: outputIndex, Item: fcItem, }) r.addItem(fcItem) } // No terminal here: triggerResponse emits the single response.done once the // whole turn (including the agentic recursion below) completes. // If we executed any assistant tools inproc, run another response cycle // so the model can speak the result. Mirrors the chat-side agentic loop // but driven server-side rather than by client round-trip. Bounded so a // degenerate "model keeps calling tools" doesn't blow the stack. The // follow-up turn shares the same liveResponse, so its output accumulates // into the one response.done. if executedAssistantTool { if toolTurn+1 >= maxAssistantToolTurns { xlog.Warn("realtime: assistant tool-turn limit reached, stopping the agentic loop", "limit", maxAssistantToolTurns, "model", session.Model) return } triggerResponseAtTurn(ctx, session, conv, t, nil, toolTurn+1, r) } } // Helper functions to generate unique IDs func generateSessionID() string { // Generate a unique session ID // Implement as needed return "sess_" + generateUniqueID() } func generateConversationID() string { // Generate a unique conversation ID // Implement as needed return "conv_" + generateUniqueID() } func generateItemID() string { // Generate a unique item ID // Implement as needed return "item_" + generateUniqueID() } func generateUniqueID() string { // 16 random bytes, hex-encoded. Must be collision-free: session, item, // response and call IDs build on this, and the conversation tracks/removes // items by ID (e.g. cancel() in realtime_stream.go, conversation.item.retrieve). // A constant would make every ID alias and corrupt that bookkeeping. var b [16]byte _, _ = rand.Read(b[:]) return hex.EncodeToString(b[:]) }