From b77f2096860577c2ffe285af9480f2af111c0802 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 3 Jul 2026 10:56:44 +0200 Subject: [PATCH] voice: pre-route quiet-hours toggle, whisper ctx cancellation, stale reply fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveQuietToggle runs in HandlePushToTalk before the router so 'тихий режим' works regardless of classifier confidence. - whisper_full() runs in a goroutine with ctx.Done() select so the handler returns promptly on timeout/shutdown. - StubReplier.IntentQuery no longer claims query is unimplemented. --- cmd/mavend/voice.go | 48 +++++++++++++++++++--------------- cmd/mavsttd/whisper_handler.go | 24 ++++++++++++++--- internal/voice/replier.go | 2 +- 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index e5f819b..5529a79 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -269,6 +269,14 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo return h.reply(ctx, reply, nil) } + // 1c. 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); handled { + return h.reply(ctx, reply, nil) + } + // 2. router — classify the utterance. dec, err := h.router.Route(ctx, text, h.now()) if err != nil { @@ -379,11 +387,6 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) return "готово." case router.IntentSystem: - // Quiet-hours toggle — "quiet on" / "тихий режим" — writes - // a config fact the gate reads. Check before the query-only path. - if reply := h.handleQuietToggle(ctx, dec); reply != "" { - return reply - } // System-status queries return to the Replier for phrasing. // The handler emits the current answer inline (no DB / RAG needed). return h.replySystem(ctx, dec) @@ -468,29 +471,32 @@ func ruPlural(n int, one, two, many string) string { } } -// handleQuietToggle — checks if the utterance toggles quiet hours. -// Writes a `quiet_hours` config fact (value "true"/"false") so the loop -// gate reads it next tick. Returns a reply text, or "" if no match. -func (h *reactiveHandler) handleQuietToggle(ctx context.Context, dec router.Decision) string { - u := strings.ToLower(dec.Utterance) - // Match: "quiet on" / "quiet off" / "тихий режим" / "не беспокоить" etc. +// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when +// the utterance is a quiet-on/off command; ("", false) otherwise. Called from +// HandlePushToTalk BEFORE the router so a classifier miscue can't drop it. +func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) { + u := strings.ToLower(strings.TrimSpace(text)) var on, off bool - for _, kw := range []string{"quiet on", "quiet mode", "тихий режим", "не беспокоить", "не шуми"} { + // Match as whole-token phrases so "тихий" in "тихий режим включи" still + // catches, but "тихий" alone in "очень тихий сегодня день" doesn't fire. + // The confirm turn is handled above, so "да"/"нет" won't reach here. + for _, kw := range []string{"quiet on", "quiet mode", "тихий режим", "тихий", "не шуми", "не беспокоить", "тихо"} { if strings.Contains(u, kw) { on = true break } } - for _, kw := range []string{"quiet off", "quiet end", "выключи тихий", "отключи тихий", "шумный режим"} { - if strings.Contains(u, kw) { - off = true - break + if !on { + for _, kw := range []string{"quiet off", "quiet end", "громкий режим", "шумный режим", "отмени тихий", "выключи тихий", "не тихо"} { + if strings.Contains(u, kw) { + off = true + break + } } } if !on && !off { - return "" + return "", false } - now := h.now() val := "false" reply := "тихий режим выключен." if on { @@ -498,7 +504,7 @@ func (h *reactiveHandler) handleQuietToggle(ctx context.Context, dec router.Deci reply = "тихий режим включён. буду реже напоминать." } if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{ - Ts: now, + Ts: h.now(), Kind: "config", Key: "quiet_hours", Value: val, @@ -506,9 +512,9 @@ func (h *reactiveHandler) handleQuietToggle(ctx context.Context, dec router.Deci Confidence: 1.0, }); err != nil { log.Printf("voice: write quiet_hours: %v", err) - return "не получилось переключить тихий режим." + return "не получилось переключить тихий режим.", true } - return reply + return reply, true } // replySystem answers system-observable queries using the handler's clock diff --git a/cmd/mavsttd/whisper_handler.go b/cmd/mavsttd/whisper_handler.go index 2ed64cb..d23fda8 100644 --- a/cmd/mavsttd/whisper_handler.go +++ b/cmd/mavsttd/whisper_handler.go @@ -34,6 +34,9 @@ func newWhisperHandler(modelPath string) (*whisperHandler, error) { } func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeReq) (worker.TranscribeResp, error) { + if err := ctx.Err(); err != nil { + return worker.TranscribeResp{}, fmt.Errorf("whisper: context done before transcribe: %w", err) + } a := req.Audio if len(a.Bytes) == 0 { return worker.TranscribeResp{}, fmt.Errorf("whisper: empty audio") @@ -59,10 +62,25 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe params.language = lang params.detect_language = false + // CGo blocks the goroutine; whisper has no portable CGo-friendly abort + // callback. Run in a goroutine so the caller's context cancellation at + // least returns promptly — the CGo goroutine leaks until whisper finishes + // but the caller doesn't hang. + type result struct { + code int + } + ch := make(chan result, 1) cSamples := (*C.float)(unsafe.Pointer(&samples[0])) - res := C.whisper_full(h.ctx, params, cSamples, C.int(nSamples)) - if res != 0 { - return worker.TranscribeResp{}, fmt.Errorf("whisper: full failed: %d", int(res)) + go func() { + ch <- result{code: int(C.whisper_full(h.ctx, params, cSamples, C.int(nSamples)))} + }() + select { + case r := <-ch: + if r.code != 0 { + return worker.TranscribeResp{}, fmt.Errorf("whisper: full failed: %d", r.code) + } + case <-ctx.Done(): + return worker.TranscribeResp{}, fmt.Errorf("whisper: %w", ctx.Err()) } nSegments := int(C.whisper_full_n_segments(h.ctx)) diff --git a/internal/voice/replier.go b/internal/voice/replier.go index 1a12618..cf4bb46 100644 --- a/internal/voice/replier.go +++ b/internal/voice/replier.go @@ -77,7 +77,7 @@ func (s *StubReplier) Reply(d router.Decision) string { case router.IntentNote: return "сохранила заметку." case router.IntentQuery: - return "это пока не подключено — чтение из памяти появится позже." + return "поискала в заметках — ничего не нашла." default: return "приняла." }