Add Spotify playback, web search, and Mini 2 autostart

This commit is contained in:
2026-11-10 15:54:23 +03:00
parent 76a0eb86d0
commit 58d28c7683
31 changed files with 1282 additions and 64 deletions
+2
View File
@@ -5,3 +5,5 @@ OPENROUTER_API_KEY=
TELEGRAM_BOT_TOKEN=
TELEGRAM_OWNER_ID=
ADMIN_HOST=miniapp.pepega.systems
SPOTIFY_CLIENT_ID=
SPOTIFY_REDIRECT_URI=https://miniapp.pepega.systems/api/spotify/callback
+12 -5
View File
@@ -1,6 +1,6 @@
# Алиса для Станции Мини 2
Собственный голосовой клиент для рутированной Mini 2, сервер на Node.js и пульт в Telegram. На колонке работает экспериментальный детектор, обученный локально на нескольких произнесениях «Алиса». После обнаружения он подаёт сигнал. Команду надо произнести после сигнала.
Собственный голосовой клиент для рутированной Mini 2, сервер на Node.js и пульт в Telegram. На колонке работает экспериментальный детектор, обученный локально на нескольких произнесениях «Алиса». Когда клиент готов принимать команду, верхнее кольцо загорается сине-фиолетовым. Произнеси команду после включения кольца.
```text
Mini 2 → WSS с PCM16 / 16 кГц → Scribe v2 Realtime
@@ -29,7 +29,7 @@ node --env-file=.env server/index.mjs
docker compose -f deploy/compose.yaml up -d --build
```
Caddy проксирует голосовое соединение и `/healthz` на `alice.pepega.systems`, а пульт и API на `miniapp.pepega.systems`. История и редактирование настроек требуют Telegram `initData` с проверенной подписью и ID владельца из `TELEGRAM_OWNER_ID`. В пульте можно выбрать модель OpenRouter, голос и модель синтеза, изменить системный промпт и очистить историю. В текущем API распознавания в реальном времени доступна одна модель.
Caddy проксирует голосовое соединение и `/healthz` на `alice.pepega.systems`, а пульт и API на `miniapp.pepega.systems`. История и редактирование настроек требуют Telegram `initData` с проверенной подписью и ID владельца из `TELEGRAM_OWNER_ID`. В пульте можно выбрать модель OpenRouter, голос и модель синтеза, изменить системный промпт, подключить Spotify и очистить историю. В текущем API распознавания в реальном времени доступна одна модель.
## Протокол колонки
@@ -38,8 +38,15 @@ Caddy проксирует голосовое соединение и `/healthz`
1. Сервер посылает `{"type":"ready"}`.
2. Клиент посылает `{"type":"start"}`, затем бинарные блоки mono PCM16LE 16 кГц и `{"type":"end"}`.
3. Сервер присылает JSON `partial` и `transcript` с полем `text`, затем бинарные блоки mono PCM16LE 24 кГц.
4. Сервер присылает `{"type":"done"}` или `{"type":"error","code":"…"}` и закрывает соединение.
4. Для команды Spotify сервер присылает `device_tool` с полями `id`, `name` и `arguments`. Клиент возвращает `device_tool_result` с тем же `id` и полем `result`.
5. Сервер присылает `{"type":"done"}` или `{"type":"error","code":"…"}` и закрывает соединение.
## Музыка
## Spotify и поиск в интернете
Spotify Connect и управление музыкой пока не подключены. Для ручного Connect Spotify предлагает [Soloist](https://developer.spotify.com/documentation/soloist), которому нужны собственный ключ Spotify и PipeWire или PulseAudio на Linux. На Mini 2 штатный тракт ALSA. [Правила Spotify для разработчиков](https://developer.spotify.com/compliance-tips) запрещают использовать их инструменты для собственного голосового управления.
Музыку на колонке воспроизводит официальный Spotify Soloist. На Mini 2 он запускается с отдельным ARM64 runtime и PulseAudio. Музыка и ответы идут через разные входы микшера. Кольцо включается после приглушения музыки, а после ответа громкость музыкального входа восстанавливается.
Для поиска по названию создай Spotify Web API приложение с Redirect URI `https://miniapp.pepega.systems/api/spotify/callback`, задай его Client ID в `SPOTIFY_CLIENT_ID` на сервере и подключи аккаунт в пульте. Soloist отдельно требует свой ключ и первый выбор устройства «Pepega Mini 2» в приложении Spotify. Сессия Connect сохраняется на колонке.
Клод вызывает `spotify_play` для трека, исполнителя, альбома или плейлиста. Поддерживаются пауза, продолжение, следующий и предыдущий трек и установка громкости. Для актуальных сведений и проверки неизвестных фактов Клод может вызвать [серверный веб-поиск OpenRouter](https://openrouter.ai/docs/guides/features/server-tools/web-search).
[Spotify Soloist](https://developer.spotify.com/documentation/soloist) · [Рецепт librespot для сравнения](https://hackalice.me/recipes/spotify/)
+1 -1
View File
@@ -10,7 +10,7 @@ alice.pepega.systems {
}
miniapp.pepega.systems {
@miniapp path / /style.css /app.js /api/admin /api/admin/*
@miniapp path / /style.css /app.js /api/admin /api/admin/* /api/spotify/start /api/spotify/callback
handle @miniapp {
reverse_proxy alice-backend:3000
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"time"
)
const (
cacheBytesLimit = int64(16 << 20)
cacheFilesLimit = 1024
cacheScanLimit = 4096
cacheGrace = 30 * time.Second
)
var errScanLimit = errors.New("cache scan limit reached")
type cacheFile struct {
path string
size int64
mod time.Time
info fs.FileInfo
}
// cleanCache operates only in the dedicated Soloist cache directory. Recent files
// are left alone to avoid interfering with downloads still being written.
func cleanCache(root string, now time.Time) error {
info, err := os.Lstat(root)
if err != nil {
return err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("cache root must be a directory, not a symlink")
}
var files []cacheFile
var total int64
scanned := 0
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
scanned++
if scanned > cacheScanLimit {
return errScanLimit
}
if !d.Type().IsRegular() {
return nil
}
i, err := d.Info()
if err != nil {
return err
}
if !i.Mode().IsRegular() {
return nil
}
files = append(files, cacheFile{path, i.Size(), i.ModTime(), i})
total += i.Size()
return nil
})
if err != nil && !errors.Is(err, errScanLimit) {
return err
}
sort.Slice(files, func(i, j int) bool { return files[i].mod.Before(files[j].mod) })
count := len(files)
for _, f := range files {
if total <= cacheBytesLimit && count <= cacheFilesLimit && !errors.Is(err, errScanLimit) {
break
}
if now.Sub(f.mod) < cacheGrace {
continue
}
// Do not follow symlinks or delete a file that changed during the scan.
current, e := os.Lstat(f.path)
if e != nil {
if errors.Is(e, os.ErrNotExist) {
continue
}
return e
}
if !current.Mode().IsRegular() || !os.SameFile(f.info, current) || current.Size() != f.size || !current.ModTime().Equal(f.mod) {
continue
}
if e = os.Remove(f.path); e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
total -= f.size
count--
// When truncated, clear enough entries for the next traversal to advance.
if errors.Is(err, errScanLimit) && count <= cacheFilesLimit {
break
}
}
if errors.Is(err, errScanLimit) {
return fmt.Errorf("%w; will retry", err)
}
return nil
}
func runCacheJanitor(root string) error {
if err := os.MkdirAll(root, 0700); err != nil {
return err
}
for {
if err := cleanCache(root, time.Now()); err != nil {
fmt.Fprintln(os.Stderr, "spotify cache:", err)
}
time.Sleep(3 * time.Second)
}
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestCleanCacheOldestAndSymlink(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
now := time.Now()
create := func(path string, size int64, age time.Duration) {
t.Helper()
f, e := os.Create(path)
if e != nil {
t.Fatal(e)
}
if e = f.Truncate(size); e != nil {
t.Fatal(e)
}
f.Close()
m := now.Add(-age)
if e = os.Chtimes(path, m, m); e != nil {
t.Fatal(e)
}
}
old := filepath.Join(root, "old")
newer := filepath.Join(root, "newer")
active := filepath.Join(root, "active")
target := filepath.Join(outside, "keep")
create(old, 10<<20, time.Hour)
create(newer, 8<<20, time.Minute)
create(active, 1<<20, 0)
create(target, 1, time.Hour)
if e := os.Symlink(target, filepath.Join(root, "link")); e != nil {
t.Fatal(e)
}
if e := cleanCache(root, now); e != nil {
t.Fatal(e)
}
if _, e := os.Stat(old); !os.IsNotExist(e) {
t.Fatalf("old file still exists: %v", e)
}
for _, p := range []string{newer, active, target} {
if _, e := os.Stat(p); e != nil {
t.Fatal(e)
}
}
}
func TestCleanCacheProtectsRecentAndRejectsSymlinkRoot(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "active")
f, e := os.Create(path)
if e != nil {
t.Fatal(e)
}
if e = f.Truncate(20 << 20); e != nil {
t.Fatal(e)
}
f.Close()
if e = cleanCache(root, time.Now()); e != nil {
t.Fatal(e)
}
if _, e = os.Stat(path); e != nil {
t.Fatal(e)
}
link := filepath.Join(t.TempDir(), "link")
if e = os.Symlink(root, link); e != nil {
t.Fatal(e)
}
if e = cleanCache(link, time.Now()); e == nil {
t.Fatal("symlink root accepted")
}
}
+5
View File
@@ -0,0 +1,5 @@
module alice-back/music
go 1.21
require github.com/gorilla/websocket v1.5.3
+2
View File
@@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strings"
"time"
"github.com/gorilla/websocket"
)
type request struct {
Name string `json:"name"`
Arguments struct {
URI string `json:"uri"`
Action string `json:"action"`
Volume *int `json:"volume"`
Active bool `json:"active"`
} `json:"arguments"`
}
var validURI = regexp.MustCompile(`^spotify:(track|artist|album|playlist|episode):[A-Za-z0-9]{22}$`)
func execute(req request) (map[string]interface{}, error) {
if req.Name == "duck" {
volume := "100%"
if req.Arguments.Active {
volume = "18%"
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/data/opt/alice-backend/spotify/pactl", "set-sink-volume", "music", volume)
if err := cmd.Run(); err != nil {
return nil, errors.New("music_audio_unavailable")
}
return map[string]interface{}{"ok": true}, nil
}
command := map[string]interface{}{"type": "command"}
if req.Name == "spotify_play" {
if !validURI.MatchString(req.Arguments.URI) {
return nil, errors.New("invalid_spotify_uri")
}
command["command"] = "play"
command["uri"] = req.Arguments.URI
} else if req.Name == "spotify_control" {
actions := map[string]string{"pause": "pause", "resume": "play", "next": "skip_next", "previous": "skip_prev", "volume": "set_volume"}
action, ok := actions[req.Arguments.Action]
if !ok {
return nil, errors.New("invalid_music_action")
}
command["command"] = action
if action == "set_volume" {
if req.Arguments.Volume == nil || *req.Arguments.Volume < 0 || *req.Arguments.Volume > 100 {
return nil, errors.New("invalid_music_volume")
}
command["volume"] = *req.Arguments.Volume
}
} else {
return nil, errors.New("unknown_music_tool")
}
dialer := websocket.Dialer{HandshakeTimeout: 2 * time.Second}
conn, _, err := dialer.Dial("ws://127.0.0.1:9090", nil)
if err != nil {
return nil, errors.New("spotify_unavailable")
}
defer conn.Close()
conn.SetReadLimit(256 * 1024)
deadline := time.Now().Add(10 * time.Second)
_ = conn.SetReadDeadline(deadline)
_ = conn.SetWriteDeadline(deadline)
var auth struct {
Type string `json:"type"`
LoggedIn bool `json:"logged_in"`
}
if err := conn.ReadJSON(&auth); err != nil || auth.Type != "auth_state" || !auth.LoggedIn {
return nil, errors.New("spotify_not_paired")
}
if err := conn.WriteJSON(command); err != nil {
return nil, errors.New("spotify_disconnected")
}
accepted := false
for {
var event map[string]interface{}
if err := conn.ReadJSON(&event); err != nil {
if accepted {
return map[string]interface{}{"accepted": true, "status": "pending"}, nil
}
return nil, errors.New("spotify_response_timeout")
}
switch event["type"] {
case "error":
return nil, errors.New("spotify_command_failed")
case "command_result":
if event["command"] == command["command"] {
accepted = true
if err := conn.WriteJSON(map[string]string{"type": "command", "command": "get_state"}); err != nil {
return nil, errors.New("spotify_disconnected")
}
}
case "playback_state":
if accepted {
return map[string]interface{}{"accepted": true, "status": event["status"], "item": event["item"], "volume": event["volume"]}, nil
}
}
}
}
func main() {
cacheDir := flag.String("cache-janitor", "", "maintain the dedicated Soloist cache directory")
flag.Parse()
if *cacheDir != "" {
if err := runCacheJanitor(*cacheDir); err != nil {
fmt.Fprintln(os.Stderr, "spotify cache:", err)
os.Exit(1)
}
return
}
var req request
decoder := json.NewDecoder(io.LimitReader(os.Stdin, 8193))
decoder.DisallowUnknownFields()
var result map[string]interface{}
if err := decoder.Decode(&req); err != nil {
result = map[string]interface{}{"error": "invalid_music_request"}
} else {
var err error
result, err = execute(req)
if err != nil {
result = map[string]interface{}{"error": strings.TrimSpace(err.Error())}
}
}
data, err := json.Marshal(result)
if err != nil || len(data) > 12000 {
data = []byte(`{"error":"music_result_too_large"}`)
}
fmt.Println(string(data))
}
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Build on an x86-64 Linux Docker host; writes the archive to the current directory.
set -euo pipefail
out="${1:-$PWD/alice-spotify-runtime.tar.gz}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
cat > "$work/build.sh" <<'INNER'
#!/bin/bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
dpkg --add-architecture arm64
apt-get update -qq
apt-get install -y -qq --no-install-recommends curl ca-certificates binutils python3 > /dev/null
mkdir -p /work/debs/partial /work/root /work/out/lib /work/out/bin /work/out/etc
# An empty dpkg status prevents installed amd64 packages from satisfying arm64 dependencies.
apt-get -y --download-only --no-install-recommends -o APT::Architecture=arm64 -o Dir::State::status=/dev/null -o Dir::Cache::archives=/work/debs install pulseaudio:arm64 pulseaudio-utils:arm64 > /work/apt.log
for f in /work/debs/*.deb; do dpkg-deb -x "$f" /work/root; done
curl -fL --retry 3 https://soloist-builds.spotifycdn.com/soloist_release_arm64.tar.gz -o /work/soloist.tar.gz
mkdir /work/soloist
tar -xzf /work/soloist.tar.gz -C /work/soloist
find /work/soloist -type f -maxdepth 4 -print
cp /work/root/usr/bin/pulseaudio /work/root/usr/bin/pacat /work/root/usr/bin/pactl /work/out/bin/
cp -a /work/soloist/. /work/out/soloist/
# Follow DT_NEEDED edges, including module dependencies and PulseAudio's private libs.
python3 - <<'PY'
import os, subprocess, shutil, glob
root='/work/root'; out='/work/out'; paths={}
for base, dirs, files in os.walk(root):
for f in files:
p=os.path.join(base,f)
if '.so' in f and os.path.isfile(p): paths.setdefault(f,p)
seeds=[out+'/bin/pulseaudio',out+'/bin/pacat',out+'/bin/pactl']
for base,dirs,files in os.walk(out+'/soloist'):
for f in files:
p=os.path.join(base,f)
if open(p,'rb').read(4)==b'\x7fELF': seeds.append(p)
mods=glob.glob(root+'/usr/lib/aarch64-linux-gnu/pulseaudio/modules/module-{alsa-sink,native-protocol-unix}.so')
# glob does not expand braces.
mods=[glob.glob(root+'/usr/lib/**/module-'+n+'.so',recursive=True)[0] for n in ('alsa-sink','native-protocol-unix','remap-sink')]
for p in mods: shutil.copy2(p,out+'/lib/'); seeds.append(out+'/lib/'+os.path.basename(p))
seen=set(); missing=set()
while seeds:
p=seeds.pop()
try: s=subprocess.check_output(['readelf','-d',p],stderr=subprocess.DEVNULL,text=True)
except subprocess.CalledProcessError: continue
for line in s.splitlines():
if '(NEEDED)' not in line: continue
name=line.split('[')[1].split(']')[0]
if name in seen: continue
seen.add(name)
if name not in paths: missing.add(name); continue
src=paths[name]; dest=out+'/lib/'+name
shutil.copy2(src,dest,follow_symlinks=True); seeds.append(dest)
if missing: raise SystemExit('Missing ELF dependencies: '+', '.join(sorted(missing)))
loader=glob.glob(root+'/lib/ld-linux-aarch64.so.1')+glob.glob(root+'/usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1')
shutil.copy2(loader[0],out+'/lib/ld-linux-aarch64.so.1',follow_symlinks=True)
PY
# ALSA configuration is data loaded at runtime, not an ELF dependency.
cp -a /work/root/usr/share/alsa /work/out/alsa
cat > /work/out/etc/default.pa <<'PA'
load-module module-native-protocol-unix auth-anonymous=1
load-module module-alsa-sink sink_name=station device=hw:0,0
load-module module-remap-sink sink_name=music master=station
set-default-sink music
PA
cat > /work/out/etc/daemon.conf <<'PA'
daemonize = no
exit-idle-time = -1
flat-volumes = no
PA
cat > /work/out/run <<'RUN'
#!/bin/sh
set -eu
base=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
export ALSA_CONFIG_PATH="$base/alsa/alsa.conf"
export ALSA_CONFIG_DIR="$base/alsa"
export PULSE_RUNTIME_PATH="${PULSE_RUNTIME_PATH:-/data/local/tmp/alice-pulse}"
export PULSE_CONFIG_PATH="$base/etc"
mkdir -p "$PULSE_RUNTIME_PATH"
exec "$base/lib/ld-linux-aarch64.so.1" --library-path "$base/lib" "$@"
RUN
chmod +x /work/out/run
(cd /work/out && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS)
for f in /work/debs/*.deb; do dpkg-deb -f "$f" Package Version Architecture; printf "\n"; done > /work/out/DEBIAN-PACKAGES.txt
(cd /work/out && sha256sum DEBIAN-PACKAGES.txt >> SHA256SUMS)
sha256sum /work/soloist.tar.gz > /work/out/SOLOIST-ARCHIVE-SHA256.txt
(cd /work/out && sha256sum SOLOIST-ARCHIVE-SHA256.txt >> SHA256SUMS)
tar -czf /output/runtime.tar.gz -C /work/out .
du -sh /work/out /output/runtime.tar.gz
INNER
chmod +x "$work/build.sh"
docker run --rm --platform linux/amd64 -v "$work:/output" debian:trixie-slim bash /output/build.sh
cp "$work/runtime.tar.gz" "$out"
printf 'Built %s\n' "$out"
+14 -2
View File
@@ -1,6 +1,7 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { validateSettings } from './store.mjs';
import { spotifyStatus, startSpotify, finishSpotify } from './spotify.mjs';
const assets = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
@@ -75,17 +76,28 @@ export async function handleAdmin(req, res, store) {
return true;
}
if (route !== '/api/admin' && route !== '/api/admin/settings' && route !== '/api/admin/history') return false;
if (route === '/api/spotify/callback' && req.method === 'GET') {
const params = new URL(req.url, 'http://localhost').searchParams;
let success = false;
try { await finishSpotify(params.get('state'), params.get('code')); success = true; } catch {}
res.writeHead(success ? 200 : 400, { ...securityHeaders, 'content-type': 'text/html; charset=utf-8' });
res.end(success ? '<!doctype html><html lang="ru"><meta charset="utf-8"><title>Spotify подключён</title><p>Spotify подключён. Вернитесь в пульт.</p></html>' : '<!doctype html><html lang="ru"><meta charset="utf-8"><title>Ошибка подключения</title><p>Не удалось подключить Spotify. Попробуйте снова из пульта.</p></html>');
return true;
}
if (route !== '/api/admin' && route !== '/api/admin/settings' && route !== '/api/admin/history' && route !== '/api/spotify/start') return false;
const auth = req.headers.authorization ?? '';
if (!auth.startsWith('tma ') || !verifyTelegram(auth.slice(4), process.env.TELEGRAM_BOT_TOKEN, process.env.TELEGRAM_OWNER_ID)) {
json(res, 401, { error: 'telegram_auth' });
return true;
}
if (route === '/api/admin' && req.method === 'GET') {
if (route === '/api/spotify/start' && req.method === 'POST') {
try { json(res, 200, { url: startSpotify() }); } catch { json(res, 503, { error: 'spotify_not_configured' }); }
} else if (route === '/api/admin' && req.method === 'GET') {
json(res, 200, {
settings: store.getSettings(),
history: store.getHistory().reverse(),
spotify: spotifyStatus(),
});
} else if (route === '/api/admin/settings' && req.method === 'PUT') {
const settings = await readSettings(req);
+58
View File
@@ -0,0 +1,58 @@
import { randomUUID } from 'node:crypto';
import WebSocket from 'ws';
import { searchSpotify } from './spotify.mjs';
const spotifyUri = /^spotify:(track|artist|album|playlist|episode):[A-Za-z0-9]{22}$/;
export function deviceTools(client, signal) {
const pending = new Map();
signal.addEventListener('abort', () => {
for (const { reject, timeout } of pending.values()) {
clearTimeout(timeout);
reject(new Error('device_disconnected'));
}
pending.clear();
}, { once: true });
function request(name, args) {
if (signal.aborted || client.readyState !== WebSocket.OPEN) throw new Error('device_disconnected');
const id = randomUUID();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error('device_tool_timeout'));
}, 17000);
pending.set(id, { resolve, reject, timeout });
client.send(JSON.stringify({ type: 'device_tool', id, name, arguments: args }));
});
}
return {
accept(message) {
if (message?.type !== 'device_tool_result') return false;
const call = pending.get(message.id);
if (!call) return true;
clearTimeout(call.timeout);
pending.delete(message.id);
call.resolve(message.result ?? { error: 'empty_device_result' });
return true;
},
async execute(name, args) {
if (name !== 'spotify_play') return request(name, args);
let selected;
if (args.uri) {
if (!spotifyUri.test(args.uri)) return { error: 'invalid_spotify_uri' };
selected = { uri: args.uri };
} else {
try {
selected = (await searchSpotify(args.query, signal))[0];
} catch {
return { error: 'spotify_search_unavailable', hint: 'Подключи Spotify в пульте Telegram и повтори запрос.' };
}
}
if (!selected) return { error: 'spotify_not_found' };
const result = await request(name, { uri: selected.uri });
return { ...result, selection: selected };
},
};
}
+73 -24
View File
@@ -1,4 +1,5 @@
import WebSocket from 'ws';
import { tools, validateTool } from './tools.mjs';
const env = process.env;
const sttUrl = env.ELEVEN_STT_URL || 'wss://api.elevenlabs.io/v1/speech-to-text/realtime';
@@ -64,31 +65,79 @@ export async function openTts(settings, signal, onAudio) {
return { ws, done, text(text) { if (ws.readyState !== WebSocket.OPEN) throw new Error('tts_closed'); ws.send(JSON.stringify(flash ? { text } : { inputs: [{ text, voice_id: settings.voiceId }] })); }, finish() { if (ws.readyState !== WebSocket.OPEN) throw new Error('tts_closed'); ws.send(JSON.stringify(flash ? { text: '' } : { close_socket: true })); } };
}
export async function streamAnswer(settings, history, userText, signal, onText) {
export async function streamAnswer(settings, history, userText, signal, onText, executeDeviceTool) {
if (env.LLM_MODE === 'echo') { onText(userText); return userText; }
if (!env.OPENROUTER_API_KEY) throw new Error('llm_config');
const messages = [{ role: 'system', content: settings.systemPrompt }, ...history.slice(-10).flatMap(t => [{ role: 'user', content: t.user }, { role: 'assistant', content: t.assistant }]), { role: 'user', content: userText }];
const response = await fetch(llmUrl, { method: 'POST', signal, headers: { Authorization: `Bearer ${env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: settings.llmModel, messages, stream: true, max_tokens: 512 }) });
if (!response.ok || !response.body) throw new Error('llm_error');
let buffer = '', answer = '', finished = false;
const decoder = new TextDecoder();
const consume = event => {
const payload = event.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
if (!payload) return;
if (payload === '[DONE]') { finished = true; return; }
const message = JSON.parse(payload);
if (message.error) throw new Error('llm_error');
const content = message.choices?.[0]?.delta?.content;
if (typeof content === 'string' && content) { answer += content; if (answer.length > 12000) throw new Error('llm_limit'); onText(content); }
};
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
if (buffer.length > 65536) throw new Error('llm_limit');
let match;
while ((match = /\r?\n\r?\n/.exec(buffer))) { consume(buffer.slice(0, match.index)); buffer = buffer.slice(match.index + match[0].length); }
const moscowDate = new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', dateStyle: 'long' }).format(new Date());
const systemPrompt = `${settings.systemPrompt}\nСегодня ${moscowDate} (Москва). Используй веб-поиск для актуальных сведений и когда требуется проверить сомнительные факты. Вызывай инструменты без вступительных фраз. Подтверждай воспроизведение музыки только после успешного результата инструмента. Отвечай для озвучивания без Markdown и ссылок, если их не попросили.`;
const messages = [{ role: 'system', content: systemPrompt }, ...history.slice(-10).flatMap(t => [{ role: 'user', content: t.user }, { role: 'assistant', content: t.assistant }]), { role: 'user', content: userText }];
let answer = '', totalCalls = 0;
for (let round = 0; round < 4; round++) {
const response = await fetch(llmUrl, { method: 'POST', signal, headers: { Authorization: `Bearer ${env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: settings.llmModel, messages, tools, stream: true, max_tokens: 1200, ...(settings.llmModel === 'anthropic/claude-haiku-5.5' ? { reasoning: { enabled: false, effort: 'low' } } : {}) }) });
if (!response.ok || !response.body) throw new Error('llm_error');
let buffer = '', content = '', done = false, finishReason;
const calls = new Map();
const decoder = new TextDecoder();
const consume = event => {
const payload = event.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
if (!payload) return;
if (payload === '[DONE]') { done = true; return; }
const message = JSON.parse(payload);
if (message.error) throw new Error('llm_error');
const choice = message.choices?.[0];
if (!choice) return;
if (choice.finish_reason) finishReason = choice.finish_reason;
const delta = choice.delta || {};
if (typeof delta.content === 'string' && delta.content) {
const prefix = !content && answer && !/\s$/.test(answer) && !/^\s/.test(delta.content) ? ' ' : '';
content += delta.content; answer += prefix + delta.content;
if (answer.length > 12000) throw new Error('llm_limit');
onText(prefix + delta.content);
}
for (const part of delta.tool_calls || []) {
if (!Number.isInteger(part.index) || part.index < 0 || part.index >= 4) throw new Error('llm_limit');
const call = calls.get(part.index) || { id: '', type: 'function', function: { name: '', arguments: '' } };
if (part.id) call.id += part.id;
if (part.type && part.type !== 'function') throw new Error('llm_error');
if (part.function?.name) call.function.name += part.function.name;
if (part.function?.arguments) call.function.arguments += part.function.arguments;
if (call.id.length > 512 || call.function.name.length > 128 || call.function.arguments.length > 8192) throw new Error('llm_limit');
calls.set(part.index, call);
}
};
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
if (buffer.length > 65536) throw new Error('llm_limit');
let match;
while ((match = /\r?\n\r?\n/.exec(buffer))) { consume(buffer.slice(0, match.index)); buffer = buffer.slice(match.index + match[0].length); }
}
buffer += decoder.decode();
if (buffer.trim()) consume(buffer);
if (!done || !finishReason) throw new Error('llm_incomplete');
if (!calls.size) {
if (finishReason !== 'stop' || !answer.trim()) throw new Error('llm_incomplete');
return answer;
}
if (finishReason !== 'tool_calls') throw new Error('llm_incomplete');
const ordered = [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, call]) => call);
if ((totalCalls += ordered.length) > 4 || round === 3 || ordered.some(c => !c.id || !c.function.name)) throw new Error('llm_limit');
messages.push({ role: 'assistant', content: content || null, tool_calls: ordered });
for (const call of ordered) {
if (signal.aborted) throw new Error('aborted');
let result;
try {
const args = validateTool(call.function.name, JSON.parse(call.function.arguments));
if (typeof executeDeviceTool !== 'function') throw new Error('device_unavailable');
result = await executeDeviceTool(call.function.name, args);
if (signal.aborted) throw new Error('aborted');
result = JSON.stringify(result ?? { ok: true });
if (result.length > 8192) result = JSON.stringify({ error: 'result_too_large' });
} catch (error) {
if (signal.aborted) throw error;
result = JSON.stringify({ error: 'tool_failed' });
}
messages.push({ role: 'tool', tool_call_id: call.id, content: result });
}
}
buffer += decoder.decode();
if (buffer.trim()) consume(buffer);
if (!finished || !answer.trim()) throw new Error('llm_incomplete');
return answer;
throw new Error('llm_limit');
}
+22 -1
View File
@@ -8,6 +8,8 @@ const showSettings = document.querySelector('#show-settings');
const clear = document.querySelector('#clear');
const form = document.querySelector('#form');
const turns = document.querySelector('#turns');
const spotifyStatus = document.querySelector('#spotify-status');
const spotifyConnect = document.querySelector('#spotify-connect');
const dateFormat = new Intl.DateTimeFormat('ru', { dateStyle: 'medium', timeStyle: 'short' });
@@ -61,7 +63,25 @@ showHistory.onclick = async () => {
status.textContent = error.message;
}
};
showSettings.onclick = () => selectTab(false);
function renderSpotify(spotify) {
spotifyStatus.textContent = !spotify.configured ? 'Spotify пока не настроен на сервере.' : spotify.connected ? 'Аккаунт подключён.' : 'Подключи аккаунт для управления музыкой.';
spotifyConnect.disabled = !spotify.configured;
spotifyConnect.textContent = spotify.connected ? 'Подключить другой аккаунт' : 'Подключить Spotify';
}
showSettings.onclick = async () => {
selectTab(false);
try { renderSpotify((await api()).spotify); } catch (error) { status.textContent = error.message; }
};
spotifyConnect.onclick = async () => {
spotifyConnect.disabled = true;
try {
const response = await fetch('/api/spotify/start', { method: 'POST', headers: { Authorization: 'tma ' + telegram.initData } });
if (!response.ok) throw new Error('Не удалось начать подключение Spotify.');
const { url } = await response.json();
telegram.openLink(url);
} catch (error) { status.textContent = error.message; }
finally { spotifyConnect.disabled = false; }
};
form.onsubmit = async (event) => {
event.preventDefault();
@@ -101,6 +121,7 @@ if (!telegram?.initData) {
const data = await api();
for (const [name, value] of Object.entries(data.settings)) form.elements.namedItem(name).value = value;
renderTurns(data.history);
renderSpotify(data.spotify);
status.textContent = '';
admin.hidden = false;
} catch (error) {
+1
View File
@@ -26,6 +26,7 @@
<div id="turns"></div>
</section>
<section id="settings" hidden>
<div class="spotify-card"><h2>Spotify</h2><p id="spotify-status">Проверяем подключение…</p><button id="spotify-connect" type="button" class="primary">Подключить Spotify</button></div>
<h2>Голосовой тракт</h2>
<form id="form">
<label>Распознавание<select name="sttModel"><option value="scribe_v2_realtime">Scribe v2 Realtime</option></select></label>
+3
View File
@@ -39,3 +39,6 @@ label { display: grid; gap: 5px; font-size: 13px; font-weight: 650; }
input, select, textarea { width: 100%; background: var(--surface); border: 1px solid transparent; border-radius: 11px; padding: 10px; font-size: 16px; font-weight: 400; }
textarea { resize: vertical; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.spotify-card { background: var(--surface); padding: 16px; border-radius: 15px; margin-bottom: 24px; }
.spotify-card p { color: var(--muted); margin: 5px 0 12px; }
+96
View File
@@ -0,0 +1,96 @@
import { createHash, randomBytes } from 'node:crypto';
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const pending = new Map();
let refreshInFlight;
const clientId = () => process.env.SPOTIFY_CLIENT_ID || '';
const redirectUri = () => process.env.SPOTIFY_REDIRECT_URI || 'https://miniapp.pepega.systems/api/spotify/callback';
const tokenPath = () => join(process.env.DATA_DIR || './data', 'spotify.json');
const base64url = bytes => bytes.toString('base64url');
function loadToken() {
try {
const token = JSON.parse(readFileSync(tokenPath(), 'utf8'));
return typeof token.refresh_token === 'string' && token.refresh_token ? token : null;
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
function saveToken(token) {
const path = tokenPath();
mkdirSync(process.env.DATA_DIR || './data', { recursive: true, mode: 0o700 });
const temp = `${path}.${randomBytes(8).toString('hex')}.tmp`;
try {
writeFileSync(temp, JSON.stringify(token), { mode: 0o600, flag: 'wx' });
renameSync(temp, path);
chmodSync(path, 0o600);
} catch (error) {
try { unlinkSync(temp); } catch {}
throw error;
}
}
export function spotifyStatus() {
return { configured: Boolean(clientId()), connected: Boolean(loadToken()) };
}
export function startSpotify() {
if (!clientId()) throw new Error('spotify_not_configured');
const now = Date.now();
for (const [key, entry] of pending) if (entry.expires <= now) pending.delete(key);
// One owner: a new attempt invalidates previous attempts.
pending.clear();
const state = base64url(randomBytes(32));
const verifier = base64url(randomBytes(64));
pending.set(state, { verifier, expires: now + 600_000 });
const url = new URL('https://accounts.spotify.com/authorize');
url.search = new URLSearchParams({ client_id: clientId(), response_type: 'code', redirect_uri: redirectUri(), state, code_challenge_method: 'S256', code_challenge: base64url(createHash('sha256').update(verifier).digest()), scope: 'user-read-playback-state user-modify-playback-state' });
return url.toString();
}
async function tokenRequest(params, signal) {
const response = await fetch('https://accounts.spotify.com/api/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: clientId(), ...params }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error('spotify_token_error');
const data = await response.json();
if (typeof data.access_token !== 'string' || !Number.isFinite(data.expires_in) || data.expires_in <= 0) throw new Error('spotify_token_error');
return data;
}
export async function finishSpotify(state, code) {
if (typeof state !== 'string' || !state || typeof code !== 'string' || !code || code.length > 4096) throw new Error('spotify_callback_invalid');
const entry = pending.get(state);
if (!entry || entry.expires <= Date.now()) { pending.delete(state); throw new Error('spotify_callback_invalid'); }
pending.delete(state);
const data = await tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: redirectUri(), code_verifier: entry.verifier });
if (typeof data.refresh_token !== 'string' || !data.refresh_token) throw new Error('spotify_token_error');
saveToken({ access_token: data.access_token, refresh_token: data.refresh_token, expires_at: Date.now() + data.expires_in * 1000 });
}
async function accessToken() {
const current = loadToken();
if (!current) throw new Error('spotify_not_connected');
if (current.access_token && current.expires_at > Date.now() + 60_000) return current.access_token;
if (!refreshInFlight) {
refreshInFlight = (async () => {
const data = await tokenRequest({ grant_type: 'refresh_token', refresh_token: current.refresh_token });
const next = { access_token: data.access_token, refresh_token: data.refresh_token || current.refresh_token, expires_at: Date.now() + data.expires_in * 1000 };
// A new authorization can complete while refresh is in flight.
const latest = loadToken();
if (!latest || latest.refresh_token !== current.refresh_token) return latest?.access_token || next.access_token;
saveToken(next);
return next.access_token;
})().finally(() => { refreshInFlight = undefined; });
}
return refreshInFlight;
}
export async function searchSpotify(query, signal) {
if (typeof query !== 'string' || !query.trim() || query.length > 500) throw new Error('spotify_invalid_query');
const token = await accessToken();
if (signal?.aborted) throw new Error('aborted');
const url = new URL('https://api.spotify.com/v1/search');
url.search = new URLSearchParams({ q: query.trim(), type: 'track,artist,album,playlist', limit: '5' });
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal });
if (!response.ok) throw new Error('spotify_search_error');
const data = await response.json();
const results = ['tracks', 'artists', 'albums', 'playlists'].flatMap(key => (data[key]?.items || []).filter(item => item?.uri && item?.name).map(item => ({ uri: item.uri, name: item.name, type: item.type, artists: (item.artists || []).map(artist => artist.name), album: item.album?.name || null })));
const normalize = value => value.normalize('NFKC').trim().toLocaleLowerCase().replace(/\s+/g, ' ');
const exactArtist = results.find(item => item.type === 'artist' && normalize(item.name) === normalize(query));
if (exactArtist) return [exactArtist, ...results.filter(item => item !== exactArtist)];
return results;
}
+17
View File
@@ -0,0 +1,17 @@
// OpenRouter executes web search itself; Spotify calls are handled by the device.
export const tools = [
{ type: 'openrouter:web_search' },
{ type: 'function', function: { name: 'spotify_play', description: 'Play a track, artist, album or playlist on Spotify by search query or Spotify URI. Normalize Russian transliterations of artist names to their canonical spelling when known.', parameters: { type: 'object', properties: { query: { type: 'string', maxLength: 500 }, uri: { type: 'string', maxLength: 500 } }, additionalProperties: false } } },
{ type: 'function', function: { name: 'spotify_control', description: 'Control Spotify playback or set volume.', parameters: { type: 'object', properties: { action: { type: 'string', enum: ['pause', 'resume', 'next', 'previous', 'volume'] }, volume: { type: 'integer', minimum: 0, maximum: 100 } }, required: ['action'], additionalProperties: false } } }
];
export function validateTool(name, args) {
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('invalid_arguments');
const keys = Object.keys(args);
if (name === 'spotify_play') {
if (keys.some(k => !['query', 'uri'].includes(k)) || !keys.length || keys.some(k => typeof args[k] !== 'string' || !args[k].trim() || args[k].length > 500)) throw new Error('invalid_arguments');
} else if (name === 'spotify_control') {
if (keys.some(k => !['action', 'volume'].includes(k)) || !['pause', 'resume', 'next', 'previous', 'volume'].includes(args.action) || (args.action === 'volume' ? !Number.isInteger(args.volume) || args.volume < 0 || args.volume > 100 : 'volume' in args)) throw new Error('invalid_arguments');
} else throw new Error('unknown_tool');
return args;
}
+6 -2
View File
@@ -1,5 +1,6 @@
import WebSocket from 'ws';
import { openStt, openTts, streamAnswer } from './providers.mjs';
import { deviceTools } from './device.mjs';
const MAX_AUDIO = 60 * 16000 * 2;
const MAX_BUFFERED = 2 * 1024 * 1024;
@@ -7,6 +8,7 @@ const MAX_BUFFERED = 2 * 1024 * 1024;
export function handleTurn(client, store) {
const controller = new AbortController();
const { signal } = controller;
const device = deviceTools(client, signal);
let state = 'waiting', stt, tts, pending, bytes = 0, received = 0, messages = 0, ended = false, terminal = false;
const timeout = setTimeout(() => fail('timeout'), 120000);
const send = obj => { if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify(obj)); };
@@ -27,6 +29,7 @@ export function handleTurn(client, store) {
}
async function respond(text, settings) {
if (state !== 'processing' || terminal) return;
state = 'responding';
text = text.trim();
if (!text) return fail('empty_transcript');
send({ type: 'transcript', text });
@@ -37,7 +40,7 @@ export function handleTurn(client, store) {
client.send(audio, { binary: true });
});
if (terminal) return;
const answer = await streamAnswer(settings, store.getHistory(), text, signal, part => tts.text(part));
const answer = await streamAnswer(settings, store.getHistory(), text, signal, part => tts.text(part), device.execute);
tts.finish();
await tts.done;
if (terminal) return;
@@ -49,7 +52,7 @@ export function handleTurn(client, store) {
let settings;
let chain = Promise.resolve();
client.on('message', (data, binary) => {
if (++messages > 10000 || (received += data.length) > MAX_AUDIO + 65536) return fail('input_limit');
if (++messages > 10000 || (received += data.length) > MAX_AUDIO + 131072) return fail('input_limit');
chain = chain.then(async () => {
if (terminal) return;
if (binary) {
@@ -62,6 +65,7 @@ export function handleTurn(client, store) {
}
let msg;
try { msg = JSON.parse(data.toString()); } catch { return fail('invalid_message'); }
if (device.accept(msg)) return;
if (msg?.type === 'start' && state === 'waiting') {
state = 'connecting'; settings = store.getSettings();
try {
+21 -3
View File
@@ -19,7 +19,7 @@ cd /data/opt/alice-backend
./with-mic.sh ptt -voice-threshold 55
```
Нажми Enter, дождись сигнала, затем скажи команду. Настройки `-voice-threshold`, `-silence` и `-max` регулируют порог голоса, паузу в конце и предел длительности. Значение 55 проверено на этой колонке вблизи микрофона.
Нажми Enter, дождись сине-фиолетового кольца, затем скажи команду. Настройки `-voice-threshold`, `-silence` и `-max` регулируют порог голоса, паузу в конце и предел длительности. Значение 55 проверено на этой колонке вблизи микрофона.
## Локальная активация по фразе
@@ -28,9 +28,9 @@ cd /data/opt/alice-backend
./with-mic.sh wake -voice-threshold 55 -once
```
При обучении нажимай Enter перед каждым образцом и после сигнала произноси «Алиса», затем сделай паузу. Клиент сохраняет спектральные признаки в `wake-templates.json` с правами `0600`. На тестовой колонке эти образцы уже записаны. В режиме `wake` произнеси «Алиса», дождись сигнала и скажи команду. Флаг `-once` завершает сеанс после первого успешного ответа. Без флага можно провести несколько ходов за один пробный запуск.
При обучении нажимай Enter перед каждым образцом и после включения кольца произноси «Алиса», затем сделай паузу. Клиент сохраняет спектральные признаки в `wake-templates.json` с правами `0600`. На тестовой колонке эти образцы уже записаны. В режиме `wake` произнеси «Алиса», дождись сине-фиолетового кольца и скажи команду. Флаг `-once` завершает сеанс после первого успешного ответа. Без флага можно провести несколько ходов за один пробный запуск.
Клиент сопоставляет целый сегмент речи со спектральными шаблонами методом DTW. Из-за этого после «Алиса» требуется короткая пауза. Оценки пропусков и ложных срабатываний на расстоянии, под музыку и у разных людей пока нет. Клиент использует один канал микрофона и простой даунсемплинг, AEC и прерывание речи в ответе отсутствуют.
Клиент сопоставляет целый сегмент речи со спектральными шаблонами методом DTW и отбрасывает слишком короткие или длинные сегменты. Из-за этого после «Алиса» требуется короткая пауза. Оценки пропусков и ложных срабатываний на расстоянии, под музыку и у разных людей пока нет. Клиент использует один канал микрофона и простой даунсемплинг, AEC и прерывание речи в ответе отсутствуют.
До локального срабатывания соединение с сервером не открывается. После сигнала начало команды буферизуется в RAM, а слово активации остаётся на колонке. Проверенное состояние кнопки mute на этой Mini 2 читается из `/sys/class/gpio/gpio414/value`, где `1` означает разрешённый микрофон. В mute клиент отбрасывает вход, сбрасывает состояние детектора и прерывает текущую передачу. При недоступном GPIO применяется то же поведение. Состояние можно читать из другого узла через `-mute-gpio`, если на конкретной ревизии этот сигнал расположен иначе.
@@ -42,3 +42,21 @@ export DEVICE_TOKEN="$(cat device-token)"
```
Этот режим отправляет тестовый файл (mono PCM16LE, 16 кГц, до 30 секунд) и выводит ответ через ALSA (mono PCM16LE, 24 кГц). Микрофон при этом не открывается.
## Автозапуск и музыка
Загрузочный скрипт `S99alice` запускает наш клиент, а `S98alice-music` поднимает Soloist и PulseAudio. Штатный `S99quasar` пропускает запуск, пока существует `/data/opt/alice-backend/stock-disabled`. Оригинал сохранён как `S99quasar.original` рядом с клиентом.
Для подготовки зависимостей Spotify запусти [скрипт сборки](../scripts/build-spotify-runtime.sh) на x86-64 Linux с Docker. Архив содержит проверяемый список файлов и версий. Распаковывай его потоком в `/data/opt/alice-backend/spotify`, поскольку BusyBox tar на Mini 2 принимает несжатый поток. В `spotify/etc/alice.pa` установи конфигурацию [pulse.conf](pulse.conf), а `pulse-control.sh` скопируй в `spotify/pactl`. Бинарник `music-control` собирается для Linux ARM64 из [music](../music), ключ Soloist хранится в файле `soloist-key` с правами `0600`. Сессия Spotify Connect в `soloist-data/`, кэш ограничен 32 МиБ в tmpfs.
Чтобы вернуть штатную Алису, войди по SSH под root и выполни:
```sh
cd /data/opt/alice-backend
mount -o remount,rw /
./install-init.sh restore
mount -o remount,ro /
/system/vendor/quasar/q start
```
Логи текущего запуска находятся в `/tmp/alice-station.log`, `/tmp/alice-pulse.log` и `/tmp/alice-soloist.log`.
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
set -u
base=/data/opt/alice-backend
pidfile=/tmp/alice-music-supervisor.pid
is_ours() {
case "$1" in ''|*[!0-9]*) return 1 ;; esac
[ -r "/proc/$1/cmdline" ] && tr '\0' '\n' < "/proc/$1/cmdline" | grep -qx "$base/spotify-supervisor.sh"
}
case "${1:-}" in
start)
[ -f "$base/stock-disabled" ] || exit 0
if [ -r "$pidfile" ]; then p=$(cat "$pidfile"); is_ours "$p" && exit 0; fi
setsid "$base/spotify-supervisor.sh" </dev/null >/dev/null 2>&1 &
umask 077
echo $! > "$pidfile"
;;
stop)
[ -r "$pidfile" ] || exit 0
p=$(cat "$pidfile")
if is_ours "$p"; then
kill -TERM "$p" 2>/dev/null || true
for second in 1 2 3 4 5 6 7 8 9 10; do is_ours "$p" || break; sleep 1; done
if is_ours "$p"; then echo 'Music supervisor failed to stop' >&2; exit 1; fi
fi
rm -f "$pidfile"
;;
restart) "$0" stop && "$0" start ;;
*) echo "Usage: $0 {start|stop|restart}" >&2; exit 2 ;;
esac
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
base=/data/opt/alice-backend
pidfile=/tmp/alice-supervisor.pid
is_supervisor() {
case "$1" in ''|*[!0-9]*) return 1 ;; esac
[ -r "/proc/$1/cmdline" ] || return 1
# Match a complete argv element, not a substring or an unrelated reused PID.
tr '\000' '\n' < "/proc/$1/cmdline" | grep -Fqx "$base/station-supervisor.sh"
}
case "${1:-}" in
start)
[ -f "$base/stock-disabled" ] || exit 0
if [ -f "$pidfile" ]; then
p=$(cat "$pidfile")
if is_supervisor "$p"; then exit 0; fi
rm -f "$pidfile"
fi
setsid "$base/station-supervisor.sh" </dev/null >/dev/null 2>&1 &
echo $! > "$pidfile"
;;
stop)
if [ -f "$pidfile" ]; then
p=$(cat "$pidfile")
if is_supervisor "$p"; then
kill -TERM "$p" 2>/dev/null || :
n=0
while is_supervisor "$p"; do
if [ "$n" -ge 25 ]; then echo 'Station supervisor did not stop' >&2; exit 1; fi
sleep 1
n=$((n + 1))
done
fi
rm -f "$pidfile"
fi
;;
restart) "$0" stop && "$0" start ;;
*) echo "Usage: $0 {start|stop|restart}" >&2; exit 2 ;;
esac
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
type deviceTool struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
type limitedOutput struct {
bytes.Buffer
overflow bool
}
func (b *limitedOutput) Write(p []byte) (int, error) {
n := len(p)
room := 16384 - b.Len()
if room < n {
b.overflow = true
if room < 0 {
room = 0
}
p = p[:room]
}
_, _ = b.Buffer.Write(p)
return n, nil
}
var musicMu sync.Mutex
func musicCall(name string, args json.RawMessage) json.RawMessage {
musicMu.Lock()
defer musicMu.Unlock()
fail := func(s string) json.RawMessage { b, _ := json.Marshal(map[string]string{"error": s}); return b }
if name != "spotify_play" && name != "spotify_control" && name != "duck" {
return fail("unsupported device tool")
}
if len(args) == 0 {
args = json.RawMessage(`{}`)
}
if !json.Valid(args) {
return fail("invalid arguments")
}
input, e := json.Marshal(map[string]interface{}{"name": name, "arguments": args})
if e != nil {
return fail("invalid arguments")
}
path := os.Getenv("ALICE_MUSIC_HELPER")
if path == "" {
path = "/data/opt/alice-backend/music-control"
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, path)
terminateWithClient(cmd)
cmd.Stdin = bytes.NewReader(input)
var out limitedOutput
cmd.Stdout = &out
if e = cmd.Run(); e != nil {
return fail(fmt.Sprintf("music helper failed: %v", e))
}
if out.overflow || !json.Valid(bytes.TrimSpace(out.Bytes())) {
return fail("invalid music helper output")
}
return json.RawMessage(bytes.TrimSpace(out.Bytes()))
}
type ring struct {
mu sync.Mutex
state string
done chan struct{}
finished chan struct{}
once sync.Once
}
var lights = &ring{state: "idle"}
func (r *ring) set(s string) { r.mu.Lock(); r.state = s; r.mu.Unlock() }
func (r *ring) start() {
r.done = make(chan struct{})
r.finished = make(chan struct{})
go func() {
defer close(r.finished)
t := time.NewTicker(150 * time.Millisecond)
defer t.Stop()
tick := 0
var last [3]int
first := true
for {
r.mu.Lock()
s := r.state
r.mu.Unlock()
if !unmuted() {
s = "mute"
}
var rgb [3]int
switch s {
case "listening":
rgb = [3]int{12, 0, 70}
case "processing":
rgb = [3]int{8, 0, 18}
case "speaking":
rgb = [3]int{0, 35, 3}
case "mute":
rgb = [3]int{45, 0, 0}
case "error":
if tick%4 < 2 {
rgb = [3]int{50, 0, 0}
}
}
if first || rgb != last {
r.write(rgb)
last = rgb
first = false
}
tick++
select {
case <-r.done:
r.write([3]int{})
return
case <-t.C:
}
}
}()
}
func (r *ring) stop() { r.once.Do(func() { close(r.done); <-r.finished }) }
func (r *ring) flashError() { r.set("error"); time.Sleep(1200 * time.Millisecond); r.set("idle") }
func (r *ring) write(rgb [3]int) {
for i, color := range []string{"red", "green", "blue"} {
for n := 1; n <= 6; n++ {
path := filepath.Join("/sys/class/leds", fmt.Sprintf("mini2:%s:ring%d", color, n), "brightness")
_ = os.WriteFile(path, []byte(fmt.Sprint(rgb[i])), 0)
}
}
}
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Install reversible boot hooks. Run as root after copying the files to the device.
set -eu
base=/data/opt/alice-backend
init=/etc/init.d
case "${1:-}" in
install)
[ -x "$base/alice-station" ] && [ -x "$base/station-supervisor.sh" ] && [ -r "$base/device-token" ] && [ -r "$base/wake-templates.json" ] || { echo 'Missing station binary, supervisor, token or templates' >&2; exit 1; }
chmod 600 "$base/device-token"
if [ ! -e "$base/S99quasar.original" ]; then cp -p "$init/S99quasar" "$base/S99quasar.original"; fi
cp "$base/S99alice" "$init/S99alice"
chmod 755 "$init/S99alice"
if [ -x "$base/S98alice-music" ]; then
cp "$base/S98alice-music" "$init/S98alice-music"
chmod 755 "$init/S98alice-music"
fi
cat > "$init/S99quasar.alice-new" <<'WRAPPER'
#!/bin/sh
if [ -f /data/opt/alice-backend/stock-disabled ]; then exit 0; fi
exec /data/opt/alice-backend/S99quasar.original "$@"
WRAPPER
chmod 755 "$init/S99quasar.alice-new"
mv "$init/S99quasar.alice-new" "$init/S99quasar"
touch "$base/stock-disabled"
;;
restore)
if [ -x "$init/S99alice" ]; then "$init/S99alice" stop; fi
if [ -x "$init/S98alice-music" ]; then "$init/S98alice-music" stop; fi
rm -f "$init/S98alice-music"
rm -f "$base/stock-disabled"
if [ -f "$base/S99quasar.original" ]; then cp -p "$base/S99quasar.original" "$init/S99quasar"; fi
rm -f "$init/S99alice"
;;
*) echo "Usage: $0 {install|restore}" >&2; exit 2;;
esac
+40 -26
View File
@@ -2,8 +2,6 @@ package main
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/binary"
"encoding/json"
@@ -242,19 +240,6 @@ func unmuted() bool {
b, e := os.ReadFile(mutePath)
return e == nil && strings.TrimSpace(string(b)) == "1"
}
func cue(device string) error {
samples := make([]int16, 2400)
for i := range samples {
samples[i] = int16(12000 * math.Sin(2*math.Pi*880*float64(i)/24000) * math.Sin(math.Pi*float64(i)/float64(len(samples))))
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
c := exec.CommandContext(ctx, "aplay", "-q", "-D", device, "-t", "raw", "-f", "S16_LE", "-r", "24000", "-c", "1")
c.Stdin = bytes.NewReader(pcm(samples))
c.Stderr = os.Stderr
terminateWithClient(c)
return c.Run()
}
func waitRecord(r io.Reader, max time.Duration, silence time.Duration, threshold float64) ([][]float64, error) {
var samples []int16
var frames [][]float64
@@ -299,7 +284,7 @@ func waitRecord(r io.Reader, max time.Duration, silence time.Duration, threshold
}
return frames, nil
}
func enroll(r io.Reader, path string, count int, threshold float64, playback string) error {
func enroll(r io.Reader, path string, count int, threshold float64) error {
var all []template
in := bufio.NewReader(os.Stdin)
for i := 0; i < count; i++ {
@@ -310,9 +295,7 @@ func enroll(r io.Reader, path string, count int, threshold float64, playback str
for !unmuted() {
time.Sleep(100 * time.Millisecond)
}
if e := cue(playback); e != nil {
return e
}
lights.set("listening")
if live, ok := r.(*liveReader); ok {
live.discardPending()
}
@@ -320,6 +303,7 @@ func enroll(r io.Reader, path string, count int, threshold float64, playback str
if e != nil {
return e
}
lights.set("idle")
all = append(all, template{f})
fmt.Printf("Recorded %d frames.\n", len(f))
}
@@ -358,6 +342,8 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
if fileAudio == nil && !unmuted() {
return errMuted
}
_ = musicCall("duck", json.RawMessage(`{"active":true}`))
defer func() { _ = musicCall("duck", json.RawMessage(`{"active":false}`)); lights.set("idle") }()
c, e := connect(url, token)
if e != nil {
return e
@@ -383,11 +369,9 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
fileAudio = fileAudio[n:]
}
} else {
if e = cue(playback); e != nil {
return e
}
live.discardPending()
// Keep the onset in a short local buffer. It contains only post-cue audio.
lights.set("listening")
// Keep the onset in a short local buffer. It contains only post-activation audio.
const prerollSamples = rate / 5
pre := make([]int16, 0, prerollSamples+hop)
started := time.Now()
@@ -433,14 +417,18 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
return errMuted
}
if !heard {
return errors.New("no speech detected after cue")
return errors.New("no speech detected")
}
}
_ = c.SetWriteDeadline(time.Now().Add(15 * time.Second))
if e = c.WriteJSON(message{Type: "end"}); e != nil {
return e
}
lights.set("processing")
player := exec.Command("aplay", "-q", "-D", playback, "-t", "raw", "-f", "S16_LE", "-r", "24000", "-c", "1")
if path := os.Getenv("ALICE_PLAYER"); path != "" {
player = exec.Command(path)
}
player.Stderr = os.Stderr
terminateWithClient(player)
w, e := player.StdinPipe()
@@ -464,6 +452,7 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
return e
}
if typ == websocket.BinaryMessage {
lights.set("speaking")
if _, e = w.Write(data); e != nil {
return e
}
@@ -475,6 +464,16 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
return e
}
switch m.Type {
case "device_tool":
var req deviceTool
if e = json.Unmarshal(data, &req); e != nil {
return e
}
result := musicCall(req.Name, req.Arguments)
_ = c.SetWriteDeadline(time.Now().Add(15 * time.Second))
if e = c.WriteJSON(map[string]interface{}{"type": "device_tool_result", "id": req.ID, "result": result}); e != nil {
return e
}
case "done":
if e = w.Close(); e != nil {
return e
@@ -504,6 +503,8 @@ func main() {
silence := flag.Duration("silence", 1200*time.Millisecond, "end after speech silence")
flag.Parse()
mutePath = *muteGPIO
lights.start()
defer lights.stop()
if *th <= 0 || *max <= 0 || *silence <= 0 || *count < 1 || *count > 10 {
log.Fatal("invalid threshold, duration, or sample count")
}
@@ -554,10 +555,16 @@ func main() {
defer cleanup()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() { <-sig; cleanup(); os.Exit(0) }()
go func() {
<-sig
cleanup()
_ = musicCall("duck", json.RawMessage(`{"active":false}`))
lights.stop()
os.Exit(0)
}()
live := newLiveReader(r)
if *mode == "enroll" {
if e = enroll(live, *path, *count, *th, *playback); e != nil {
if e = enroll(live, *path, *count, *th); e != nil {
cleanup()
log.Fatal(e)
}
@@ -620,6 +627,10 @@ func main() {
if quiet >= 35 || len(f) >= 250 {
best := math.Inf(1)
for _, t := range ts {
// Enrollment and wake windows both include the final pause.
if len(f)*3 < len(t.Frames)*2 || len(f)*2 > len(t.Frames)*3 {
continue
}
if d := distance(f, t.Frames); d < best {
best = d
}
@@ -629,6 +640,9 @@ func main() {
log.Println("wake detected")
if e = exchange(*url, token, live, nil, *th, *max, *silence, *playback); e != nil {
log.Printf("session: %v", e)
if !errors.Is(e, errMuted) {
lights.flashError()
}
} else if *oneTurn {
return
}
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
base=/data/opt/alice-backend/spotify
export PULSE_SERVER=unix:/tmp/alice-pulse/native
export PULSE_RUNTIME_PATH=/tmp/alice-pulse
export PULSE_COOKIE=/tmp/alice-pulse/cookie
exec "$base/run" "$base/bin/pactl" "$@"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
base=/data/opt/alice-backend/spotify
if [ ! -S /tmp/alice-pulse/native ]; then
exec /usr/bin/aplay -q -D default -t raw -f S16_LE -r 24000 -c 1
fi
export PULSE_SERVER=unix:/tmp/alice-pulse/native
export PULSE_RUNTIME_PATH=/tmp/alice-pulse
export PULSE_COOKIE=/tmp/alice-pulse/cookie
export PULSE_SINK=station
exec "$base/run" "$base/bin/pacat" --playback --raw --format=s16le --rate=24000 --channels=1 --latency-msec=50 --client-name="Alice Voice" --stream-name=Voice
+5
View File
@@ -0,0 +1,5 @@
.fail
load-module module-native-protocol-unix socket=/tmp/alice-pulse/native auth-anonymous=1 auth-cookie=/tmp/alice-pulse/cookie
load-module module-alsa-sink sink_name=station device=hw:0,0 channels=2 rate=48000 format=s16le tsched=0
load-module module-remap-sink sink_name=music master=station
set-default-sink station
+77
View File
@@ -0,0 +1,77 @@
#!/bin/sh
set -u
base=/data/opt/alice-backend
runtime="$base/spotify"
cd "$base" || exit 1
umask 077
exec 8>/tmp/alice-spotify.lock
flock -n 8 || exit 1
export PULSE_SERVER=unix:/tmp/alice-pulse/native
export PULSE_RUNTIME_PATH=/tmp/alice-pulse
export PULSE_COOKIE=/tmp/alice-pulse/cookie
export PULSE_STATE_PATH="$base/spotify-state"
export PULSE_SINK=music
pulse=
soloist=
janitor=
timer=
stopping=0
terminate() {
stopping=1
for child in "$timer" "$soloist" "$pulse" "$janitor"; do
[ -z "$child" ] || kill -TERM "$child" 2>/dev/null || true
done
}
trap terminate TERM INT HUP
trap terminate EXIT
mkdir -p "$PULSE_RUNTIME_PATH" "$PULSE_STATE_PATH" "$base/soloist-data" /tmp/alice-spotify-cache
if ! grep -q ' /tmp/alice-spotify-cache ' /proc/mounts; then
mount -t tmpfs -o size=32m,mode=0700,nosuid,nodev,noexec tmpfs /tmp/alice-spotify-cache || exit 1
fi
chmod 700 "$PULSE_RUNTIME_PATH" "$PULSE_STATE_PATH" "$base/soloist-data" /tmp/alice-spotify-cache
if [ ! -f "$PULSE_COOKIE" ]; then dd if=/dev/urandom of="$PULSE_COOKIE" bs=256 count=1 2>/dev/null; fi
: >/tmp/alice-cache.log
./music-control -cache-janitor /tmp/alice-spotify-cache >>/tmp/alice-cache.log 2>&1 8>&- &
janitor=$!
bound_logs() {
for log in /tmp/alice-pulse.log /tmp/alice-soloist.log /tmp/alice-cache.log; do
if [ -f "$log" ] && [ "$(wc -c < "$log")" -gt 524288 ]; then : > "$log"; fi
done
}
# Session restoration requires network and wall time valid for TLS.
while [ "$stopping" -eq 0 ] && { [ "$(date +%s)" -lt 1700000000 ] || ! ip route | grep -q '^default '; }; do
sleep 3 & timer=$!; wait "$timer" || true; timer=
done
while [ "$stopping" -eq 0 ]; do
: >/tmp/alice-pulse.log
"$runtime/run" "$runtime/bin/pulseaudio" -n --daemonize=no --exit-idle-time=-1 --dl-search-path="$runtime/lib" --file="$runtime/etc/alice.pa" --log-target=stderr >>/tmp/alice-pulse.log 2>&1 8>&- &
pulse=$!
ready=0
for second in 1 2 3 4 5; do
[ "$stopping" -eq 0 ] || break
if "$runtime/pactl" info >/dev/null 2>&1; then ready=1; break; fi
sleep 1
done
if [ "$ready" -eq 1 ] && [ "$stopping" -eq 0 ]; then
: >/tmp/alice-soloist.log
"$runtime/run" "$runtime/soloist/soloist" --device-name "Pepega Mini 2" --api-key "$(cat "$base/soloist-key")" --data-dir "$base/soloist-data" --cache-dir /tmp/alice-spotify-cache --cache-size 100 --ws 127.0.0.1:9090 >>/tmp/alice-soloist.log 2>&1 8>&- &
soloist=$!
while [ "$stopping" -eq 0 ] && kill -0 "$soloist" 2>/dev/null && kill -0 "$pulse" 2>/dev/null; do
bound_logs
sleep 3 & timer=$!; wait "$timer" || true; timer=
done
fi
for child in "$soloist" "$pulse"; do
if [ -n "$child" ]; then kill -TERM "$child" 2>/dev/null || true; wait "$child" 2>/dev/null || true; fi
done
soloist=
pulse=
bound_logs
if [ "$stopping" -eq 0 ]; then sleep 5 & timer=$!; wait "$timer" || true; timer=; fi
done
[ -z "$janitor" ] || wait "$janitor" 2>/dev/null || true
umount /tmp/alice-spotify-cache || true
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# Long-lived supervisor. Logs are replaced before each run to stay bounded on tmpfs.
set -u
cd /data/opt/alice-backend || exit 1
umask 077
export ALICE_PLAYER=/data/opt/alice-backend/pulse-player.sh
exec 9>/tmp/alice-backend-mic.lock
flock -n 9 || exit 1
# Match stock boot configuration and expose the physical microphone mute input.
echo 1 > /proc/sys/vm/overcommit_memory || echo 'Cannot set overcommit_memory' >&2
if [ ! -d /sys/class/gpio/gpio414 ]; then
echo 414 > /sys/class/gpio/export || echo 'Cannot export GPIO414' >&2
if [ -d /sys/class/gpio/gpio414 ]; then
echo in > /sys/class/gpio/gpio414/direction || echo 'Cannot set GPIO414 direction' >&2
fi
fi
child=
timer=
stopping=0
stop() {
stopping=1
for p in "$child" "$timer"; do [ -z "$p" ] || kill -TERM "$p" 2>/dev/null || :; done
}
trap stop TERM INT HUP
reset_duck() {
helper=${ALICE_MUSIC_HELPER:-/data/opt/alice-backend/music-control}
if [ -x "$helper" ]; then
printf '%s\n' '{"name":"duck","arguments":{"active":false}}' | "$helper" >/dev/null 2>&1 || :
fi
}
reset_duck
while [ "$stopping" -eq 0 ]; do
# Let DHCP and time synchronization finish before accepting voice turns.
if [ ! -r device-token ] || [ "$(date +%s)" -lt 1700000000 ] || ! ip route | grep -q '^default '; then
sleep 3 & timer=$!; wait "$timer" || :; timer=
continue
fi
DEVICE_TOKEN=$(cat device-token)
export DEVICE_TOKEN
: > /tmp/alice-station.log
./alice-station -mode wake -voice-threshold 55 -templates /data/opt/alice-backend/wake-templates.json >> /tmp/alice-station.log 2>&1 9>&- &
child=$!
while [ "$stopping" -eq 0 ] && kill -0 "$child" 2>/dev/null; do
if [ "$(wc -c < /tmp/alice-station.log)" -gt 524288 ]; then : >/tmp/alice-station.log; fi
sleep 3 & timer=$!; wait "$timer" || :; timer=
done
wait "$child" || :
# A signal interrupts wait; reap the station before releasing the microphone lock.
[ "$stopping" -eq 0 ] || wait "$child" 2>/dev/null || :
child=
[ "$stopping" -ne 0 ] && break
sleep 5 & timer=$!; wait "$timer" || :; timer=
done
reset_duck
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
const requests = [];
let responder;
const server = http.createServer(async (req, res) => {
let body = ''; for await (const chunk of req) body += chunk;
requests.push(JSON.parse(body));
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
for (const event of responder(requests.length)) res.write(`data: ${JSON.stringify(event)}\n\n`);
res.end('data: [DONE]\n\n');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
process.env.OPENROUTER_URL = `http://127.0.0.1:${server.address().port}`;
process.env.OPENROUTER_API_KEY = 'test';
process.env.LLM_MODE = 'openrouter';
const { streamAnswer } = await import('../server/providers.mjs');
test.after(() => server.close());
const event = (delta, finish_reason = null) => ({ choices: [{ delta, finish_reason }] });
const settings = { systemPrompt: 'test', llmModel: 'test' };
test('assembles fragmented calls before executing and streams text', async () => {
requests.length = 0;
responder = n => n === 1 ? [event({ tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'spotify_play', arguments: '{"query":"' } }] }), event({ tool_calls: [{ index: 0, function: { arguments: 'Queen"}' } }] }, 'tool_calls')] : [event({ content: 'Готово' }, 'stop')];
const calls = [], text = [];
const answer = await streamAnswer(settings, [], 'play', new AbortController().signal, part => text.push(part), async (name, args) => { calls.push([name, args]); return { ok: true }; });
assert.equal(answer, 'Готово'); assert.deepEqual(text, ['Готово']);
assert.deepEqual(calls, [['spotify_play', { query: 'Queen' }]]);
assert.equal(requests[0].tools[0].type, 'openrouter:web_search');
assert.equal(requests[1].messages.at(-1).tool_call_id, 'call_1');
});
test('does not execute incomplete tool calls', async () => {
requests.length = 0;
responder = () => [event({ tool_calls: [{ index: 0, id: 'x', function: { name: 'spotify_control', arguments: '{"action":"next"}' } }] }, 'length')];
let called = false;
await assert.rejects(streamAnswer(settings, [], 'next', new AbortController().signal, () => {}, async () => { called = true; }), /llm_incomplete/);
assert.equal(called, false);
});
test('limits repeated tool calls', async () => {
requests.length = 0;
responder = n => [event({ tool_calls: [{ index: 0, id: `call_${n}`, function: { name: 'spotify_control', arguments: '{"action":"next"}' } }] }, 'tool_calls')];
let calls = 0;
await assert.rejects(streamAnswer(settings, [], 'next', new AbortController().signal, () => {}, async () => { calls++; return { ok: true }; }), /llm_limit/);
assert.equal(calls, 3);
});
+32
View File
@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { startSpotify, finishSpotify, spotifyStatus, searchSpotify } from '../server/spotify.mjs';
test('PKCE state is single use and token persists privately; exact artist ranks first', async () => {
const dir = mkdtempSync(join(tmpdir(), 'spotify-test-'));
process.env.DATA_DIR = dir; process.env.SPOTIFY_CLIENT_ID = 'test-client';
const original = globalThis.fetch;
let verifier;
globalThis.fetch = async (url, options) => {
if (String(url).includes('/api/token')) {
verifier = options.body.get('code_verifier');
return Response.json({ access_token: 'access', refresh_token: 'refresh', expires_in: 3600 });
}
return Response.json({ tracks: { items: [{ uri: 'spotify:track:1', name: 'Song', type: 'track' }] }, artists: { items: [{ uri: 'spotify:artist:1', name: 'Aphex Twin', type: 'artist' }] } });
};
try {
const url = new URL(startSpotify()), state = url.searchParams.get('state');
await assert.rejects(finishSpotify('wrong', 'code'));
await finishSpotify(state, 'code');
assert.equal(createHash('sha256').update(verifier).digest('base64url'), url.searchParams.get('code_challenge'));
await assert.rejects(finishSpotify(state, 'code'));
assert.equal(statSync(join(dir, 'spotify.json')).mode & 0o777, 0o600);
assert.equal(JSON.parse(readFileSync(join(dir, 'spotify.json'))).refresh_token, 'refresh');
assert.deepEqual(spotifyStatus(), { configured: true, connected: true });
assert.equal((await searchSpotify('aphex twin'))[0].type, 'artist');
} finally { globalThis.fetch = original; rmSync(dir, { recursive: true, force: true }); }
});