Use Mini 2 firmware audio processing and Scribe v3 Live
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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` рядом с клиентом.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+11
@@ -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
|
||||
@@ -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
@@ -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
@@ -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))]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user