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 0f5b564..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 @@ -551,6 +553,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` | поведение диалога | ### Ключевое слово @@ -579,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 попадает только запрос после @@ -609,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 колонки (по умолчанию первая); @@ -1051,5 +1061,12 @@ WebSocket `GET /v1/stream`, авторизация `Authorization: Bearer get`, ` set <0..100>` # and ` mute on|off`. Takes precedence over MARUSYA_MIXER_CONTROL. # See configs/volume-capsule-ipc.example.sh. diff --git a/deploy/server/docker-compose.yaml b/deploy/server/docker-compose.yaml index 9859b70..326b38a 100644 --- a/deploy/server/docker-compose.yaml +++ b/deploy/server/docker-compose.yaml @@ -20,7 +20,7 @@ services: dockerfile: deploy/server/Dockerfile args: VERSION: ${VERSION:-dev} - image: ghcr.io/yawaflua/marusya-server:latest + image: ghcr.io/yawaflua/opencapsule-server:latest container_name: marusya-server restart: unless-stopped ports: @@ -60,7 +60,7 @@ services: profiles: ["wake"] build: context: ../wake - image: ghcr.io/yawaflua/marusya-wake:latest + image: ghcr.io/yawaflua/opencapsule-wake:latest container_name: marusya-wake restart: unless-stopped environment: diff --git a/deploy/wake/wake_server.py b/deploy/wake/wake_server.py index 36af6e4..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 ---------- @@ -405,11 +461,33 @@ 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): + 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 = [] 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 +508,35 @@ 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): + 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) await asyncio.get_running_loop().create_future() diff --git a/docker-compose.yaml b/docker-compose.yaml index 15c3a6a..75b6a12 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,6 @@ services: marusya-server: - image: ghcr.io/yawaflua/marusya-server:latest + image: ghcr.io/yawaflua/opencapsule-server:latest container_name: marusya-server restart: unless-stopped ports: @@ -39,7 +39,7 @@ services: max-file: "3" marusya-wake: - image: ghcr.io/yawaflua/marusya-wake:latest + image: ghcr.io/yawaflua/opencapsule-wake:latest container_name: marusya-wake restart: unless-stopped environment: 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) + } +} 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/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") + } +} diff --git a/internal/provider/openaichat/fastpath.go b/internal/provider/openaichat/fastpath.go index 5ebe950..9718c36 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,13 @@ 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})`) + volumeWordRE = regexp.MustCompile(`(?i)(?:громкост|громко|звук)[а-яё]*\s+(?:на\s+|с\s+|в\s+|до\s+)?(половин[а-яё]*|максимум[а-яё]*|минимум[а-яё]*|всю|все|весь)`) 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 +53,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 +102,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 +137,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 } @@ -138,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 = "Звук выключен." @@ -145,10 +175,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 +193,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 +383,188 @@ 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 +} + +// 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 { + 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..08cec2d --- /dev/null +++ b/internal/provider/openaichat/fastpath_phrases_test.go @@ -0,0 +1,131 @@ +// 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) + } +} + +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") + } +} 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/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/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() 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/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) } 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()