diff --git a/.gitignore b/.gitignore index bc65ecf..0abfce4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /node_modules/ /data/ /dist/ +/wake-helper/target/ diff --git a/README.md b/README.md index 9622015..5ee8bae 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/music/main.go b/music/main.go index 14bd395..79bdba9 100644 --- a/music/main.go +++ b/music/main.go @@ -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() diff --git a/scripts/build-spotify-runtime.sh b/scripts/build-spotify-runtime.sh index 731982f..cba9716 100755 --- a/scripts/build-spotify-runtime.sh +++ b/scripts/build-spotify-runtime.sh @@ -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) diff --git a/server/providers.mjs b/server/providers.mjs index 9878083..28cc739 100644 --- a/server/providers.mjs +++ b/server/providers.mjs @@ -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 { diff --git a/server/quick-actions.mjs b/server/quick-actions.mjs new file mode 100644 index 0000000..aca2609 --- /dev/null +++ b/server/quick-actions.mjs @@ -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, + }; +} diff --git a/server/tools.mjs b/server/tools.mjs index bffccc8..ab30b59 100644 --- a/server/tools.mjs +++ b/server/tools.mjs @@ -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; } diff --git a/server/turns.mjs b/server/turns.mjs index b6173e2..2a0e2f4 100644 --- a/server/turns.mjs +++ b/server/turns.mjs @@ -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')); }); diff --git a/station/README.md b/station/README.md index 9eae697..b65f668 100644 --- a/station/README.md +++ b/station/README.md @@ -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` рядом с клиентом. diff --git a/station/device.go b/station/device.go index ee416bf..128b5d7 100644 --- a/station/device.go +++ b/station/device.go @@ -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) } } } diff --git a/station/install-init.sh b/station/install-init.sh index 794d8be..f63c5d9 100755 --- a/station/install-init.sh +++ b/station/install-init.sh @@ -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" diff --git a/station/keyword.go b/station/keyword.go new file mode 100644 index 0000000..95cae8d --- /dev/null +++ b/station/keyword.go @@ -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 + } + } +} diff --git a/station/main.go b/station/main.go index eff496f..d8e4e23 100644 --- a/station/main.go +++ b/station/main.go @@ -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 diff --git a/station/station-supervisor.sh b/station/station-supervisor.sh index e415e6f..2b69885 100755 --- a/station/station-supervisor.sh +++ b/station/station-supervisor.sh @@ -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 diff --git a/station/volume.go b/station/volume.go index 027b4ef..959ada6 100644 --- a/station/volume.go +++ b/station/volume.go @@ -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") } diff --git a/station/wake-helper.sh b/station/wake-helper.sh new file mode 100755 index 0000000..9710e3c --- /dev/null +++ b/station/wake-helper.sh @@ -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" "$@" diff --git a/test/voice.test.mjs b/test/voice.test.mjs index c705c88..982a345 100644 --- a/test/voice.test.mjs +++ b/test/voice.test.mjs @@ -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))]); }); diff --git a/wake-helper/Cargo.lock b/wake-helper/Cargo.lock new file mode 100644 index 0000000..4468ade --- /dev/null +++ b/wake-helper/Cargo.lock @@ -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" diff --git a/wake-helper/Cargo.toml b/wake-helper/Cargo.toml new file mode 100644 index 0000000..ecee3e6 --- /dev/null +++ b/wake-helper/Cargo.toml @@ -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 } diff --git a/wake-helper/src/main.rs b/wake-helper/src/main.rs new file mode 100644 index 0000000..f9d3276 --- /dev/null +++ b/wake-helper/src/main.rs @@ -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> { + 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(()) +}