Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -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/<owner>/marusya-server
# ghcr.io/<owner>/marusya-wake
# ghcr.io/<owner>/opencapsule-server
# ghcr.io/<owner>/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-<short>" from every push.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
4 changes: 2 additions & 2 deletions AUTHORS
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
11 changes: 9 additions & 2 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -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
36 changes: 26 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Marusya × Gemini
# OpenCapsula

Голосовой ассистент для умной колонки «Маруся» на базе **Gemini Live API**.
OpenCapsula — открытый голосовой ассистент для умной колонки «Маруся» (VK Капсула) на базе **Gemini Live API**.
Проект состоит из двух частей на Go:

| Бинарник | Где работает | Что делает |
Expand All @@ -19,16 +19,17 @@
## Быстрый старт

Нужен сервер с 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 и папка для сервера**

```bash
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`)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 попадает только запрос после
Expand Down Expand Up @@ -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 колонки (по умолчанию первая);
Expand Down Expand Up @@ -1052,5 +1061,12 @@ WebSocket `GET /v1/stream`, авторизация `Authorization: Bearer <token

## Лицензия

[Apache License 2.0](LICENSE). Copyright 2026 The Marusya-Gemini Authors: Dmitri 'yawaflua' Shimanski и контрибьюторы из [AUTHORS](AUTHORS) (см. [NOTICE](NOTICE)).
Как предложить изменения — в [CONTRIBUTING.md](CONTRIBUTING.md).
Проект распространяется под лицензией [Apache License 2.0](LICENSE).

Copyright © 2026 The OpenCapsula Authors — Дмитрий «yawaflua» Шиманский и контрибьюторы (см. [AUTHORS](AUTHORS) и [NOTICE](NOTICE)).

Как предложить изменения, описано в [CONTRIBUTING.md](CONTRIBUTING.md).

## Отказ от ответственности

Проект развивается независимыми энтузиастами. Он не аффилирован с VK (МКПАО «ВК»), не одобрен и не поддерживается компанией или её сотрудниками. «Маруся», «VK Капсула» и связанные названия являются товарными знаками их правообладателей и используются здесь только для описания совместимости.
18 changes: 18 additions & 0 deletions cmd/marusya-ask/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -67,13 +70,21 @@ 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)
}
} else {
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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions deploy/server/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
74 changes: 70 additions & 4 deletions deploy/wake/wake_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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()


Expand All @@ -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
Expand All @@ -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 ----------


Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading