From e1a5431c231a5de37acdef3cf4486b1e31008689 Mon Sep 17 00:00:00 2001 From: ryysel Date: Sun, 1 Nov 2026 05:37:41 +0300 Subject: [PATCH 1/9] agent: run a command on assistant state changes --- README.md | 1 + cmd/marusya-agent/main.go | 6 + configs/agent.conf.d.example | 3 + internal/indicator/statecmd/statecmd.go | 87 +++++++++++++ internal/indicator/statecmd/statecmd_test.go | 121 +++++++++++++++++++ 5 files changed, 218 insertions(+) create mode 100644 internal/indicator/statecmd/statecmd.go create mode 100644 internal/indicator/statecmd/statecmd_test.go diff --git a/README.md b/README.md index 6d47f3e..b7b2f69 100644 --- a/README.md +++ b/README.md @@ -551,6 +551,7 @@ dbus-monitor --system "sender='ru.marusya.Agent'" | `-display_rotate` | перевернуть мордашку на экране из `clock-led1…30` (сборки на стоковом ядре) | | `-ring_dev` | spidev-узел кольца, по умолчанию `/dev/spidev0.0`; кадры пишутся прямо в него, 30 к/с | | `-ring_demo` | показать все анимации кольца по очереди и выйти (остановите сервис перед запуском) | +| `-state_command` | команда, которой передаётся состояние ассистента последним аргументом: `idle`, `listening`, `thinking`, `speaking`, `error`, `disconnected`, `announcing`. Для колонок, где кольцом или экраном владеет другой процесс: агент сообщает состояние, процесс рисует. Повторы пропускаются, команда не блокирует | | `-follow_up`, `-duck_media`, `-earcons` | поведение диалога | ### Ключевое слово diff --git a/cmd/marusya-agent/main.go b/cmd/marusya-agent/main.go index ea93939..e58e20f 100644 --- a/cmd/marusya-agent/main.go +++ b/cmd/marusya-agent/main.go @@ -58,6 +58,7 @@ import ( "github.com/yawaflua/marusya-gemini/internal/indicator" "github.com/yawaflua/marusya-gemini/internal/indicator/face" "github.com/yawaflua/marusya-gemini/internal/indicator/ringled" + "github.com/yawaflua/marusya-gemini/internal/indicator/statecmd" "github.com/yawaflua/marusya-gemini/internal/logging" "github.com/yawaflua/marusya-gemini/internal/media" "github.com/yawaflua/marusya-gemini/internal/models" @@ -131,6 +132,7 @@ var ( ringDev = flag.String("ring_dev", config.Env("MARUSYA_RING_DEV", ringled.DefaultDevice), "spidev node of the LED ring.") ringledBrightness = flag.Int("ringled_brightness", 30, "LED ring brightness, per cent.") ringDemo = flag.Bool("ring_demo", false, "Play every LED ring animation once and exit.") + stateCommand = flag.String("state_command", config.Env("MARUSYA_STATE_COMMAND", ""), "Run this command with the assistant state (idle, listening, thinking, speaking, error, disconnected, announcing) on every change, e.g. to drive a ring owned by another process.") logLevel = flag.String("log_level", config.Env("MARUSYA_LOG_LEVEL", "info"), "Log level: debug, info, warn, error.") ) @@ -307,6 +309,10 @@ func run() error { dispatcher.Sensors = surf } indicators = append(indicators, surf) + if *stateCommand != "" { + indicators = append(indicators, &statecmd.Indicator{Command: strings.Fields(*stateCommand), Logger: log}) + log.Info("state command enabled", "cmd", *stateCommand) + } go surf.Run(ctx) if *dbusService { go serveDBus(ctx, surf, log) diff --git a/configs/agent.conf.d.example b/configs/agent.conf.d.example index 412d79a..550c686 100644 --- a/configs/agent.conf.d.example +++ b/configs/agent.conf.d.example @@ -16,6 +16,9 @@ MARUSYA_TIMEZONE= MARUSYA_CAPTURE_DEVICE=default MARUSYA_PLAYBACK_DEVICE=default MARUSYA_MIXER_CONTROL=Master +# Run this command with the assistant state on every change, e.g. to +# drive an LED ring owned by another process (see indicator/statecmd). +MARUSYA_STATE_COMMAND= # External volume program, called as ` get`, ` set <0..100>` # and ` mute on|off`. Takes precedence over MARUSYA_MIXER_CONTROL. # See configs/volume-capsule-ipc.example.sh. diff --git a/internal/indicator/statecmd/statecmd.go b/internal/indicator/statecmd/statecmd.go new file mode 100644 index 0000000..1fe614c --- /dev/null +++ b/internal/indicator/statecmd/statecmd.go @@ -0,0 +1,87 @@ +// Copyright 2026 The Marusya-Gemini Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package statecmd shows the assistant state by running an external command. +// It is for devices whose ring or display belongs to another process: the +// agent reports the state, that process draws it. +// +// The state is appended as the last argument: idle, listening, thinking, +// speaking, error, disconnected or announcing. Repeated states are skipped. +package statecmd + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/yawaflua/marusya-gemini/internal/indicator" + "github.com/yawaflua/marusya-gemini/internal/syscmd" +) + +// timeout bounds one run: a slow or hung command must not delay the next +// state for long. +const timeout = 3 * time.Second + +// Indicator runs a command on every state change. +type Indicator struct { + // Command is the program and its fixed arguments; the state is appended. + Command []string + // Runner executes the command. Defaults to [syscmd.Exec]. + Runner syscmd.Runner + // Logger reports failed runs. Nil discards them. + Logger *slog.Logger + + // mu guards the last shown state. + mu sync.Mutex + last indicator.State + seen bool + // runMu keeps runs in order, so a quick listening -> thinking sequence + // cannot arrive reversed. + runMu sync.Mutex +} + +var _ indicator.Indicator = (*Indicator)(nil) + +// Show implements [indicator.Indicator]. It returns at once and runs the +// command in the background. +func (i *Indicator) Show(s indicator.State) { + if len(i.Command) == 0 { + return + } + i.mu.Lock() + if i.seen && i.last == s { + i.mu.Unlock() + return + } + i.last, i.seen = s, true + i.mu.Unlock() + go i.run(s) +} + +func (i *Indicator) run(s indicator.State) { + i.runMu.Lock() + defer i.runMu.Unlock() + + r := i.Runner + if r == nil { + r = syscmd.Exec{} + } + args := append(append([]string{}, i.Command[1:]...), s.String()) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if _, err := r.Run(ctx, i.Command[0], args...); err != nil && i.Logger != nil { + i.Logger.Warn("state command failed", "state", s.String(), "err", err) + } +} diff --git a/internal/indicator/statecmd/statecmd_test.go b/internal/indicator/statecmd/statecmd_test.go new file mode 100644 index 0000000..46c64fd --- /dev/null +++ b/internal/indicator/statecmd/statecmd_test.go @@ -0,0 +1,121 @@ +// Copyright 2026 The Marusya-Gemini Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package statecmd + +import ( + "context" + "testing" + "time" + + "github.com/yawaflua/marusya-gemini/internal/indicator" +) + +type call struct { + name string + args []string +} + +type fakeRunner struct { + calls chan call +} + +func newFake() *fakeRunner { return &fakeRunner{calls: make(chan call, 8)} } + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) (string, error) { + f.calls <- call{name: name, args: args} + return "", nil +} + +func waitCall(t *testing.T, f *fakeRunner) call { + t.Helper() + select { + case c := <-f.calls: + return c + case <-time.After(2 * time.Second): + t.Fatal("no command run") + return call{} + } +} + +func noCall(t *testing.T, f *fakeRunner) { + t.Helper() + select { + case c := <-f.calls: + t.Fatalf("unexpected run: %+v", c) + case <-time.After(100 * time.Millisecond): + } +} + +func TestShowRunsCommandWithState(t *testing.T) { + r := newFake() + i := &Indicator{Command: []string{"ring", "--quiet"}, Runner: r} + + i.Show(indicator.Listening) + if c := waitCall(t, r); c.name != "ring" || len(c.args) != 2 || c.args[0] != "--quiet" || c.args[1] != "listening" { + t.Fatalf("ran %+v, want ring --quiet listening", c) + } + + i.Show(indicator.Thinking) + if c := waitCall(t, r); c.args[1] != "thinking" { + t.Fatalf("ran %+v, want thinking", c) + } + + i.Show(indicator.Idle) + if c := waitCall(t, r); c.args[1] != "idle" { + t.Fatalf("ran %+v, want idle", c) + } +} + +func TestShowSkipsRepeatedState(t *testing.T) { + r := newFake() + i := &Indicator{Command: []string{"ring"}, Runner: r} + + i.Show(indicator.Listening) + waitCall(t, r) + i.Show(indicator.Listening) + noCall(t, r) + + i.Show(indicator.Thinking) + if c := waitCall(t, r); c.args[0] != "thinking" { + t.Fatalf("ran %+v, want thinking", c) + } +} + +func TestShowWithoutCommand(t *testing.T) { + r := newFake() + i := &Indicator{Runner: r} + i.Show(indicator.Listening) + noCall(t, r) +} + +func TestShowDoesNotBlock(t *testing.T) { + r := newFake() + i := &Indicator{Command: []string{"ring"}, Runner: r} + block := make(chan struct{}) + defer close(block) + go func() { + <-block + }() + done := make(chan struct{}) + go func() { + i.Show(indicator.Speaking) + close(done) + }() + select { + case <-done: + case <-time.After(100 * time.Millisecond): + t.Fatal("Show blocked") + } +} From 9a4c28e6cef23d937dc023cf760fb750bccd7eb3 Mon Sep 17 00:00:00 2001 From: ryysel Date: Sun, 1 Nov 2026 22:53:38 +0300 Subject: [PATCH 2/9] openaichat: extend the fast path with dates, scenes, brightness and relative volume --- internal/provider/openaichat/fastpath.go | 224 +++++++++++++++++- .../openaichat/fastpath_phrases_test.go | 107 +++++++++ 2 files changed, 321 insertions(+), 10 deletions(-) create mode 100644 internal/provider/openaichat/fastpath_phrases_test.go diff --git a/internal/provider/openaichat/fastpath.go b/internal/provider/openaichat/fastpath.go index 5ebe950..44393a0 100644 --- a/internal/provider/openaichat/fastpath.go +++ b/internal/provider/openaichat/fastpath.go @@ -21,6 +21,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/yawaflua/marusya-gemini/internal/provider" "github.com/yawaflua/marusya-gemini/internal/provider/pipeline" @@ -33,12 +34,12 @@ import ( var ( // Go's \b is ASCII-only, so Cyrillic commands need an explicit // separator class instead of word boundaries. - commandRE = regexp.MustCompile(`(?i)^(сделай|поставь|включи|выключи|отключи|убери|верни|убавь|прибавь|увеличь|уменьши|громче|погромче|тише|потише|громкость|следующий|следующую|предыдущий|предыдущую|пауза|паузу|продолжи|продолжай|играй|дальше|назад|стоп|next|prev|play)(?:[\s,.:;!?-]|$)`) + commandRE = regexp.MustCompile(`(?i)^(сделай|поставь|установи|включи|выключи|отключи|убери|верни|убавь|прибавь|увеличь|уменьши|громче|погромче|тише|потише|громкость|яркость|следующий|следующую|предыдущий|предыдущую|пауза|паузу|продолжи|продолжай|играй|дальше|назад|стоп|запусти|активируй|выполни|без|next|prev|play)(?:[\s,.:;!?-]|$)`) volumeSetRE = regexp.MustCompile(`(?i)громкость\s+(?:на\s+)?(\d{1,3})`) volumeUpRE = regexp.MustCompile(`(?i)(громче|погромче|прибавь|увеличь)`) volumeDownRE = regexp.MustCompile(`(?i)(тише|потише|убавь|уменьши)`) - muteOnRE = regexp.MustCompile(`(?i)(выключи|отключи|убери)\s+(звук|громкость)`) + muteOnRE = regexp.MustCompile(`(?i)(выключи|отключи|убери)\s+(звук|громкость)|без\s+звука`) muteOffRE = regexp.MustCompile(`(?i)(включи|верни)\s+(звук|громкость)`) mediaNextRE = regexp.MustCompile(`(?i)((следующий|следующую|следующая)\s+(трек|песн)\w*|дальше|next)`) @@ -51,6 +52,22 @@ var ( homeOnRE = regexp.MustCompile(`(?i)^(включи|вруби|зажги)\s+(.+)`) homeOffRE = regexp.MustCompile(`(?i)^(выключи|отключи|погаси)\s+(.+)`) + + // Relative volume: "сделай громче на 20", "тише на 10". + volumeDeltaRE = regexp.MustCompile(`(?i)(?:на|плюс|\+)\s*(\d{1,3})`) + + // Date and weekday: the time skill already returns both. + dateRE = regexp.MustCompile(`(?i)(какое\s+(сегодня\s+)?число|какая\s+(сегодня\s+)?дата|какой\s+(сегодня\s+)?день(?:\s+недели)?)`) + + // Brightness: "яркость в зале 50", "поставь яркость торшера 50". + brightnessPlaceRE = regexp.MustCompile(`(?i)^(?:поставь|установи|сделай|включи)?\s*яркость\s+(?:на\s+)?(\d{1,3})(?:\s*(?:%|процент\w*))?(?:\s+(?:в|на|у)\s+(.+))?$`) + brightnessDeviceRE = regexp.MustCompile(`(?i)^(?:поставь|установи|сделай|включи)?\s*яркость\s+(.+?)\s+(?:на\s+)?(\d{1,3})(?:\s*(?:%|процент\w*))?$`) + + // Room first: "яркость в зале 40" ("яркость на кухне 30"). + brightnessRoomRE = regexp.MustCompile(`(?i)^(?:поставь|установи|сделай|включи)?\s*яркость\s+(?:в|на|у)\s+(.+?)\s+(?:на\s+)?(\d{1,3})(?:\s*(?:%|процент\w*))?$`) + + // Scenes: "включи сцену Спокойной ночи". + sceneRE = regexp.MustCompile(`(?i)^(запусти|включи|активируй|выполни)\s+сцену\s+(.+)$`) ) var conditionsRU = map[string]string{ @@ -84,12 +101,7 @@ var conditionsRU = map[string]string{ // the same as for a model-issued call. func (c *conversation) fastPath(ctx context.Context, text string, out *pipeline.Output) (message, bool) { t := strings.ToLower(strings.TrimSpace(text)) - // Speech recognition sometimes keeps the wake word in the transcript. - for _, w := range []string{"маруся", "алиса"} { - if strings.HasPrefix(t, w) { - t = strings.TrimLeft(strings.TrimPrefix(t, w), " ,.!?-") - } - } + t = stripWakeWord(t) if t == "" { return message{}, false } @@ -124,6 +136,19 @@ func (c *conversation) fastPath(ctx context.Context, text string, out *pipeline. return message{Role: "assistant", Content: reply}, true } + if dateRE.MatchString(t) { + res, fail, ok := c.callTool(ctx, "time_now", map[string]any{}, out) + if !ok { + return message{}, true + } + reply := fail + if reply == "" { + reply = dateReply(res) + } + out.Say(reply) + return message{Role: "assistant", Content: reply}, true + } + if !commandRE.MatchString(t) { return message{}, false } @@ -145,10 +170,10 @@ func (c *conversation) fastPath(ctx context.Context, text string, out *pipeline. call.Name, call.Args = "volume_mute", map[string]any{"muted": false} reply = "Звук включён." case volumeUpRE.MatchString(t): - call.Name, call.Args = "volume_adjust", map[string]any{"delta": 10} + call.Name, call.Args = "volume_adjust", map[string]any{"delta": volumeDelta(t)} reply = "Сделала громче." case volumeDownRE.MatchString(t): - call.Name, call.Args = "volume_adjust", map[string]any{"delta": -10} + call.Name, call.Args = "volume_adjust", map[string]any{"delta": -volumeDelta(t)} reply = "Сделала тише." case mediaNextRE.MatchString(t): call.Name, call.Args = "media_control", map[string]any{"operation": "next"} @@ -163,6 +188,12 @@ func (c *conversation) fastPath(ctx context.Context, text string, out *pipeline. call.Name, call.Args = "media_control", map[string]any{"operation": "play"} reply = "Продолжаю." default: + if msg, ok := c.sceneFastPath(ctx, t, out); ok { + return msg, true + } + if msg, ok := c.brightnessFastPath(ctx, t, out); ok { + return msg, true + } if msg, ok := c.homeFastPath(ctx, t, out); ok { return msg, true } @@ -347,3 +378,176 @@ func deviceName(d map[string]any) string { } return name } + +// stripWakeWord removes a leading wake word that speech recognition kept in +// the transcript. +func stripWakeWord(t string) string { + for _, w := range []string{"маруся"} { + if strings.HasPrefix(t, w) { + return strings.TrimLeft(strings.TrimPrefix(t, w), " ,.!?-") + } + } + return t +} + +// volumeDelta reads "на 20" from a relative volume command; the default step +// is ten, and the step is capped so one phrase cannot jump the whole range. +func volumeDelta(t string) int { + m := volumeDeltaRE.FindStringSubmatch(t) + if m == nil { + return 10 + } + d, err := strconv.Atoi(m[1]) + if err != nil || d <= 0 { + return 10 + } + return min(d, 50) +} + +var monthsRU = [...]string{"января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"} + +var weekdaysRU = map[string]string{ + "Monday": "понедельник", "Tuesday": "вторник", "Wednesday": "среда", + "Thursday": "четверг", "Friday": "пятница", "Saturday": "суббота", "Sunday": "воскресенье", +} + +// dateReply answers "какое сегодня число" and "какой сегодня день" from the +// time skill, which already returns the date and the weekday. +func dateReply(res map[string]any) string { + date, _ := res["date"].(string) + t, err := time.Parse("2006-01-02", date) + if err != nil { + return "Не получилось узнать дату." + } + day := fmt.Sprintf("%d %s", t.Day(), monthsRU[t.Month()-1]) + weekday, _ := res["weekday"].(string) + if w := weekdaysRU[weekday]; w != "" { + return fmt.Sprintf("Сегодня %s, %s.", w, day) + } + return "Сегодня " + day + "." +} + +// sceneFastPath runs a Yandex Smart Home scene by name, e.g. "включи сцену +// Спокойной ночи". +func (c *conversation) sceneFastPath(ctx context.Context, t string, out *pipeline.Output) (message, bool) { + m := sceneRE.FindStringSubmatch(t) + if m == nil { + return message{}, false + } + query := strings.TrimSuffix(strings.TrimSpace(m[2]), " пожалуйста") + if query == "" { + return message{}, false + } + res, fail, ok := c.callTool(ctx, "yandex_home_find", map[string]any{"query": query}, out) + if !ok { + return message{}, true + } + if fail != "" { + out.Say(fail) + return message{Role: "assistant", Content: fail}, true + } + var found map[string]any + for _, sc := range scenarioList(res) { + name, _ := sc["name"].(string) + if strings.Contains(strings.ToLower(name), strings.ToLower(query)) { + found = sc + break + } + } + if found == nil { + reply := "Не нашла такую сцену." + out.Say(reply) + return message{Role: "assistant", Content: reply}, true + } + id, _ := found["id"].(string) + name, _ := found["name"].(string) + _, fail, ok = c.callTool(ctx, "yandex_home_scene", map[string]any{"id": id}, out) + if !ok { + return message{}, true + } + reply := fmt.Sprintf("Запустила сцену «%s».", name) + if fail != "" { + reply = fail + } + out.Say(reply) + return message{Role: "assistant", Content: reply}, true +} + +// brightnessFastPath sets a light's brightness, e.g. "яркость в зале 50" or +// "поставь яркость торшера 50". Without a device or a room it stays with the +// model, which can ask a question. +func (c *conversation) brightnessFastPath(ctx context.Context, t string, out *pipeline.Output) (message, bool) { + level := 0 + query := "" + switch { + case brightnessRoomRE.MatchString(t): + m := brightnessRoomRE.FindStringSubmatch(t) + query = strings.TrimSpace(m[1]) + level, _ = strconv.Atoi(m[2]) + case brightnessDeviceRE.MatchString(t): + m := brightnessDeviceRE.FindStringSubmatch(t) + query = strings.TrimSpace(m[1]) + level, _ = strconv.Atoi(m[2]) + case brightnessPlaceRE.MatchString(t): + m := brightnessPlaceRE.FindStringSubmatch(t) + level, _ = strconv.Atoi(m[1]) + query = strings.TrimSpace(m[2]) + default: + return message{}, false + } + query = strings.TrimSuffix(query, " пожалуйста") + if query == "" { + return message{}, false + } + level = max(1, min(100, level)) + res, fail, ok := c.callTool(ctx, "yandex_home_find", map[string]any{"query": query}, out) + if !ok { + return message{}, true + } + if fail != "" { + out.Say(fail) + return message{Role: "assistant", Content: fail}, true + } + devices := deviceList(res) + if len(devices) == 0 { + reply := "Не нашла такое устройство." + out.Say(reply) + return message{Role: "assistant", Content: reply}, true + } + if len(devices) > 1 { + names := make([]string, 0, min(len(devices), 5)) + for _, d := range devices[:min(len(devices), 5)] { + names = append(names, deviceName(d)) + } + reply := "Нашла несколько: " + strings.Join(names, ", ") + ". Уточни комнату." + out.Say(reply) + return message{Role: "assistant", Content: reply}, true + } + id, _ := devices[0]["id"].(string) + _, fail, ok = c.callTool(ctx, "yandex_home_control", map[string]any{"id": id, "action": "set_brightness", "value": level}, out) + if !ok { + return message{}, true + } + reply := fmt.Sprintf("Яркость %d процентов.", level) + if fail != "" { + reply = fail + } + out.Say(reply) + return message{Role: "assistant", Content: reply}, true +} + +func scenarioList(res map[string]any) []map[string]any { + switch s := res["scenarios"].(type) { + case []any: + out := make([]map[string]any, 0, len(s)) + for _, v := range s { + if m, ok := v.(map[string]any); ok { + out = append(out, m) + } + } + return out + case []map[string]any: + return s + } + return nil +} diff --git a/internal/provider/openaichat/fastpath_phrases_test.go b/internal/provider/openaichat/fastpath_phrases_test.go new file mode 100644 index 0000000..7ea5dd5 --- /dev/null +++ b/internal/provider/openaichat/fastpath_phrases_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 The Marusya-Gemini Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package openaichat + +import ( + "strings" + "testing" +) + +func TestFastPathRegexPhrases(t *testing.T) { + cases := []string{ + "Включи сцену Спокойной ночи", + "Поставь яркость торшера 50", + "Запусти сцену Утро", + "Без звука", + } + for _, c := range cases { + if !commandRE.MatchString(strings.ToLower(c)) { + t.Errorf("commandRE does not match %q", c) + } + } +} + +func TestDateRE(t *testing.T) { + for _, c := range []string{"какое сегодня число", "какая дата", "какой сегодня день", "какой день недели", "какое число"} { + if !dateRE.MatchString(c) { + t.Errorf("dateRE does not match %q", c) + } + } + for _, c := range []string{"какая погода", "включи свет", "сколько времени"} { + if dateRE.MatchString(c) { + t.Errorf("dateRE matches %q", c) + } + } +} + +func TestBrightnessRE(t *testing.T) { + m := brightnessDeviceRE.FindStringSubmatch("поставь яркость торшера 50") + if m == nil || m[1] != "торшера" || m[2] != "50" { + t.Fatalf("brightnessDeviceRE = %v", m) + } + m = brightnessRoomRE.FindStringSubmatch("яркость в зале 40") + if m == nil || m[1] != "зале" || m[2] != "40" { + t.Fatalf("brightnessRoomRE = %v", m) + } + m = brightnessPlaceRE.FindStringSubmatch("яркость 30") + if m == nil || m[1] != "30" || m[2] != "" { + t.Fatalf("brightnessPlaceRE без комнаты = %v", m) + } +} + +func TestSceneRE(t *testing.T) { + m := sceneRE.FindStringSubmatch("включи сцену Спокойной ночи") + if m == nil || m[2] != "Спокойной ночи" { + t.Fatalf("sceneRE = %v", m) + } + if sceneRE.MatchString("включи свет") { + t.Fatal("sceneRE matches «включи свет»") + } +} + +func TestVolumeDelta(t *testing.T) { + cases := map[string]int{ + "сделай громче": 10, + "сделай громче на 20": 20, + "тише на 5": 5, + "прибавь на 0": 10, + "громче на 100": 50, + "убавь на 30 процентов": 30, + } + for text, want := range cases { + if got := volumeDelta(text); got != want { + t.Errorf("volumeDelta(%q) = %d, want %d", text, got, want) + } + } +} + +func TestStripWakeWord(t *testing.T) { + if got := stripWakeWord("маруся, который час"); got != "который час" { + t.Errorf("stripWakeWord = %q", got) + } + if got := stripWakeWord("алиса, который час"); got != "алиса, который час" { + t.Errorf("stripWakeWord must keep «алиса», got %q", got) + } +} + +func TestDateReply(t *testing.T) { + got := dateReply(map[string]any{"date": "2026-11-01", "weekday": "Saturday"}) + if got != "Сегодня суббота, 1 ноября." { + t.Errorf("dateReply = %q", got) + } + if got := dateReply(map[string]any{"date": "bad"}); got != "Не получилось узнать дату." { + t.Errorf("dateReply(bad) = %q", got) + } +} From b1845c84b6d0f8a5ed6f998136849a6004f58d57 Mon Sep 17 00:00:00 2001 From: ryysel Date: Sun, 1 Nov 2026 22:56:47 +0300 Subject: [PATCH 3/9] wake: decode audio off the event loop and allow a longer ping timeout --- deploy/wake/wake_server.py | 55 +++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/deploy/wake/wake_server.py b/deploy/wake/wake_server.py index 36af6e4..b9faa7a 100644 --- a/deploy/wake/wake_server.py +++ b/deploy/wake/wake_server.py @@ -405,11 +405,25 @@ def build(keywords, rate): return spotters, engines, unsupported +def feed_all(spotters, pcm, now, emit): + """Feeds one audio frame to every spotter. + + Runs on a worker thread: a Vosk decode of a busy box can take tens of + milliseconds, and blocking the event loop stalls the keepalive ping/pong + until the client drops the stream. + """ + for spotter in spotters: + spotter.feed(pcm, now, emit) + + async def handler(ws): loop = asyncio.get_running_loop() peer = ws.remote_address spotters = [] last = -COOLDOWN + # One worker per connection: frames stay in order, and a slow verify in + # vosk_pool cannot delay the audio path. + feed_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="wake-feed") def emit(keyword, engine, score, at): """Reports a keyword heard in the audio received at time at.""" @@ -430,30 +444,33 @@ def send(): loop.call_soon_threadsafe(send) - async for msg in ws: - if isinstance(msg, str): - try: - cfg = json.loads(msg) - keywords = [str(k).strip().lower() for k in cfg.get("keywords", []) if str(k).strip()] - rate = int(cfg.get("sample_rate", 16000)) - spotters, engines, unsupported = await loop.run_in_executor(None, build, keywords, rate) - except Exception as e: # noqa: BLE001 - reported to the server - log.exception("configure") - await ws.send(json.dumps({"error": str(e)})) - return - log.info("%s: keywords %s", peer, engines) - await ws.send(json.dumps({"ready": True, "engines": engines, "unsupported": unsupported}, - ensure_ascii=False)) - continue - now = time.monotonic() - for s in spotters: - s.feed(msg, now, emit) + try: + async for msg in ws: + if isinstance(msg, str): + try: + cfg = json.loads(msg) + keywords = [str(k).strip().lower() for k in cfg.get("keywords", []) if str(k).strip()] + rate = int(cfg.get("sample_rate", 16000)) + spotters, engines, unsupported = await loop.run_in_executor(None, build, keywords, rate) + except Exception as e: # noqa: BLE001 - reported to the server + log.exception("configure") + await ws.send(json.dumps({"error": str(e)})) + return + log.info("%s: keywords %s", peer, engines) + await ws.send(json.dumps({"ready": True, "engines": engines, "unsupported": unsupported}, + ensure_ascii=False)) + continue + now = time.monotonic() + await loop.run_in_executor(feed_pool, feed_all, spotters, msg, now, emit) + finally: + feed_pool.shutdown(wait=False, cancel_futures=True) async def main(): host, _, port = os.environ.get("WAKE_LISTEN", "0.0.0.0:2700").rpartition(":") vosk_model() # fail fast on a broken image - async with serve(handler, host or "0.0.0.0", int(port), max_size=1 << 20): + async with serve(handler, host or "0.0.0.0", int(port), max_size=1 << 20, + ping_interval=20, ping_timeout=60): log.info("listening on %s:%s", host, port) await asyncio.get_running_loop().create_future() From 398ca40a95c81f84313158613da60d7b3b088fb4 Mon Sep 17 00:00:00 2001 From: ryysel Date: Sun, 1 Nov 2026 23:18:55 +0300 Subject: [PATCH 4/9] wake: keep the audio stream when a single write is slow --- internal/wake/wake.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/wake/wake.go b/internal/wake/wake.go index 567117c..43ca7b4 100644 --- a/internal/wake/wake.go +++ b/internal/wake/wake.go @@ -109,10 +109,13 @@ func (s *Stream) Configure(keywords []string, sampleRate int) error { return s.ws.Write(ctx, websocket.MessageText, b) } -// Write sends a chunk of audio. It gives up quickly rather than delaying the -// caller: the spotter only needs most of the audio. +// Write sends a chunk of audio. The timeout only guards a dead spotter: the +// websocket library closes the whole connection when a write deadline expires, +// so a short timeout used to drop the stream whenever the host was busy. Five +// seconds is long enough to ride out a load spike and still bounds a stalled +// write; the caller keeps feeding audio meanwhile. func (s *Stream) Write(pcm []byte) error { - ctx, cancel := context.WithTimeout(s.ctx, time.Second) + ctx, cancel := context.WithTimeout(s.ctx, 5*time.Second) defer cancel() return s.ws.Write(ctx, websocket.MessageBinary, pcm) } From 6217d5b5ea65c85e9d8ca6150c2e467e033c0b26 Mon Sep 17 00:00:00 2001 From: ryysel Date: Mon, 2 Nov 2026 06:35:10 +0300 Subject: [PATCH 5/9] openaichat: set the volume from spoken levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «поставь громкость на половину», «на максимум», «на всю» fell through to the text model: only digits matched volumeSetRE. With the cloud model rate limited, the command was executed by a tool call but the confirmation never came. Level words are now resolved in the fast path, next to the digit form, so the answer is immediate and local. --- internal/provider/openaichat/fastpath.go | 17 +++++++++++++ .../openaichat/fastpath_phrases_test.go | 24 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/internal/provider/openaichat/fastpath.go b/internal/provider/openaichat/fastpath.go index 44393a0..9718c36 100644 --- a/internal/provider/openaichat/fastpath.go +++ b/internal/provider/openaichat/fastpath.go @@ -37,6 +37,7 @@ var ( commandRE = regexp.MustCompile(`(?i)^(сделай|поставь|установи|включи|выключи|отключи|убери|верни|убавь|прибавь|увеличь|уменьши|громче|погромче|тише|потише|громкость|яркость|следующий|следующую|предыдущий|предыдущую|пауза|паузу|продолжи|продолжай|играй|дальше|назад|стоп|запусти|активируй|выполни|без|next|prev|play)(?:[\s,.:;!?-]|$)`) volumeSetRE = regexp.MustCompile(`(?i)громкость\s+(?:на\s+)?(\d{1,3})`) + volumeWordRE = regexp.MustCompile(`(?i)(?:громкост|громко|звук)[а-яё]*\s+(?:на\s+|с\s+|в\s+|до\s+)?(половин[а-яё]*|максимум[а-яё]*|минимум[а-яё]*|всю|все|весь)`) volumeUpRE = regexp.MustCompile(`(?i)(громче|погромче|прибавь|увеличь)`) volumeDownRE = regexp.MustCompile(`(?i)(тише|потише|убавь|уменьши)`) muteOnRE = regexp.MustCompile(`(?i)(выключи|отключи|убери)\s+(звук|громкость)|без\s+звука`) @@ -163,6 +164,10 @@ func (c *conversation) fastPath(ctx context.Context, text string, out *pipeline. } call.Name, call.Args = "volume_set", map[string]any{"level": level} reply = fmt.Sprintf("Громкость %d процентов.", level) + case volumeWordRE.MatchString(t): + level := volumeWordLevel(volumeWordRE.FindStringSubmatch(t)[1]) + call.Name, call.Args = "volume_set", map[string]any{"level": level} + reply = fmt.Sprintf("Громкость %d процентов.", level) case muteOnRE.MatchString(t): call.Name, call.Args = "volume_mute", map[string]any{"muted": true} reply = "Звук выключен." @@ -390,6 +395,18 @@ func stripWakeWord(t string) string { return t } +// volumeWordLevel turns "на половину" and "на максимум" into a level. +func volumeWordLevel(word string) int { + switch { + case strings.HasPrefix(word, "половин"): + return 50 + case strings.HasPrefix(word, "минимум"): + return 10 + default: // максимум, всю, все, весь + return 100 + } +} + // volumeDelta reads "на 20" from a relative volume command; the default step // is ten, and the step is capped so one phrase cannot jump the whole range. func volumeDelta(t string) int { diff --git a/internal/provider/openaichat/fastpath_phrases_test.go b/internal/provider/openaichat/fastpath_phrases_test.go index 7ea5dd5..08cec2d 100644 --- a/internal/provider/openaichat/fastpath_phrases_test.go +++ b/internal/provider/openaichat/fastpath_phrases_test.go @@ -105,3 +105,27 @@ func TestDateReply(t *testing.T) { t.Errorf("dateReply(bad) = %q", got) } } + +func TestVolumeWordRE(t *testing.T) { + cases := map[string]int{ + "поставь громкость с половиной": 50, + "поставь громко с половиной": 50, + "громкость на половину": 50, + "громкость на максимум": 100, + "поставь громкости все": 100, + "звук на минимум": 10, + } + for phrase, want := range cases { + m := volumeWordRE.FindStringSubmatch(phrase) + if m == nil { + t.Errorf("volumeWordRE does not match %q", phrase) + continue + } + if got := volumeWordLevel(m[1]); got != want { + t.Errorf("volumeWordLevel(%q) = %d, want %d", phrase, got, want) + } + } + if volumeWordRE.MatchString("громкость 50") { + t.Error("volumeWordRE must not swallow digit commands") + } +} From 9c5549323fff5646f430d0776d69fd9fbf785200 Mon Sep 17 00:00:00 2001 From: ryysel Date: Mon, 2 Nov 2026 06:36:02 +0300 Subject: [PATCH 6/9] agent: keep the command that follows the wake word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wake spotter verifies a candidate before reporting it, so on a loaded host the server asks the speaker to listen about a second after the wake word. A command said without a pause after it («Маруся, который час») is over by then: the turn is transcribed as empty and the speaker stays silent. A pause after the wake word hid the problem. Keep the last five seconds of idle microphone audio and replay it through the endpointer when listening starts. The buffered frames already passed the noise floor and the high-pass filter, so the buffered speech goes out with the turn as usual. The high-pass filter is new as well: room rumble can sit only a few dB below speech in a quiet room, and the endpointer compares speech with the floor. Filtering below 180 Hz gives quiet speech more headroom, and the «no speech detected» log now includes the peak and the floor in dBFS, so a muted microphone is not mistaken for a quiet one. --- internal/agent/session.go | 78 ++++++++++++++++++++++++++++----- internal/audio/highpass.go | 56 +++++++++++++++++++++++ internal/audio/highpass_test.go | 45 +++++++++++++++++++ 3 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 internal/audio/highpass.go create mode 100644 internal/audio/highpass_test.go diff --git a/internal/agent/session.go b/internal/agent/session.go index b7a8463..8cc9b13 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "log/slog" + "math" "strings" "time" @@ -61,6 +62,11 @@ func (s state) indicator() indicator.State { const sendTimeout = 5 * time.Second +// idleBuffered is how much audio before the wake is kept for the replay. A +// command said right after the wake word may be completely over by the time +// the server reports the detection. +const idleBuffered = 5 * time.Second + // session is the per-connection state machine. All fields are owned by the // run goroutine. type session struct { @@ -75,18 +81,23 @@ type session struct { since time.Time turnSeq int turnID string - speech bool // turn_start was sent for the current turn - remote bool // the current turn was started by the server (remote_turn) - ending bool // the model ended the conversation: no follow-up - announce bool // the current turn plays an announcement (TypeAnnounce) - askListen bool // ...after which an answer is expected - replyTo string // the turn being recorded answers this announcement - wakeFeed bool // stream idle microphone audio for the server's wake word spotter - deafUntil time.Time // ignore the mic while our own earcon plays + speech bool // turn_start was sent for the current turn + maxDB float64 // loudest frame of the current listening window + hp *audio.HighPass // removes room rumble before the VAD + remote bool // the current turn was started by the server (remote_turn) + ending bool // the model ended the conversation: no follow-up + announce bool // the current turn plays an announcement (TypeAnnounce) + askListen bool // ...after which an answer is expected + replyTo string // the turn being recorded answers this announcement + wakeFeed bool // stream idle microphone audio for the server's wake word spotter + deafUntil time.Time // ignore the mic while our own earcon plays preroll [][]byte - failed bool // the current turn produced an error - replied bool // the current turn produced audio - ducked bool // media was paused by us and must be resumed + idle [][]byte // idle microphone audio, replayed after a wake + idleBytes int + idleAt time.Time // when the last idle frame arrived + failed bool // the current turn produced an error + replied bool // the current turn produced audio + ducked bool // media was paused by us and must be resumed heard strings.Builder said strings.Builder @@ -235,11 +246,15 @@ func (s *session) startListening(ctx context.Context, timeout time.Duration) { s.turnSeq++ s.turnID = fmt.Sprintf("%s-%d-%d", s.a.cfg.DeviceID, time.Now().Unix(), s.turnSeq) s.speech, s.failed, s.replied, s.remote, s.ending = false, false, false, false, false + s.maxDB = -120 s.announce, s.askListen, s.replyTo = false, false, "" s.preroll = s.preroll[:0] s.heard.Reset() s.said.Reset() + if s.hp == nil { + s.hp = audio.NewHighPass(180, float64(s.inFormat.SampleRateHertz)) + } s.ep.Reset(timeout) s.duck(ctx) @@ -248,11 +263,14 @@ func (s *session) startListening(ctx context.Context, timeout time.Duration) { s.deafUntil = time.Now().Add(earcon + 80*time.Millisecond) } s.setState(stateListening) + s.replayIdle(ctx) } // onFrame handles 20 ms of microphone audio. func (s *session) onFrame(ctx context.Context, f []byte) { + s.hp.Process(f) if s.state == stateIdle { + s.rememberIdle(f) s.ep.Observe(f) if s.wakeFeed && !s.a.muted.Load() { s.sendAudio(ctx, f) @@ -262,6 +280,42 @@ func (s *session) onFrame(ctx context.Context, f []byte) { if s.state != stateListening || time.Now().Before(s.deafUntil) { return } + s.listenFrame(ctx, f) +} + +// rememberIdle keeps the last idleBuffered seconds of filtered microphone +// audio, so startListening can replay what was said while the wake spotter +// was still confirming the wake word. +func (s *session) rememberIdle(f []byte) { + s.idleAt = time.Now() + limit := s.inFormat.FrameSize(idleBuffered) + s.idle = append(s.idle, append([]byte(nil), f...)) + s.idleBytes += len(f) + for s.idleBytes > limit && len(s.idle) > 1 { + s.idleBytes -= len(s.idle[0]) + s.idle = s.idle[1:] + } +} + +// replayIdle feeds the buffered idle audio to the endpointer and forgets it. +func (s *session) replayIdle(ctx context.Context) { + frames := s.idle + s.idle, s.idleBytes = nil, 0 + if time.Since(s.idleAt) > 500*time.Millisecond { + return // a reply or announcement played since; the buffer is stale + } + for _, f := range frames { + if s.state != stateListening { + return // the buffered audio already completed the utterance + } + s.listenFrame(ctx, f) + } +} + +func (s *session) listenFrame(ctx context.Context, f []byte) { + if db := audio.DBFS(audio.RMS(f)); db > s.maxDB { + s.maxDB = db + } dec := s.ep.Process(f, s.inFormat.Duration(len(f))) switch dec { @@ -283,7 +337,7 @@ func (s *session) onFrame(ctx context.Context, f []byte) { s.a.log.Info("utterance captured", "turn", s.turnID, "by", "device", "length", time.Since(s.since).Round(100*time.Millisecond)) s.setState(stateThinking) case vad.NoSpeech: - s.a.log.Info("no speech detected") + s.a.log.Info("no speech detected", "max_db", math.Round(s.maxDB), "floor_db", math.Round(s.ep.NoiseFloor())) s.declineAnswer(ctx) s.a.playEarcon(ctx, audio.EarconDone) s.goIdle(ctx) diff --git a/internal/audio/highpass.go b/internal/audio/highpass.go new file mode 100644 index 0000000..03eedb6 --- /dev/null +++ b/internal/audio/highpass.go @@ -0,0 +1,56 @@ +// Copyright 2026 The Marusya-Gemini Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package audio + +import ( + "encoding/binary" + "math" +) + +// HighPass is a one-pole high-pass filter for 16-bit little-endian mono PCM. +// Rooms bring fan and mains rumble below ~200 Hz, and the endpointer compares +// speech with the noise floor: without the filter quiet speech can sit only a +// few dB above the rumble and never count as speech. +type HighPass struct { + a float64 + x1, y1 float64 +} + +// NewHighPass returns a filter with the given cutoff in Hz. +func NewHighPass(cutoffHz, sampleRate float64) *HighPass { + if cutoffHz <= 0 || sampleRate <= 0 || cutoffHz >= sampleRate/2 { + return &HighPass{a: 1} + } + return &HighPass{a: 1 / (1 + 2*math.Pi*cutoffHz/sampleRate)} +} + +// Process filters pcm in place. +func (h *HighPass) Process(pcm []byte) { + if h == nil { + return + } + for i := 0; i+1 < len(pcm); i += 2 { + x := float64(int16(binary.LittleEndian.Uint16(pcm[i:]))) + y := h.a * (h.y1 + x - h.x1) + h.x1, h.y1 = x, y + switch { + case y > math.MaxInt16: + y = math.MaxInt16 + case y < math.MinInt16: + y = math.MinInt16 + } + binary.LittleEndian.PutUint16(pcm[i:], uint16(int16(math.Round(y)))) + } +} diff --git a/internal/audio/highpass_test.go b/internal/audio/highpass_test.go new file mode 100644 index 0000000..24fa1fb --- /dev/null +++ b/internal/audio/highpass_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 The Marusya-Gemini Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package audio + +import ( + "encoding/binary" + "math" + "testing" +) + +func tone(freq, rate float64, n int) []byte { + pcm := make([]byte, n*2) + for i := range n { + v := int16(12000 * math.Sin(2*math.Pi*freq*float64(i)/rate)) + binary.LittleEndian.PutUint16(pcm[i*2:], uint16(v)) + } + return pcm +} + +func TestHighPassKeepsSpeechAndDropsRumble(t *testing.T) { + const rate = 16000 + rumble := NewHighPass(180, rate) + speech := NewHighPass(180, rate) + low, high := tone(50, rate, 1600), tone(1000, rate, 1600) + rumble.Process(low) + speech.Process(high) + if got, want := RMS(low), 12000.0/32768.0/math.Sqrt2; got > want*0.5 { + t.Errorf("50 Hz survived the filter: rms %.3f, want well below %.3f", got, want*0.5) + } + if got, want := RMS(high), 12000.0/32768.0/math.Sqrt2; got < want*0.85 { + t.Errorf("1 kHz was attenuated: rms %.3f, want about %.3f", got, want) + } +} From 7d37daf14274d923710c701681205442c222b404 Mon Sep 17 00:00:00 2001 From: Dmitri Shimanski Date: Wed, 11 Nov 2026 01:10:01 +0200 Subject: [PATCH 7/9] server, wake: sleep while no speaker is connected The server skips ask requests at once with {"skipped": true} instead of answering 503, so marusya-ask hooks return silently. The wake spotter loads its models on the first connection and unloads them WAKE_SLEEP_AFTER seconds after the last one closes. --- cmd/marusya-ask/main.go | 18 +++++++++ deploy/wake/wake_server.py | 74 +++++++++++++++++++++++++++++++++++-- internal/server/ask.go | 13 ++++++- internal/server/ask_test.go | 33 +++++++++++++++-- internal/server/panel.go | 2 + internal/server/server.go | 18 +++++++++ 6 files changed, 150 insertions(+), 8 deletions(-) diff --git a/cmd/marusya-ask/main.go b/cmd/marusya-ask/main.go index 5e7075d..90929a9 100644 --- a/cmd/marusya-ask/main.go +++ b/cmd/marusya-ask/main.go @@ -25,6 +25,9 @@ // notification, e.g. that a permission is needed. Settings come from flags // or the environment: MARUSYA_URL (or MARUSYA_SERVER, the ws:// URL of the // speakers), MARUSYA_AUTH_TOKEN and MARUSYA_DEVICE. +// +// While no speaker is connected the server sleeps and skips the request at +// once; the hook then does nothing, and Claude Code carries on as usual. package main import ( @@ -67,6 +70,10 @@ func main() { var err error if flag.NArg() == 1 && flag.Arg(0) == "hook" { err = runHook(os.Stdin, os.Stdout) + if errors.Is(err, errSkipped) { + journal(" skipped: %v", err) + err = nil + } if err != nil { journal(" error: %v", err) } @@ -74,6 +81,10 @@ func main() { var answer string var ok bool answer, ok, err = ask(strings.Join(flag.Args(), " "), *listen, cmp(*source, "api")) + if errors.Is(err, errSkipped) { + fmt.Fprintln(os.Stderr, "marusya-ask:", err) + err = nil + } if err == nil && *listen { if !ok { fmt.Fprintln(os.Stderr, "no answer") @@ -122,9 +133,13 @@ type askResponse struct { Device string `json:"device"` Answered bool `json:"answered"` Answer string `json:"answer"` + Skipped bool `json:"skipped"` Error string `json:"error"` } +// errSkipped is returned when the server sleeps without speakers. +var errSkipped = errors.New("skipped: no speaker is connected") + // ask calls POST /v1/ask. func ask(text string, listen bool, src string) (string, bool, error) { base, err := baseURL() @@ -157,6 +172,9 @@ func ask(text string, listen bool, src string) (string, bool, error) { if resp.StatusCode != http.StatusOK { return "", false, fmt.Errorf("%s: %s", resp.Status, ar.Error) } + if ar.Skipped { + return "", false, errSkipped + } return ar.Answer, ar.Answered, nil } diff --git a/deploy/wake/wake_server.py b/deploy/wake/wake_server.py index b9faa7a..b4b7e4d 100644 --- a/deploy/wake/wake_server.py +++ b/deploy/wake/wake_server.py @@ -20,14 +20,21 @@ fuzzy matched against the keywords. Slower (the utterance has to end), but any word in any of Whisper's languages works. +The models are loaded on the first connection. When the last speaker +disconnects (marusya-server has no speakers), the spotter falls asleep after +WAKE_SLEEP_AFTER seconds: it unloads the models to free memory and loads them +again when a speaker comes back. 0 keeps them loaded. + Tuning (environment): VOSK_MIN_CONF, VOSK_HOLD, WHISPER_MODEL, -WHISPER_COMPUTE_TYPE, WHISPER_DEVICE, WHISPER_THREADS, WHISPER_MATCH, WAKE_COOLDOWN. +WHISPER_COMPUTE_TYPE, WHISPER_DEVICE, WHISPER_THREADS, WHISPER_MATCH, WAKE_COOLDOWN, +WAKE_SLEEP_AFTER. """ import asyncio import collections import concurrent.futures import difflib +import gc import json import logging import os @@ -51,6 +58,7 @@ WHISPER_THREADS = int(os.environ.get("WHISPER_THREADS", "0")) or os.cpu_count() or 4 WHISPER_MATCH = float(os.environ.get("WHISPER_MATCH", "0.8")) COOLDOWN = float(os.environ.get("WAKE_COOLDOWN", "1.5")) +SLEEP_AFTER = float(os.environ.get("WAKE_SLEEP_AFTER", "60")) # One Whisper inference at a time for all speakers: it is CPU heavy. whisper_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) @@ -71,6 +79,7 @@ def script_of(word: str) -> str: _vosk_model = None _whisper_model = None _whisper_error = None +_generation = 0 # bumped by unload_models, so a late Whisper load is dropped _lock = threading.Lock() @@ -95,6 +104,7 @@ def whisper_model(): if getattr(whisper_model, "loading", False): return None whisper_model.loading = True + generation = _generation def load(): global _whisper_model, _whisper_error @@ -106,17 +116,63 @@ def load(): m = WhisperModel(WHISPER_MODEL, device=WHISPER_DEVICE, compute_type=WHISPER_COMPUTE, cpu_threads=WHISPER_THREADS) with _lock: - _whisper_model = m + if generation == _generation: + _whisper_model = m log.info("Whisper model ready") except Exception as e: # noqa: BLE001 - reported to the server log.exception("loading Whisper failed") with _lock: - _whisper_error = e + if generation == _generation: + _whisper_error = e + finally: + with _lock: + if generation == _generation: + whisper_model.loading = False threading.Thread(target=load, daemon=True).start() return None +def unload_models(): + """Drops the models; the next connection loads them again.""" + global _vosk_model, _whisper_model, _whisper_error, _generation + with _lock: + loaded = _vosk_model is not None or _whisper_model is not None + _vosk_model = _whisper_model = _whisper_error = None + _generation += 1 + whisper_model.loading = False + gc.collect() + return loaded + + +class Sleeper: + """Unloads the models a while after the last connection closes.""" + + def __init__(self, delay): + self.delay = delay + self.clients = 0 + self.timer = None + + def connected(self): + self.clients += 1 + if self.timer is not None: + self.timer.cancel() + self.timer = None + + def disconnected(self): + self.clients -= 1 + if self.clients == 0 and self.delay > 0: + self.timer = asyncio.get_running_loop().call_later(self.delay, self.sleep) + + def sleep(self): + self.timer = None + if self.clients == 0 and unload_models(): + log.info("no speakers: models unloaded, sleeping until one connects") + + +sleeper = Sleeper(SLEEP_AFTER) + + # ---------- engines ---------- @@ -417,6 +473,14 @@ def feed_all(spotters, pcm, now, emit): async def handler(ws): + sleeper.connected() + try: + await serve_speaker(ws) + finally: + sleeper.disconnected() + + +async def serve_speaker(ws): loop = asyncio.get_running_loop() peer = ws.remote_address spotters = [] @@ -468,7 +532,9 @@ def send(): async def main(): host, _, port = os.environ.get("WAKE_LISTEN", "0.0.0.0:2700").rpartition(":") - vosk_model() # fail fast on a broken image + if not os.path.isdir(VOSK_MODEL): # fail fast on a broken image + raise SystemExit(f"no Vosk model at {VOSK_MODEL}") + import vosk # noqa: F401 - the model itself is loaded on the first connection async with serve(handler, host or "0.0.0.0", int(port), max_size=1 << 20, ping_interval=20, ping_timeout=60): log.info("listening on %s:%s", host, port) diff --git a/internal/server/ask.go b/internal/server/ask.go index 4a0653e..b8b31e1 100644 --- a/internal/server/ask.go +++ b/internal/server/ask.go @@ -36,6 +36,9 @@ import ( // {"text": "Клод спрашивает: …", "listen": true, "device": "", "timeout": 120, "source": "claude"} // → {"device": "vk-mini-a113x", "answered": true, "answer": "да, продолжай"} // +// While no speaker is connected the server sleeps (see [Server.Asleep]) and +// answers at once with {"skipped": true}, so hooks do not hang. +// // The message is synthesized, played once the speaker is free (with a chime // and an orange ring) and, with listen, the next utterance is transcribed // and returned instead of going to the model. @@ -58,6 +61,7 @@ var ( errAskTimeout = errors.New("колонка была занята всё это время") errNoAnswer = errors.New("no answer") errAskReplaced = errors.New("колонка отключилась") + errAsleep = errors.New("нет подключённых колонок: запрос пропущен") ) // pendingAsk is an announcement being delivered to a device. @@ -91,7 +95,10 @@ type AskResponse struct { Device string `json:"device"` Answered bool `json:"answered"` Answer string `json:"answer,omitempty"` - Error string `json:"error,omitempty"` + // Skipped means no speaker is connected, so nothing was said; callers + // such as hooks should carry on as if the ask API were not there. + Skipped bool `json:"skipped,omitempty"` + Error string `json:"error,omitempty"` } func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) { @@ -113,6 +120,10 @@ func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) { writeAsk(w, http.StatusBadRequest, AskResponse{Error: "text is too long"}) return } + if s.Asleep() { + writeAsk(w, http.StatusOK, AskResponse{Device: req.Device, Skipped: true, Error: errAsleep.Error()}) + return + } wait := defaultAskWait if req.Timeout > 0 { wait = min(time.Duration(req.Timeout)*time.Second, maxAskWait) diff --git a/internal/server/ask_test.go b/internal/server/ask_test.go index 23b908a..d8f1fac 100644 --- a/internal/server/ask_test.go +++ b/internal/server/ask_test.go @@ -177,8 +177,8 @@ func TestAskRejected(t *testing.T) { e := newAskEnv(t) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if r := <-e.post("tok", AskRequest{Text: "x"}); r[0] != http.StatusServiceUnavailable { - t.Errorf("no speaker = %v", r[0]) + if r := <-e.post("tok", AskRequest{Text: "x", Listen: true}); r[0] != http.StatusOK || !r[1].(AskResponse).Skipped { + t.Errorf("no speaker = %v %+v, want skipped", r[0], r[1]) } e.dial(ctx) // an old agent without announcements for range 100 { @@ -186,7 +186,7 @@ func TestAskRejected(t *testing.T) { t.Fatalf("wrong token = %v", r[0]) } r := <-e.post("tok", AskRequest{Text: "x"}) - if r[0] == http.StatusServiceUnavailable { + if r[1].(AskResponse).Skipped { time.Sleep(10 * time.Millisecond) // not registered yet continue } @@ -198,4 +198,31 @@ func TestAskRejected(t *testing.T) { t.Fatal("device did not register") } +func TestAsleepWithoutSpeakers(t *testing.T) { + s := New(Config{Provider: &textProvider{texts: make(chan string, 1)}, AuthToken: "tok", + Logger: slog.New(slog.DiscardHandler)}) + ts := httptest.NewServer(s) + defer ts.Close() + if !s.Asleep() { + t.Fatal("awake without speakers") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, m, err := dialDevice(t, ctx, ts.URL, "tok", &models.Hello{DeviceID: "kitchen"}) + if err != nil || m.Type != models.TypeReady { + t.Fatalf("dial: %v %+v", err, m) + } + if s.Asleep() { + t.Error("asleep with a speaker") + } + conn.Close("bye") + for !s.Asleep() { + select { + case <-ctx.Done(): + t.Fatal("still awake after the speaker left") + case <-time.After(10 * time.Millisecond): + } + } +} + var _ provider.Provider = (*textProvider)(nil) diff --git a/internal/server/panel.go b/internal/server/panel.go index f8fa6ef..bc50990 100644 --- a/internal/server/panel.go +++ b/internal/server/panel.go @@ -247,6 +247,7 @@ type panelState struct { Version string `json:"version"` Provider string `json:"provider"` Started time.Time `json:"started"` + Asleep bool `json:"asleep"` // no speaker is connected Tunable bool `json:"tunable"` Text bool `json:"text"` Saved bool `json:"saved"` // settings survive a restart @@ -278,6 +279,7 @@ func (s *Server) handleState(w http.ResponseWriter, _ *http.Request) { Version: s.cfg.Version, Provider: s.cfg.Provider.Name(), Started: s.started, + Asleep: s.Asleep(), Tunable: tunable, Text: provider.AcceptsText(s.cfg.Provider), Saved: s.cfg.SettingsFile != "", diff --git a/internal/server/server.go b/internal/server/server.go index 6b221a0..a7a5d6f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -422,7 +422,11 @@ func newID() string { func (s *Server) register(d *deviceSession) { s.dmu.Lock() s.devices[d.id] = d + woke := len(s.devices) == 1 s.dmu.Unlock() + if woke { + s.cfg.Logger.Info("speaker connected; waking up") + } s.events.publish(Event{Kind: EventConnect, Session: d.id, Device: d.hello.DeviceID}) s.events.devicesChanged() } @@ -430,11 +434,25 @@ func (s *Server) register(d *deviceSession) { func (s *Server) unregister(d *deviceSession) { s.dmu.Lock() delete(s.devices, d.id) + asleep := len(s.devices) == 0 s.dmu.Unlock() + if asleep { + s.cfg.Logger.Info("no speakers connected; sleeping until one connects") + } s.events.publish(Event{Kind: EventDisconnect, Session: d.id, Device: d.hello.DeviceID}) s.events.devicesChanged() } +// Asleep reports that no speaker is connected. The server then has nothing +// to do: model and wake word sessions exist only per speaker, and requests +// of the ask API (hooks of Claude Code, Home Assistant) are skipped at once +// instead of waiting for a speaker. +func (s *Server) Asleep() bool { + s.dmu.Lock() + defer s.dmu.Unlock() + return len(s.devices) == 0 +} + // device returns the connected device with the given session ID. func (s *Server) device(id string) (*deviceSession, bool) { s.dmu.Lock() From 2b965a772f9a8c3b3060f8efe6ab32e99736be24 Mon Sep 17 00:00:00 2001 From: Dmitri Shimanski Date: Wed, 11 Nov 2026 01:10:01 +0200 Subject: [PATCH 8/9] panel: tell an outdated agent from disabled plugins Show that the server sleeps without speakers, give the slumped face to a disconnected speaker and add a script that uploads plugin archives to the catalog. --- internal/indicator/face/frames.go | 7 +- internal/server/panel/app.js | 12 +- internal/server/plugins.go | 4 + internal/server/session.go | 7 +- scripts/catalog-upload.py | 185 ++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 scripts/catalog-upload.py diff --git a/internal/indicator/face/frames.go b/internal/indicator/face/frames.go index 382bef7..d8315e4 100644 --- a/internal/indicator/face/frames.go +++ b/internal/indicator/face/frames.go @@ -37,6 +37,8 @@ const ( eyeRight = seg7.B | seg7.C // pupil right eyeAngryL = seg7.A | seg7.B | seg7.G eyeAngryR = seg7.A | seg7.F | seg7.G + eyeSlumpL = seg7.D | seg7.C // _| + eyeSlumpR = seg7.E | seg7.D // |_ letterZ = seg7.A | seg7.B | seg7.G | seg7.E | seg7.D ) @@ -67,6 +69,7 @@ func tired(ms int) Frame { return fr(wide(eyeLine, eyeLine), true, ms) } func winking(ms int) Frame { return fr(wide(eyeOpen, eyeClosed), true, ms) } func down(ms int) Frame { return fr(wide(eyeLow, eyeLow), true, ms) } func angry(ms int) Frame { return fr(wide(eyeAngryL, eyeAngryR), true, ms) } +func slumped(ms int) Frame { return fr(wide(eyeSlumpL, eyeSlumpR), true, ms) } func lookLeft(ms int) Frame { return fr(toLeft(eyeLeft, eyeLeft), true, ms) } func lookRight(ms int) Frame { return fr(toRight(eyeRight, eyeRight), true, ms) } @@ -98,8 +101,8 @@ var ( } // loopSpeaking: happy eyes, the nose "talks". loopSpeaking = []Frame{happy(260), fr(wide(eyeHappy, eyeHappy), false, 160), happy(320), fr(narrow(eyeHappy, eyeHappy), true, 140)} - // loopDisconnected: sad face that sometimes nods off. - loopDisconnected = []Frame{sad(2500), tired(300), asleep(500), tired(200)} + // loopDisconnected: slumped "_| . |_" face that sometimes nods off. + loopDisconnected = []Frame{slumped(2500), tired(300), asleep(500), tired(200)} // loopSleeping: "- . -" whose eyes slowly sink and rise as if breathing. loopSleeping = []Frame{ fr(wide(eyeLine, eyeLine), true, 2600), diff --git a/internal/server/panel/app.js b/internal/server/panel/app.js index 150cdcb..ae70e2d 100644 --- a/internal/server/panel/app.js +++ b/internal/server/panel/app.js @@ -208,6 +208,7 @@ function connectEvents() { if (!state.devices.some((d) => d.session === state.selected)) state.selected = state.devices[0]?.session ?? null; renderDevices(); renderDeviceFilter(); + renderServerLine(); }); es.addEventListener("history", (e) => { const ev = JSON.parse(e.data); @@ -381,7 +382,9 @@ $("#spotify-login").addEventListener("submit", async (e) => { function renderServerLine() { const i = state.info; - $("#server-line").textContent = `Сервер ${i.version || "dev"} · ${i.provider} · запущен ${fmtDate(i.started)} в ${fmtTime(i.started)}`; + const asleep = state.devices ? !state.devices.length : i.asleep; + $("#server-line").textContent = `Сервер ${i.version || "dev"} · ${i.provider} · запущен ${fmtDate(i.started)} в ${fmtTime(i.started)}` + + (asleep ? " · спит: нет подключённых колонок" : ""); } // ---------- devices ---------- @@ -1251,7 +1254,9 @@ function renderDevicePlugins() { if (!devs.length) list.append(el("li", { class: "muted", text: "Нет подключённых колонок." })); for (const d of devs) { let body; - if (!d.plugins) body = el("small", { text: "агент без плагинов: каталог не задан или агент устарел" }); + if (!d.plugins) body = el("small", { text: d.outdated + ? "агент устарел: перезагрузите колонку или выполните rc-service marusya-agent restart" + : "плагины выключены: задайте MARUSYA_PLUGINS_DIR в /etc/conf.d/marusya-agent и перезапустите агент" }); else if (!d.tools.length) body = el("small", { text: "плагины не объявили функций" }); else body = el("div", { class: "tools" }, ...d.tools.map((t) => el("code", { text: `${t.plugin}: ${t.name}`, title: t.description }))); const fromCatalog = (d.from_catalog || []).map((p) => el("div", { class: "row catalog-speaker" }, @@ -1382,7 +1387,8 @@ function speakerButtons(v) { const box = el("div", { class: "row catalog-speakers" }, el("small", { text: "На колонку:" })); for (const d of devs) { const have = v.speakers?.[d.device]; - if (!d.catalog) box.append(el("span", { class: "badge off", title: "Обновите агент на колонке", text: `${d.device}: агент устарел` })); + if (!d.catalog && d.outdated) box.append(el("span", { class: "badge off", title: "Перезагрузите колонку или пропишите в rc-service marusya-agent restart", text: `${d.device}: агент устарел` })); + else if (!d.catalog) box.append(el("span", { class: "badge off", title: "Задайте MARUSYA_PLUGINS_DIR в /etc/conf.d/marusya-agent (например, /tmp/marusya/plugins) и выполните rc-service marusya-agent restart", text: `${d.device}: плагины выключены` })); else if (have && have === v.latest_version) box.append(el("span", { class: "badge idle", text: `${d.device}: стоит` })); else box.append(el("button", { class: "btn tonal small", type: "button", text: have ? `${d.device}: обновить с v${have}` : d.device, diff --git a/internal/server/plugins.go b/internal/server/plugins.go index 9c7bf1a..9236db6 100644 --- a/internal/server/plugins.go +++ b/internal/server/plugins.go @@ -368,6 +368,9 @@ type devicePlugins struct { Catalog bool `json:"catalog"` // FromCatalog are the catalog plugins the speaker should run. FromCatalog []models.CatalogPlugin `json:"from_catalog"` + // Outdated means the agent is not the server's build, so a missing + // feature may be its age rather than its settings. + Outdated bool `json:"outdated,omitempty"` } func (s *Server) pluginsState() pluginsState { @@ -389,6 +392,7 @@ func (s *Server) pluginsState() pluginsState { Tools: append([]models.PluginTool{}, d.hello.PluginTools...), Catalog: d.supportsFeature(models.FeatureCatalogPlugins), FromCatalog: s.catalogPluginsFor(d.hello.DeviceID), + Outdated: d.outdated(), } st.Devices = append(st.Devices, dp) } diff --git a/internal/server/session.go b/internal/server/session.go index 7d0ff83..a27cc74 100644 --- a/internal/server/session.go +++ b/internal/server/session.go @@ -178,11 +178,16 @@ func (d *deviceSession) info() DeviceInfo { LastTurn: d.lastTurn, CanSay: provider.AcceptsText(d.cfg.Provider) && slices.Contains(d.hello.Features, models.FeatureRemoteTurn), AgentVersion: d.hello.AgentVersion, - Outdated: buildinfo.Known(d.cfg.Version) && d.hello.AgentVersion != d.cfg.Version, + Outdated: d.outdated(), AutoUpdate: d.hello.AutoUpdate, } } +// outdated reports that the agent is not the server's build. +func (d *deviceSession) outdated() bool { + return buildinfo.Known(d.cfg.Version) && d.hello.AgentVersion != d.cfg.Version +} + func (d *deviceSession) run(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() diff --git a/scripts/catalog-upload.py b/scripts/catalog-upload.py new file mode 100644 index 0000000..d4447b5 --- /dev/null +++ b/scripts/catalog-upload.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Marusya-Gemini Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Uploads plugin archives to the plugin catalog (registry/). + +For every ZIP it reads plugin.json, creates the project (slug = id) if you +have none with that slug, uploads the version from plugin.json unless it is +already there and submits the project for review. With --publish (needs the +admin role) it publishes the project right away. + + python3 scripts/catalog-upload.py --email you@example.com bin/plugins/*.zip + +The password is asked for, or taken from $MARUSYA_CATALOG_PASSWORD. +Standard library only. +""" + +import argparse +import base64 +import getpass +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +import zipfile + + +class APIError(Exception): + def __init__(self, status, message): + super().__init__("HTTP %d: %s" % (status, message)) + self.status = status + + +class Catalog: + def __init__(self, url): + self.url = url.rstrip("/") + self.token = "" + + def call(self, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(self.url + path, data=data, method=method) + req.add_header("Accept", "application/json") + # Cloudflare's browser check refuses the default Python-urllib agent. + req.add_header("User-Agent", "marusya-catalog-upload/1.0 (+https://github.com/yawaflua/Gemini-for-Capsula)") + if data is not None: + req.add_header("Content-Type", "application/json") + if self.token: + req.add_header("Authorization", "Bearer " + self.token) + try: + with urllib.request.urlopen(req, timeout=120) as res: + raw = res.read() + except urllib.error.HTTPError as e: + raw = e.read() + try: + msg = json.loads(raw).get("message") or raw.decode(errors="replace") + except ValueError: + msg = raw.decode(errors="replace") + raise APIError(e.code, msg.strip()) from None + return json.loads(raw) if raw.strip() else {} + + def login(self, email, password): + res = self.call("POST", "/v1/auth/login", {"email": email, "password": password}) + self.token = res["tokens"]["access_token"] + return res["user"] + + def my_projects(self): + out, token = {}, "" + while True: + q = {"pageSize": 100} + if token: + q["pageToken"] = token + res = self.call("GET", "/v1/users/me/projects?" + urllib.parse.urlencode(q)) + for p in res.get("projects") or []: + out[p["slug"]] = p + token = res.get("next_page_token") or "" + if not token: + return out + + +def manifest(path): + """Returns plugin.json from the archive root or its single folder.""" + with zipfile.ZipFile(path) as z: + names = [n for n in z.namelist() if n.rstrip("/").split("/")[-1] == "plugin.json"] + names.sort(key=lambda n: n.count("/")) + if not names or names[0].count("/") > 1: + raise ValueError("no plugin.json in the archive root") + return json.loads(z.read(names[0])) + + +def upload(cat, path, projects, args): + man = manifest(path) + pid, version = man["id"], man.get("version") or "" + if not version: + raise ValueError("plugin.json has no version") + name = man.get("name") or pid + description = man.get("description") or name + + project = projects.get(pid) + if project is None: + project = cat.call("POST", "/v1/projects", { + "slug": pid, "name": name, "description": description, "git_url": args.git_url, + }) + projects[pid] = project + print(" проект создан") + + versions = cat.call("GET", "/v1/projects/%s/versions" % project["id"]).get("versions") or [] + if any(v.get("version") == version for v in versions): + print(" версия %s уже есть" % version) + else: + with open(path, "rb") as f: + data = base64.b64encode(f.read()).decode() + cat.call("POST", "/v1/projects/%s/versions" % project["id"], { + "version": version, + "description": description, + "changelog": args.changelog, + "readme": "", + "tag": "VERSION_TAG_RELEASE", + "metadata": { + "slug": pid, "description": description, + "licenses": [args.license], "authors": args.author, + "abi_version": "1", "api_version": "1", + }, + "archive": {"content_type": "application/zip", "data": data}, + }) + print(" версия %s загружена" % version) + + status = cat.call("GET", "/v1/projects/id/%s" % project["id"]).get("status", "") + if status in ("PROJECT_STATUS_DRAFT", "PROJECT_STATUS_REJECTED", "", None): + cat.call("POST", "/v1/projects/%s:submit" % project["id"], {"comment": args.comment}) + status = "PROJECT_STATUS_PENDING_REVIEW" + print(" отправлен на модерацию") + if args.publish and status != "PROJECT_STATUS_PUBLISHED": + cat.call("POST", "/v1/admin/projects/%s:set-status" % project["id"], + {"status": "PROJECT_STATUS_PUBLISHED", "reason": args.comment}) + print(" опубликован") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("archives", nargs="+", help="ZIP archives with plugin.json") + ap.add_argument("--registry", default=os.environ.get("MARUSYA_PLUGIN_REGISTRY", "https://marusya.ywfl.dev")) + ap.add_argument("--email", required=True) + ap.add_argument("--git-url", default="https://github.com/yawaflua/Gemini-for-Capsula") + ap.add_argument("--license", default="Apache-2.0") + ap.add_argument("--author", action="append", default=None, help="repeatable; default: your nickname") + ap.add_argument("--changelog", default="Первая версия.") + ap.add_argument("--comment", default="Пример из репозитория Маруси.") + ap.add_argument("--publish", action="store_true", help="publish at once (admin only)") + args = ap.parse_args() + + password = os.environ.get("MARUSYA_CATALOG_PASSWORD") or getpass.getpass("Пароль от %s: " % args.registry) + cat = Catalog(args.registry) + try: + user = cat.login(args.email, password) + except APIError as e: + sys.exit("вход не удался: %s" % e) + args.author = args.author or [user.get("username") or args.email] + projects = cat.my_projects() + + failed = 0 + for path in args.archives: + print(os.path.basename(path)) + try: + upload(cat, path, projects, args) + except (APIError, ValueError, OSError, KeyError, zipfile.BadZipFile) as e: + failed += 1 + print(" ошибка: %s" % e) + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() From adc976babfbd84dea201612d581bab25615cb5ba Mon Sep 17 00:00:00 2001 From: Dmitri Shimanski Date: Wed, 11 Nov 2026 01:10:01 +0200 Subject: [PATCH 9/9] Rename the project to OpenCapsula Publish the images as opencapsule-server and opencapsule-wake and keep tagging the obsolete marusya-* names for compatibility. Rename the project in the notice and authors and add a disclaimer of affiliation with VK. --- .github/workflows/docker.yml | 38 ++++++++++++++++++++++++------- AUTHORS | 4 ++-- NOTICE | 11 +++++++-- README.md | 36 +++++++++++++++++++++-------- deploy/server/docker-compose.yaml | 4 ++-- docker-compose.yaml | 4 ++-- 6 files changed, 71 insertions(+), 26 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e90d7f9..6bad055 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,11 +1,15 @@ # Copyright 2026 The Marusya-Gemini Authors. # SPDX-License-Identifier: Apache-2.0 -# Builds the marusya-server and marusya-wake images for linux/amd64 and -# linux/arm64 and pushes them to GitHub Container Registry: +# Builds the opencapsule-server and opencapsule-wake images for linux/amd64 +# and linux/arm64 and pushes them to GitHub Container Registry: # -# ghcr.io//marusya-server -# ghcr.io//marusya-wake +# ghcr.io//opencapsule-server +# ghcr.io//opencapsule-wake +# +# The same manifests are also tagged under the obsolete names marusya-server +# and marusya-wake, annotated as deprecated, so existing compose files keep +# pulling updates until they switch to the new names. # # Tags: "latest" and "master" from master, "dev" and "stage" from stage, # "1.2.3" / "1.2" from v1.2.3 tags, "sha-" from every push. @@ -61,10 +65,10 @@ jobs: fail-fast: false matrix: image: - - name: marusya-server + - name: opencapsule-server context: . file: deploy/server/Dockerfile - - name: marusya-wake + - name: opencapsule-wake context: deploy/wake file: deploy/wake/Dockerfile platform: @@ -137,7 +141,11 @@ jobs: needs: build strategy: matrix: - image: [marusya-server, marusya-wake] + include: + - image: opencapsule-server + legacy: marusya-server + - image: opencapsule-wake + legacy: marusya-wake runs-on: ubuntu-24.04 permissions: contents: read @@ -150,7 +158,9 @@ jobs: merge-multiple: true - id: vars - run: echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${{ matrix.image }}" >> "$GITHUB_OUTPUT" + run: | + echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${{ matrix.image }}" >> "$GITHUB_OUTPUT" + echo "legacy=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${{ matrix.legacy }}" >> "$GITHUB_OUTPUT" - id: meta uses: docker/metadata-action@v5 @@ -179,5 +189,17 @@ jobs: $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '${{ steps.vars.outputs.image }}@sha256:%s ' *) + # Obsolete name, kept for compatibility: same tags, same manifests. + - name: Tag the obsolete name + env: + IMAGE: ${{ steps.vars.outputs.image }} + LEGACY: ${{ steps.vars.outputs.legacy }} + run: | + tags=$(jq -cr --arg i "$IMAGE" --arg l "$LEGACY" \ + '.tags | map("-t " + ($l + ltrimstr($i))) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") + docker buildx imagetools create $tags \ + --annotation "index:org.opencontainers.image.description=OBSOLETE: use $IMAGE instead" \ + "$IMAGE:${{ steps.meta.outputs.version }}" + - name: Inspect image run: docker buildx imagetools inspect ${{ steps.vars.outputs.image }}:${{ steps.meta.outputs.version }} diff --git a/AUTHORS b/AUTHORS index f897b13..dd28300 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,5 +1,5 @@ -# The Marusya-Gemini Authors: the copyright holders referred to as -# "The Marusya-Gemini Authors" in the source file headers. +# The OpenCapsula Authors: the copyright holders of the project (the source +# file headers still say "The Marusya-Gemini Authors", its former name). # # Contributors add themselves here in their first pull request, one line # per person: Name (GitHub handle). diff --git a/NOTICE b/NOTICE index 00cde79..4084aef 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,12 @@ -Marusya × Gemini -Copyright 2026 The Marusya-Gemini Authors: +OpenCapsula (formerly Marusya × Gemini) +Copyright 2026 The OpenCapsula Authors: Dmitri 'yawaflua' Shimanski and project contributors (see AUTHORS). Licensed under the Apache License, Version 2.0 (see LICENSE). + +OpenCapsula is developed by independent enthusiasts. It is not affiliated +with, endorsed or supported by VK (MKPAO "VK") or its employees. "Маруся", +"VK Капсула" and related names are trademarks of their respective owners and +are used only to describe compatibility. + +Source: https://github.com/yawaflua/OpenCapsule diff --git a/README.md b/README.md index 2d6d5b3..ceb5218 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Marusya × Gemini +# OpenCapsula -Голосовой ассистент для умной колонки «Маруся» на базе **Gemini Live API**. +OpenCapsula — открытый голосовой ассистент для умной колонки «Маруся» (VK Капсула) на базе **Gemini Live API**. Проект состоит из двух частей на Go: | Бинарник | Где работает | Что делает | @@ -19,8 +19,9 @@ ## Быстрый старт Нужен сервер с Docker (amd64 или arm64) в одной сети с колонкой и root по SSH на колонке. -Готовые образы `ghcr.io/yawaflua/marusya-server` и `ghcr.io/yawaflua/marusya-wake` публичные, -собирать ничего не надо. +Готовые образы `ghcr.io/yawaflua/opencapsule-server` и `ghcr.io/yawaflua/opencapsule-wake` публичные, +собирать ничего не надо. Старые имена `marusya-server` и `marusya-wake` устарели: под ними +публикуются те же образы для совместимости, но лучше перейти на новые. **1. Docker и папка для сервера** @@ -28,7 +29,7 @@ curl -fsSL https://get.docker.com | sh mkdir -p ~/marusya/plugins && cd ~/marusya sudo chown 65532 plugins # плагины хранят тут своё состояние -curl -fsSLO https://raw.githubusercontent.com/yawaflua/Gemini-for-Capsula/master/docker-compose.yaml +curl -fsSLO https://raw.githubusercontent.com/yawaflua/OpenCapsule/master/docker-compose.yaml ``` **2. Ключи в `.env`** (рядом с `docker-compose.yaml`) @@ -200,8 +201,9 @@ docker compose -f deploy/server/docker-compose.yaml logs -f ``` Готовые образы для amd64 и arm64 CI собирает из `master`, `stage` и тегов `v*` -(`.github/workflows/docker.yml`): `ghcr.io/yawaflua/marusya-server` и -`ghcr.io/yawaflua/marusya-wake`. Тег `latest` соответствует `master`, тег `dev` +(`.github/workflows/docker.yml`): `ghcr.io/yawaflua/opencapsule-server` и +`ghcr.io/yawaflua/opencapsule-wake`. Те же образы пока выходят и под старыми именами +`marusya-server` и `marusya-wake`, но они помечены устаревшими (obsolete). Тег `latest` соответствует `master`, тег `dev` соответствует `stage`. Чтобы не собирать локально: ```bash @@ -580,6 +582,10 @@ cd deploy/server && docker compose --profile wake up -d --build - Можно задать до 10 слов через запятую. Панель предупредит, если слово не поддерживается. - Кнопка и `-wake_command` на колонке продолжают работать. Без `MARUSYA_WAKE_URL`, а также со старым агентом звук в режиме ожидания никуда не отправляется. +- Когда к серверу не подключена ни одна колонка, сервис засыпает: через `WAKE_SLEEP_AFTER` + секунд (по умолчанию 60) он выгружает модели Vosk и Whisper из памяти и загружает их снова, + когда колонка вернётся. Первое срабатывание после сна приходит на пару секунд позже. + `WAKE_SLEEP_AFTER=0` держит модели загруженными всегда. > Важно: при серверном слове микрофон слушает постоянно, и звук уходит на ваш сервер > в локальной сети. Дальше сервера он не идёт: в Gemini попадает только запрос после @@ -610,7 +616,10 @@ curl -s http://192.168.31.80:8080/v1/ask -H "Authorization: Bearer $MARUSYA_AUTH 4. С `listen` колонка 10 секунд ждёт ответа. Ответ распознаётся и возвращается в JSON. Модели он не передаётся, Маруся сама ничего не отвечает. 5. Если нажать кнопку во время чтения, колонка перестанет читать и сразу начнёт слушать. -Если никто не ответил, придёт `"answered": false`. Поля запроса: +Если никто не ответил, придёт `"answered": false`. Если к серверу не подключена ни одна +колонка, сервер спит: запрос сразу пропускается с `"skipped": true` (код 200), ничего не +озвучивается и не ждёт. Хук `marusya-ask` в этом случае молча завершается, и Claude Code +работает как без него. Поля запроса: - `text` — что сказать, до 1000 символов; - `listen` — ждать ли ответа; - `device` — ID колонки (по умолчанию первая); @@ -1052,5 +1061,12 @@ WebSocket `GET /v1/stream`, авторизация `Authorization: Bearer