Tighten wake detection and keep short commands quiet

This commit is contained in:
2026-11-10 22:35:13 +03:00
parent 6b483c9c08
commit a27589447e
20 changed files with 833 additions and 92 deletions
+1
View File
@@ -6,3 +6,4 @@
/node_modules/
/data/
/dist/
/wake-helper/target/
+3 -3
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
@@ -43,10 +43,10 @@ Caddy проксирует голосовое соединение и `/healthz`
## Spotify и поиск в интернете
Музыку на колонке воспроизводит официальный Spotify Soloist. На Mini 2 он запускается с отдельным ARM64 runtime и PulseAudio. Музыка и ответы идут через разные входы микшера. Кольцо включается после приглушения музыки, а после ответа громкость музыкального входа восстанавливается.
Музыку на колонке воспроизводит официальный 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_play` для трека, исполнителя, альбома или плейлиста. Поддерживаются пауза, продолжение, следующий и предыдущий трек и установка громкости. Короткие команды «пауза», «выключи музыку» и «громкость 50» выполняются без озвучивания подтверждения. Для актуальных сведений и проверки неизвестных фактов Клод может вызвать [серверный веб-поиск 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
@@ -32,7 +32,7 @@ func execute(req request) (map[string]interface{}, error) {
if req.Name == "duck" {
volume := "100%"
if req.Arguments.Active {
volume = "18%"
volume = "0%"
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
+5 -1
View File
@@ -13,7 +13,7 @@ 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
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 libstdc++6: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
@@ -30,6 +30,10 @@ for base, dirs, files in os.walk(root):
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']
# The wake helper's bundled TensorFlow Lite C library loads libstdc++ at runtime.
cxx=out+'/lib/libstdc++.so.6'
shutil.copy2(paths['libstdc++.so.6'],cxx,follow_symlinks=True)
seeds.append(cxx)
for base,dirs,files in os.walk(out+'/soloist'):
for f in files:
p=os.path.join(base,f)
+1 -1
View File
@@ -25,7 +25,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' }), 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' }), signal);
let final = false;
ws.on('message', data => {
try {
+39
View File
@@ -0,0 +1,39 @@
const units = 'ноль один два три четыре пять шесть семь восемь девять десять одиннадцать двенадцать тринадцать четырнадцать пятнадцать шестнадцать семнадцать восемнадцать девятнадцать'.split(' ');
const tens = 'двадцать тридцать сорок пятьдесят шестьдесят семьдесят восемьдесят девяносто'.split(' ');
function volumeLevel(text) {
if (/^\d{1,3}$/.test(text)) return Number(text);
if (text === 'сто') return 100;
const words = text.split(' ');
if (words.length === 1 && units.includes(words[0])) return units.indexOf(words[0]);
const decade = tens.indexOf(words[0]);
if (decade === -1) return -1;
if (words.length === 1) return (decade + 2) * 10;
const unit = units.indexOf(words[1]);
return words.length === 2 && unit >= 1 && unit <= 9 ? (decade + 2) * 10 + unit : -1;
}
export function quickAction(transcript) {
const text = transcript.toLowerCase().replace(/[.,!?;:…\s]+/gu, ' ').trim()
.replace(/^алиса\s+/, '').replace(/^пожалуйста\s+|\s+пожалуйста$/g, '');
if (/^(?:пауза|на паузу|(?:поставь|поставить)(?: музыку| воспроизведение)? на паузу|(?:приостанови|выключи|останови) (?:музыку|воспроизведение))$/.test(text)) {
return {
name: 'spotify_control',
args: { action: 'pause' },
done: 'Музыка на паузе.',
failure: 'Не удалось поставить музыку на паузу.',
success: result => result?.accepted === true && ['paused', 'stopped'].includes(result.status),
};
}
const match = text.match(/^(?:(?:поставь|установи|сделай) )?громкость(?: на| до)? (.+?)(?: ?%| процент(?:а|ов)?)?$/);
if (!match) return null;
const level = volumeLevel(match[1]);
if (level < 0 || level > 100) return null;
return {
name: 'station_volume',
args: { level },
done: `Громкость ${level}%.`,
failure: 'Не удалось изменить громкость колонки.',
success: result => result?.ok === true && result.level === level,
};
}
+4 -1
View File
@@ -2,7 +2,8 @@
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 } } }
{ type: 'function', function: { name: 'spotify_control', description: 'Control Spotify playback or set the Spotify stream 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 } } },
{ type: 'function', function: { name: 'station_volume', description: 'Set the overall speaker volume for both speech and music. Use this for a general volume request.', parameters: { type: 'object', properties: { level: { type: 'integer', minimum: 0, maximum: 100 } }, required: ['level'], additionalProperties: false } } },
];
export function validateTool(name, args) {
@@ -12,6 +13,8 @@ export function validateTool(name, args) {
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 if (name === 'station_volume') {
if (keys.length !== 1 || !Number.isInteger(args.level) || args.level < 0 || args.level > 100) throw new Error('invalid_arguments');
} else throw new Error('unknown_tool');
return args;
}
+35 -9
View File
@@ -1,15 +1,19 @@
import WebSocket from 'ws';
import { openStt, openTts, streamAnswer } from './providers.mjs';
import { deviceTools } from './device.mjs';
import { quickAction } from './quick-actions.mjs';
const MAX_AUDIO = 60 * 16000 * 2;
const STT_CHUNK_BYTES = 3200; // 100 ms of mono PCM16 at 16 kHz.
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;
let state = 'waiting', stt, tts, bytes = 0, received = 0, messages = 0, ended = false, terminal = false;
let pending = [];
let pendingBytes = 0;
const timeout = setTimeout(() => fail('timeout'), 120000);
const send = obj => { if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify(obj)); };
function cleanup() {
@@ -27,6 +31,14 @@ export function handleTurn(client, store) {
if (!stt || stt.readyState !== WebSocket.OPEN || stt.bufferedAmount > MAX_BUFFERED) return fail('stt_unavailable');
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);
terminal = true;
send({ type: 'done' });
cleanup();
if (client.readyState === WebSocket.OPEN) client.close(1000);
}
async function respond(text, settings) {
if (state !== 'processing' || terminal) return;
state = 'responding';
@@ -34,19 +46,24 @@ export function handleTurn(client, store) {
if (!text) return fail('empty_transcript');
send({ type: 'transcript', text });
try {
const action = quickAction(text);
if (action) {
const result = await device.execute(action.name, action.args).catch(() => null);
if (terminal) return;
if (action.success(result)) return complete(text, action.done);
}
tts = await openTts(settings, signal, audio => {
if (terminal || client.readyState !== WebSocket.OPEN) return;
if (client.bufferedAmount > MAX_BUFFERED) return fail('client_too_slow');
client.send(audio, { binary: true });
});
if (terminal) return;
const answer = await streamAnswer(settings, store.getHistory(), text, signal, part => tts.text(part), device.execute);
const answer = action?.failure ?? await streamAnswer(settings, store.getHistory(), text, signal, part => tts.text(part), device.execute);
if (action) tts.text(answer);
tts.finish();
await tts.done;
if (terminal) return;
store.recordTurn(text, answer);
terminal = true; send({ type: 'done' }); cleanup();
if (client.readyState === WebSocket.OPEN) client.close(1000);
complete(text, answer);
} catch { fail('upstream_error'); }
}
let settings;
@@ -59,8 +76,13 @@ export function handleTurn(client, store) {
if (state !== 'recording') return fail('invalid_state');
const audio = Buffer.from(data);
if (!audio.length || audio.length % 2 || (bytes += audio.length) > MAX_AUDIO) return fail('invalid_audio');
if (pending) sendChunk(pending);
pending = audio;
if (pendingBytes >= STT_CHUNK_BYTES) {
sendChunk(Buffer.concat(pending, pendingBytes));
pending = [];
pendingBytes = 0;
}
pending.push(audio);
pendingBytes += audio.length;
return;
}
let msg;
@@ -74,8 +96,12 @@ export function handleTurn(client, store) {
state = 'recording';
} catch { fail('stt_error'); }
} else if (msg?.type === 'end' && state === 'recording') {
if (!pending) return fail('empty_audio');
ended = true; state = 'processing'; sendChunk(pending, true); pending = null;
if (!pendingBytes) return fail('empty_audio');
ended = true;
state = 'processing';
sendChunk(Buffer.concat(pending, pendingBytes), true);
pending = [];
pendingBytes = 0;
} else fail('invalid_state');
}).catch(() => fail('internal_error'));
});
+9 -3
View File
@@ -23,6 +23,12 @@ cd /data/opt/alice-backend
## Локальная активация по фразе
Автозапуск использует [русскую модель microWakeWord «Алиса»](https://github.com/olegchumakov/microwakeword-models/tree/7a3490e3c5de3ec21442abcc7a44c74fd979917a/alisa). Произнеси слово, дождись сине-фиолетового кольца и скажи команду. Непрерывную фразу без паузы модель может пропустить.
Собери `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`.
Режим записи индивидуальных шаблонов остаётся для ручных экспериментов.
```sh
./with-mic.sh enroll -samples 4 -voice-threshold 55
./with-mic.sh wake -voice-threshold 55 -once
@@ -30,9 +36,9 @@ cd /data/opt/alice-backend
При обучении нажимай 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`, если на конкретной ревизии этот сигнал расположен иначе.
До локального срабатывания соединение с сервером не открывается. После активации начало команды буферизуется в RAM, а слово активации остаётся на колонке. Проверенное состояние кнопки mute на этой Mini 2 читается из `/sys/class/gpio/gpio414/value`, где `1` означает разрешённый микрофон. В mute клиент отбрасывает вход, сбрасывает состояние детектора и прерывает текущую передачу. При недоступном GPIO применяется то же поведение. Состояние можно читать из другого узла через `-mute-gpio`, если на конкретной ревизии этот сигнал расположен иначе.
## Проверка из файла
@@ -45,7 +51,7 @@ export DEVICE_TOKEN="$(cat device-token)"
## Автозапуск и музыка
Сенсорные кнопки `+` и `−` меняют общую громкость музыки и ответов на 5 процентных пунктов. Значение сохраняется между запусками.
Сенсорные кнопки `+` и `−` меняют общую громкость музыки и ответов на 10 процентных пунктов. Кольцо показывает уровень от зелёного к красному, а короткий тон звучит с выбранной громкостью. Голосовая команда установки уровня подсвечивает кольцо без звука. Значение сохраняется между запусками.
Загрузочный скрипт `S99alice` запускает наш клиент, а `S98alice-music` поднимает Soloist и PulseAudio. Штатный `S99quasar` пропускает запуск, пока существует `/data/opt/alice-backend/stock-disabled`. Оригинал сохранён как `S99quasar.original` рядом с клиентом.
+60 -11
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
@@ -17,6 +18,23 @@ type deviceTool struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
func runDeviceTool(req deviceTool) json.RawMessage {
if req.Name != "station_volume" {
return musicCall(req.Name, req.Arguments)
}
var args struct{ Level *int }
if json.Unmarshal(req.Arguments, &args) != nil || args.Level == nil || *args.Level < 0 || *args.Level > 100 {
return json.RawMessage(`{"error":"invalid_volume"}`)
}
if err := setStationVolume(*args.Level); err != nil {
log.Printf("station volume: %v", err)
return json.RawMessage(`{"error":"station_volume_failed"}`)
}
lights.showVolume(*args.Level)
return json.RawMessage(fmt.Sprintf(`{"ok":true,"level":%d}`, *args.Level))
}
type limitedOutput struct {
bytes.Buffer
overflow bool
@@ -81,11 +99,35 @@ type ring struct {
done chan struct{}
finished chan struct{}
once sync.Once
volume int
volumeUntil time.Time
}
var lights = &ring{state: "idle"}
func (r *ring) set(s string) { r.mu.Lock(); r.state = s; r.mu.Unlock() }
func (r *ring) showVolume(level int) {
r.mu.Lock()
defer r.mu.Unlock()
r.volume = level
r.volumeUntil = time.Now().Add(900 * time.Millisecond)
}
func volumeFrame(level int) [6][3]int {
var frame [6][3]int
red := level
green := 100 - level
// Fill six segments proportionally, with a green zero-level marker.
for i := range frame {
fill := max(0, min(100, 6*level-100*i))
frame[i] = [3]int{red * fill / 100, green * fill / 100, 0}
}
if level == 0 {
frame[0] = [3]int{0, 15, 0}
}
return frame
}
func (r *ring) start() {
r.done = make(chan struct{})
r.finished = make(chan struct{})
@@ -94,11 +136,11 @@ func (r *ring) start() {
t := time.NewTicker(150 * time.Millisecond)
defer t.Stop()
tick := 0
var last [3]int
var last [6][3]int
first := true
for {
r.mu.Lock()
s := r.state
s, level, volumeUntil := r.state, r.volume, r.volumeUntil
r.mu.Unlock()
if !unmuted() {
s = "mute"
@@ -118,15 +160,22 @@ func (r *ring) start() {
rgb = [3]int{50, 0, 0}
}
}
if first || rgb != last {
r.write(rgb)
last = rgb
var frame [6][3]int
for i := range frame {
frame[i] = rgb
}
if time.Now().Before(volumeUntil) {
frame = volumeFrame(level)
}
if first || frame != last {
r.write(frame)
last = frame
first = false
}
tick++
select {
case <-r.done:
r.write([3]int{})
r.write([6][3]int{})
return
case <-t.C:
}
@@ -135,11 +184,11 @@ func (r *ring) start() {
}
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)
func (r *ring) write(frame [6][3]int) {
for i, rgb := range frame {
for j, color := range []string{"red", "green", "blue"} {
path := filepath.Join("/sys/class/leds", fmt.Sprintf("mini2:%s:ring%d", color, i+1), "brightness")
_ = os.WriteFile(path, []byte(fmt.Sprint(rgb[j])), 0)
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ 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; }
[ -x "$base/alice-station" ] && [ -x "$base/station-supervisor.sh" ] && [ -r "$base/device-token" ] && [ -s "$base/alisa.tflite" ] && [ -x "$base/wake-helper.sh" ] && [ -x "$base/alice-wake-helper" ] || { echo 'Missing station binary, supervisor, token or keyword model' >&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"
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"encoding/binary"
"errors"
"io"
"log"
"os"
"os/exec"
"time"
)
type keywordDetector struct {
cmd *exec.Cmd
input io.WriteCloser
output *os.File
packet [1 + hop*2]byte
}
func startKeywordDetector(helper, model string) (*keywordDetector, error) {
cmd := exec.Command(helper, model)
cmd.Stderr = os.Stderr
terminateWithClient(cmd)
input, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
output, err := cmd.StdoutPipe()
if err != nil {
input.Close()
return nil, err
}
if err := cmd.Start(); err != nil {
input.Close()
output.Close()
return nil, err
}
detector := &keywordDetector{cmd: cmd, input: input, output: output.(*os.File)}
if _, err := detector.result(5 * time.Second); err != nil {
detector.close()
return nil, err
}
return detector, nil
}
func (d *keywordDetector) result(timeout time.Duration) (bool, error) {
if err := d.output.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return false, err
}
var result [1]byte
if _, err := io.ReadFull(d.output, result[:]); err != nil {
return false, err
}
if result[0] > 1 {
return false, errors.New("invalid keyword detector response")
}
return result[0] == 1, nil
}
func (d *keywordDetector) process(samples []int16) (bool, error) {
d.packet[0] = 1
for i, sample := range samples {
binary.LittleEndian.PutUint16(d.packet[1+i*2:], uint16(sample))
}
if _, err := d.input.Write(d.packet[:]); err != nil {
return false, err
}
return d.result(time.Second)
}
func (d *keywordDetector) reset() error {
if _, err := d.input.Write([]byte{0}); err != nil {
return err
}
_, err := d.result(time.Second)
return err
}
func (d *keywordDetector) close() {
d.input.Close()
d.cmd.Process.Kill()
d.cmd.Wait()
}
func runKeywordWake(live *liveReader, detector *keywordDetector, activate func() error, once bool) error {
log.Print("Listening locally for the Алиса keyword model")
raw := make([]byte, captureBlock)
muted := false
for {
if _, err := io.ReadFull(live, raw); err != nil {
return err
}
if !unmuted() {
if !muted {
if err := detector.reset(); err != nil {
return err
}
muted = true
}
continue
}
muted = false
detected, err := detector.process(downsample(raw))
if err != nil {
return err
}
if !detected {
continue
}
log.Print("wake word detected")
if err := activate(); err != nil {
log.Printf("session: %v", err)
if !errors.Is(err, errMuted) && !errors.Is(err, errNoSpeech) {
lights.flashError()
}
} else if once {
return nil
}
live.discardPending()
if err := detector.reset(); err != nil {
return err
}
}
}
+69 -23
View File
@@ -2,6 +2,7 @@ package main
import (
"bufio"
"context"
"crypto/tls"
"encoding/binary"
"encoding/json"
@@ -185,7 +186,7 @@ type liveReader struct {
}
func newLiveReader(r io.Reader) *liveReader {
l := &liveReader{chunks: make(chan []byte, 2)}
l := &liveReader{chunks: make(chan []byte, 64)}
go func() {
for {
b := make([]byte, captureBlock)
@@ -235,6 +236,7 @@ func (l *liveReader) Read(p []byte) (int, error) {
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)
@@ -338,12 +340,29 @@ func connect(url, token string) (*websocket.Conn, error) {
_ = c.SetReadDeadline(time.Time{})
return c, nil
}
func playbackCommand(ctx context.Context, device string) *exec.Cmd {
player := exec.CommandContext(ctx, "aplay", "-q", "-D", device, "-t", "raw", "-f", "S16_LE", "-r", "24000", "-c", "1")
if path := os.Getenv("ALICE_PLAYER"); path != "" {
player = exec.CommandContext(ctx, path)
}
player.Stderr = os.Stderr
terminateWithClient(player)
return player
}
func exchange(url, token string, live *liveReader, fileAudio []byte, threshold float64, max, silence time.Duration, playback string) error {
if fileAudio == nil && !unmuted() {
if fileAudio == 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()
}
c, e := connect(url, token)
if e != nil {
return e
@@ -369,7 +388,6 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
fileAudio = fileAudio[n:]
}
} else {
live.discardPending()
lights.set("listening")
// Keep the onset in a short local buffer. It contains only post-activation audio.
const prerollSamples = rate / 5
@@ -380,6 +398,9 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
var voiced int
buf := make([]byte, captureBlock)
for time.Since(started) < max {
if !heard && time.Since(started) >= 4*time.Second {
break
}
if _, e = io.ReadFull(live, buf); e != nil {
return e
}
@@ -421,7 +442,7 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
return errMuted
}
if !heard {
return errors.New("no speech detected")
return errNoSpeech
}
}
_ = c.SetWriteDeadline(time.Now().Add(15 * time.Second))
@@ -429,25 +450,14 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
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()
if e != nil {
return e
}
if e = player.Start(); e != nil {
return e
}
var player *exec.Cmd
var w io.WriteCloser
defer func() {
if player != nil {
w.Close()
if player.Process != nil {
player.Process.Kill()
}
player.Wait()
}
}()
_ = c.SetReadDeadline(time.Now().Add(90 * time.Second))
for {
@@ -456,6 +466,18 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
return e
}
if typ == websocket.BinaryMessage {
if player == nil {
cmd := playbackCommand(context.Background(), playback)
w, e = cmd.StdinPipe()
if e != nil {
return e
}
if e = cmd.Start(); e != nil {
w.Close()
return e
}
player = cmd
}
lights.set("speaking")
if _, e = w.Write(data); e != nil {
return e
@@ -473,17 +495,20 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, threshold f
if e = json.Unmarshal(data, &req); e != nil {
return e
}
result := musicCall(req.Name, req.Arguments)
result := runDeviceTool(req)
_ = 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 player == nil {
return nil
}
if e = w.Close(); e != nil {
return e
}
e = player.Wait()
player.Process = nil
player = nil
return e
case "error":
return fmt.Errorf("server error: %s", m.Code)
@@ -498,6 +523,8 @@ func main() {
device := flag.String("capture", "hw:0,1", "ALSA capture device")
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")
keywordHelper := flag.String("wake-helper", "/data/opt/alice-backend/wake-helper.sh", "local keyword inference executable")
path := flag.String("templates", "wake-templates.json", "local enrollment file")
th := flag.Float64("voice-threshold", 350, "speech RMS threshold")
match := flag.Float64("match-threshold", 1.3, "experimental DTW maximum distance")
@@ -519,10 +546,12 @@ func main() {
var ts []template
var e error
if *mode == "wake" {
if *keywordModel == "" {
ts, e = loadTemplates(*path)
if e != nil {
log.Fatal(e)
}
}
} else if *mode != "ptt" && *mode != "enroll" && *mode != "file" {
log.Fatal("unknown mode")
}
@@ -592,7 +621,23 @@ func main() {
}
}
go listenVolumeButtons()
go listenVolumeButtons(*playback)
if *keywordModel != "" {
detector, err := startKeywordDetector(*keywordHelper, *keywordModel)
if err != nil {
cleanup()
log.Fatalf("keyword model: %v", err)
}
err = runKeywordWake(live, detector, func() error {
return exchange(*url, token, live, nil, *th, *max, *silence, *playback)
}, *oneTurn)
detector.close()
if err != nil {
cleanup()
log.Fatalf("keyword detector: %v", err)
}
return
}
// Detect whole speech segments locally before opening a voice connection.
log.Println("Listening locally for enrolled phrase")
@@ -646,13 +691,14 @@ 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) {
if !errors.Is(e, errMuted) && !errors.Is(e, errNoSpeech) {
lights.flashError()
}
} else if *oneTurn {
return
}
}
live.discardPending()
f = nil
active = false
quiet = 0
+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 -templates /data/opt/alice-backend/wake-templates.json >> /tmp/alice-station.log 2>&1 9>&- &
./alice-station -mode wake -voice-threshold 55 -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
+57 -10
View File
@@ -1,24 +1,30 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"log"
"math"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const volumeFile = "/data/opt/alice-backend/station-volume"
const pulseControl = "/data/opt/alice-backend/spotify/pactl"
func listenVolumeButtons() {
var volumeMu sync.Mutex
var toneMu sync.Mutex
func listenVolumeButtons(playback string) {
names, _ := filepath.Glob("/sys/class/input/event*/device/name")
for _, path := range names {
name, err := os.ReadFile(path)
@@ -40,9 +46,13 @@ func listenVolumeButtons() {
return
}
if delta := volumeStep(eventData); delta != 0 {
if err := changeVolume(delta); err != nil {
level, err := changeVolume(delta)
if err != nil {
log.Printf("volume button: %v", err)
continue
}
lights.showVolume(level)
go playVolumeTone(playback, level)
}
}
}
@@ -58,14 +68,16 @@ func volumeStep(event [24]byte) int {
}
switch code {
case 114: // KEY_VOLUMEDOWN
return -5
return -10
case 115: // KEY_VOLUMEUP
return 5
return 10
}
return 0
}
func changeVolume(delta int) error {
func changeVolume(delta int) (int, error) {
volumeMu.Lock()
defer volumeMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -75,10 +87,10 @@ func changeVolume(delta int) error {
}
data, err := exec.CommandContext(ctx, pulseControl, "--format=json", "list", "sinks").Output()
if err != nil {
return err
return 0, err
}
if err := json.Unmarshal(data, &sinks); err != nil {
return err
return 0, err
}
for _, sink := range sinks {
if sink.Name != "station" {
@@ -91,9 +103,23 @@ func changeVolume(delta int) error {
current := (channel.Value*100 + 32768) / 65536
next := max(0, min(100, current+delta))
if next == current {
return nil
return current, nil
}
volume := strconv.Itoa(next)
return next, applyVolume(ctx, next)
}
return 0, errors.New("station sink unavailable")
}
func setStationVolume(level int) error {
volumeMu.Lock()
defer volumeMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return applyVolume(ctx, level)
}
func applyVolume(ctx context.Context, level int) error {
volume := strconv.Itoa(level)
if err := exec.CommandContext(ctx, pulseControl, "set-sink-volume", "station", volume+"%").Run(); err != nil {
return err
}
@@ -102,5 +128,26 @@ func changeVolume(delta int) error {
}
return os.Rename(volumeFile+".tmp", volumeFile)
}
return errors.New("station sink unavailable")
func playVolumeTone(playback string, level int) {
if level == 0 || !toneMu.TryLock() {
return
}
defer toneMu.Unlock()
// The master output attenuates this short, soft tone at the selected level.
const sampleRate = 24000
samples := make([]int16, sampleRate*90/1000)
frequency := 430 + 4*float64(level)
for i := range samples {
envelope := math.Sin(math.Pi * float64(i) / float64(len(samples)-1))
samples[i] = int16(3400 * envelope * envelope * math.Sin(2*math.Pi*frequency*float64(i)/sampleRate))
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
player := playbackCommand(ctx, playback)
player.Stdin = bytes.NewReader(pcm(samples))
if err := player.Run(); err != nil {
log.Printf("volume tone: %v", err)
}
}
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -eu
base=/data/opt/alice-backend
umask 077
export XDG_CACHE_HOME=/tmp/alice-wake-cache
exec "$base/spotify/run" "$base/alice-wake-helper" "$@"
+44 -8
View File
@@ -4,7 +4,7 @@ import { once } from 'node:events';
import { WebSocketServer, WebSocket } from 'ws';
// Local upstreams exercise the real framing without provider credentials or network access.
test('one PCM turn streams through Scribe and dialogue TTS', async () => {
test('voice turns stream dialogue and skip synthesis for confirmed quick actions', async () => {
const stt = new WebSocketServer({ port: 0 });
const tts = new WebSocketServer({ port: 0 });
await Promise.all([once(stt, 'listening'), once(tts, 'listening')]);
@@ -13,32 +13,68 @@ test('one PCM turn streams through Scribe and dialogue TTS', async () => {
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`;
let input = Buffer.alloc(0), ttsText = '', recorded;
let transcript = 'Привет';
let syntheses = 0;
stt.on('connection', (ws, req) => {
assert.equal(new URL(req.url, 'http://x').searchParams.get('commit_strategy'), 'manual');
ws.on('message', data => {
const msg = JSON.parse(data);
input = Buffer.concat([input, Buffer.from(msg.audio_base_64, 'base64')]);
if (msg.commit) ws.send(JSON.stringify({ message_type: 'committed_transcript', text: 'Привет' }));
if (msg.commit) ws.send(JSON.stringify({ message_type: 'committed_transcript', text: transcript }));
});
});
tts.on('connection', ws => ws.on('message', data => {
tts.on('connection', ws => {
syntheses++;
ws.on('message', data => {
const msg = JSON.parse(data);
if (msg.inputs) ttsText += msg.inputs[0].text;
if (msg.close_socket) { ws.send(JSON.stringify({ audio: Buffer.from([1, 2, 3, 4]).toString('base64') })); ws.send(JSON.stringify({ is_final: true })); }
}));
});
});
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; } };
local.on('connection', ws => handleTurn(ws, store));
async function turn(reply) {
const client = new WebSocket(`ws://127.0.0.1:${local.address().port}`);
const seen = [], audio = [];
client.on('message', (data, binary) => { if (binary) audio.push(...data); else { const msg = JSON.parse(data); seen.push(msg.type); if (msg.type === 'ready') { client.send(JSON.stringify({ type: 'start' })); client.send(Buffer.from([5, 6, 7, 8])); client.send(JSON.stringify({ type: 'end' })); } } });
const seen = [], audio = [], calls = [];
client.on('message', (data, binary) => {
if (binary) {
audio.push(...data);
return;
}
const msg = JSON.parse(data);
seen.push(msg.type);
if (msg.type === 'ready') {
client.send(JSON.stringify({ type: 'start' }));
client.send(Buffer.from([5, 6, 7, 8]));
client.send(JSON.stringify({ type: 'end' }));
} else if (msg.type === 'device_tool') {
calls.push([msg.name, msg.arguments]);
client.send(JSON.stringify({ type: 'device_tool_result', id: msg.id, result: reply }));
}
});
await once(client, 'close');
assert.deepEqual(seen, ['ready', 'transcript', 'done']);
return { seen, audio, calls };
}
assert.deepEqual(await turn(), { seen: ['ready', 'transcript', 'done'], audio: [1, 2, 3, 4], calls: [] });
assert.deepEqual([...input], [5, 6, 7, 8]);
assert.deepEqual(audio, [1, 2, 3, 4]);
assert.equal(ttsText, 'Привет');
assert.deepEqual(recorded, ['Привет', 'Привет']);
transcript = 'Алиса, громкость пятьдесят процентов.';
assert.deepEqual(await turn({ ok: true, level: 50 }), {
seen: ['ready', 'transcript', 'device_tool', 'done'], audio: [], calls: [['station_volume', { level: 50 }]],
});
assert.deepEqual(recorded, [transcript, 'Громкость 50%.']);
transcript = 'Выключи музыку.';
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, 'Музыка на паузе.']);
await Promise.all([new Promise(r => local.close(r)), new Promise(r => stt.close(r)), new Promise(r => tts.close(r))]);
});
+302
View File
@@ -0,0 +1,302 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "alice-wake-helper"
version = "0.1.0"
dependencies = [
"micro-wakeword",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cc"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "itoa"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "micro-wakeword"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5707a29c8f201960b6e8a12b72966442dca7e9bf129d560dc5fefb2413ffc1e"
dependencies = [
"cc",
"serde",
"serde_json",
"sha2",
"tflite-c-rs",
"thiserror 2.0.21",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.10",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tflite-c-rs"
version = "0.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a4e7b10e8ecae34ca2ba5e6f9b2f7f1cfad102945847da141c6ff4f897ab759"
dependencies = [
"libloading",
"thiserror 1.0.69",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl 2.0.21",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.10",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "alice-wake-helper"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
micro-wakeword = { version = "=0.1.3", default-features = false }
+44
View File
@@ -0,0 +1,44 @@
use micro_wakeword::{AUDIO_BLOCK_SAMPLES, Detector};
use std::io::{self, Read, Write};
use std::path::PathBuf;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let model = PathBuf::from(
std::env::args_os()
.nth(1)
.ok_or("usage: alice-wake-helper MODEL")?,
);
let mut detector = Detector::builder(model)
.wake_word("Алиса")
.probability_cutoff(0.90)
.sliding_window_size(6)
.build()?;
let mut input = io::stdin().lock();
let mut output = io::stdout().lock();
let mut bytes = [0; AUDIO_BLOCK_SAMPLES * 2];
let mut samples = [0; AUDIO_BLOCK_SAMPLES];
output.write_all(&[0])?;
output.flush()?;
let mut opcode = [0];
while input.read(&mut opcode)? != 0 {
let detected = match opcode[0] {
0 => {
detector.reset()?;
false
}
1 => {
input.read_exact(&mut bytes)?;
for (sample, pair) in samples.iter_mut().zip(bytes.chunks_exact(2)) {
*sample = i16::from_le_bytes([pair[0], pair[1]]);
}
detector.process_audio(&samples)?.is_some()
}
_ => return Err("unknown wake helper command".into()),
};
output.write_all(&[u8::from(detected)])?;
output.flush()?;
}
Ok(())
}