// Package main is mavend's voice wiring + reactive handler. // // Two responsibilities for the audio path: // // 1. CONSTRUCTION: read cfg.Voice, build the stt/tts/transcribers (Stub // in-process by default, Remote via worker socket when configured), // the router (stage-0 grammar + HashEmbedder classifier seeded with // floor examples — production swaps in the ONNX multilingual model // later), the voice TCP listener, the sessions registry, the // voicesink, and wire the voicesink into the dispatcher's Voice slot. // // 2. HANDLER: a concrete voice.Handler that processes PushToTalk // requests: stt → router → action → replier → tts → reply. The // handler is what makes the audio round-trip "live". It wires to the // CoreAPI in-process (the daemon already has it as ipc.NewStoreAPI(st) // for module-IPC — the reactive path uses the same CoreAPI off the // same store; both are the "core = the only key-holder" path through // the daemon-embedded adapter). // // The "actions" handled today (per spec order; some deferred): // // - IntentFact: WriteFact via CoreAPI. The router's Slots.Key/Value feed // the write; Source = "tap:voice" (the voice path is a tap, value=1.0 // confidence — the user said it out loud, maven trusts the capture). // - IntentReminder: CreateReminder via CoreAPI. The router already // resolved relative→absolute at capture ("in 4h" → fire_ts); the // CoreAPI stores it as-is. // - IntentAct: the tool executor runs the matched fn against the store's // ENABLED allowlist (internal/tool). A verb not on it is scaffolded as a // 'proposed' tool a human enables on the authed mavweb surface (never // voice). Destructive tools run only after a spoken confirm turn. // - IntentNote: chroma/vector-store deferred. The handler replies // "saved" without persisting — a stub on the way to chroma. // - IntentQuery: RAG-over-chroma deferred. The handler replies "I'll // look that up later" — same shape as the other deferred slots. // - Clarify: the router's stage-3 confidence gate fired; reply "didn't // catch that, can you rephrase?" // // The Replier (voice.StubReplier today) renders the reply TEXT across all // these branches. The TTS synthesiser (tts.Stub today) renders that text // to audio. The PushToTalkResp carries BOTH so the client can play (audio) // AND log (text) for tests asserting the round-trip. package main import ( "context" "errors" "fmt" "log" "strings" "sync" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/crawl" "github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/stt" "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/tts" "github.com/kami/maven/internal/ttsnorm" "github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/weather" ) // reactiveHandler — voice.Handler implementation. One method: turn a // PushToTalkReq into a reply (audio + text). The handler is concurrency- // safe (the wired stt/tts/router/api all are); called from per-conn // goroutines on the voice.Server. type reactiveHandler struct { stt stt.Transcriber tts tts.Synthesizer router *router.Router // recall — the note-and-fact recall subsystem: the embedder, the vector // store it writes into, the personal boundary, and the two numbers that // gate an answer. Grouped rather than spread across the handler because a // handler that recalls needs all five and a handler that does not needs // none of them (Vikunja #433, docs/handler-wiring.md). recall recallWiring // api — the CoreAPI the handler reads and writes through. Wired with the // bare store adapter and UPGRADED by main once the daemonAPI exists; see // upgradeAPI. api ipc.CoreAPI tools *tool.Executor matcher *tool.Matcher phraser phraser.Phraser replier voice.Replier now func() time.Time // crawler reads a web page he names out loud (queryWeb). nil ⇒ on-demand // page reading is off, which is the default: no `crawl` block, no fetch. crawler *crawl.Crawler // search asks a self-hosted SearXNG (querySearch), the first world source // once his own data has had its turn. nil ⇒ off, the default: no `search` // block, no query ever leaves the LAN. search *searchWiring // kiwix searches the offline ZIMs (queryKiwix), the fallback behind the // live search and the last source before the model answers from its own // weights. nil ⇒ off, the default. kiwix *kiwixWiring // feedsOn — whether any RSS feed is configured (config.Feeds). It changes // only what she SAYS when asked and nothing is there: "ленты не настроены" // instead of "ничего нового", which are different truths. feedsOn bool // home — the Home Assistant client (Vikunja #256). nil ⇒ the house is not // configured, which is the default: no `smarthome` block, no reads, no // switches. Control does not go through this field — it goes through the // act allowlist and tool.Executor, like every other mutating act. home *homeWiring // netscan — the LAN scanner (Vikunja #257). nil ⇒ off, which is the // default. A scan is a read, so it has no allowlist row; what keeps it // safe is that its range comes from config and from nowhere else. netscan *netWiring weatherProvider weather.Provider weatherLocation string // default location for weather queries dataStore *store.Store // direct store access for event extraction + pattern detection // timeParser — used as a fallback for stage-0 reminder grammar matches // (where the extractor didn't run). Shared with the router's extractor. // The production dateparser will replace StubDateTimeParser here too. timeParser router.DateTimeParser // dialogueSessions carries slots across turns for follow-ups. Keyed by the // reach the turn arrived on (dialogueIDOf), like the clarify store: one // slot per reach, not one for the box. nil ⇒ no carry-over. dialogueSessions *dialogue.SessionStore // decisions holds the last few turns' arbitration records (V-564): who // claimed the turn, who lost it and who was never asked. In memory and // bounded, because a turn record is read minutes later or never, and none // of his words belong in a table that outlives the diagnosis. nil ⇒ nothing // is recorded, which is what a test that did not ask for one gets. decisions *decision.Ring // traces persists those same records (V-629, routingtrace.go). The ring is // still what /trace reads; this is the second sink, and it exists because the // routing heads cannot be fitted without real utterances. nil ⇒ the ring // alone, which is the behaviour every box had before 06-08-2026. traces traceWriter // encoderID names the encoder body live on this box, stored beside each // trace: a fitted distance means nothing under another body. Empty ⇒ no // embedder, so the classifier was the keyword floor. encoderID string // clarifyStore parks the request behind an open question she asked (see // clarify.go). nil ⇒ she falls back to the canned "не поняла" reply. clarifyStore *dialogue.ClarifyStore // clarifyMaxAttempts — questions per request before she gives up out loud. // 0 ⇒ dialogue.DefaultMaxAttempts (3). Set from VoiceConfig. clarifyMaxAttempts int // extractor parses the answer to an open question, with the same parsers // the router's own stage-2 uses. extractor router.Extractor // pending destructive-act confirmation. A destructive act replies with a // "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as // the y/n answer. ponytail: single slot, single-user box — a second act // while one waits overwrites it (last-asked wins); expires after confirmTTL. mu sync.Mutex lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go) pending *pendingAct pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n // surfacedItems — the Praxis item ids she last read out, in the order she // read them, so "отметь второй пункт" has a second pункт to mean (Vikunja // #516). Same single-slot posture as pending above: the next attention digest // replaces the list, because a position only refers to the last one spoken. // No TTL — a stale position resolves to an item that Praxis will report as // already acknowledged, which is a harmless answer, unlike a stale // confirmation that would execute something. surfacedItems []string ecosystem *ecosystemWiring // nexus + hexis + praxis clients } // HandlePushToTalk — the full reactive round-trip. Each step's failure // surfaces as a short reply text + empty audio OR an error; the voice // server translates an error into a wire RpcError. Today the handler // prefers a canned error-reply over an error return (a user-facing "didn't // catch that" is better than a wire error the client surfaces as // "internal"); the only error returned is a synthesizer fault (no audio // to ship back). func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushToTalkReq, _ uint64) (voice.PushToTalkResp, error) { // 1. stt — transcribe the audio. text, _, err := h.stt.Transcribe(ctx, req.Audio) if err != nil { log.Printf("voice: stt error: %v", err) return h.reply(ctx, "не получилось разобрать речь — попробуй ещё раз.", nil) } if text == "" { return h.reply(ctx, "ничего не услышала — попробуй ещё раз.", nil) } log.Printf("voice: stt → %q", text) // 2-5. the shared turn pipeline (confirm → clarify → route → dialogue → // action → replier), identical to the text path. replyText := h.runTurn(ctx, text, sourceVoice) // 6. tts — synthesise the reply text; return to the voice server which // ships it back on the conn. return h.reply(ctx, replyText, nil) } // upgradeAPI points the handler at the daemon's own CoreAPI once main has // built it. // // Wiring order forces this. wireVoice runs before the tick loop exists, so it // can only be handed the bare store adapter — and that adapter answers DayPlan // (and TickTrace, and MorningStatus) with "not available via direct store // API", because a day plan is assembled by the tick loop and is not a table to // read. So queryDayPlan, which the query chain reaches for "какие у меня планы // на сегодня", failed on the deployed daemon for every caller. main already // back-patches the other direction (daemonAPI.chatFn = handler.handleText); // this is the same seam in reverse. // // Safe against the obvious loop: nothing in the voice path calls api.Chat, so // pointing the handler at an API whose Chat IS the handler cannot recurse. func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) { if h == nil || api == nil { return } h.api = api } // handleText — the core reactive path without stt/tts. Used by the IPC Chat // endpoint (and eventually by telegram). Splits out the audio bookends from // HandlePushToTalk so text channels share the same routing logic. func (h *reactiveHandler) handleText(ctx context.Context, conversation, text string) string { log.Printf("voice: handleText: %q", text) return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), text, sourceText) } // turnSource — which channel this utterance arrived on, in the same provenance // vocabulary facts use (internal/event). It is threaded through runTurn because // a turn can write a fact, and a fact that lies about where it came from is // worse than no fact: provenance is the first column read when asking why a // daemon-wide setting is the way it is. type turnSource string const ( sourceVoice turnSource = "tap:voice" // HandlePushToTalk, a real microphone sourceText turnSource = "tap:text" // handleText: mavweb /api/chat, telegram ) // runTurn — the reactive turn pipeline shared by the voice and text entry // points: expired-clarify notice → confirm answer → clarify answer → quiet // toggle → route → dialogue merge → clarify question → action → replier. // Takes the already-transcribed utterance, returns the reply text; the voice // path wraps it in stt/tts, the text path returns it as-is. // // The ordering is load-bearing — see the step comments. func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) (reply string) { // 0. the decision record (V-564). Installed here rather than in the IPC // entry point, so the mic, telegram and the web all leave the same trail — // a record only the web produced would be missing exactly the turns that // are hardest to reproduce. It rides the context, costs a few dozen structs // on a human-rate path, and no claim site can change a route with it. if h.decisions != nil { var rec *decision.Record ctx, rec = decision.With(ctx, text) decision.Expect(ctx, decision.StagePreRoute, preRouteLadder) defer func() { done := rec.Finish(h.now()) h.decisions.Push(done) h.persistDecision(ctx, done, src) }() } // 0b. the turn's routing, computed at most once and shared (Vikunja #560). // The clarify resolver reads it to decide what this utterance IS before // claiming it, and step 5 acts on the same decision — routing twice would // cost a second on the resident model and could disagree with itself. now := h.now() rt := h.newTurnRoute(text, now) ctx = withTurnRoute(ctx, rt) // 1. expired clarify — a question was parked but its TTL ran out, so the // request behind it is gone. Say that out loud (see clarify.go) and carry // on: these words are still routed as a fresh utterance below, with the // notice glued in front of whatever the fresh routing answers. Checked // BEFORE the answer path: reading a parked question drops an expired one. // // Taken before the confirm check, not after, because a confirm turn returns // early. He can be asked a question, walk off, come back and say "да" to a // confirm that is still parked; computing the notice after that return meant // he answered the confirm and never heard that the older request was let go. expiredNotice := h.clarifyExpiredNotice(ctx) // 2. confirm turn — if a destructive act is parked, this utterance is its // y/n answer, not a fresh command. Handled before routing so "да" doesn't // get classified as some other intent. if reply, handled := h.resolveConfirm(ctx, text); notePreRoute(ctx, "confirm", handled) { return withNotice(expiredNotice, reply) } // 3. clarify answer — if she asked a live question last turn, this // utterance is its answer, not a fresh command. After the confirm check: a // y/n gate is armed by her own prompt and is the narrower claim on the // utterance. // A live question and an expired one cannot both exist for one dialogue id, // so the notice is empty here in practice. withNotice anyway: every exit // from runTurn carries it, and that is what stops the next one from // forgetting. if reply, handled := h.resolveClarifyAnswer(ctx, text); notePreRoute(ctx, "clarify-answer", handled) { return withNotice(expiredNotice, reply) } // It did not claim the turn. If it let a parked request go to get out of the // way, that has to be said in front of whatever these words are answered // with — carried on the same notice, so every exit below keeps it. expiredNotice = withNotice(expiredNotice, rt.dropped) // 3b. and if it SUSPENDED a request instead of letting it go, the question // comes back on the end of whatever these words are answered with (Vikunja // #561). A deferred append rather than a call at each exit: there are eight // returns between here and the replier, and the flow has to survive all of // them — one that forgot would be a request parked for ever, waiting for an // answer to a question he never heard asked. defer func() { reply = withResumed(reply, rt.resume) }() // 4. quiet-hours toggle — keyword match, not classifier-dependent. // "тихий режим" / "quiet on" would route through the classifier // unreliably (it's a command, not a free-form query), so we match it // before routing. Same pattern as the confirm turn above. if reply, handled := h.resolveQuietToggle(ctx, text, src); notePreRoute(ctx, "quiet-toggle", handled) { return withNotice(expiredNotice, reply) } // 4b. spoken snooze — "не сейчас" / "потом" answers the nudge she just // sent. Only handled when a pending nudge is actually inside the window // (snooze.go); otherwise the words route normally, because "потом" is an // ordinary word and eating every one of them would break real sentences. if reply, handled := h.resolveSnooze(ctx, text, src); notePreRoute(ctx, "snooze", handled) { return withNotice(expiredNotice, reply) } // 4c. spoken ack — "готово" closes that same nudge as `acted`. Only the // contentless form is intercepted here; "выпил воды" keeps routing and // closes the nudge after its fact lands (ackFromFact, step 8b). if reply, handled := h.resolveAck(ctx, text, src); notePreRoute(ctx, "ack", handled) { return withNotice(expiredNotice, reply) } // 4d. spoken correction — "нет, это была заметка" points at the previous // turn and names what it should have been (repair.go). Before routing, // like the confirm and clarify turns: routing the correction as a fresh // utterance files the correction itself instead of fixing anything. if reply, handled := h.resolveRepair(ctx, text); notePreRoute(ctx, "repair", handled) { return withNotice(expiredNotice, reply) } // 4d-ii. and the same correction without a target — "нет, не так" (V-636). // After the targeted one, which is the narrower claim: an utterance that // names an intent is answered by redoing the request, and this rung only // gets the ones that name nothing. if reply, handled := h.resolveUntargetedRepair(ctx, text); notePreRoute(ctx, "repair-negative", handled) { return withNotice(expiredNotice, reply) } // 4e. ordinal selection — "второй", "первую сделал" pick from the list she // just read (ordinal.go). Before routing, and only when a list is actually // bound to the session: with nothing offered, "второй" is an ordinary word // and keeps routing. if reply, handled := h.resolveCandidate(ctx, text, src); notePreRoute(ctx, "ordinal", handled) { return withNotice(expiredNotice, reply) } // 5. route. An elliptical follow-up — "а завтра?" — is answered from the // previous turn instead (continuation.go): the intent is the part it is // missing, so no amount of routing recovers it, and the model's guess // costs seconds to obtain and is close to a coin flip. Everything else // goes to the router. dec, cont, prev, err := rt.resolve(ctx) if err != nil { // ErrNoIntents ⇒ classifier unseeded (cold boot). reply with a // "still warming up" rather than a wire error. if errors.Is(err, router.ErrNoIntents) { return withNotice(expiredNotice, "я ещё не понимаю свободную речь — скоро научусь.") } log.Printf("voice: router error: %v", err) return withNotice(expiredNotice, "не получилось разобрать команду.") } log.Printf("voice: route result: intent=%s slots=%+v", dec.Intent, dec.Slots) // 6. dialogue — fill this turn's missing slots from a prior same-intent // turn (follow-ups like «напомни завтра» → «…позвонить маме»), then remember // this turn for the next follow-up. Only same-intent, non-expired, non- // clarify turns carry (see followUpMerge). Best-effort: nil store ⇒ skipped. // A continuation already carries the previous turn's slots, so there is // nothing left to inherit — but it is still remembered, so a chain of them // ("а завтра?" … "а послезавтра?") keeps working. if h.dialogueSessions != nil { if !cont { merged := followUpMerge(prev, dec, now) noteMerge(ctx, dec, merged) dec = merged } if !dec.Clarify { h.rememberTurn(ctx, prev, dec, now) } } // 7. clarify — something she needs is missing. If one named thing is missing, // ask about it and park the request (clarify.go); otherwise the replier's // canned reply stands. // // Not gated on dec.Clarify alone (Vikunja #557). A turn the cascade routed // confidently but incompletely skipped this entirely: "напомни позвонить" // reached applyAction, failed on the missing time, parked nothing, and the // "в семь вечера" that followed was web-searched as a world question. A // required slot that missingFor names is a gap whatever the confidence. if dec.Clarify || len(missingFor(dec)) > 0 { if reply := h.hexisBeforeClarify(ctx, dec); reply != "" { return withNotice(expiredNotice, reply) } if question, asked := h.askClarify(ctx, dec); asked { noteTerminal(ctx, "clarify-ask", dec.Intent, "the route was below the threshold, so she asked instead of acting") return withNotice(expiredNotice, question) } } // Remember what this turn was routed as, so the next utterance can correct // it. Only turns she acts on: a clarify asked instead of acting, so there // is nothing yet to be wrong about. if !dec.Clarify { h.recordTurn(text, dec.Intent) } // 8. action — execute the decision's intent. errors here surface as // short reply text (the user wants to know the action didn't land); // the round-trip stays alive. replyText := h.applyAction(ctx, dec) log.Printf("voice: applyAction returned: %q", replyText) // A query turn was already claimed by a source inside the chain; every other // intent has no chain and no scoreboard, so the handler is the winner. noteTerminal(ctx, "action-handler", dec.Intent, "") // 8b. a fact that answers a live nudge closes it as `acted` (ack.go). // Silent: the fact reply stands, she does not congratulate him for it. h.ackFromFact(ctx, dec) // 9. replier — phrase the reply across the router decision. if replyText == "" { replyText = h.replier.Reply(ctx, dec) } return withNotice(expiredNotice, replyText) } // applyAction — executes the router's Decision. Intent-by-intent: // // - IntentFact: WriteFact via CoreAPI. Source = "tap:voice" (a voice // capture is a tap; confidence 1.0). // - IntentReminder: CreateReminder via CoreAPI. // - IntentAct: tool-executor deferred (no-op today; the reply says so). // - IntentNote / IntentQuery: chroma/RAG deferred (no-op; reply says so). // - Clarify: the router's stage-3 fired; no action. // // Returns "" when the Replier should phrase the reply (the default path); // returns a non-empty string when the action path wants to OVERRIDE the // reply text (e.g. an action error the user should hear SPECIFICALLY, not // a generic "ok"). Errors surface as a short reply text the user hears. func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) string { if dec.Clarify { return "" // the Replier phrases clarify } if handler, ok := actionHandlers[dec.Intent]; ok { return handler(h, ctx, dec) } return "" } // replySystem answers system-observable queries using the handler's clock // and (in future) system interfaces. The decision's utterance is parsed // for keywords to determine what the user is asking about. func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision) string { u := strings.ToLower(dec.Utterance) now := h.now() // The topic and the day come from different places on a continuation. // "а завтра?" names the day and nothing else; what he is asking ABOUT // lives in the previous turn, which continuation.go copied into // Slots.Text. Dates keep parsing from the utterance — that is the part // the ellipsis actually restates — and only the keyword match widens. // // Gated on Continued, and that gate is load-bearing. followUpMerge fills // an empty Text from the previous same-intent turn, so without it a plain // "привет" after "какой сегодня день" inherited the old topic and got // answered with the date. Seen on the deployed daemon, 01-08-2026. topic := u if dec.Continued { if t := strings.ToLower(dec.Slots.Text); t != "" && t != u { topic = u + " " + t } } // stage-0 grammars catch the exact time/date patterns, but duration // queries ("сколько времени прошло") bypass the grammar's build filter // and can reach replySystem via the classifier path. Guard against them. if hasDurationWords(u) { return "пока не умею отвечать на этот вопрос." } switch { case strings.Contains(topic, "час") || strings.Contains(topic, "врем"): // "который час в киеве" — she keeps one clock, so any named place gets // the honest answer. Never local time dressed up as the city's. if mentionsUnknownPlace(u) { return onlyLocalTimeReply } return "сейчас " + ruClock(now) case strings.Contains(topic, "день") || strings.Contains(topic, "числ"): // "какое число завтра" — answer for the day the user asked about, // not today. Reuses the router's calendar day-word parser. day := now prefix := "сегодня" if d, ok := router.ParseCalendarDate(u, now); ok { day = d prefix = dayPrefix(now, d) } else if mentionsUnknownDay(u) { // He named a day she cannot work out ("в пятницу", "через неделю"). // Answering today's date here would be the same silent wrong answer // this arm was fixed for, so say what she can do instead. return onlyNearDaysReply } dow := lexicon.Weekday(int(day.Weekday())) month := lexicon.MonthGenitive(int(day.Month())) return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year()) case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"): return "присутствие пока не подключено к голосовому запросу." case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"): return "системная статистика пока не подключена." default: return "пока не умею отвечать на этот вопрос." } } // sessionAsTurn projects a stored session onto the dialogue.Turn shape used in // history lists. Shared by chatHistory and rememberTurn (clarify.go) so the // same session is described the same way in both places. func sessionAsTurn(s *dialogue.Session) dialogue.Turn { return dialogue.Turn{ Intent: s.Intent, Slots: s.Slots, Text: s.Slots.Text, } } // chatHistory collects dialogue turns from the session store for the current // conversation. Returns prior user utterances (newest last) up to a depth of // 4 turns. Returns nil when there's no session or no history. func (h *reactiveHandler) chatHistory(ctx context.Context) []dialogue.Turn { if h.dialogueSessions == nil { return nil } now := h.now() prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now) if prev == nil { return nil } // History already includes the immediate prior turn (set by the dialogue // merge in runTurn's step 6, above), plus up to 3 more from deeper history. out := make([]dialogue.Turn, 0, 1+len(prev.History)) out = append(out, sessionAsTurn(prev)) out = append(out, prev.History...) return out } // reply wraps a text reply through TTS to produce a PushToTalkResp. If TTS // fails, the response carries an empty audio + the text — the client can // still display text if it can't play. The routedChannels field is // reserved for a future "the dispatcher also forwarded to ntfy/telegram" // reply (today the reactive path doesn't dispatch nudges; that's the loop // tick's job). func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (voice.PushToTalkResp, error) { spoken := ttsnorm.Speakable(text) log.Printf("voice: reply → %q", text) audioOut, err := h.tts.Synthesize(ctx, spoken) if err != nil { log.Printf("voice: tts error: %v", err) return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audio.Audio{}}, nil } return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audioOut}, nil }