Tighten wake detection and keep short commands quiet
This commit is contained in:
@@ -6,3 +6,4 @@
|
||||
/node_modules/
|
||||
/data/
|
||||
/dist/
|
||||
/wake-helper/target/
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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` рядом с клиентом.
|
||||
|
||||
|
||||
+65
-16
@@ -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
|
||||
@@ -76,16 +94,40 @@ func musicCall(name string, args json.RawMessage) json.RawMessage {
|
||||
}
|
||||
|
||||
type ring struct {
|
||||
mu sync.Mutex
|
||||
state string
|
||||
done chan struct{}
|
||||
finished chan struct{}
|
||||
once sync.Once
|
||||
mu sync.Mutex
|
||||
state string
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
-28
@@ -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() {
|
||||
return errMuted
|
||||
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() {
|
||||
w.Close()
|
||||
if player.Process != nil {
|
||||
if player != nil {
|
||||
w.Close()
|
||||
player.Process.Kill()
|
||||
player.Wait()
|
||||
}
|
||||
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,9 +546,11 @@ func main() {
|
||||
var ts []template
|
||||
var e error
|
||||
if *mode == "wake" {
|
||||
ts, e = loadTemplates(*path)
|
||||
if e != nil {
|
||||
log.Fatal(e)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+64
-17
@@ -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,16 +103,51 @@ 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)
|
||||
if err := exec.CommandContext(ctx, pulseControl, "set-sink-volume", "station", volume+"%").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(volumeFile+".tmp", []byte(volume+"\n"), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(volumeFile+".tmp", volumeFile)
|
||||
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
|
||||
}
|
||||
if err := os.WriteFile(volumeFile+".tmp", []byte(volume+"\n"), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(volumeFile+".tmp", volumeFile)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return errors.New("station sink unavailable")
|
||||
}
|
||||
|
||||
Executable
+6
@@ -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" "$@"
|
||||
+46
-10
@@ -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));
|
||||
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' })); } } });
|
||||
await once(client, 'close');
|
||||
assert.deepEqual(seen, ['ready', 'transcript', 'done']);
|
||||
async function turn(reply) {
|
||||
const client = new WebSocket(`ws://127.0.0.1:${local.address().port}`);
|
||||
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');
|
||||
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))]);
|
||||
});
|
||||
|
||||
Generated
+302
@@ -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"
|
||||
@@ -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 }
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user