mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-04 19:43:17 -05:00
* chore(backends): remove the liquid-audio Python backend The backend targets liquid-audio, which loads LFM2 checkpoints only. Remove its directory, the gallery file and the LFM2.5-Audio model entries, the library and generated backend gallery entries, the backend matrix and CI job, the Makefile targets and the golden test fixtures that listed it. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(config): drop liquid-audio from capabilities, importers and UI With the liquid-audio backend gone, remove its capability entry, its importer, its artifact-gate entry and the realtime_audio heuristic that only matched that backend. realtime_audio is now set only through known_usecases. Update the Talk and fine-tuning pages, the docs and the swagger text that named the backend. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(llama-cpp): bump llama.cpp to e486a4b5 Move the llama.cpp pin to e486a4b559617dc88bf79978996f6c670b6bbc9d, the master head of 2026-10-28. It contains the LFM3-Audio support (ggml-org/llama.cpp#31257) and the realtime server split (ggml-org/llama.cpp#31269). The score task patch is regenerated against the new pin. It applies with git apply and with patch -p1, with no offset and no fuzz. Its hunks in server-task.h and server-context.cpp were rebased by hand next to SERVER_TASK_TYPE_REALTIME and the control dispatch. The C++ backend was built on CPU for this change: the llama, mtmd and server-context libraries and the grpc-server binary link. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(llama-cpp): compile server-realtime.cpp into grpc-server server-context.cpp includes server-realtime.h, and its realtime slot type is defined in server-realtime.cpp. Upstream compiles that file into the server library. The gRPC server is one translation unit, so it must include the file too, or the link fails. The include is guarded by __has_include. Forks that predate the realtime split do not ship the file, so they keep building as before. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(llama-cpp): probe realtime and TTS voice support in prepare.sh prepare.sh writes llama_compat.h from probes on the checkout. Add two: LOCALAI_HAS_REALTIME (SERVER_TASK_TYPE_REALTIME in server-task.h) and LOCALAI_HAS_TTS_VOICE (set_voice in mtmd-helper.h). The probes let the gRPC server choose the realtime and voice code paths per checkout, so forks without them keep compiling. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(llama-cpp): advertise speech recognition and speech-to-speech llama-cpp now registers the AudioTranscription and AudioToAudioStream RPCs, with the transcript and realtime_audio usecases. The default usecase stays chat, so a plain GGUF is still a chat model. The backend implements the RPCs in a follow-up change; until then the calls return UNIMPLEMENTED. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(config): add pipeline.any_to_any for speech-to-speech models A realtime_audio model that serves the whole speech loop itself sets pipeline.any_to_any. The flag is off by default, so chained pipelines and existing realtime_audio configs are unchanged. The realtime_audio heuristic stays explicit: a llama-cpp model is never a Talk-page model unless known_usecases says so. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(config): read the audio encoder and speech-to-speech projector flags Add HasAudioEncoderProjector for clip.has_audio_encoder and HasSpeechToSpeechProjector, which requires the LFM3 audio encoder family together with the gen-audio decoder. Qwen3-TTS sets both encoder flags too, so the flags alone cannot tell it apart from LFM3. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(importer): import LFM3-Audio projectors as speech-to-speech An mmproj that carries the LFM3 audio encoder and the gen-audio decoder now imports with known_usecases realtime_audio, chat, transcript and tts, pipeline.any_to_any: true, the parallel:2 option and the tokenizer template. Other gen-audio projectors keep the TTS-only result, Qwen3-TTS included. The probe runs against a local HTTP server in the tests. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(llama-cpp): named-voice TTS and the LFM3 ASR prompt Models without a speaker reference (LFM3-Audio) take a named voice through their "tts" chat template instead of a reference file. The request is validated with needs_reference=false, the prompt is rendered with common_chat_get_tts_prompt(), and the voice is set on the gen-audio input. Qwen3-TTS Base keeps the reference-file rule. The branch is guarded by LOCALAI_HAS_TTS_VOICE, so forks without named voices keep the old behaviour. Transcription takes its prompt from common_chat_get_asr_prompt(). LFM3 gets a system turn that names the language and an empty user turn. Qwen3-ASR's preset is unchanged: fixed user text, no system turn, and a caller prompt is still appended after a newline. The preset call is guarded by a new LOCALAI_HAS_ASR_PRESET probe. The pure parts sit in tts_request_options.h and transcription_prompt.h, with standalone tests run by backend/cpp/run-unit-tests.sh. Add the test-extra-backend-llama-cpp-lfm3 target. It runs the tts, ttsstream and transcription specs against LFM3-Audio GGUF weights. It is a manual run and is not wired into CI. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): mock AudioToAudioStream and pending realtime S2S specs The mock backend serves AudioToAudioStream deterministically: session created, commit with RMS, a tool call on the first turn with tools, then transcript pieces and paced 24 kHz tone frames, with response.cancel ending a reply as cancelled. The realtime S2S specs cover the mock and real-weights variants. They skip until the /v1/realtime S2S path lands, so the mock suite stays green in CI. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(llama-cpp): speech-to-speech AudioToAudioStream on the realtime slot Implement AudioToAudioStream on upstream's realtime slot, so a model that answers after the audio (LFM3-Audio) can hold a conversation over the existing duplex RPC. The proto schema does not change: tools travel in the config, tool calls leave as function_call_arguments.done and tool results arrive as a function_call_output item. realtime_session.h keeps what needs no model: the turn text derived from the chat template (with a tool turn), the conversation state, a filter that keeps <tool_call> blocks out of the transcript and drops audio produced inside them, and the translation of slot results into events. grpc-server.cpp wires it to the slot with control tasks and parses calls with the parser the chat template selects. The unit test uses a fake renderer. A ctest-only check renders the real LFM3 template and compares the slot text with the full render. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add the LFM3-Audio-2B entries Two llama-cpp entries for LiquidAI/LFM3-Audio-2B-GGUF, the Q4_K_M default (lfm3-audio-2b) and the Q8_0 build (lfm3-audio-2b-q8), both with the Q8_0 audio projector. Each declares pipeline.any_to_any and the realtime_audio, chat, transcript and tts usecases the importer writes for an LFM3 projector, with parallel:2 and the tokenizer template. The Q4 entry lists the Q8 one as a variant. File sizes and sha256 come from the Hugging Face API for the pinned repo revision. Speaker reference files and the VAD model are not part of the entries: VAD stays optional. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: describe LFM3-Audio speech-to-speech and its gallery entries Adds a speech-to-speech section to the Realtime API page (any_to_any configuration, VAD versus manual commit, tools, transcripts, barge-in, limits), a pipeline.any_to_any row, a llama.cpp speech-to-speech row in the compatibility table, and LFM3-Audio mentions on the TTS and ASR pages. The feature is marked experimental: the behaviour follows the backend session contract and has not been run with real weights. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): run any-to-any models on AudioToAudioStream A realtime_audio model with pipeline.any_to_any now opens one duplex stream to the backend and keeps it for the session, replacing the "any-to-any models not implemented" branch. The chained pipeline is unchanged. The endpoint forwards input audio (with a pre-roll from speech start when a VAD model is set, otherwise every chunk and a client commit), sends response.create, and turns the backend's events into the usual realtime events: audio and transcript deltas, tool calls through the existing function_call items, and a single response.done. A function call output is forwarded before the next response. Barge-in sends response.cancel ahead of the new speech and stops forwarding audio at once. The user's speech is transcribed on the side and reported on the user item; a failure there does not touch the response. Server VAD is on only when pipeline.vad is set. Classifier mode, compaction, streaming.llm, voice gating, sound detection and semantic_vad are rejected for these models. The mock backend now matches the llama.cpp contract (session.updated, the cancel error when idle) and its VAD reports the loud stretches of the audio, so the S2S e2e specs run a VAD turn. The specs are enabled. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): add the real-weights realtime S2S variant and the gRPC s2s spec The realtime S2S specs now check reply audio on real models: duration, loudness, clipping, and keywords found in the model transcript or in a separate ASR of the output audio. run-realtime-lfm3.sh stages the llama-cpp and sherpa-onnx backends, the LFM3-Audio files and the sherpa VAD, ASR and TTS models, then runs the real variant. It is manual and never part of CI: `make test-realtime` still filters out real-models. The backend suite gains an s2s capability (spoken turn, tool call, tool result, spoken answer over AudioToAudioStream) and optional TTS duration and RMS bounds plus a transcript keyword check, all enabled for test-extra-backend-llama-cpp-lfm3. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): expect the idle-cancel error from the mock S2S The mock now answers a response.cancel with no reply running with response_cancel_not_active, as the llama.cpp backend does. The cancel spec still treated that cancel as a no-op and read the error as extra data after the stream ended. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(talk): show any-to-any models as one speech-to-speech model /api/pipeline-models lists a pipeline.any_to_any model with any_to_any set and its optional VAD. A realtime_audio model that is not any-to-any is listed only when it has the four pipeline slots, since the session takes the chained path for it. The Talk page shows a speech-to-speech model as one model: no four-slot view, the VAD slot only when a VAD model is set, and the female and male voices offered. Without a VAD model the server does not detect turns, so the page offers Done speaking, which sends input_audio_buffer.commit and then response.create. Text-only replies stream through response.output_text.delta and .done. self_contained stays in the response for clients built before any_to_any. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): refuse a llama-cpp realtime model without pipeline.any_to_any llama.cpp has no VAD, transcription or TTS RPCs, so filling the empty pipeline slots with the model name failed later with UNIMPLEMENTED. Fail at session start and name the missing setting instead. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): hold manual-commit S2S audio until the commit In manual-commit mode the endpoint streamed every appended chunk to the backend, so input_audio_buffer.clear only emptied the Go buffer while the model kept the audio, and idle audio filled its context between turns. Keep the audio in the input buffer and send it, behind pending text and tool-result items, when the client commits or asks for a response. Frames and controls now go through one ordered write queue, so no gRPC send happens under the audio buffer lock. A tool result created while a VAD turn is open is refused with a clear error. When the stream ends outside a response the session reports it and closes, and a bare EOF from Send reports the status that Recv holds. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): drop the S2S readiness gate and stale comments The gate was always true. Some comments referred to work items that readers of the repository cannot see. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(realtime): add the liquid-audio migration note and the clear semantics Say that the Python liquid-audio backend is gone, which gallery entries replace it and that LFM2.5-Audio has no replacement. Describe what input_audio_buffer.clear drops for speech-to-speech sessions now that manual-commit audio is held back until the commit. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(talk): do not promise turn detection when no VAD model is set The idle note said the server notices when you stop talking. A speech-to-speech model without a VAD model needs Done speaking, so the note now says that. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): make the mock S2S backend keep the llama-cpp session limits The mock accepted what the real backend refuses and hid two endpoint bugs. It now treats output_modalities from the config as a fixed session mode and picks the default per response from the tools set at that time. A fixed text plus audio mode with tools reads the tool call out loud. After the first commit or message, session.update of instructions, voice or tools is an error that does not belong to a response, committing an empty buffer is an error, and response.create while a reply runs is refused. Errors of a refused response.create carry response_scoped in their meta. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): leave the S2S output mode to the backend unless the client sets one The stream config sent text plus audio when the session had no tools yet. The llama-cpp backend keeps a mode from the config as a fixed session mode, and tools added later by session.update never cleared it. The model then read tool calls out loud. Send output_modalities only when the client chose them. The backend picks the default of each response from the tools it has when the response starts. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(llama-cpp): mark the realtime errors that answer a response.create The error event now carries response_scoped: true when it ends the request for a response (a refused response.create, a failed commit made by it, or a stream failure). Other errors, such as a refused session.update or a commit of an empty buffer, are not about a response and one that runs keeps generating. The client needs the difference to know whether a response.done is still coming. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): end an S2S response only on the errors that belong to it Every backend error ended the response in flight, although the backend also reports errors about other things (a refused session.update, a commit of an empty buffer) and goes on generating. The rest of the reply was lost and the next response.create met either a refusal or the leftover events of the old reply. End the response only on an error marked response_scoped, forward the others to the client and keep waiting for response.done. Drop the events buffered by an earlier response when a new one starts, and skip the commit of a turn that forwarded no audio. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): run the S2S input transcription detached from the commit slot The commit waited for the side transcription of the user audio while it held the commit slot, and response.create waits for that slot. The ASR latency was added before every reply, and on a backend with few slots a busy ASR task could stall the reply. The model already has the audio, so run the transcription detached under the session context and release the slot at once. The transcript is still reported on the user item. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(realtime): do not pin the place of the input transcript in the S2S order The transcript of the user audio is reported on the side and may land before or after the first response events. Check the order of the response events without it. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): stop an S2S reader leak and a cancel that waits on the write queue The reader waited on a channel it closes itself when the events buffer was full, so it outlived the session after a turn ended on an error or timed out. It now also stops when the session closes. Stopping a response sent the cancel through the write queue and waited for it before the grace timer started. With backpressure on the stream the wait never ended and nothing read the events meanwhile. The timer now starts first and the cancel is only queued. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): let a chained pipeline run without a VAD model A pipeline that names no pipeline.vad failed to build, because the VAD slot was always loaded, and a new session still started with server_vad. The slot is now optional for chained pipelines as it already is for any-to-any models: the session starts with turn detection off and the client commits each turn. Turning server_vad on for such a session is refused, and a switch to a pipeline without a VAD drops turn detection. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add LFM3-Audio VAD and chained-pipeline entries Add lfm3-audio-2b-vad and -q8-vad (any-to-any with silero-vad-ggml as the turn detector), and lfm3-audio-2b-pipeline, -pipeline-vad and the -q8 forms (the model chained through its own transcription, chat and speech slots, with and without the VAD). The gallery has no dependencies between models, so the VAD entries state that silero-vad-ggml must be installed first. Drop the variants list from lfm3-audio-2b: it let the installer pick the larger Q8 build on any host with enough memory, so a plain install of the Q4 entry did not get Q4. All entries are now independent. Tests cover the entry shapes, the single-build install and their acceptance by the realtime endpoint. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): run the realtime specs for any-to-any and chained shapes The realtime specs covered one any-to-any model with a VAD. They now run for any-to-any and for a chained pipeline, each with and without a VAD, on the mock backend in CI. VAD-less shapes also check that turn detection starts off and that server_vad is refused. The manual real-weights script writes one config per shape that mirrors the gallery entries and runs the four shapes in turn (LFM3_MATRIX picks a subset). Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(realtime): document the LFM3-Audio VAD and chained-pipeline entries List the eight LFM3-Audio gallery entries, explain that silero-vad-ggml must be installed before the -vad entries, describe the chained pipeline on one model and the behaviour of a pipeline without a VAD, and describe the shape matrix of the e2e specs. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(realtime): wait for the detached input transcription in the S2S specs The input transcription runs in its own goroutine, so the transcription completed and failed events can arrive after the response ends. Poll for them instead of reading the event list right after the response. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): release the S2S stream before running server-side tool calls A response held the stream slot until it returned, and it ran the tool calls from inside that scope. A server-side tool starts a follow-up response on the same stream, which then waited for the slot its parent still held, so the client never got response.done. Run the tool calls once the slot is free; the event order is unchanged. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): end an S2S session whose stream dies, and stop queuing after close A stream that died during a response left the session open: later requests failed one by one and VAD frames kept being forwarded. The response now ends the session through the transport, and enqueue drops requests once the session is closed so the write queue cannot grow. The writer is now the only caller of Send and of CloseSend. close() used to call CloseSend while the writer could be inside Send, which gRPC does not allow. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): close the open S2S turn when the VAD stops Switching turn detection to manual in the middle of a VAD turn left the turn forwarding, and the next client commit sent the whole buffer a second time. Stopping the VAD now commits the forwarded audio on the backend and drops it from the input buffer. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): keep a tool result pending while an S2S user turn is open The check that refuses a tool result during a user turn ran when the item arrived, but the item was forwarded later, and the result was marked as sent before the backend took it. If the user spoke in between, the backend refused it and it was never retried. The result is now claimed and queued under the audio lock only when no turn is open, and otherwise stays pending for the next commit or response. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(talk): empty the input buffer for models without a VAD Without voice detection the microphone fills the server's input buffer for the whole session, so Done speaking committed idle silence and the echo of the previous reply. Clear the buffer when the session starts and when a reply ends. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(importer): keep an existing parallel option on LFM3-Audio imports The speech-to-speech import added parallel:2 even when the options already carried another parallel value. Use the same check as the hardware defaults, which also covers n_parallel. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(talk): list chained pipelines that have no VAD /api/pipeline-models required a VAD for a chained pipeline, so the VAD-less LFM3-Audio pipeline entries never showed up on the Talk page. Any pipeline with transcription, LLM and speech slots now qualifies. The Talk page treats every model without a VAD as manual-turn, and the session sheet says so for chained pipelines too. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(llama-cpp): bump llama.cpp to 0ac1a7fd Move the llama.cpp pin to 0ac1a7fd35c127a18c0cbc6c57d12e256a6a385b, the master head of 2026-10-29. It contains tool calls in the realtime server (ggml-org/llama.cpp#31287). The score task patch is regenerated so that it applies with no offset and no fuzz. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(functions): keep a null tool argument null A tagged parameter whose text was "null" came out as an empty string: unmarshalling JSON null into a string succeeds and leaves it empty, so the value was taken for a string. Check the first byte of the JSON value instead. A parameter with the text "null" now becomes JSON null, as in llama.cpp. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(functions): add an lfm3 XML format preset The LFM3 chat template writes a call as <tool_call>, <function=NAME>, one <parameter=KEY> block per argument, then the closing tags. The qwen3-coder format has the same shape and auto-detection already found it, but xml_format_preset: lfm3 was not a known name. Add the preset, with tests for one call, a call without arguments, two calls, string and non-string values (numbers, booleans, null, arrays, objects), a call between text, and unfinished input. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): parse tool calls with the chat template, as upstream Since ggml-org/llama.cpp#31287 the realtime server handles tools with the parser and grammar of the chat template. Do the same in the speech-to-speech stream instead of a hard-coded tag filter and a parse of the whole text at the end. - Each response gets a common_chat_session with the tools of the session and parallel calls. Its grammar and preserved tokens go into the sampling of the response, so the slot keeps the call tokens in the text and constrains the call. - The text of the response is fed to the session piece by piece (task_result_state::update_chat_msg). Content deltas become the transcript; tool-call deltas are collected into one function_call_arguments.done per call at the end. Audio after the start of a call is dropped. - A tool_choice of auto, none or required is accepted in the params of the config, in session.update and in response.create, with the response overriding the session. - The tool turn is derived from a conversation in which the assistant called a tool, like upstream, not from a plain reply. - response.done carries raw_text, everything the model wrote with the call markup, for a caller that parses it another way. If the parser throws, the rest of the response is content and the raw text is the way out. ToolCallFilter is no longer used and goes in the next commit. The unit tests cover the splitter contract (content, call deltas, the final piece, whole characters only). realtime_chat_check feeds LFM3-format text through the session in pieces of 1, 7 and all bytes and checks the call, the transcript and the grammar. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(realtime): drop the tool-call text filter The parser of the chat template now splits content from tool calls, so ToolCallFilter, which looked for the literal <tool_call> tags, has no caller. Remove it with its tests. Every case it covered (a call between text, split tags, several calls, an unfinished call, text after a call) is handled by the parser, and realtime_chat_check runs the LFM3 format through it in pieces of different sizes. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): fall back to the raw text for S2S tool calls For an any-to-any model, the calls the backend parsed still win. When there are none and the request had tools, parse the raw text that response.done now carries the way the chained pipeline parses the text of its LLM (CleanupLLMResult, ParseFunctionCall, ParseTextContent), so a call format the backend parser does not know can still be served with function.xml_format_preset or the other function settings of the model. The content around the calls replaces the streamed transcript only if the config captures it, as in the chained path. Also pass tool_choice to the backend: the session mode with the stream config and with session.update, and the mode of a response.create override with that event. A named function cannot be forced, so it is not sent. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): carry raw_text in the mock S2S response.done Match the llama-cpp session: with tools set, response.done has raw_text, the call markup of the LFM3 template for a tool turn and the transcript for any other reply. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(functions): parse tool blocks that have text between them The iterative XML parser stopped at the first scope block that was preceded by text, so "<tool_call>A</tool_call> then <tool_call>B" gave one call. Skip text between blocks once a call was found; text before the first block still ends the parse as before. Blocks that follow each other, or only whitespace, already worked. Tests cover three calls back to back and with text between them, with the lfm3 preset and with auto-detection. They also pin the current reading of argument values: the parser has no tool schema, so a value such as 42 or true is read as JSON even when the schema types it as a string. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): read LFM3-Audio tool calls with the lfm3 preset Set function.xml_format_preset: lfm3 on the eight LFM3-Audio-2B entries and in the config the importer writes for a speech-to-speech projector, so the server parses the call format of the LFM3 chat template when the backend returns no call (and in the chained pipeline entries, which use the regular chat path). gallery/index.yaml is regenerated from the library. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(realtime): describe how S2S tool calls are read Replace the description of the hard-coded tag filter with the parser and grammar of the chat template, tool_choice, and the raw-text fallback in the server. Document function.xml_format_preset, and set it in the real-weights e2e model configs like the gallery entries do. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(functions): keep earlier calls when a later block does not parse After a valid call, text that holds the opening tag again (a prose mention, a markdown example, a malformed second block) made the parser treat it as a new block, fail, and return no calls at all. Remember the state after each complete block and fall back to it when a later one fails: the calls found so far are kept, parsing stops there and the remaining text stays in the content. Text skipped between two valid blocks now goes to the content instead of being dropped, so StripToolCallMarkup keeps it. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): do not drop audio after a tool call started In mixed mode the speech for text written before a call can still be pending when the call starts, so dropping every audio frame after the first call delta cut the speech while its transcript was sent. The llama.cpp server never drops audio when it reads a result; do the same. If a model speaks the call markup aloud, that is model behaviour to observe on real weights, not something to filter here. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): flush held-back text when a response is cancelled The parser got is_partial=true on a cancelled response, so text it held back (a possible start of a tag) never reached the transcript. The llama.cpp server parses a cancelled response with is_partial=false; do the same. A cancelled response still makes no tool call. The path where the parser throws already sends the text as content and needs no change. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(realtime): stop the fake S2S stream from sending after Close The barge-in spec failed now and then with a data race and "send on closed channel": its script pushes audio from a goroutine, and the session closes the stream while that goroutine still runs. A push now does nothing once the stream is closed, and Close waits for a push in flight. The production close order is right: the writer closes the stream only after it stops sending. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): align the S2S tool-call fallback with the backend Four gaps in how the Go side reads the tool calls of an any-to-any reply: - With tool_choice none (the response value wins over the session value) the backend builds no tool parser, yet the raw-text fallback still turned a call the model wrote into a function_call item. Skip it. - Without a capture regex the fallback never cleaned the transcript, so the stored item and transcript.done kept the raw call markup. When the call is XML, use the text left after the markup is cut out; a reply that is only a call now has an empty transcript. - A named function as tool_choice mapped to nothing, so the backend kept the previous mode. Refuse it with an error in session.update and response.create. - A response-level tools list changed what the fallback looked for, but the backend only has the tools of the session. Use those in both. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(realtime): say how S2S tool_choice and response tools behave Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(e2e): use the real llama-cpp backend in the real realtime run The suite always registered the mock backend under the name llama-cpp, so a run with REALTIME_S2S_MODEL set never reached the llama-cpp backend found in REALTIME_BACKENDS_PATH. Register the mock only when that run is off. The real run converts the input audio with ffmpeg. Check for it in the suite and in run-realtime-lfm3.sh before any download or backend start, with a message that says what to install, and list it as a prerequisite in the script header and the Makefile target. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(realtime): cover the tools path of a client session.update to the S2S backend A tool call that never came back from an any-to-any model raised the question whether the tools of the session reach the backend at all. Add a spec that sends the session.update of the real-weights e2e (flat Responses API tool, no session type) and checks that the backend gets the tool in the chat-completions form. Log the number of tools sent when the stream opens and when a session.update carries them, so a run log shows it without reading the wire. The "No tools received" line of the llama-cpp backend does not show it: the server prints it for the input transcription request, which has no tools by design. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): interrupt the chained pipeline reply while it is in flight With unary stages, the default of the lfm3 pipeline gallery entries, the chained pipeline synthesizes the whole reply and sends it as one audio delta right before response.done. The barge-in spec waited for that delta and then rendered the interruption with the TTS, so the first response was already done when the speech started and ended completed instead of cancelled. Real runs on a GPU showed it with both quantizations. For a pipeline, start the interruption at response.created, which comes before the LLM and TTS stages. Render the interruption audio before the first utterance is sent, so nothing sits between the reply and the interruption. The any-to-any shape still waits for the first audio delta, which that backend streams while it generates. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): accept a number word and its homophone in the spoken-answer check The check that the reply audio says what the transcript says needs a shared word of 4+ letters. A short answer failed it: the model wrote "Four" and the ASR heard "for". Compare numbers in one spoken form, so digits, number words and their homophones match each other. A homophone only counts when the other text has the number itself, so "for" on both sides still shares nothing and the check does not pass on a stop word. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(llama-cpp): ask LFM3-Audio for function calls when a realtime session has tools The default system prompt of a realtime session was "Respond with interleaved text and audio." whatever the tools. Under that prompt LFM3-Audio answers in words ("I'll check the weather ...") and never writes the call. The model card, and the llama.cpp realtime example client, use "Respond with function calls, and interleaved text and audio." when the session has tools, and the model then calls the tool. Pick the default by the tools of the session, and treat the plain default that the LocalAI server sends back as no choice: it follows the tools too, also when they arrive with a session.update before the first audio. A prompt the client wrote is kept as it is. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): transcribe the input of an any-to-any turn between responses The input transcript of a speech-to-speech turn ran at the same time as the reply, on the same model. llama.cpp serves both from one context: the realtime slot wants embeddings on, the chat slot of the transcript wants them off, and each flip makes it reserve its compute buffers again. In a GB10 run that was two reserves of about 175 ms per step, the reply fell to 2.6 tokens per second and the transcript to 380 ms per token, until the transcript was done. When pipeline.transcription is the model of the stream, run the transcript after the response, or before one that is not started yet, and keep responses out while it runs. A separate transcription model still runs in parallel. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): send the input transcript of an any-to-any turn before its reply The previous change moved the same-model transcript after the response. A client that reads until response.done then never saw conversation.item.input_audio_transcription.completed: the transcript was made (the log shows it) but sent after the end of the window. Run it in line with the commit instead. A VAD turn transcribes and then responds. A client response.create already waits for the commit slot, so it starts once the transcript is out. The transcript event now comes before any output of the response of its turn, for a manual commit and for a VAD turn, and never overlaps the response on the llama.cpp context. A transcription model of its own still runs in parallel. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): transcribe the input of an any-to-any model only when asked The stream answers from the audio, so the transcript of the input is for the client only. On the model of the stream it is costly: llama.cpp serves the transcript request and the realtime stream from one context and rebuilds its compute buffers on every step while both are active. On a GB10 that was 26 to 29 reserves of about 175 ms per turn, against 0 to 1 on the llama.cpp server, and the reply ran at a few tokens per second. An any-to-any model no longer fills pipeline.transcription with its own name. Without it the session does not transcribe the input; with another model it does, in parallel with the reply; with the model itself named it does, before the reply. The reply transcript always comes from the stream. The gallery entries set none, so they get the fast path. The mock configs and run-realtime-lfm3.sh name a transcription model, so the real-weights spec still covers the input transcript. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): end a response as cancelled when a barge-in lands during the LLM call The chained pipeline cancels the response context on a barge-in. When that happens while the model is generating, the backend call fails with the context error, and the response ended as failed: an error event and no response.done. A client that waits for the terminal of the interrupted response then waits forever. On a GB10 the q4 model answered before the barge-in arrived, so only the slower q8 model showed it: the spec read events for 30 s and timed out. When the context is cancelled, a failed Predict call or a failed result is a cancel, as the TTS step and the streamed LLM path already treat it. The response ends with response.done{cancelled} and no error. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): wait for the input transcript that comes from a model of its own An any-to-any model now gets its input transcript from a separate ASR that runs next to the reply. The reply is often over first, so a spec that stops reading at response.done saw no transcript in the VAD shape. Keep reading, up to 30 s, until the transcript arrives. The contract is that it comes, not when. Add a guard spec for what the input transcription is handed: the whole turn, in a WAV that says the rate it was recorded at (the session rate for a client commit, 16 kHz for a VAD turn). Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): check the errors of Close in the realtime specs golangci-lint (errcheck) flagged the unchecked Close of the websocket connections, the response body and the fake stream. Use the `defer func() { _ = x.Close() }()` form the other e2e specs use. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): write the mock S2S specs with Ginkgo and drop the env read forbidigo only allows Ginkgo/Gomega specs and no os.Getenv outside the sanctioned places. Convert the mock S2S tests to Describe/It with a suite bootstrap (the package had none), with the same 11 cases. The mock no longer reads MOCK_S2S_FRAMES: only these specs set it, so the reply length is a package variable they change per spec and restore afterwards. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
3474 lines
131 KiB
Go
3474 lines
131 KiB
Go
package openai
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"math"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"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/services/failover"
|
|
"github.com/mudler/LocalAI/core/services/routing/router"
|
|
"github.com/mudler/LocalAI/core/services/voiceprofile"
|
|
"github.com/mudler/LocalAI/core/services/voicerecognition"
|
|
"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}
|
|
}
|
|
|
|
// modalitiesWithAlias returns output_modalities, falling back to the legacy
|
|
// beta `modalities` field when only the alias was supplied. OpenAI's Realtime
|
|
// beta named this field `modalities`; the GA field is `output_modalities`.
|
|
// Accepting the alias keeps beta clients (and the large amount of community
|
|
// sample code that still sends `modalities`) from silently receiving audio
|
|
// when they asked for text-only. The GA field wins when both are present.
|
|
func modalitiesWithAlias(output, alias []types.Modality) []types.Modality {
|
|
if len(output) > 0 {
|
|
return output
|
|
}
|
|
return alias
|
|
}
|
|
|
|
// 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
|
|
ttsParams map[string]string
|
|
voiceRelease func()
|
|
// 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 speech models 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
|
|
|
|
// Classifier holds the LocalAI classifier-mode config (prefill-scored
|
|
// option selection instead of generation), seeded from
|
|
// pipeline.classifier and replaced wholesale by session.update's
|
|
// localai_classifier field. nil means off.
|
|
Classifier *types.ClassifierConfig
|
|
|
|
// 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
|
|
|
|
// sessionCtx is the session-lifetime context: cancelled at teardown
|
|
// (conncoord Teardown, BEFORE respSink.shutdown joins the response
|
|
// goroutines) but NOT by barge-in / response supersede (those cancel
|
|
// respSink's per-response contexts only). Committed-turn work that must
|
|
// outlive a barge-in but must never outlive the session — the
|
|
// transcription of a VAD commit (issue #12445) — runs under it.
|
|
sessionCtx context.Context
|
|
sessionCancel context.CancelFunc
|
|
|
|
// commitOrderMu guards commitTail AND is the shared ordering boundary
|
|
// between the two commit producers (see issueCommit).
|
|
commitOrderMu sync.Mutex
|
|
// s2s is the backend stream of an any-to-any session (pipeline.any_to_any);
|
|
// nil for chained pipelines. Set before the read loop starts.
|
|
s2s *s2sSession
|
|
// commitTail is the newest commit slot. Slots form a chain that orders
|
|
// user-item appends in speech order, not transcription-completion order
|
|
// (see commitSlot).
|
|
commitTail *commitSlot
|
|
}
|
|
|
|
// commitSlot orders committed user turns in the conversation. The
|
|
// transcriptions of consecutive turns run in parallel (each under the session
|
|
// context, so a barge-in on a later turn does not cancel an earlier
|
|
// transcription), but a turn may append its user item only after the previous
|
|
// turn has appended its own — and the slot releases (done closes) only after
|
|
// the previous commit has FINISHED, on every exit path (success, error, empty
|
|
// transcript, teardown). Without the append gate a fast second transcription
|
|
// would commit ahead of a slow first one; without the release gate a failed
|
|
// middle commit would release the third turn before the first appended. In
|
|
// both cases the assistant response — and the conversation history — would
|
|
// see the user input out of order or missing (issue #12445 + review follow-up).
|
|
type commitSlot struct {
|
|
// prevDone is the previous slot's done channel (nil for the first commit);
|
|
// this commit's item append waits on it, and its slot release waits on it
|
|
// too (on every exit path).
|
|
prevDone chan struct{}
|
|
// done is closed when the commit function has returned — it appended its
|
|
// user item or aborted (error, empty transcript, teardown), and generated
|
|
// its own response unless that was cancelled — AND the predecessor has
|
|
// finished, so the next commit is released in order.
|
|
done chan struct{}
|
|
// appended records that this commit appended its user item to the
|
|
// conversation. It is written by the commit goroutine before done closes
|
|
// and must only be read after done is closed.
|
|
appended bool
|
|
// source is who issued the commit. An any-to-any session answers a VAD
|
|
// commit itself and waits for response.create after a client commit.
|
|
source respcoord.Source
|
|
}
|
|
|
|
// nextCommitSlot claims the next position in the commit order. It is called
|
|
// at commit ISSUE time (VAD CommitTurn / client input_audio_buffer.commit) —
|
|
// synchronously on the issuing goroutine, in the order the turns were
|
|
// detected — before the (parallel) transcriptions start, so slot order ==
|
|
// speech order even if the commit goroutines schedule out of order. The
|
|
// production commit paths go through issueCommit (slot claim + issue under
|
|
// one lock); this primitive is also used by tests that drive a commit body
|
|
// directly.
|
|
func (session *Session) nextCommitSlot() *commitSlot {
|
|
session.commitOrderMu.Lock()
|
|
defer session.commitOrderMu.Unlock()
|
|
return session.nextCommitSlotLocked()
|
|
}
|
|
|
|
// nextCommitSlotLocked claims the next slot; commitOrderMu must be held.
|
|
func (session *Session) nextCommitSlotLocked() *commitSlot {
|
|
var prevDone chan struct{}
|
|
if session.commitTail != nil {
|
|
prevDone = session.commitTail.done
|
|
}
|
|
slot := &commitSlot{prevDone: prevDone, done: make(chan struct{})}
|
|
session.commitTail = slot
|
|
return slot
|
|
}
|
|
|
|
// issueCommit is the single ordering boundary both commit producers — the VAD
|
|
// CommitTurn (realtime_turncoord.go) and the client input_audio_buffer.commit
|
|
// (read loop) — must go through. Claiming the next commit slot and issuing
|
|
// the commit body happen under ONE lock, so slot order == issue order: the
|
|
// coordinator's supersession (a newer issue cancels the in-flight response)
|
|
// can only cancel a response issued LATER in speech order, never an earlier
|
|
// turn whose slot a later issue would overtake (issue #12445, review
|
|
// follow-up: "slot reservation and response issuance need a shared ordering
|
|
// boundary across both producers"). respSink.issue is non-blocking — it
|
|
// registers the body and applies the coordinator start; the commit work runs
|
|
// in the spawned goroutine — so holding the lock across it does not stall
|
|
// VAD/barge-in handling.
|
|
func (session *Session) issueCommit(parent context.Context, source respcoord.Source, run func(ctx context.Context, slot *commitSlot)) {
|
|
session.commitOrderMu.Lock()
|
|
defer session.commitOrderMu.Unlock()
|
|
slot := session.nextCommitSlotLocked()
|
|
slot.source = source
|
|
// A non-interrupting VAD commit must not supersede a response at speech end.
|
|
ifIdle := source == respcoord.SourceVAD && !session.interruptResponseEnabled()
|
|
session.respSink.issueWithPolicy(parent, source, ifIdle, func(ctx context.Context) {
|
|
run(ctx, slot)
|
|
})
|
|
}
|
|
|
|
// issueClientCommit issues a client commit (input_audio_buffer.commit) through
|
|
// the shared commit-ordering boundary, so slot order == issue order across the
|
|
// client and VAD producers.
|
|
func (session *Session) issueClientCommit(allAudio []byte, conv *Conversation, t Transport) {
|
|
session.issueCommit(context.Background(), respcoord.SourceClient, func(ctx context.Context, slot *commitSlot) {
|
|
commitUtterance(ctx, allAudio, session, conv, t, slot)
|
|
})
|
|
}
|
|
|
|
// issueClientResponse issues a client response (response.create). With
|
|
// client-driven turns the commit and response.create are sent back-to-back, so
|
|
// the response waits for the newest commit (client or VAD) to finish before the
|
|
// LLM starts: the LLM must answer the real transcript, not an empty one
|
|
// (client-commit LLM/STT race). Reading the commit tail and issuing the
|
|
// response happen under the same lock as issueCommit, so the response is
|
|
// ordered after every commit issued before it and before any issued later.
|
|
func (session *Session) issueClientResponse(resp types.ResponseCreateParams, conv *Conversation, t Transport) {
|
|
session.commitOrderMu.Lock()
|
|
defer session.commitOrderMu.Unlock()
|
|
tail := session.commitTail
|
|
pending := false
|
|
if tail != nil {
|
|
select {
|
|
case <-tail.done:
|
|
default:
|
|
pending = true
|
|
}
|
|
}
|
|
session.respSink.issue(context.Background(), respcoord.SourceClient, func(ctx context.Context) {
|
|
if pending {
|
|
select {
|
|
case <-tail.done:
|
|
case <-ctx.Done():
|
|
}
|
|
if ctx.Err() != nil {
|
|
// Cancelled, superseded or torn down while waiting: close the
|
|
// response the way a cancelled one is closed, so the client is
|
|
// never left without a terminal event.
|
|
sendUnstartedResponse(t, &resp, types.ResponseStatusCancelled, nil)
|
|
return
|
|
}
|
|
if !tail.appended {
|
|
// The commit this response waited for produced no user item
|
|
// (transcription error, empty transcript, voice gate
|
|
// rejection; the commit already reported the cause). Do not
|
|
// run the LLM on a history that lacks the turn.
|
|
sendUnstartedResponse(t, &resp, types.ResponseStatusFailed, &types.StatusDetail{
|
|
Type: "failed",
|
|
Error: &types.Error{
|
|
Type: "invalid_request_error",
|
|
Code: "input_not_committed",
|
|
Message: "the committed input audio produced no transcript, so no response was generated",
|
|
},
|
|
})
|
|
return
|
|
}
|
|
}
|
|
triggerResponse(ctx, session, conv, t, &resp)
|
|
})
|
|
}
|
|
|
|
// sendUnstartedResponse emits the response.created / response.done pair for a
|
|
// response that ended before the LLM started, so the client sees a complete
|
|
// lifecycle with the given terminal status.
|
|
func sendUnstartedResponse(t Transport, overrides *types.ResponseCreateParams, status types.ResponseStatus, details *types.StatusDetail) {
|
|
r := &liveResponse{id: generateUniqueID()}
|
|
if overrides != nil {
|
|
r.metadata = overrides.Metadata
|
|
}
|
|
sendEvent(t, types.ResponseCreatedEvent{
|
|
ServerEventBase: types.ServerEventBase{},
|
|
Response: types.Response{
|
|
ID: r.id,
|
|
Object: "realtime.response",
|
|
Status: types.ResponseStatusInProgress,
|
|
Metadata: r.metadata,
|
|
},
|
|
})
|
|
sendEvent(t, types.ResponseDoneEvent{
|
|
ServerEventBase: types.ServerEventBase{},
|
|
Response: types.Response{
|
|
ID: r.id,
|
|
Object: "realtime.response",
|
|
Status: status,
|
|
StatusDetails: details,
|
|
Metadata: r.metadata,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Session) installVoiceBinding(voice string, params map[string]string, release func()) {
|
|
if release == nil {
|
|
release = func() {}
|
|
}
|
|
var once sync.Once
|
|
s.Voice = voice
|
|
s.ttsParams = maps.Clone(params)
|
|
s.voiceRelease = func() { once.Do(release) }
|
|
}
|
|
|
|
func (s *Session) releaseVoiceBinding() {
|
|
if s.voiceRelease != nil {
|
|
s.voiceRelease()
|
|
}
|
|
}
|
|
|
|
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,
|
|
LocalAIClassifier: s.Classifier,
|
|
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)
|
|
// ClassifyTurn prefill-scores each classifier option as a candidate
|
|
// continuation of the conversation (LocalAI classifier-mode extension)
|
|
// and returns the softmax distribution in option order. Runs on the
|
|
// pipeline's scoring model (classifier.model, defaulting to the LLM) —
|
|
// no autoregressive decode happens.
|
|
ClassifyTurn(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string) ([]router.LabelScore, error)
|
|
// PrewarmClassifier primes the scoring backend's prompt cache for a
|
|
// newly registered option list (fired async on registration) so the
|
|
// first turns after a session.update don't pay the option-list
|
|
// prefill. Best-effort and idempotent per option set.
|
|
PrewarmClassifier(ctx context.Context, options []types.ClassifierOption, normalization string)
|
|
// FillToolArguments completes the chosen option's argument slots with a
|
|
// short grammar-constrained completion that continues the exact scoring
|
|
// prompt (so the backend's prompt cache stays warm) and returns the
|
|
// spliced tool-arguments JSON plus the raw slot values (for reply
|
|
// templating) — the hybrid between prefill-only classification and full
|
|
// generation.
|
|
FillToolArguments(ctx context.Context, messages schema.Messages, options []types.ClassifierOption, normalization string, chosen *types.ClassifierOption) (string, map[string]string, 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 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.Pipeline.IsAnyToAny() {
|
|
return prepareAnyToAnyConfig(cfg)
|
|
}
|
|
if cfg.HasUsecases(config.FLAG_REALTIME_AUDIO) {
|
|
// llama.cpp has no VAD, transcription or TTS RPCs, so a model that
|
|
// names no pipeline would fail later with UNIMPLEMENTED on every slot.
|
|
if config.IsLlamaCppBackend(cfg.Backend) {
|
|
return "invalid_pipeline", "a llama-cpp realtime_audio model must set pipeline.any_to_any: true", false
|
|
}
|
|
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.
|
|
// Without a VAD model nothing detects turns, so the client commits them.
|
|
turnDetection := defaultSessionTurnDetection(cfg)
|
|
inputAudioTranscription := &types.AudioTranscription{Model: sttModel}
|
|
anyToAny := cfg.Pipeline.IsAnyToAny()
|
|
if anyToAny && s2sInputTranscriptionModel(cfg) == "" {
|
|
inputAudioTranscription = nil // the stream answers from the audio; see s2sInputTranscriptionModel
|
|
}
|
|
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 anyToAny {
|
|
instructions = s2sDefaultInstructions
|
|
}
|
|
if assistantSystemPrompt != "" {
|
|
instructions = assistantSystemPrompt + "\n\n" + instructions
|
|
}
|
|
|
|
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)
|
|
if anyToAny {
|
|
// The context lives in the backend; there is nothing to summarize here.
|
|
session.CompactionEnabled = false
|
|
}
|
|
classifier, err := classifierConfigFromPipeline(cfg.Pipeline.Classifier)
|
|
if err != nil {
|
|
sendError(t, "invalid_pipeline", "pipeline classifier: "+err.Error(), "", "")
|
|
return
|
|
}
|
|
session.Classifier = classifier
|
|
|
|
// 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()
|
|
|
|
// Session-lifetime context for committed-turn work that must outlive a
|
|
// barge-in but not the session (transcription, commit ordering). Cancelled
|
|
// at teardown BEFORE respSink.shutdown joins the response goroutines.
|
|
session.sessionCtx, session.sessionCancel = context.WithCancel(context.Background())
|
|
|
|
// 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(),
|
|
application.FailoverManager(),
|
|
)
|
|
} 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
|
|
}
|
|
if wrapped, ok := m.(*wrappedModel); ok {
|
|
resolvedVoice, params, release, resolveErr := resolveRealtimeVoice(context.Background(), wrapped.TTSConfig.TTSConfig.Voice, wrapped.TTSConfig, application.VoiceProfileStore())
|
|
if resolveErr != nil {
|
|
xlog.Error("failed to resolve realtime voice", "error", resolveErr)
|
|
sendError(t, "voice_profile_error", resolveErr.Error(), "", "")
|
|
return
|
|
}
|
|
session.installVoiceBinding(resolvedVoice, params, release)
|
|
defer session.releaseVoiceBinding()
|
|
wrapped.setTTSParams(params)
|
|
}
|
|
session.ModelInterface = m
|
|
// A pipeline-seeded option list gets its scoring prompt prewarmed
|
|
// alongside the model warm-up below, so the session's first turn
|
|
// doesn't pay the option-list prefill.
|
|
prewarmClassifier(session)
|
|
|
|
// 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: config.PipelineStageVoiceRecognition, 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 opener, ok := m.(s2sOpener); ok {
|
|
if err := openS2SForSession(session, opener, t); err != nil {
|
|
xlog.Error("failed to open the speech-to-speech stream", "error", err)
|
|
sendError(t, "model_load_error", "Failed to open the speech-to-speech stream: "+err.Error(), "", "")
|
|
return
|
|
}
|
|
defer session.closeS2S()
|
|
}
|
|
|
|
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(),
|
|
})
|
|
|
|
// Sent after session.created, which clients expect as the first event.
|
|
// This function runs until the connection closes, so the defer stops the
|
|
// events at session end. A transcription session.update that swaps the
|
|
// model restarts them for the new model's chains.
|
|
stopFailoverEvents := startModelFailoverEvents(t, m)
|
|
defer func() { stopFailoverEvents() }()
|
|
|
|
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 {
|
|
prevModel := session.ModelInterface
|
|
if err := updateTransSession(
|
|
session,
|
|
&e.Session,
|
|
application.ModelConfigLoader(),
|
|
application.ModelLoader(),
|
|
application.ApplicationConfig(),
|
|
application.FailoverManager(),
|
|
application.VoiceRegistry(),
|
|
); err != nil {
|
|
xlog.Error("failed to update session", "error", err)
|
|
// The cause is validation feedback on the client's own
|
|
// payload — echo it so UIs can show something actionable.
|
|
sendError(t, "session_update_error", fmt.Sprintf("Failed to update session: %v", err), "", "")
|
|
continue
|
|
}
|
|
|
|
toggleVAD()
|
|
|
|
sendEvent(t, types.SessionUpdatedEvent{
|
|
ServerEventBase: types.ServerEventBase{
|
|
EventID: "event_TODO",
|
|
},
|
|
Session: session.ToServer(),
|
|
})
|
|
if session.ModelInterface != prevModel {
|
|
stopFailoverEvents()
|
|
stopFailoverEvents = startModelFailoverEvents(t, session.ModelInterface)
|
|
}
|
|
}
|
|
|
|
// 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),
|
|
application.VoiceProfileStore(),
|
|
); err != nil {
|
|
xlog.Error("failed to update session", "error", err)
|
|
sendError(t, "session_update_error", fmt.Sprintf("Failed to update session: %v", err), "", "")
|
|
continue
|
|
}
|
|
|
|
toggleVAD()
|
|
session.s2sSyncConfig()
|
|
|
|
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.s2sOnAppendLocked(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
|
|
}
|
|
|
|
commitClientAudio(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
|
|
if session.s2s != nil && !s2sItemSupported(item) {
|
|
sendError(t, "invalid_request_error", "this conversation item is "+s2sUnsupportedMessage, "", e.EventID)
|
|
continue
|
|
}
|
|
if item.FunctionCallOutput != nil && session.s2sUserTurnOpen() {
|
|
sendError(t, "invalid_request_error", "a tool result cannot be added while the user is speaking; send it after the turn ends", "", e.EventID)
|
|
continue
|
|
}
|
|
// 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 session.s2s != nil {
|
|
sendError(t, "invalid_request_error", "conversation.item.delete is "+s2sUnsupportedMessage, "", e.EventID)
|
|
continue
|
|
}
|
|
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))
|
|
if session.s2s != nil {
|
|
sendError(t, "invalid_request_error", "conversation.item.truncate is "+s2sUnsupportedMessage, "", e.EventID)
|
|
continue
|
|
}
|
|
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))
|
|
if err := validateClassifierActivation(session.ModelInterface, e.Response.LocalAIClassifier); err != nil {
|
|
sendError(t, "invalid_request_error", "Invalid response classifier: "+err.Error(), "", e.EventID)
|
|
continue
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// A speech-to-speech session with client-driven turns holds the audio
|
|
// back until a commit; a response.create over uncommitted audio
|
|
// commits it first, so the model hears what the client sent.
|
|
if session.s2s != nil && session.s2sManualTurns() && session.hasBufferedInput() {
|
|
commitClientAudio(session, conversation, t)
|
|
}
|
|
|
|
// With client-driven turns the commit and this response.create are
|
|
// sent back-to-back; the response waits for the in-flight commit's
|
|
// STT (and user-item append) to finish so the LLM answers the real
|
|
// transcript, not an empty one (client-commit LLM/STT race).
|
|
session.issueClientResponse(e.Response, conversation, t)
|
|
|
|
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, fm *failover.Manager, voices voicerecognition.Registry) 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, fm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if tm, ok := m.(*transcriptOnlyModel); ok {
|
|
tm.voiceRegistry = voices
|
|
}
|
|
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, profiles *voiceprofile.Store) error {
|
|
sessionLock.Lock()
|
|
defer sessionLock.Unlock()
|
|
|
|
if update.Realtime == nil {
|
|
return nil
|
|
}
|
|
|
|
rt := update.Realtime
|
|
if session.s2s != nil {
|
|
if err := checkS2SUpdate(session, rt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
explicitVoice := rt.Audio != nil && rt.Audio.Output != nil && rt.Audio.Output.Voice != ""
|
|
rebuild := rt.Model != "" || explicitVoice || (rt.Audio != nil && rt.Audio.Input != nil && rt.Audio.Input.Transcription != nil)
|
|
|
|
if session.s2s != nil {
|
|
// The model is fixed for the session: a voice or transcription
|
|
// language change needs no rebuild.
|
|
rebuild = false
|
|
if explicitVoice {
|
|
session.Voice = string(rt.Audio.Output.Voice)
|
|
}
|
|
}
|
|
|
|
candidateModelName := session.Model
|
|
candidateConfig := session.ModelConfig
|
|
candidateTranscription := session.InputAudioTranscription
|
|
|
|
if rt.Model != "" && session.s2s == nil {
|
|
cfg, err := cl.LoadModelConfigFileByNameDefaultOptions(rt.Model, appConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg == nil || (cfg.Pipeline.Transcription == "" || cfg.Pipeline.TTS == "" || cfg.Pipeline.LLM == "") {
|
|
return fmt.Errorf("model is not a valid pipeline model: %s", rt.Model)
|
|
}
|
|
|
|
candidateModelName = rt.Model
|
|
candidateConfig = cfg
|
|
candidateTranscription = &types.AudioTranscription{Model: cfg.Pipeline.Transcription}
|
|
}
|
|
|
|
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 == "" && candidateTranscription != nil {
|
|
trUpd.Model = candidateTranscription.Model
|
|
}
|
|
candidateTranscription = &trUpd
|
|
if trUpd.Model != "" {
|
|
cfgCopy := *candidateConfig
|
|
candidateConfig = &cfgCopy
|
|
candidateConfig.Pipeline.Transcription = trUpd.Model
|
|
}
|
|
}
|
|
|
|
// A pipeline without a VAD model cannot detect turns: refuse to turn
|
|
// detection on, and drop it when the update moves to such a pipeline.
|
|
noVAD := session.s2s == nil && candidateConfig.Pipeline.VAD == ""
|
|
if noVAD && rt.Audio != nil && rt.Audio.Input != nil && rt.Audio.Input.TurnDetectionSet && turnDetectionActive(rt.Audio.Input.TurnDetection) {
|
|
return errors.New("turn detection needs a VAD model; set pipeline.vad on the model, or commit turns manually")
|
|
}
|
|
|
|
candidateModel := session.ModelInterface
|
|
candidateVoice := session.Voice
|
|
candidateParams := maps.Clone(session.ttsParams)
|
|
var candidateRelease func()
|
|
selectVoice := (rt.Model != "" || explicitVoice) && session.s2s == nil
|
|
if rebuild {
|
|
m, err := newModel(&candidateConfig.Pipeline, cl, ml, appConfig, evaluator, routing)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
candidateModel = m
|
|
wrapped := m.(*wrappedModel)
|
|
if selectVoice {
|
|
configuredVoice := wrapped.TTSConfig.TTSConfig.Voice
|
|
if explicitVoice {
|
|
configuredVoice = string(rt.Audio.Output.Voice)
|
|
}
|
|
candidateVoice, candidateParams, candidateRelease, err = resolveRealtimeVoice(
|
|
context.Background(), configuredVoice, wrapped.TTSConfig, profiles,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
wrapped.setTTSParams(candidateParams)
|
|
}
|
|
|
|
if rt.LocalAIClassifier != nil {
|
|
if err := validateClassifierActivation(candidateModel, rt.LocalAIClassifier); err != nil {
|
|
if candidateRelease != nil {
|
|
candidateRelease()
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
oldRelease := session.voiceRelease
|
|
session.TranscriptionOnly = false
|
|
session.Model = candidateModelName
|
|
session.ModelConfig = candidateConfig
|
|
session.ModelInterface = candidateModel
|
|
session.InputAudioTranscription = candidateTranscription
|
|
if selectVoice {
|
|
session.installVoiceBinding(candidateVoice, candidateParams, candidateRelease)
|
|
if oldRelease != nil {
|
|
oldRelease()
|
|
}
|
|
}
|
|
|
|
if rebuild {
|
|
// 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 !candidateConfig.Pipeline.DisableWarmup {
|
|
go func() {
|
|
if err := candidateModel.Warmup(context.Background()); err != nil {
|
|
xlog.Error("realtime warmup failed after session.update", "error", err)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
if noVAD {
|
|
session.TurnDetection = nil
|
|
}
|
|
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.LocalAIClassifier != nil {
|
|
// Replace-not-merge, like tools: the client owns the whole option
|
|
// list. Invalid configs reject the update without touching the
|
|
// session's current classifier.
|
|
session.Classifier = rt.LocalAIClassifier
|
|
prewarmClassifier(session)
|
|
}
|
|
|
|
if rt.MaxOutputTokens != 0 {
|
|
session.MaxOutputTokens = rt.MaxOutputTokens
|
|
}
|
|
|
|
if mods := modalitiesWithAlias(rt.OutputModalities, rt.Modalities); len(mods) > 0 {
|
|
session.OutputModalities = mods
|
|
}
|
|
|
|
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.s2sOnAppendLocked(pcmBytes)
|
|
}
|
|
session.AudioBufferLock.Unlock()
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// commitClientAudio commits the buffered input audio on a client's request. On
|
|
// a speech-to-speech session the audio goes to the backend only now, ahead of
|
|
// the commit, so a cleared buffer never reaches it.
|
|
func commitClientAudio(session *Session, conversation *Conversation, t Transport) {
|
|
session.AudioBufferLock.Lock()
|
|
allAudio := make([]byte, len(session.InputAudioBuffer))
|
|
copy(allAudio, session.InputAudioBuffer)
|
|
session.InputAudioBuffer = nil
|
|
session.AudioBufferLock.Unlock()
|
|
session.s2sCommitAudio(allAudio, conversation)
|
|
|
|
sendEvent(t, types.InputAudioBufferCommittedEvent{
|
|
ServerEventBase: types.ServerEventBase{},
|
|
ItemID: generateItemID(),
|
|
})
|
|
|
|
// Issue through the shared commit-ordering boundary and record the
|
|
// in-flight commit so the back-to-back response.create waits for the
|
|
// STT before the LLM starts (client-commit LLM/STT race).
|
|
session.issueClientCommit(allAudio, conversation, t)
|
|
}
|
|
|
|
// 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
|
|
|
|
// vadWarmupMarginSec pads the VAD scan window beyond the largest silence the
|
|
// commit test can need to measure. It covers silero's cold-start (the LSTM
|
|
// state converges within a few hundred ms — the model has no longer-range
|
|
// memory, which is why clipping is sound at all) plus sherpa's segment
|
|
// hysteresis (min_speech 0.25s / min_silence 0.5s), which must fit inside
|
|
// the clip for segments to open and close at all.
|
|
const vadWarmupMarginSec = 1.0
|
|
|
|
// maxTurnBufferSec bounds the raw input buffer. Without it a turn that never
|
|
// pauses (continuous noise or speech: silero segments every tick, so the
|
|
// no-speech clear never runs and nothing commits) grows the buffer toward the
|
|
// 100MB append cap, and with it the per-tick copy+resample and the
|
|
// commit-time WAV/batch decode. 90s keeps all of those trivial; only an
|
|
// unbroken >90s turn loses head audio from a server_vad batch transcription
|
|
// (semantic mode already consumed it incrementally via the live stream).
|
|
const maxTurnBufferSec = 90.0
|
|
|
|
// 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:
|
|
vadTick(sink, silenceThreshold)
|
|
}
|
|
}
|
|
}
|
|
|
|
// vadTick runs one turn-detection inspection of the session's input buffer:
|
|
// snapshot, resample, silero scan, live-ASR drain, and the coordinator
|
|
// transitions that follow. Extracted from handleVAD so specs can drive turn
|
|
// detection synchronously without the ticker (same shape as
|
|
// classifySoundWindow).
|
|
func vadTick(sink *turnSink, silenceThreshold float64) {
|
|
session := sink.session
|
|
t := sink.transport
|
|
lts := sink.lts
|
|
vadContext := sink.vadContext
|
|
|
|
// 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()
|
|
// Retention bound: drop the buffer head beyond maxTurnBufferSec so that a
|
|
// turn that never pauses can't grow memory, the per-tick copy+resample,
|
|
// or the commit-time decode without limit (this also bounds the
|
|
// runVAD-error path below, which can't trim). A mid-turn trim shifts
|
|
// every buffer-relative cursor, so the live-feed and EOU positions are
|
|
// rebased by the trimmed amount. Whole input-seconds only: second-aligned
|
|
// cuts keep the resampled tail sample-identical to the suffix of the
|
|
// previous whole-buffer resample, so the live feed stays gapless.
|
|
bytesPerSec := session.InputSampleRate * 2
|
|
if maxBytes := int(maxTurnBufferSec) * bytesPerSec; len(session.InputAudioBuffer) > maxBytes {
|
|
trimSecs := (len(session.InputAudioBuffer) - maxBytes + bytesPerSec - 1) / bytesPerSec
|
|
session.InputAudioBuffer = append([]byte(nil), session.InputAudioBuffer[trimSecs*bytesPerSec:]...)
|
|
lts.rebase(float64(trimSecs))
|
|
sink.lastSpeechEndSec = max(0, sink.lastSpeechEndSec-float64(trimSecs))
|
|
}
|
|
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)) {
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Scan window: silero's recurrent state carries only a few hundred ms of
|
|
// context, so audio older than the largest silence the commit test can
|
|
// need to measure (plus warm-up margin) contributes nothing to the
|
|
// tail's classification — clip it instead of rescanning the whole turn
|
|
// every tick (~3.3ms of silero per buffered second, quadratic over a
|
|
// turn). Segment times are rebased back to whole-buffer coordinates so
|
|
// every downstream consumer (trailing-silence math, eouPending, the
|
|
// live-feed cursor) is untouched.
|
|
scan := aints
|
|
clipOffsetSec := 0.0
|
|
if maxScan := int(vadScanWindowSec(sv, silenceThreshold, session.ModelConfig) * localSampleRate); len(aints) > maxScan {
|
|
scan = aints[len(aints)-maxScan:]
|
|
clipOffsetSec = float64(len(aints)-maxScan) / localSampleRate
|
|
}
|
|
|
|
segments, err := runVAD(vadContext, session, scan)
|
|
if err != nil {
|
|
if err.Error() == "unexpected speech end" {
|
|
xlog.Debug("VAD cancelled")
|
|
return
|
|
}
|
|
xlog.Error("failed to process audio", "error", err)
|
|
sendError(t, "processing_error", "Failed to process audio: "+err.Error(), "", "")
|
|
return
|
|
}
|
|
for i := range segments {
|
|
segments[i].Start += float32(clipOffsetSec)
|
|
// End == 0 is the "segment still open" sentinel — leave it alone.
|
|
if segments[i].End != 0 {
|
|
segments[i].End += float32(clipOffsetSec)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// An open turn whose scan window is all silence: the turn's speech
|
|
// is entirely older than the clip, so the trailing silence is at
|
|
// least the window — which the window sizing guarantees exceeds
|
|
// every commit threshold. Commit with the last speech end this
|
|
// turn observed instead of discarding real speech as no-speech.
|
|
// With no clip in effect (clipOffsetSec == 0) silero really saw
|
|
// the whole turn, and zero segments keeps its historical meaning:
|
|
// the earlier onset was reclassified as noise — clear it below.
|
|
if _, speaking := sink.coord.State().(turncoord.Speaking); speaking &&
|
|
clipOffsetSec > 0 && sink.lastSpeechEndSec > 0 {
|
|
vadCommit(sink, retranscribe, aints, len(allAudio), audioLength, sink.lastSpeechEndSec, false)
|
|
return
|
|
}
|
|
if 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.
|
|
sink.lastSpeechEndSec = 0
|
|
if err := sink.coord.Apply(turncoord.Abort{Reason: turncoord.AbortNoSpeech}); err != nil {
|
|
xlog.Error("turncoord: abort(no_speech) failed", "error", err)
|
|
}
|
|
session.s2sAbortTurn()
|
|
}
|
|
return
|
|
}
|
|
|
|
// 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 session.s2s != nil {
|
|
if _, speaking := sink.coord.State().(turncoord.Speaking); !speaking {
|
|
session.s2sBeginTurn(float64(segments[0].Start))
|
|
}
|
|
}
|
|
if err := sink.coord.Apply(turncoord.Onset{Turn: turncoord.TurnID(generateItemID())}); err != nil {
|
|
xlog.Error("turncoord: onset failed", "error", err)
|
|
}
|
|
|
|
// Track where speech last ended, in whole-buffer seconds: once these
|
|
// segments scroll out of the scan clip, the silence-outran-the-window
|
|
// commit above still needs a speech end to report. An open segment
|
|
// (End == 0) means speech reaches the end of the inspected audio.
|
|
if end := segments[len(segments)-1].End; end != 0 {
|
|
sink.lastSpeechEndSec = float64(end)
|
|
} else {
|
|
sink.lastSpeechEndSec = audioLength
|
|
}
|
|
|
|
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 {
|
|
return
|
|
}
|
|
|
|
threshold := silenceThreshold
|
|
eouPending := false
|
|
if sv != nil {
|
|
eouPending = lts.eouPending(segments)
|
|
threshold = lts.thresholdSec(eouPending, sv)
|
|
}
|
|
|
|
if float32(audioLength)-segEndTime > float32(threshold) {
|
|
vadCommit(sink, retranscribe, aints, len(allAudio), audioLength, float64(segEndTime), eouPending)
|
|
}
|
|
}
|
|
|
|
// vadCommit runs the commit tail of a VAD tick: the semantic commit log, the
|
|
// retranscribe gate, the buffer trim, and the coordinator's Silence event
|
|
// (speech_stopped + committed + finalize live stream + issue the response).
|
|
// Shared by the normal trailing-silence commit and the
|
|
// silence-outran-the-scan-window commit.
|
|
func vadCommit(sink *turnSink, retranscribe bool, aints []int16, inspectedBytes int, audioLength, segEndTime float64, eouPending bool) {
|
|
session := sink.session
|
|
lts := sink.lts
|
|
|
|
if sink.sv != nil {
|
|
trigger, eouLag := lts.commitTrigger(eouPending, segEndTime)
|
|
xlog.Info("semantic_vad: committing turn",
|
|
"trigger", trigger,
|
|
"speech_end_s", segEndTime,
|
|
"eou_lag_s", eouLag,
|
|
"silence_s", audioLength-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(sink.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
|
|
return
|
|
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, inspectedBytes, 0)
|
|
session.AudioBufferLock.Unlock()
|
|
session.s2sEndTurn()
|
|
|
|
// 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
|
|
sink.lastSpeechEndSec = 0
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// vadScanWindowSec sizes the tail of the buffer silero inspects each tick.
|
|
// The window must contain the largest trailing silence the commit test can
|
|
// need to measure — server_vad's silence window, or the semantic eagerness
|
|
// fallback (the post-EOU window is shorter) — plus vadWarmupMarginSec.
|
|
// pipeline.turn_detection.vad_window_sec can widen it; values below the floor
|
|
// are ignored, since a narrower window would make long silences unmeasurable
|
|
// and turns uncommittable.
|
|
func vadScanWindowSec(sv *types.RealtimeSessionSemanticVad, silenceThreshold float64, cfg *config.ModelConfig) float64 {
|
|
needed := silenceThreshold
|
|
if sv != nil {
|
|
needed = eagernessMaxSilenceSec(sv.Eagerness)
|
|
}
|
|
window := needed + vadWarmupMarginSec
|
|
if cfg != nil && cfg.Pipeline.TurnDetection.VadWindowSec > window {
|
|
window = cfg.Pipeline.TurnDetection.VadWindowSec
|
|
}
|
|
return window
|
|
}
|
|
|
|
func commitUtterance(ctx context.Context, utt []byte, session *Session, conv *Conversation, t Transport, slot *commitSlot) {
|
|
commitUtteranceWithTranscript(ctx, utt, nil, nil, "", session, conv, t, slot)
|
|
}
|
|
|
|
// 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, slot *commitSlot) {
|
|
// sctx is the session-lifetime context (see Session.sessionCtx): it
|
|
// survives a barge-in (which cancels only the per-response ctx) but is
|
|
// cancelled at teardown. Unit tests that build a bare Session leave it
|
|
// nil; fall back like responseSink.Perform does for a nil parent.
|
|
sctx := session.sessionCtx
|
|
if sctx == nil {
|
|
sctx = context.Background()
|
|
}
|
|
|
|
// Release the slot on EVERY exit — including the empty-utt early return,
|
|
// transcription errors, gate rejections, empty transcripts and teardown —
|
|
// but ONLY after the predecessor has finished: a failed or skipped middle
|
|
// commit must not release the next commit before the earlier one appended
|
|
// its item, or the next response would be built on a history missing the
|
|
// earlier turn (issue #12445, review follow-up schedule 1). The session
|
|
// context can still stop the wait, so teardown never blocks on a
|
|
// never-finishing predecessor.
|
|
defer func() {
|
|
if slot.prevDone != nil {
|
|
select {
|
|
case <-slot.prevDone:
|
|
case <-sctx.Done():
|
|
}
|
|
}
|
|
close(slot.done)
|
|
}()
|
|
if len(utt) == 0 {
|
|
return
|
|
}
|
|
|
|
// An any-to-any session needs no transcript to answer: the audio is
|
|
// already with the backend. The speaker gate is refused for these models
|
|
// at session setup (prepareAnyToAnyConfig).
|
|
if session.s2s != nil {
|
|
s2sCommitTurn(ctx, utt, itemID, session, conv, t, slot)
|
|
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() {
|
|
// Turn work like the transcription: must outlive a barge-in (the
|
|
// decision belongs to THIS utterance), so run under the session
|
|
// context (issue #12445).
|
|
r, rerr := session.voiceGate.Resolve(sctx, 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()
|
|
}
|
|
|
|
// joinResolve waits for the speaker resolution once and caches the outcome,
|
|
// so it can be joined early (before the transcript is final) and again at
|
|
// the gate below.
|
|
var resolved *resolveOutcome
|
|
joinResolve := func() resolveOutcome {
|
|
if resolved == nil {
|
|
out := <-resolveCh
|
|
resolved = &out
|
|
}
|
|
return *resolved
|
|
}
|
|
|
|
// A transcription-only session never reaches generateResponse, which is
|
|
// where a response session announces the speaker. Announce it here
|
|
// instead, before the turn's completed event, so the client knows who
|
|
// spoke when the final transcript arrives. As on the response path, a
|
|
// speaker that an enforcing gate rejects is not announced.
|
|
var beforeCompleted func()
|
|
if runResolve && session.TranscriptionOnly && session.voiceGate.cfg.AnnounceEnabled() {
|
|
beforeCompleted = func() {
|
|
out := joinResolve()
|
|
if out.err != nil {
|
|
return
|
|
}
|
|
if session.voiceGate.cfg.EnforceGate() {
|
|
alreadyVerified := false
|
|
if session.voiceGate.cfg.When == config.VoiceGateWhenFirst {
|
|
session.gateMu.Lock()
|
|
alreadyVerified = session.voiceVerified
|
|
session.gateMu.Unlock()
|
|
}
|
|
allowed, _ := session.voiceGate.authorize(out.res)
|
|
if proceed, _ := session.voiceGate.decide(alreadyVerified, allowed); !proceed {
|
|
return
|
|
}
|
|
}
|
|
s := out.res.speaker
|
|
announceSpeaker(t, session, itemID, &s)
|
|
}
|
|
}
|
|
|
|
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, beforeCompleted); 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, beforeCompleted); 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.
|
|
//
|
|
// The transcription runs under the SESSION context, not the turn's
|
|
// response context: a barge-in (new speech onset) or a newer commit
|
|
// cancels that context while Whisper is still in flight, and
|
|
// cancelling the transcription would lose the user's input for this
|
|
// turn ("transcription_failed: context canceled", issue #12445). The
|
|
// session context is cancelled at teardown instead (conncoord
|
|
// Teardown, before respSink.shutdown joins this goroutine), so the
|
|
// transcription can never outlive the session. The response to this
|
|
// turn is still cancelled — see the ctx check below — only the
|
|
// transcript survives, so the next response is built on the full
|
|
// utterance.
|
|
transcript, err = emitTranscription(sctx, t, session, itemID, f.Name(), beforeCompleted)
|
|
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 {
|
|
joinResolve()
|
|
}
|
|
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 {
|
|
// Session context, like the transcription: a turn whose response
|
|
// context is already cancelled (a skipped overlapping turn, a barge-in)
|
|
// still gets its sound events.
|
|
if sderr := emitSoundDetection(sctx, 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 := joinResolve()
|
|
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 && strings.TrimSpace(transcript) != "" {
|
|
// Commit ordering (append gate): wait until the previous committed
|
|
// turn has appended its user item, so the conversation history — and
|
|
// the response built on it — sees the turns in speech order, not
|
|
// transcription-completion order (issue #12445, review schedule 2).
|
|
// The deferred slot release (release gate) enforces the same order on
|
|
// every other exit path. Aborts on teardown.
|
|
if slot.prevDone != nil {
|
|
select {
|
|
case <-slot.prevDone:
|
|
case <-sctx.Done():
|
|
return
|
|
}
|
|
}
|
|
// If the turn's response context was cancelled while the (detached)
|
|
// transcription ran — barge-in, superseded by a newer commit — the
|
|
// user item still commits, so the LLM context keeps the full user
|
|
// input, but no response is generated for this (superseded) turn:
|
|
// the newer speech triggers its own response on the complete history.
|
|
// (Teardown is not a barge-in: the slot wait above already returned
|
|
// when the session context was cancelled.)
|
|
if ctx.Err() != nil {
|
|
xlog.Debug("skipping response: turn context cancelled during transcription (barge-in); committing user item only")
|
|
slot.appended = true
|
|
appendUserItem(conv, t, utt, transcript, speaker)
|
|
return
|
|
}
|
|
slot.appended = true
|
|
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
|
|
}
|
|
// A backend answering with an empty message means "no speech", not a
|
|
// reason to panic the VAD goroutine.
|
|
if resp == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// 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
|
|
// appendUserItem adds the committed user turn to the conversation and
|
|
// notifies the client, returning the item. Split out of generateResponse so a
|
|
// turn whose response was superseded (barge-in during transcription) can still
|
|
// commit its item — the LLM context must keep the full user input — without
|
|
// generating a response for it (issue #12445).
|
|
func appendUserItem(conv *Conversation, t Transport, utt []byte, transcript string, speaker *types.Speaker) types.MessageItemUnion {
|
|
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,
|
|
})
|
|
return item
|
|
}
|
|
|
|
// generateResponse creates the user message item for a committed turn and
|
|
// triggers the assistant response (LLM + TTS) for it.
|
|
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 := appendUserItem(conv, t, utt, transcript, speaker)
|
|
|
|
announceSpeaker(t, session, item.User.ID, speaker)
|
|
|
|
triggerResponse(ctx, session, conv, t, nil)
|
|
}
|
|
|
|
// announceSpeaker surfaces the recognized speaker of itemID to the client as a
|
|
// conversation.item.speaker event when the pipeline enables identity.announce.
|
|
// It skips the event for an unidentified speaker unless announce_unknown is
|
|
// set, and for a nil speaker (the resolution failed or did not run).
|
|
func announceSpeaker(t Transport, session *Session, itemID string, speaker *types.Speaker) {
|
|
if speaker == nil || session.voiceGate == nil || !session.voiceGate.cfg.AnnounceEnabled() {
|
|
return
|
|
}
|
|
if speaker.Matched || session.voiceGate.cfg.Identity.AnnounceUnknown {
|
|
sendEvent(t, types.ConversationItemSpeakerEvent{
|
|
ItemID: itemID,
|
|
Speaker: *speaker,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// metadata is echoed back on response.created and response.done. It is the
|
|
// only thing tying a terminal event to the response.create that asked for
|
|
// it, which is what lets a client run an out-of-band response alongside the
|
|
// spoken conversation and still recognise its own answer.
|
|
metadata map[string]string
|
|
}
|
|
|
|
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()}
|
|
if overrides != nil {
|
|
r.metadata = overrides.Metadata
|
|
}
|
|
sendEvent(t, types.ResponseCreatedEvent{
|
|
ServerEventBase: types.ServerEventBase{},
|
|
Response: types.Response{
|
|
ID: r.id,
|
|
Object: "realtime.response",
|
|
Status: types.ResponseStatusInProgress,
|
|
Metadata: r.metadata,
|
|
},
|
|
})
|
|
|
|
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,
|
|
Metadata: r.metadata,
|
|
},
|
|
})
|
|
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),
|
|
Metadata: r.metadata,
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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) {
|
|
if session.s2s != nil {
|
|
s2sResponseTurn(ctx, session, conv, t, overrides, toolTurn, r)
|
|
return
|
|
}
|
|
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...)
|
|
}
|
|
|
|
// Classifier mode replaces autoregressive generation for the first turn
|
|
// of a response: prefill-only scoring picks a registered option and its
|
|
// canned reply/tool is emitted through the standard response protocol.
|
|
// Agentic follow-ups (toolTurn > 0) always generate — the option list
|
|
// describes user intents, not tool outputs. This branch must precede the
|
|
// streamed-LLM path below or streaming pipelines would bypass it.
|
|
if cc := resolveClassifier(session.Classifier, overrides); toolTurn == 0 && cc.Active() {
|
|
if classifierRespond(ctx, session, conv, t, r, cc, conversationHistory, overrides, toolTurn) {
|
|
return
|
|
}
|
|
// fallback.mode "generate": fall through to normal generation.
|
|
}
|
|
|
|
// 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 = modalitiesWithAlias(overrides.OutputModalities, overrides.Modalities)
|
|
}
|
|
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 {
|
|
if ctx.Err() != nil {
|
|
// A barge-in cancelled the response: a cancel has its own terminal.
|
|
xlog.Debug("Response cancelled before LLM inference (barge-in)")
|
|
r.outcome = outcomeCancelled
|
|
return
|
|
}
|
|
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 {
|
|
if ctx.Err() != nil {
|
|
// The barge-in landed while the model was generating: the call fails
|
|
// with the context error. Without this the response ended as failed,
|
|
// with an error and no response.done, and the client waited for it.
|
|
xlog.Debug("Response cancelled during LLM inference (barge-in)")
|
|
r.outcome = outcomeCancelled
|
|
return
|
|
}
|
|
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)
|
|
if config.TemplateConfig.UseTokenizerTemplate {
|
|
thinkingStartToken = reasoning.DetectThinkingStartTokenInTemplate(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 <think>…</think>
|
|
// 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 != "" {
|
|
if !emitAssistantMessage(ctx, session, conv, t, r, finalSpeech, overrides) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// emitAssistantMessage appends an assistant item carrying finalSpeech to the
|
|
// conversation and emits the standard response events for it —
|
|
// output_item.added, content_part.added, audio-transcript or output-text
|
|
// deltas, TTS audio via emitSpeech (unless the resolved modalities are
|
|
// text-only), content_part.done and output_item.done. Shared by the buffered
|
|
// generation path and classifier mode. Returns false when the response was
|
|
// cancelled (barge-in) or failed — r.outcome is already recorded and the
|
|
// caller must emit no further items.
|
|
func emitAssistantMessage(ctx context.Context, session *Session, conv *Conversation, t Transport, r *liveResponse, finalSpeech string, overrides *types.ResponseCreateParams) bool {
|
|
// response.created/done are emitted once per response.create by
|
|
// triggerResponse; every turn (including agentic recursion) shares this id.
|
|
responseID := r.id
|
|
{
|
|
// 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 = modalitiesWithAlias(overrides.OutputModalities, overrides.Modalities)
|
|
}
|
|
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 false
|
|
}
|
|
|
|
// 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 false
|
|
}
|
|
xlog.Error("TTS failed", "error", err)
|
|
sendError(t, "tts_error", fmt.Sprintf("TTS generation failed: %v", err), "", item.Assistant.ID)
|
|
r.outcome = outcomeFailed
|
|
return false
|
|
}
|
|
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)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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[:])
|
|
}
|