Use Mini 2 firmware audio processing and Scribe v3 Live

This commit is contained in:
2026-11-10 23:02:27 +03:00
parent a27589447e
commit 9847ad6752
17 changed files with 416 additions and 184 deletions
+2
View File
@@ -1,6 +1,8 @@
# Provide the actual values privately in .env.
DEVICE_TOKEN=
ELEVENLABS_API_KEY=
# Optional comma-separated names for Scribe Realtime (+20% STT cost when set).
ELEVEN_STT_KEYTERMS=
OPENROUTER_API_KEY=
TELEGRAM_BOT_TOKEN=
TELEGRAM_OWNER_ID=
+2 -2
View File
@@ -3,11 +3,11 @@
Собственный голосовой клиент для рутированной Mini 2, сервер на Node.js и пульт в Telegram. На колонке работает русская модель ключевого слова «Алиса». Когда клиент готов принимать команду, верхнее кольцо загорается сине-фиолетовым. Произнеси команду после включения кольца.
```text
Mini 2 → WSS с PCM16 / 16 кГц → Scribe v2 Realtime
Mini 2 → VQE → WSS с PCM16 / 16 кГц → Scribe v3 Live
← WSS с PCM16 / 24 кГц ← Eleven v4 Turbo ← OpenRouter / Haiku 5.5
```
Scribe выдаёт промежуточный текст, а запрос к LLM начинается после подтверждения конца команды. Текстовые фрагменты из OpenRouter сразу идут в ElevenLabs, звуковые фрагменты сразу уходят на колонку. Запрос и ответ в текстовом виде сохраняются после успешного хода. Сервер держит 50 последних ходов и использует до 10 из них как контекст. Аудио сервер не пишет на диск. Облачные провайдеры обрабатывают отправленные им аудио и текст.
Модель Scribe можно переключить в пульте, включая Scribe v2 Realtime. Scribe выдаёт промежуточный текст, а запрос к LLM начинается после подтверждения конца команды. Текстовые фрагменты из OpenRouter сразу идут в ElevenLabs, звуковые фрагменты сразу уходят на колонку. Запрос и ответ в текстовом виде сохраняются после успешного хода. Сервер держит 50 последних ходов и использует до 10 из них как контекст. Аудио сервер не пишет на диск. Облачные провайдеры обрабатывают отправленные им аудио и текст.
## Запуск и проверка
+1 -1
View File
@@ -32,7 +32,7 @@ func execute(req request) (map[string]interface{}, error) {
if req.Name == "duck" {
volume := "100%"
if req.Arguments.Active {
volume = "0%"
volume = "75%"
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
+5 -2
View File
@@ -2,6 +2,7 @@ import WebSocket from 'ws';
import { tools, validateTool } from './tools.mjs';
const env = process.env;
const sttKeyterms = (env.ELEVEN_STT_KEYTERMS || '').split(',').map(term => term.trim()).filter(Boolean).slice(0, 50);
const sttUrl = env.ELEVEN_STT_URL || 'wss://api.elevenlabs.io/v1/speech-to-text/realtime';
const ttsUrl = env.ELEVEN_TTS_URL || 'wss://api.elevenlabs.io/v1/text-to-dialogue/stream-input';
const flashUrl = env.ELEVEN_FLASH_URL || 'wss://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream-input';
@@ -9,7 +10,9 @@ const llmUrl = env.OPENROUTER_URL || 'https://openrouter.ai/api/v1/chat/completi
function endpoint(base, params) {
const url = new URL(base);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
for (const [key, value] of Object.entries(params)) {
for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(key, item);
}
return url.toString();
}
function connect(url, signal) {
@@ -25,7 +28,7 @@ function connect(url, signal) {
}
export async function openStt(settings, signal, onPartial, onFinal, onFailure) {
const ws = await connect(endpoint(sttUrl, { model_id: settings.sttModel, audio_format: 'pcm_16000', language_code: 'ru', commit_strategy: 'manual', filter_background_audio: 'true' }), signal);
const ws = await connect(endpoint(sttUrl, { model_id: settings.sttModel, audio_format: 'pcm_16000', language_code: 'ru', commit_strategy: 'manual', filter_background_audio: 'true', keyterms: sttKeyterms }), signal);
let final = false;
ws.on('message', data => {
try {
+1 -1
View File
@@ -29,7 +29,7 @@
<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>
<label>Распознавание<select name="sttModel"><option value="scribe_v3_live">Scribe v3 Live</option><option value="scribe_v2_realtime">Scribe v2 Realtime</option></select></label>
<label>Модель OpenRouter<input name="llmModel" required maxlength="120" spellcheck="false" autocomplete="off"></label>
<label>Синтез речи<select name="ttsModel">
<option value="eleven_v4_turbo">Eleven v4 Turbo</option>
+3 -2
View File
@@ -3,19 +3,20 @@ import { join } from 'node:path';
export const defaults = Object.freeze({
llmModel: 'anthropic/claude-haiku-5.5',
sttModel: 'scribe_v2_realtime',
sttModel: 'scribe_v3_live',
ttsModel: 'eleven_v4_turbo',
voiceId: '21m00Tcm4TlvDq8ikWAM',
systemPrompt: 'Ты голосовой помощник Алиса. Отвечай по-русски, коротко и по делу. Пиши так, чтобы ответ хорошо звучал вслух: обычный текст, без Markdown. Если не знаешь ответа, скажи об этом прямо.',
});
const sttModels = [defaults.sttModel, 'scribe_v2_realtime'];
export const ttsModels = ['eleven_v4_turbo', 'eleven_v3_conversational', 'eleven_flash_v2_5'];
export function validateSettings(input) {
if (typeof input !== 'object' || input === null) return null;
const { llmModel, sttModel, ttsModel, voiceId, systemPrompt } = input;
if (typeof llmModel !== 'string' || !/^[\w.~:-]+\/[\w.:~-]+$/.test(llmModel) || llmModel.length > 120) return null;
if (sttModel !== defaults.sttModel || !ttsModels.includes(ttsModel)) return null;
if (!sttModels.includes(sttModel) || !ttsModels.includes(ttsModel)) return null;
if (typeof voiceId !== 'string' || !/^[a-zA-Z0-9]{20}$/.test(voiceId)) return null;
if (typeof systemPrompt !== 'string' || !systemPrompt.trim() || systemPrompt.length > 4000) return null;
return { llmModel, sttModel, ttsModel, voiceId, systemPrompt: systemPrompt.trim() };
+7 -3
View File
@@ -11,7 +11,7 @@ export function handleTurn(client, store) {
const controller = new AbortController();
const { signal } = controller;
const device = deviceTools(client, signal);
let state = 'waiting', stt, tts, bytes = 0, received = 0, messages = 0, ended = false, terminal = false;
let state = 'waiting', stt, tts, bytes = 0, received = 0, messages = 0, ended = false, terminal = false, wake = false;
let pending = [];
let pendingBytes = 0;
const timeout = setTimeout(() => fail('timeout'), 120000);
@@ -32,7 +32,7 @@ export function handleTurn(client, store) {
stt.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: chunk.toString('base64'), ...(commit ? { commit: true } : {}) }));
}
function complete(text, answer) {
store.recordTurn(text, answer);
if (text) store.recordTurn(text, answer);
terminal = true;
send({ type: 'done' });
cleanup();
@@ -43,6 +43,10 @@ export function handleTurn(client, store) {
if (state !== 'processing' || terminal) return;
state = 'responding';
text = text.trim();
if (wake) {
text = text.replace(/^[«"“]?алиса(?:[»"”.,!?…:;\s–—-]+|$)/iu, '');
if (!text) return complete('', '');
}
if (!text) return fail('empty_transcript');
send({ type: 'transcript', text });
try {
@@ -89,7 +93,7 @@ export function handleTurn(client, store) {
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();
state = 'connecting'; settings = store.getSettings(); wake = msg.wake === true;
try {
stt = await openStt(settings, signal, text => { if (!terminal && state === 'recording') send({ type: 'partial', text }); }, text => { if (!ended || terminal) return; respond(text, settings); }, () => fail('stt_error'));
if (terminal) return;
+14 -5
View File
@@ -1,6 +1,6 @@
# Клиент Mini 2
Клиент собирается в статический ARM64-бинарник для ядра Mini 2. Для захвата и вывода он запускает штатные `arecord` и `aplay`, которые на этой прошивке собраны для armhf.
Клиент собирается в статический ARM64-бинарник для ядра Mini 2. Для захвата он запускает штатный `arecord` и небольшой armhf-процесс, который вызывает VQE из прошивки. Для вывода используются PulseAudio и штатный `aplay` в ручных тестах.
```sh
cd station
@@ -8,6 +8,15 @@ go test ./...
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o alice-station .
```
Собери процесс VQE на машине с Zig из корня репозитория.
```sh
zig cc -target arm-linux-gnueabihf.2.28 -mcpu=cortex_a53 -O2 -s \
-Wall -Wextra -Werror station/vqe/capture.c -ldl -o alice-vqe
```
Помести `alice-vqe` и `station/vqe-capture.sh` с правами на запуск в `/data/opt/alice-backend/`. Обёртка проверяет SHA-256 штатной `libyandex_evil.so` перед загрузкой её закрытого ABI. Сверяй ABI заново после смены прошивки. Системные файлы не меняются. Канал `plug:default` даёт четыре микрофона и два синхронных канала обратной связи динамика при 48 кГц. Пресет `yandexmini_2` возвращает `vqe_0` в mono PCM16 при 16 кГц после эхоподавления и шумоподавления. Ручной запуск без `-vqe-helper` берёт один сырой микрофон для диагностики.
На тестовой колонке файлы лежат в `/data/opt/alice-backend/`. Помести туда бинарник, `with-mic.sh` и отдельный `DEVICE_TOKEN` в файле `device-token` с правами `0600`. Для WSS нужны доступ в сеть, корректное время и доверенный набор сертификатов.
## Ручной тест с микрофоном
@@ -23,7 +32,7 @@ cd /data/opt/alice-backend
## Локальная активация по фразе
Автозапуск использует [русскую модель microWakeWord «Алиса»](https://github.com/olegchumakov/microwakeword-models/tree/7a3490e3c5de3ec21442abcc7a44c74fd979917a/alisa). Произнеси слово, дождись сине-фиолетового кольца и скажи команду. Непрерывную фразу без паузы модель может пропустить.
Автозапуск использует [русскую модель microWakeWord «Алиса»](https://github.com/olegchumakov/microwakeword-models/tree/7a3490e3c5de3ec21442abcc7a44c74fd979917a/alisa). Сине-фиолетовое кольцо загорается после локальной активации. Клиент сохраняет начало команды и при произнесении без паузы. Срабатывание на слитную фразу зависит от модели wake word.
Собери `alice-wake-helper` командой `cargo build --locked --release --target aarch64-unknown-linux-gnu` из каталога [wake-helper](../wake-helper) с настроенным ARM64 GNU toolchain. Положи helper и `wake-helper.sh` в `/data/opt/alice-backend`. Там же нужен файл `alisa.tflite`, скачанный из указанной версии модели. Его SHA-256 — `481cf6c180d3b5cbebfa6af47b4f9544c631d05b73cecf51fb8363ab7d143f38`. Helper использует приватный ARM64 runtime из каталога `spotify/`, включая `libstdc++.so.6`.
@@ -36,9 +45,9 @@ cd /data/opt/alice-backend
При обучении нажимай Enter перед каждым образцом и после включения кольца произноси «Алиса», затем сделай паузу. Клиент сохраняет спектральные признаки в `wake-templates.json` с правами `0600`. На тестовой колонке эти образцы уже записаны. В режиме `wake` произнеси «Алиса», дождись сине-фиолетового кольца и скажи команду. Флаг `-once` завершает сеанс после первого успешного ответа. Без флага можно провести несколько ходов за один пробный запуск.
При ручном запуске с шаблонами клиент сопоставляет целый сегмент речи методом DTW и отбрасывает слишком короткие или длинные сегменты. Из-за этого после «Алиса» требуется короткая пауза. Оценки пропусков и ложных срабатываний на расстоянии, под музыку и у разных людей пока нет. Клиент использует один канал микрофона и простой даунсемплинг, AEC и прерывание речи в ответе отсутствуют.
При ручном запуске с шаблонами клиент сопоставляет целый сегмент речи методом DTW и отбрасывает слишком короткие или длинные сегменты. Из-за этого после «Алиса» требуется короткая пауза. Оценки пропусков и ложных срабатываний на расстоянии, под музыку и у разных людей пока нет. Штатный VQE доступен в этом режиме через тот же флаг `-vqe-helper`.
До локального срабатывания соединение с сервером не открывается. После активации начало команды буферизуется в RAM, а слово активации остаётся на колонке. Проверенное состояние кнопки mute на этой Mini 2 читается из `/sys/class/gpio/gpio414/value`, где `1` означает разрешённый микрофон. В mute клиент отбрасывает вход, сбрасывает состояние детектора и прерывает текущую передачу. При недоступном GPIO применяется то же поведение. Состояние можно читать из другого узла через `-mute-gpio`, если на конкретной ревизии этот сигнал расположен иначе.
До локального срабатывания соединение с сервером не открывается. Кольцевой буфер в RAM держит последние 800 мс. Для слитной команды начало потока может включать «Алиса», и сервер убирает это слово с начала распознанного текста. После паузы передаётся только сама команда. Аудио в файл не записывается. Проверенное состояние кнопки mute на этой Mini 2 читается из `/sys/class/gpio/gpio414/value`, где `1` означает разрешённый микрофон. В mute клиент отбрасывает вход, сбрасывает состояние детектора и прерывает текущую передачу. При недоступном GPIO применяется то же поведение. Состояние можно читать из другого узла через `-mute-gpio`, если на конкретной ревизии этот сигнал расположен иначе.
## Проверка из файла
@@ -51,7 +60,7 @@ export DEVICE_TOKEN="$(cat device-token)"
## Автозапуск и музыка
Сенсорные кнопки `+` и `−` меняют общую громкость музыки и ответов на 10 процентных пунктов. Кольцо показывает уровень от зелёного к красному, а короткий тон звучит с выбранной громкостью. Голосовая команда установки уровня подсвечивает кольцо без звука. Значение сохраняется между запусками.
Сенсорные кнопки `+` и `−` меняют общую громкость музыки и ответов на 10 процентных пунктов. Кольцо показывает уровень от зелёного к красному, а короткий тон звучит с выбранной громкостью. Голосовая команда установки уровня подсвечивает кольцо без звука. Значение сохраняется между запусками. На время запроса и ответа приглушается только музыкальный sink на 7,5 дБ, затем его уровень восстанавливается.
Загрузочный скрипт `S99alice` запускает наш клиент, а `S98alice-music` поднимает Soloist и PulseAudio. Штатный `S99quasar` пропускает запуск, пока существует `/data/opt/alice-backend/stock-disabled`. Оригинал сохранён как `S99quasar.original` рядом с клиентом.
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"encoding/binary"
"errors"
"io"
"os"
"os/exec"
"strings"
"time"
)
const pcmBlock = hop * 2
const rawBlock = hop * 3 * 16
// downsample is retained for manual raw-mic tests on the Mini 2.
func downsample(raw []byte) []int16 {
out := make([]int16, len(raw)/(8*2*3))
for i := range out {
var sum int32
for j := 0; j < 3; j++ {
off := (i*3 + j) * 16
sum += int32(int16(binary.LittleEndian.Uint16(raw[off:])))
}
out[i] = int16(sum / 3)
}
return out
}
func pcm(samples []int16) []byte {
data := make([]byte, len(samples)*2)
for i, sample := range samples {
binary.LittleEndian.PutUint16(data[i*2:], uint16(sample))
}
return data
}
func capture(device, vqeHelper string) (*liveReader, func(), error) {
channels, period, buffer, block := "8", "512", "2048", rawBlock
if vqeHelper != "" {
device, channels, buffer, block = "plug:default", "6", "4096", pcmBlock
}
recorder := exec.Command("arecord", "-q", "-D", device, "-t", "raw", "-f", "S16_LE", "-r", "48000",
"-c", channels, "--period-size="+period, "--buffer-size="+buffer)
recorder.Stderr = os.Stderr
terminateWithClient(recorder)
pipe, err := recorder.StdoutPipe()
if err != nil {
return nil, nil, err
}
if err = recorder.Start(); err != nil {
pipe.Close()
return nil, nil, err
}
var processor *exec.Cmd
stop := func() {
if processor != nil {
processor.Process.Kill()
processor.Wait()
}
recorder.Process.Kill()
recorder.Wait()
}
if vqeHelper != "" {
helper := exec.Command(vqeHelper)
helper.Stdin = pipe.(*os.File)
helper.Stderr = os.Stderr
terminateWithClient(helper)
output, err := helper.StdoutPipe()
if err != nil {
stop()
return nil, nil, err
}
if err = helper.Start(); err != nil {
output.Close()
stop()
return nil, nil, err
}
processor, pipe = helper, output
}
return newLiveReader(pipe.(*os.File), block), stop, nil
}
// Keep draining ALSA during playback and network startup. When the queue is
// full, old frames can be dropped, but a recording must never splice a gap.
type liveReader struct {
chunks chan audioFrame
err error
last uint64
}
type audioFrame struct {
seq uint64
pcm []int16
}
func newLiveReader(pipe *os.File, block int) *liveReader {
live := &liveReader{chunks: make(chan audioFrame, 300)}
go func() {
defer close(live.chunks)
buf := make([]byte, block)
for seq := uint64(1); ; seq++ {
_ = pipe.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, live.err = io.ReadFull(pipe, buf); live.err != nil {
return
}
var samples []int16
if block == rawBlock {
samples = downsample(buf)
} else {
samples = make([]int16, hop)
for i := range samples {
samples[i] = int16(binary.LittleEndian.Uint16(buf[i*2:]))
}
}
frame := audioFrame{seq, samples}
select {
case live.chunks <- frame:
default:
select {
case <-live.chunks:
default:
}
live.chunks <- frame
}
}
}()
return live
}
func (l *liveReader) next() ([]int16, error) {
frame, ok := <-l.chunks
if !ok {
return nil, l.err
}
last := l.last
l.last = frame.seq
if last != 0 && frame.seq != last+1 {
return nil, errors.New("microphone buffer overrun")
}
return frame.pcm, nil
}
func (l *liveReader) discardPending() {
l.last = 0
for {
select {
case _, ok := <-l.chunks:
if !ok {
return
}
default:
return
}
}
}
var mutePath string
var errMuted = errors.New("microphone muted or mute state unavailable")
var errNoSpeech = errors.New("no speech detected")
func unmuted() bool {
data, err := os.ReadFile(mutePath)
return err == nil && strings.TrimSpace(string(data)) == "1"
}
+20 -5
View File
@@ -82,15 +82,21 @@ func (d *keywordDetector) close() {
d.cmd.Wait()
}
func runKeywordWake(live *liveReader, detector *keywordDetector, activate func() error, once bool) error {
func runKeywordWake(live *liveReader, detector *keywordDetector, activate func([]int16) error, once bool) error {
log.Print("Listening locally for the Алиса keyword model")
raw := make([]byte, captureBlock)
// Keep 800 ms locally so recognition can see the whole wake word and
// any beginning of the command that overlaps the detector decision.
preroll := make([]int16, 8*rate/10)
next := 0
available := 0
muted := false
for {
if _, err := io.ReadFull(live, raw); err != nil {
samples, err := live.next()
if err != nil {
return err
}
if !unmuted() {
next, available = 0, 0
if !muted {
if err := detector.reset(); err != nil {
return err
@@ -100,7 +106,10 @@ func runKeywordWake(live *liveReader, detector *keywordDetector, activate func()
continue
}
muted = false
detected, err := detector.process(downsample(raw))
copy(preroll[next:], samples)
next = (next + hop) % len(preroll)
available = min(available+hop, len(preroll))
detected, err := detector.process(samples)
if err != nil {
return err
}
@@ -108,7 +117,12 @@ func runKeywordWake(live *liveReader, detector *keywordDetector, activate func()
continue
}
log.Print("wake word detected")
if err := activate(); err != nil {
audio := make([]int16, 0, available)
if available == len(preroll) {
audio = append(audio, preroll[next:]...)
}
audio = append(audio, preroll[:next]...)
if err := activate(audio); err != nil {
log.Printf("session: %v", err)
if !errors.Is(err, errMuted) && !errors.Is(err, errNoSpeech) {
lights.flashError()
@@ -117,6 +131,7 @@ func runKeywordWake(live *liveReader, detector *keywordDetector, activate func()
return nil
}
live.discardPending()
next, available = 0, 0
if err := detector.reset(); err != nil {
return err
}
+55 -152
View File
@@ -4,7 +4,6 @@ import (
"bufio"
"context"
"crypto/tls"
"encoding/binary"
"encoding/json"
"errors"
"flag"
@@ -27,7 +26,6 @@ import (
const rate = 16000
const frameSamples = 320
const hop = 160
const captureBlock = hop * 3 * 16
type template struct {
Frames [][]float64 `json:"frames"`
@@ -35,6 +33,7 @@ type template struct {
type message struct {
Type string `json:"type"`
Code string `json:"code,omitempty"`
Wake bool `json:"wake,omitempty"`
}
var hann [frameSamples]float64
@@ -141,116 +140,15 @@ func loadTemplates(path string) ([]template, error) {
return t, nil
}
// Convert each 480-frame/8-channel block to 160 mono samples. Only physical
// mic channel 0 is used, never the odd channels (which may have other meanings).
func downsample(raw []byte) []int16 {
n := len(raw) / (8 * 2 * 3)
out := make([]int16, n)
for i := 0; i < n; i++ {
var sum int32
for j := 0; j < 3; j++ {
off := (i*3 + j) * 16
sum += int32(int16(binary.LittleEndian.Uint16(raw[off:])))
}
out[i] = int16(sum / 3)
}
return out
}
func pcm(s []int16) []byte {
b := make([]byte, len(s)*2)
for i, x := range s {
binary.LittleEndian.PutUint16(b[i*2:], uint16(x))
}
return b
}
func capture(device string) (*exec.Cmd, io.ReadCloser, error) {
c := exec.Command("arecord", "-q", "-D", device, "-t", "raw", "-f", "S16_LE", "-r", "48000", "-c", "8", "--period-time=10000", "--buffer-time=80000")
c.Stderr = os.Stderr
terminateWithClient(c)
p, e := c.StdoutPipe()
if e != nil {
return nil, nil, e
}
if e = c.Start(); e != nil {
return nil, nil, e
}
return c, p, nil
}
// Continuously drain the microphone even when the network or user is slow. Drop
// old frames rather than sending stale audio captured before activation.
type liveReader struct {
chunks chan []byte
remainder []byte
err error
}
func newLiveReader(r io.Reader) *liveReader {
l := &liveReader{chunks: make(chan []byte, 64)}
go func() {
for {
b := make([]byte, captureBlock)
if _, e := io.ReadFull(r, b); e != nil {
l.err = e
close(l.chunks)
return
}
select {
case l.chunks <- b:
default:
select {
case <-l.chunks:
default:
}
l.chunks <- b
}
}
}()
return l
}
func (l *liveReader) discardPending() {
l.remainder = nil
for {
select {
case _, ok := <-l.chunks:
if !ok {
return
}
default:
return
}
}
}
func (l *liveReader) Read(p []byte) (int, error) {
if len(l.remainder) == 0 {
b, ok := <-l.chunks
if !ok {
return 0, l.err
}
l.remainder = b
}
n := copy(p, l.remainder)
l.remainder = l.remainder[n:]
return n, nil
}
var mutePath string
var errMuted = errors.New("microphone muted or mute state unavailable")
var errNoSpeech = errors.New("no speech detected")
func unmuted() bool {
b, e := os.ReadFile(mutePath)
return e == nil && strings.TrimSpace(string(b)) == "1"
}
func waitRecord(r io.Reader, max time.Duration, silence time.Duration, threshold float64) ([][]float64, error) {
func waitRecord(r *liveReader, max time.Duration, silence time.Duration, threshold float64) ([][]float64, error) {
var samples []int16
var frames [][]float64
start := time.Now()
last := start
heard := false
buf := make([]byte, hop*3*16)
for time.Since(start) < max {
if _, e := io.ReadFull(r, buf); e != nil {
s, e := r.next()
if e != nil {
return nil, e
}
if !unmuted() {
@@ -261,7 +159,6 @@ func waitRecord(r io.Reader, max time.Duration, silence time.Duration, threshold
start = time.Now()
continue
}
s := downsample(buf)
samples = append(samples, s...)
if len(samples) >= frameSamples {
frame := samples[:frameSamples]
@@ -286,7 +183,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) error {
func enroll(r *liveReader, path string, count int, threshold float64) error {
var all []template
in := bufio.NewReader(os.Stdin)
for i := 0; i < count; i++ {
@@ -298,9 +195,7 @@ func enroll(r io.Reader, path string, count int, threshold float64) error {
time.Sleep(100 * time.Millisecond)
}
lights.set("listening")
if live, ok := r.(*liveReader); ok {
live.discardPending()
}
r.discardPending()
f, e := waitRecord(r, 4*time.Second, 350*time.Millisecond, threshold)
if e != nil {
return e
@@ -318,7 +213,7 @@ func enroll(r io.Reader, path string, count int, threshold float64) error {
func connect(url, token string) (*websocket.Conn, error) {
h := http.Header{}
h.Set("Authorization", "Bearer "+token)
d := websocket.Dialer{HandshakeTimeout: 15 * time.Second, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
d := websocket.Dialer{HandshakeTimeout: 2 * time.Second, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
c, resp, e := d.Dial(url, h)
if e != nil {
if resp != nil {
@@ -327,7 +222,7 @@ func connect(url, token string) (*websocket.Conn, error) {
return nil, e
}
c.SetReadLimit(4 << 20)
_ = c.SetReadDeadline(time.Now().Add(20 * time.Second))
_ = c.SetReadDeadline(time.Now().Add(750 * time.Millisecond))
var m message
if e = c.ReadJSON(&m); e != nil {
c.Close()
@@ -350,18 +245,19 @@ func playbackCommand(ctx context.Context, device string) *exec.Cmd {
return player
}
func exchange(url, token string, live *liveReader, fileAudio []byte, threshold float64, max, silence time.Duration, playback string) error {
if fileAudio == nil {
func exchange(url, token string, live *liveReader, fileAudio []byte, wakeAudio []int16, threshold float64, max, silence time.Duration, playback string) error {
if live != nil {
if !unmuted() {
return errMuted
}
}
_ = musicCall("duck", json.RawMessage(`{"active":true}`))
defer func() { _ = musicCall("duck", json.RawMessage(`{"active":false}`)); lights.set("idle") }()
if live != nil {
// Drop frames recorded before the music output was muted, then keep
// buffering while the WebSocket handshake runs.
live.discardPending()
lights.set("listening")
_ = musicCall("duck", json.RawMessage(`{"active":true}`))
defer func() { _ = musicCall("duck", json.RawMessage(`{"active":false}`)); lights.set("idle") }()
if wakeAudio == nil {
live.discardPending()
}
} else {
defer lights.set("idle")
}
c, e := connect(url, token)
if e != nil {
@@ -388,29 +284,30 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
fileAudio = fileAudio[n:]
}
} else {
lights.set("listening")
// Keep the onset in a short local buffer. It contains only post-activation audio.
const prerollSamples = rate / 5
// Preserve the utterance across activation, including commands spoken
// together with the wake word. Count PCM frames, independent of TLS timing.
const prerollSamples = rate
pre := make([]int16, 0, prerollSamples+hop)
started := time.Now()
lastVoice := started
pre = append(pre, wakeAudio...)
var frames, lastVoice, voiced, sent int
var peakRMS float64
heard := false
var voiced int
buf := make([]byte, captureBlock)
for time.Since(started) < max {
if !heard && time.Since(started) >= 4*time.Second {
for ; frames < int(max/(10*time.Millisecond)); frames++ {
if !heard && frames >= 400 {
break
}
if _, e = io.ReadFull(live, buf); e != nil {
return e
s, err := live.next()
if err != nil {
return err
}
if !unmuted() {
return errMuted
}
s := downsample(buf)
if rms(s) >= threshold {
level := rms(s)
peakRMS = math.Max(peakRMS, level)
if level >= threshold {
voiced++
lastVoice = time.Now()
lastVoice = frames
} else {
voiced = 0
}
@@ -420,24 +317,31 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
copy(pre, pre[len(pre)-prerollSamples:])
pre = pre[:prerollSamples]
}
if voiced < 4 {
if voiced < 3 {
continue
}
_ = c.SetWriteDeadline(time.Now().Add(15 * time.Second))
if e = c.WriteJSON(message{Type: "start"}); e != nil {
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
if e = c.WriteJSON(message{Type: "start", Wake: wakeAudio != nil}); e != nil {
return e
}
heard = true
s = pre
// After a deliberate pause, send the command onset without
// any remnant of the wake word.
if frames+1-voiced >= 22 {
s = pre[len(pre)-min(len(pre), rate/5):]
}
}
_ = c.SetWriteDeadline(time.Now().Add(15 * time.Second))
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
if e = c.WriteMessage(websocket.BinaryMessage, pcm(s)); e != nil {
return e
}
if heard && time.Since(lastVoice) >= silence {
sent += len(s)
if frames-lastVoice >= int(silence/(10*time.Millisecond)) {
break
}
}
log.Printf("capture: %d ms streamed, peak frame RMS %.0f", sent*1000/rate, peakRMS)
if !unmuted() {
return errMuted
}
@@ -521,6 +425,7 @@ func main() {
muteGPIO := flag.String("mute-gpio", "/sys/class/gpio/gpio414/value", "physical microphone mute value, 1 = unmuted")
input := flag.String("input", "", "16 kHz mono PCM16LE file for file mode (max 30 seconds)")
device := flag.String("capture", "hw:0,1", "ALSA capture device")
vqeHelper := flag.String("vqe-helper", "", "Mini 2 firmware VQE capture processor")
playback := flag.String("playback", "default", "ALSA playback device")
url := flag.String("url", "wss://alice.pepega.systems/v1/voice", "server websocket URL")
keywordModel := flag.String("wake-model", "", "optional local microWakeWord model path")
@@ -568,7 +473,7 @@ func main() {
if len(b) == 0 || len(b) > 30*rate*2 || len(b)%2 != 0 {
log.Fatal("input must contain at most 30 seconds of PCM16LE and have an even nonzero size")
}
if e = exchange(*url, token, nil, b, *th, *max, *silence, *playback); e != nil {
if e = exchange(*url, token, nil, b, nil, *th, *max, *silence, *playback); e != nil {
log.Fatal(e)
}
return
@@ -579,12 +484,12 @@ func main() {
log.Fatal(e)
}
}
cmd, r, e := capture(*device)
live, stopCapture, e := capture(*device, *vqeHelper)
if e != nil {
log.Fatal(e)
}
var once sync.Once
cleanup := func() { once.Do(func() { r.Close(); cmd.Process.Kill(); cmd.Wait() }) }
cleanup := func() { once.Do(stopCapture) }
defer cleanup()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
@@ -595,7 +500,6 @@ func main() {
lights.stop()
os.Exit(0)
}()
live := newLiveReader(r)
if *mode == "enroll" {
if e = enroll(live, *path, *count, *th); e != nil {
cleanup()
@@ -608,7 +512,7 @@ func main() {
for !unmuted() {
time.Sleep(100 * time.Millisecond)
}
e = exchange(*url, token, live, nil, *th, *max, *silence, *playback)
e = exchange(*url, token, live, nil, nil, *th, *max, *silence, *playback)
if errors.Is(e, errMuted) {
log.Println("microphone muted; waiting for unmute")
continue
@@ -628,8 +532,8 @@ func main() {
cleanup()
log.Fatalf("keyword model: %v", err)
}
err = runKeywordWake(live, detector, func() error {
return exchange(*url, token, live, nil, *th, *max, *silence, *playback)
err = runKeywordWake(live, detector, func(preroll []int16) error {
return exchange(*url, token, live, nil, preroll, *th, *max, *silence, *playback)
}, *oneTurn)
detector.close()
if err != nil {
@@ -641,13 +545,13 @@ func main() {
// Detect whole speech segments locally before opening a voice connection.
log.Println("Listening locally for enrolled phrase")
raw := make([]byte, hop*3*16)
var samples []int16
var f [][]float64
active := false
quiet := 0
for {
if _, e = io.ReadFull(live, raw); e != nil {
s, e := live.next()
if e != nil {
cleanup()
log.Fatal(e)
}
@@ -658,7 +562,6 @@ func main() {
quiet = 0
continue
}
s := downsample(raw)
samples = append(samples, s...)
if len(samples) < frameSamples {
continue
@@ -689,7 +592,7 @@ func main() {
log.Printf("wake candidate distance=%.3f", best)
if best <= *match {
log.Println("wake detected")
if e = exchange(*url, token, live, nil, *th, *max, *silence, *playback); e != nil {
if e = exchange(*url, token, live, nil, nil, *th, *max, *silence, *playback); e != nil {
log.Printf("session: %v", e)
if !errors.Is(e, errMuted) && !errors.Is(e, errNoSpeech) {
lights.flashError()
+9 -4
View File
@@ -4,7 +4,7 @@ import (
"encoding/binary"
"io"
"math"
"strings"
"os"
"testing"
)
@@ -44,10 +44,15 @@ func TestTemplateValidation(t *testing.T) {
}
func TestLiveReaderEOFStaysEOF(t *testing.T) {
r := newLiveReader(strings.NewReader(""))
b := make([]byte, 1)
in, out, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer in.Close()
out.Close()
r := newLiveReader(in, pcmBlock)
for i := 0; i < 2; i++ {
if _, err := r.Read(b); err != io.EOF {
if _, err := r.next(); err != io.EOF {
t.Fatalf("read %d: %v", i, err)
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ while [ "$stopping" -eq 0 ]; do
DEVICE_TOKEN=$(cat device-token)
export DEVICE_TOKEN
: > /tmp/alice-station.log
./alice-station -mode wake -voice-threshold 55 -wake-model /data/opt/alice-backend/alisa.tflite >> /tmp/alice-station.log 2>&1 9>&- &
./alice-station -mode wake -voice-threshold 24 -vqe-helper /data/opt/alice-backend/vqe-capture.sh -wake-model /data/opt/alice-backend/alisa.tflite >> /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
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# The firmware library's C ABI is private. Refuse to load an unverified build.
set -eu
cd "$(dirname "$0")"
library=/system/vendor/quasar/libs/libyandex_evil.so
if ! printf '%s %s\n' fa7ad1544fe9ddfccedcf15acc031768345b0b8c7333bc1c0b47bc476dd4152f "$library" | sha256sum -c - >/dev/null; then
echo 'VQE: unsupported firmware library' >&2
exit 1
fi
export LD_LIBRARY_PATH=/system/vendor/quasar/libs
exec ./alice-vqe
+98
View File
@@ -0,0 +1,98 @@
// Mini 2 armhf firmware ABI, verified against libyandex_evil BuildID
// d1d0f12f8f8389c0ca72672c61530161b85353c9 (Quasar 3.21).
// stdin: ALSA plug:default, 48 kHz S16_LE, four mics followed by stereo speaker feedback.
// stdout: vqe_0, 16 kHz mono S16_LE.
#include <dlfcn.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
enum { INPUT = 1536, OUTPUT = 512, MICS = 4, SPEAKERS = 2, ASR_CHANNEL = 6 };
static const char *(*error_message)(void *);
static void (*error_free)(void *);
static void check(void *error) {
if (!error) return;
fprintf(stderr, "VQE: %s\n", error_message(error));
error_free(error);
exit(1);
}
static void *symbol(void *lib, const char *name) {
void *address = dlsym(lib, name);
if (!address) {
fprintf(stderr, "VQE: %s\n", dlerror());
exit(1);
}
return address;
}
static int16_t pcm(float sample) {
if (!isfinite(sample)) return 0;
float value = sample * 32768.0f;
if (value >= 32767) return 32767;
if (value <= -32768) return -32768;
return (int16_t)value;
}
int main(void) {
void *lib = dlopen("/system/vendor/quasar/libs/libyandex_evil.so", RTLD_NOW | RTLD_LOCAL);
if (!lib) {
fprintf(stderr, "VQE: %s\n", dlerror());
return 1;
}
#define VQE(name) symbol(lib, "YandexVqeC_" #name)
error_message = VQE(Error_GetMessage);
error_free = VQE(Error_Free);
void *(*preset)(const char *, void **) = VQE(Config_CreateFromPreset);
void (*free_config)(void *) = VQE(Config_Free);
int (*input_rate)(void *) = VQE(Config_GetInputSamplingRate);
int (*output_rate)(void *) = VQE(Config_GetOutputSamplingRate);
int (*input_chunk)(void *) = VQE(Config_GetInputChunkSize);
int (*mic_count)(void *) = VQE(Config_GetMicsCount);
int (*speaker_count)(void *) = VQE(Config_GetSpeakersCount);
void *(*create)(void *, const char *, size_t, const char **, void **) = VQE(Create);
void (*destroy)(void *) = VQE(Free);
void *(*process)(void *, const float *, size_t, const float *, size_t) = VQE(Process);
void *(*channel)(void *, int, size_t, const float **, size_t *) = VQE(GetOutputChannelData);
#undef VQE
void *config = NULL, *vqe = NULL;
check(preset("yandexmini_2", &config));
if (input_rate(config) != 48000 || output_rate(config) != 16000 ||
input_chunk(config) != INPUT || mic_count(config) != MICS || speaker_count(config) != SPEAKERS) {
fprintf(stderr, "VQE: incompatible Mini 2 preset\n");
return 1;
}
check(create(config, "/system/vendor/quasar/vqe_models", 0, NULL, &vqe));
free_config(config);
int16_t input[INPUT][MICS + SPEAKERS], output[OUTPUT];
float mics[INPUT][MICS], speakers[INPUT][SPEAKERS];
// Suppress the PDM startup transient while warming the processor (512 ms).
unsigned warmup = 16;
while (fread(input, sizeof(input), 1, stdin) == 1) {
for (size_t i = 0; i < INPUT; ++i) {
for (size_t j = 0; j < MICS; ++j) mics[i][j] = input[i][j] / 32768.0f;
for (size_t j = 0; j < SPEAKERS; ++j) speakers[i][j] = input[i][MICS+j] / 32768.0f;
}
check(process(vqe, &mics[0][0], INPUT * MICS, &speakers[0][0], INPUT * SPEAKERS));
const float *samples = NULL;
size_t count = 0;
check(channel(vqe, ASR_CHANNEL, 0, &samples, &count));
if (count != OUTPUT || !samples) {
fprintf(stderr, "VQE: incompatible output frame\n");
return 1;
}
if (warmup) {
--warmup;
continue;
}
for (size_t i = 0; i < OUTPUT; ++i) output[i] = pcm(samples[i]);
if (fwrite(output, sizeof(output), 1, stdout) != 1 || fflush(stdout) != 0) return 1;
}
destroy(vqe);
return ferror(stdin) ? 1 : 0;
}
+3 -1
View File
@@ -31,11 +31,13 @@ test('settings and bounded history survive a restart', t => {
const directory = mkdtempSync(join(tmpdir(), 'alice-store-'));
t.after(() => rmSync(directory, { recursive: true }));
const store = openStore(directory);
const changed = validateSettings({ ...store.getSettings(), systemPrompt: ' Тест ' });
assert.equal(store.getSettings().sttModel, 'scribe_v3_live');
const changed = validateSettings({ ...store.getSettings(), sttModel: 'scribe_v2_realtime', systemPrompt: ' Тест ' });
store.updateSettings(changed);
for (let i = 0; i < 55; i++) store.recordTurn(String(i), 'Ответ');
const reopened = openStore(directory);
assert.equal(reopened.getSettings().systemPrompt, 'Тест');
assert.equal(reopened.getSettings().sttModel, 'scribe_v2_realtime');
assert.equal(reopened.getHistory().length, 50);
assert.equal(reopened.getHistory()[0].user, '5');
reopened.clearHistory();
+19 -5
View File
@@ -9,6 +9,7 @@ test('voice turns stream dialogue and skip synthesis for confirmed quick actions
const tts = new WebSocketServer({ port: 0 });
await Promise.all([once(stt, 'listening'), once(tts, 'listening')]);
process.env.ELEVENLABS_API_KEY = 'test';
process.env.ELEVEN_STT_KEYTERMS = 'Aphex Twin, Boards of Canada';
process.env.LLM_MODE = 'echo';
process.env.ELEVEN_STT_URL = `ws://127.0.0.1:${stt.address().port}/stt`;
process.env.ELEVEN_TTS_URL = `ws://127.0.0.1:${tts.address().port}/tts`;
@@ -16,7 +17,10 @@ test('voice turns stream dialogue and skip synthesis for confirmed quick actions
let transcript = 'Привет';
let syntheses = 0;
stt.on('connection', (ws, req) => {
assert.equal(new URL(req.url, 'http://x').searchParams.get('commit_strategy'), 'manual');
const params = new URL(req.url, 'http://x').searchParams;
assert.equal(params.get('commit_strategy'), 'manual');
assert.equal(params.get('model_id'), 'scribe_v3_live');
assert.deepEqual(params.getAll('keyterms'), ['Aphex Twin', 'Boards of Canada']);
ws.on('message', data => {
const msg = JSON.parse(data);
input = Buffer.concat([input, Buffer.from(msg.audio_base_64, 'base64')]);
@@ -34,9 +38,9 @@ test('voice turns stream dialogue and skip synthesis for confirmed quick actions
const { handleTurn } = await import('../server/turns.mjs');
const local = new WebSocketServer({ port: 0 });
await once(local, 'listening');
const store = { getSettings: () => ({ sttModel: 'scribe_v2_realtime', ttsModel: 'eleven_v4_turbo', voiceId: 'voice', systemPrompt: '' }), getHistory: () => [], recordTurn: (...args) => { recorded = args; } };
const store = { getSettings: () => ({ sttModel: 'scribe_v3_live', ttsModel: 'eleven_v4_turbo', voiceId: 'voice', systemPrompt: '' }), getHistory: () => [], recordTurn: (...args) => { recorded = args; } };
local.on('connection', ws => handleTurn(ws, store));
async function turn(reply) {
async function turn(reply, wake = false) {
const client = new WebSocket(`ws://127.0.0.1:${local.address().port}`);
const seen = [], audio = [], calls = [];
client.on('message', (data, binary) => {
@@ -47,7 +51,7 @@ test('voice turns stream dialogue and skip synthesis for confirmed quick actions
const msg = JSON.parse(data);
seen.push(msg.type);
if (msg.type === 'ready') {
client.send(JSON.stringify({ type: 'start' }));
client.send(JSON.stringify({ type: 'start', wake }));
client.send(Buffer.from([5, 6, 7, 8]));
client.send(JSON.stringify({ type: 'end' }));
} else if (msg.type === 'device_tool') {
@@ -74,7 +78,17 @@ test('voice turns stream dialogue and skip synthesis for confirmed quick actions
assert.deepEqual(await turn({ accepted: true, status: 'paused' }), {
seen: ['ready', 'transcript', 'device_tool', 'done'], audio: [], calls: [['spotify_control', { action: 'pause' }]],
});
assert.equal(syntheses, 1);
assert.deepEqual(recorded, [transcript, 'Музыка на паузе.']);
transcript = 'Алиса, выключи музыку.';
assert.deepEqual(await turn({ accepted: true, status: 'paused' }, true), {
seen: ['ready', 'transcript', 'device_tool', 'done'], audio: [], calls: [['spotify_control', { action: 'pause' }]],
});
assert.deepEqual(recorded, ['выключи музыку.', 'Музыка на паузе.']);
transcript = 'Алиса.';
assert.deepEqual(await turn(undefined, true), { seen: ['ready', 'done'], audio: [], calls: [] });
assert.deepEqual(recorded, ['выключи музыку.', 'Музыка на паузе.']);
assert.equal(syntheses, 1);
await Promise.all([new Promise(r => local.close(r)), new Promise(r => stt.close(r)), new Promise(r => tts.close(r))]);
});