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
37 changes: 27 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 @@ -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` | поведение диалога |

### Ключевое слово
Expand Down Expand Up @@ -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 попадает только запрос после
Expand Down Expand Up @@ -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 колонки (по умолчанию первая);
Expand Down Expand Up @@ -1051,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 Капсула» и связанные названия являются товарными знаками их правообладателей и используются здесь только для описания совместимости.
6 changes: 6 additions & 0 deletions cmd/marusya-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.")
)
Expand Down Expand Up @@ -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)
Expand Down
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
3 changes: 3 additions & 0 deletions configs/agent.conf.d.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cmd> get`, `<cmd> set <0..100>`
# and `<cmd> mute on|off`. Takes precedence over MARUSYA_MIXER_CONTROL.
# See configs/volume-capsule-ipc.example.sh.
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
Loading
Loading