Restore Mini 2 touch controls and expressive replies

This commit is contained in:
2026-11-13 11:20:37 +03:00
parent c2a0a7bc2f
commit 5a23f5a1ce
10 changed files with 458 additions and 77 deletions
+4
View File
@@ -59,3 +59,7 @@ Caddy проксирует голосовое соединение и `/healthz`
В «Пульте Алисы» можно включить сигнал активации и настроить окно продолжения разговора. По умолчанию сигнал отключён, а окно открывается на три секунды после голосового ответа. «Стоп» и «хватит» распознаются на колонке и прекращают текущий ответ. Spotify продолжает воспроизведение с обычным уровнем.
Штатный локальный детектор также принимает «громче», «тише», «пауза», «продолжи», «дальше» и «назад». Голосовой ассистент может показать состояние Spotify Connect, назвать текущий трек, перемотать, включить повтор или перемешивание, посмотреть и дополнить очередь. Для изменения плейлистов нужно повторно авторизовать Spotify в Mini App. Пульт показывает состояние колонки и последний известный трек. Источник состояния воспроизведения — Spotify Connect.
Тап по центру переключает воспроизведение Spotify или останавливает активный ответ. Удержание центра три секунды открывает прослушивание. Тап «−» или «+» меняет громкость на 10 процентных пунктов, удержание повторяет шаг. Свайп через три зоны от «−» к «+» включает следующий трек. Свайп обратно возвращает к началу текущего трека после пяти секунд воспроизведения и включает предыдущий в первые пять секунд.
Eleven v4 Turbo и Eleven v3 Conversational могут получать один тег интонации в начале ответа. Модель выбирает подачу при генерации текста ответа. Повседневные фразы и короткие команды остаются нейтральными. В истории сохраняется чистый текст.
+40
View File
@@ -58,6 +58,7 @@ func execute(req request) (map[string]interface{}, error) {
"pause": "pause", "resume": "play", "next": "skip_next", "previous": "skip_prev",
"volume": "set_volume", "seek": "seek", "shuffle": "set_shuffle", "repeat": "set_repeat_context",
"queue": "get_queue", "enqueue": "add_to_queue", "status": "get_state",
"toggle": "get_state", "swipe_previous": "get_state",
}
action, ok := actions[req.Arguments.Action]
if !ok {
@@ -123,6 +124,42 @@ func execute(req request) (map[string]interface{}, error) {
}
return nil, errors.New("spotify_not_paired")
}
panelAction := ""
if req.Arguments.Action == "toggle" || req.Arguments.Action == "swipe_previous" {
if err := conn.WriteJSON(command); err != nil {
return nil, errors.New("spotify_disconnected")
}
for {
var event struct {
Type, Status string
Position struct {
Milliseconds int64 `json:"position_ms"`
}
}
if err := conn.ReadJSON(&event); err != nil || event.Type == "error" {
return nil, errors.New("spotify_state_unavailable")
}
if event.Type != "playback_state" {
continue
}
if req.Arguments.Action == "toggle" {
panelAction = "resume"
command["command"] = "play"
if event.Status == "playing" || event.Status == "buffering" {
panelAction = "pause"
command["command"] = "pause"
}
} else {
panelAction = "previous"
command["command"] = "skip_prev"
if event.Position.Milliseconds >= 5000 {
command["command"] = "seek"
command["position_ms"] = 0
}
}
break
}
}
// Soloist exposes context and track repeat as separate flags.
if req.Arguments.Action == "repeat" {
reset := "set_repeat_track"
@@ -164,6 +201,9 @@ func execute(req request) (map[string]interface{}, error) {
case "command_result":
if event["command"] == command["command"] {
accepted = true
if panelAction != "" {
return map[string]interface{}{"accepted": true, "action": panelAction}, nil
}
if err := conn.WriteJSON(map[string]string{"type": "command", "command": "get_state"}); err != nil {
return nil, errors.New("spotify_disconnected")
}
+16 -3
View File
@@ -69,6 +69,17 @@ export async function openTts(settings, signal, onAudio) {
}
const sentenceSegments = new Intl.Segmenter('ru', { granularity: 'sentence' });
const deliveryTags = ['excited', 'curious', 'warmly', 'sympathetic', 'mischievously'];
const deliveryPrompt = `Обычные вопросы, команды и подтверждения озвучивай нейтрально. Если контекст явно эмоциональный, можешь начать окончательный ответ одним тегом подачи: ${deliveryTags.map(tag => `[${tag}]`).join(', ')}. Подбирай его по смыслу и настроению реплики. Используй сдержанную интонацию, не ставь теги внутри ответа, не добавляй звуковые эффекты и не комментируй выбор подачи.`;
function spokenReply(content, detailed, expressive) {
const lead = /^\s*\[([a-z]+)\](?=\s|$)/iu.exec(content);
const tag = lead?.[1].toLowerCase();
const clean = content.replace(/\[[a-z][a-z ,'-]{0,63}\]|\[[a-z][a-z ,'-]{0,63}$/giu, '').replace(/\s+/gu, ' ').trim();
const answer = detailed ? clean : shortReply(clean);
const audio = answer && expressive && deliveryTags.includes(tag) ? `[${tag}] ${answer}` : answer;
return { answer, audio };
}
function shortReply(text) {
const compact = Array.from(sentenceSegments.segment(text.trim()), part => part.segment).slice(0, 2).join('').trim();
@@ -87,9 +98,10 @@ export async function streamAnswer(settings, history, userText, signal, onText,
return answer;
}
if (!env.OPENROUTER_API_KEY) throw new Error('llm_config');
const expressive = settings.ttsModel === 'eleven_v4_turbo' || settings.ttsModel === 'eleven_v3_conversational';
const moscowDate = new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', dateStyle: 'long' }).format(new Date());
const systemPrompt = `${settings.systemPrompt}\nСегодня ${moscowDate} (Москва). Используй веб-поиск для актуальных сведений и когда требуется проверить сомнительные факты. Твоя языковая модель: ${settings.llmModel}. Обычно отвечай одной короткой фразой, максимум двумя предложениями. Подробности давай по просьбе. На простой вопрос отвечай сразу, без служебных оговорок, и не возвращайся к предыдущим темам без просьбы. При неясности задай один короткий уточняющий вопрос. Вызывай инструменты без вступительных фраз. Сообщай результат команды один раз. Упоминай громкость, только если её спросили или попросили изменить. Подтверждай трек, только если инструмент вернул актуальный трек. Отвечай для озвучивания без Markdown и ссылок, если их не попросили.`;
const messages = [{ role: 'system', content: systemPrompt }, ...history.slice(-10).flatMap(t => [{ role: 'user', content: t.user }, { role: 'assistant', content: t.assistant }]), { role: 'user', content: userText }];
const messages = [{ role: 'system', content: expressive ? `${systemPrompt}\n${deliveryPrompt}` : systemPrompt }, ...history.slice(-10).flatMap(t => [{ role: 'user', content: t.user }, { role: 'assistant', content: t.assistant }]), { role: 'user', content: userText }];
const detailed = /подробн|развернут|пошагов|детальн/iu.test(userText);
let totalCalls = 0;
for (let round = 0; round < 4; round++) {
@@ -134,8 +146,9 @@ export async function streamAnswer(settings, history, userText, signal, onText,
if (!done || !finishReason) throw new Error('llm_incomplete');
if (!calls.size) {
if (!['stop', 'length'].includes(finishReason) || !content.trim()) throw new Error('llm_incomplete');
const answer = detailed ? content.trim() : shortReply(content);
onText(answer);
const { answer, audio } = spokenReply(content, detailed, expressive);
if (!answer) throw new Error('llm_incomplete');
onText(audio);
return answer;
}
if (finishReason !== 'tool_calls') throw new Error('llm_incomplete');
+7 -4
View File
@@ -12,9 +12,10 @@ import (
)
type conversation struct {
mu sync.Mutex
cancel context.CancelFunc
commands chan byte
mu sync.Mutex
cancel context.CancelFunc
commands chan byte
panelWake chan struct{}
}
func (c *conversation) begin() (context.Context, func()) {
@@ -30,12 +31,14 @@ func (c *conversation) begin() (context.Context, func()) {
}
}
func (c *conversation) stop() {
func (c *conversation) stop() bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.cancel != nil {
c.cancel()
return true
}
return false
}
func (c *conversation) command(cmd byte) {
+17 -6
View File
@@ -83,7 +83,7 @@ func (d *keywordDetector) close() {
d.cmd.Wait()
}
func runKeywordWake(live *liveReader, detector *keywordDetector, activate func([]int16) error, once bool) error {
func runKeywordWake(live *liveReader, detector *keywordDetector, panelWake <-chan struct{}, activate func([]int16) error, once bool) error {
if detector == nil {
log.Print("Listening locally with the Mini 2 firmware spotter")
} else {
@@ -111,6 +111,12 @@ func runKeywordWake(live *liveReader, detector *keywordDetector, activate func([
continue
}
muted = false
button := false
select {
case <-panelWake:
button = true
default:
}
copy(preroll[next:], frame.pcm)
next = (next + hop) % len(preroll)
available = min(available+hop, len(preroll))
@@ -121,15 +127,20 @@ func runKeywordWake(live *liveReader, detector *keywordDetector, activate func([
return err
}
}
if !detected {
if !detected && !button {
continue
}
log.Print("wake word detected")
audio := make([]int16, 0, available)
if available == len(preroll) {
audio = append(audio, preroll[next:]...)
if button {
log.Print("touch panel activated assistant")
live.discardPending()
} else {
log.Print("wake word detected")
if available == len(preroll) {
audio = append(audio, preroll[next:]...)
}
audio = append(audio, preroll[:next]...)
}
audio = append(audio, preroll[:next]...)
if err := activate(audio); err != nil {
log.Printf("session: %v", err)
if !errors.Is(err, errMuted) && !errors.Is(err, errNoSpeech) && !errors.Is(err, context.Canceled) {
+3 -3
View File
@@ -538,7 +538,7 @@ func main() {
log.Fatal(e)
}
}
dialog := &conversation{commands: make(chan byte, 4)}
dialog := &conversation{commands: make(chan byte, 4), panelWake: make(chan struct{}, 1)}
go dialog.localCommands()
if *mode == "wake" {
go reportPlayback(*url, token)
@@ -584,7 +584,7 @@ func main() {
}
}
go listenVolumeButtons(*playback, dialog.stop)
go listenPanel(*playback, dialog)
if *keywordModel != "" || *spotterModel != "" {
var detector *keywordDetector
if *keywordModel != "" {
@@ -595,7 +595,7 @@ func main() {
}
defer detector.close()
}
err := runKeywordWake(live, detector, func(preroll []int16) error {
err := runKeywordWake(live, detector, dialog.panelWake, func(preroll []int16) error {
return dialog.dialogue(*url, token, live, preroll, *th, *max, *silence, *playback, *oneTurn)
}, *oneTurn)
if err != nil {
+291
View File
@@ -0,0 +1,291 @@
package main
import (
"context"
"encoding/binary"
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
)
const (
panelMinus = 114
panelPlus = 115
panelCenter = 582
panelMinTap = 18 * time.Millisecond
panelGap = 155 * time.Millisecond
panelMaxSwipe = 520 * time.Millisecond
panelHold = 500 * time.Millisecond
panelRepeat = 250 * time.Millisecond
panelWakeHold = 3 * time.Second
panelCooldown = 170 * time.Millisecond
panelMaxHold = 6 * time.Second
)
type panelAction string
const (
panelQuieter panelAction = "quieter"
panelLouder panelAction = "louder"
panelToggle panelAction = "toggle"
panelNext panelAction = "next"
panelPrevious panelAction = "swipe_previous"
panelWake panelAction = "wake"
)
type panelGesture struct {
path [3]uint16
count int
down uint8
start time.Time
lastRelease time.Time
nextHold time.Time
cooldown time.Time
held bool
invalid bool
ignoreToSync bool
}
func panelZone(code uint16) uint8 {
switch code {
case panelMinus:
return 1
case panelCenter:
return 2
case panelPlus:
return 4
}
return 0
}
func (g *panelGesture) finish(now time.Time) panelAction {
var action panelAction
if !g.held && !g.invalid {
switch {
case g.count == 1 && g.lastRelease.Sub(g.start) >= panelMinTap:
switch g.path[0] {
case panelMinus:
action = panelQuieter
case panelPlus:
action = panelLouder
case panelCenter:
action = panelToggle
}
case g.count == 3 && g.lastRelease.Sub(g.start) <= panelMaxSwipe:
if g.path == [3]uint16{panelMinus, panelCenter, panelPlus} {
action = panelNext
} else if g.path == [3]uint16{panelPlus, panelCenter, panelMinus} {
action = panelPrevious
}
}
}
cooldown := g.cooldown
if action == panelNext || action == panelPrevious {
cooldown = now.Add(panelCooldown)
}
*g = panelGesture{cooldown: cooldown}
return action
}
func (g *panelGesture) tick(now time.Time) panelAction {
if g.count == 0 {
return ""
}
if g.down == 0 && !now.Before(g.lastRelease.Add(panelGap)) {
return g.finish(now)
}
if g.down == 0 || g.invalid || g.count != 1 || now.Before(g.nextHold) {
return ""
}
g.held = true
if now.Sub(g.start) >= panelMaxHold {
g.invalid = true
return ""
}
g.nextHold = now.Add(panelRepeat)
switch g.path[0] {
case panelMinus:
return panelQuieter
case panelPlus:
return panelLouder
case panelCenter:
g.invalid = true
return panelWake
}
return ""
}
func (g *panelGesture) key(now time.Time, code uint16, value uint32) {
zone := panelZone(code)
if zone == 0 || g.ignoreToSync || value > 1 {
return
}
if value == 0 {
if g.down&zone != 0 {
g.down &^= zone
g.lastRelease = now
}
return
}
if g.down&zone != 0 {
return
}
g.down |= zone
if g.count == 0 {
g.start = now
g.nextHold = now.Add(panelHold)
if code == panelCenter {
g.nextHold = now.Add(panelWakeHold)
}
g.invalid = now.Before(g.cooldown)
}
if g.held || g.count == len(g.path) {
g.invalid = true
return
}
g.path[g.count] = code
g.count++
}
type panelEvent struct {
kind, code uint16
value uint32
}
func readPanel(device io.Reader, events chan<- panelEvent) error {
defer close(events)
// The ARM64 process sees a 16-byte timeval before the event fields.
var raw [24]byte
for {
if _, err := io.ReadFull(device, raw[:]); err != nil {
return err
}
events <- panelEvent{
kind: binary.LittleEndian.Uint16(raw[16:18]),
code: binary.LittleEndian.Uint16(raw[18:20]),
value: binary.LittleEndian.Uint32(raw[20:24]),
}
}
}
func listenPanel(playback string, dialog *conversation) {
names, _ := filepath.Glob("/sys/class/input/event*/device/name")
for _, path := range names {
name, err := os.ReadFile(path)
if err != nil || strings.TrimSpace(string(name)) != "sar0_pad0" {
continue
}
event := filepath.Base(filepath.Dir(filepath.Dir(path)))
device, err := os.Open(filepath.Join("/dev/input", event))
if err != nil {
log.Printf("open touch panel: %v", err)
return
}
defer device.Close()
log.Printf("touch panel ready on %s", event)
events := make(chan panelEvent, 24)
go func() {
if err := readPanel(device, events); err != nil {
log.Printf("read touch panel: %v", err)
}
}()
runPanel(events, playback, dialog)
return
}
log.Print("touch panel input device unavailable")
}
func runPanel(events <-chan panelEvent, playback string, dialog *conversation) {
volume := make(chan panelAction, 1)
music := make(chan panelAction, 1)
defer close(volume)
defer close(music)
go func() {
for action := range volume {
delta := 10
if action == panelQuieter {
delta = -10
}
level, err := changeVolume(delta)
if err != nil {
log.Printf("panel volume: %v", err)
continue
}
lights.showVolume(level)
go playVolumeTone(playback, level)
}
}()
go func() {
for action := range music {
args, _ := json.Marshal(map[string]string{"action": string(action)})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
result := musicCallContext(ctx, "spotify_control", args)
cancel()
var reply struct {
Error, Action string
Accepted bool
}
if err := json.Unmarshal(result, &reply); err != nil || reply.Error != "" {
log.Printf("panel Spotify action %s failed", action)
lights.confirm("error")
} else if reply.Accepted {
feedback := reply.Action
if action == panelNext {
feedback = "next"
}
lights.confirm(feedback)
}
}
}()
emit := func(action panelAction) {
switch action {
case panelWake:
if unmuted() && !dialog.stop() {
select {
case dialog.panelWake <- struct{}{}:
default:
}
}
case panelQuieter, panelLouder:
select {
case volume <- action:
default:
}
case panelToggle, panelNext, panelPrevious:
if action == panelToggle && dialog.stop() {
return
}
select {
case music <- action:
default:
}
}
}
var gesture panelGesture
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case event, ok := <-events:
if !ok {
return
}
now := time.Now()
if event.kind == 0 && event.code == 3 { // SYN_DROPPED
gesture = panelGesture{ignoreToSync: true}
} else if event.kind == 0 && event.code == 0 {
gesture.ignoreToSync = false
} else if event.kind == 1 {
emit(gesture.tick(now))
gesture.key(now, event.code, event.value)
}
case now := <-ticker.C:
emit(gesture.tick(now))
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"testing"
"time"
)
func TestPanelSwipeAndPartialSwipeAreExclusive(t *testing.T) {
for _, tc := range []struct {
zones []uint16
want panelAction
}{
{[]uint16{panelMinus, panelCenter, panelPlus}, panelNext},
{[]uint16{panelPlus, panelCenter, panelMinus}, panelPrevious},
{[]uint16{panelMinus, panelCenter}, ""},
{[]uint16{panelMinus, panelCenter, panelMinus}, ""},
{[]uint16{panelPlus}, panelLouder},
{[]uint16{panelCenter}, panelToggle},
} {
var g panelGesture
now := time.Unix(100, 0)
for _, code := range tc.zones {
g.key(now, code, 1)
now = now.Add(45 * time.Millisecond)
g.key(now, code, 0)
now = now.Add(35 * time.Millisecond)
if got := g.tick(now); got != "" {
t.Fatalf("%v: action before end of gesture: %q", tc.zones, got)
}
}
now = now.Add(panelGap)
if got := g.tick(now); got != tc.want {
t.Fatalf("%v: got %q, want %q", tc.zones, got, tc.want)
}
if got := g.tick(now.Add(time.Second)); got != "" {
t.Fatalf("%v: unexpected duplicate action %q", tc.zones, got)
}
}
}
func TestPanelHoldRepeatsWithoutReleaseTap(t *testing.T) {
var g panelGesture
start := time.Unix(100, 0)
g.key(start, panelMinus, 1)
g.key(start.Add(100*time.Millisecond), panelMinus, 2)
if got := g.tick(start.Add(499 * time.Millisecond)); got != "" {
t.Fatalf("early hold: %q", got)
}
if got := g.tick(start.Add(panelHold)); got != panelQuieter {
t.Fatalf("first hold step: %q", got)
}
if got := g.tick(start.Add(panelHold + panelRepeat)); got != panelQuieter {
t.Fatalf("repeat hold step: %q", got)
}
g.key(start.Add(time.Second), panelMinus, 0)
if got := g.tick(start.Add(2 * time.Second)); got != "" {
t.Fatalf("hold release emitted %q", got)
}
g.key(start.Add(3*time.Second), panelCenter, 1)
if got := g.tick(start.Add(3*time.Second + panelWakeHold)); got != panelWake {
t.Fatalf("center hold emitted %q", got)
}
}
-61
View File
@@ -3,17 +3,13 @@ package main
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"log"
"math"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
@@ -24,63 +20,6 @@ const pulseControl = "/data/opt/alice-backend/spotify/pactl"
var volumeMu sync.Mutex
var toneMu sync.Mutex
func listenVolumeButtons(playback string, stop func()) {
names, _ := filepath.Glob("/sys/class/input/event*/device/name")
for _, path := range names {
name, err := os.ReadFile(path)
if err != nil || strings.TrimSpace(string(name)) != "sar0_pad0" {
continue
}
event := filepath.Base(filepath.Dir(filepath.Dir(path)))
device, err := os.Open(filepath.Join("/dev/input", event))
if err != nil {
log.Printf("open volume buttons: %v", err)
return
}
defer device.Close()
// Linux ARM64 input_event starts with a 16-byte timeval.
var eventData [24]byte
for {
if _, err := io.ReadFull(device, eventData[:]); err != nil {
log.Printf("read volume buttons: %v", err)
return
}
if binary.LittleEndian.Uint16(eventData[16:18]) == 1 &&
binary.LittleEndian.Uint16(eventData[18:20]) == 582 &&
binary.LittleEndian.Uint32(eventData[20:24]) == 1 {
stop()
continue
}
if delta := volumeStep(eventData); delta != 0 {
level, err := changeVolume(delta)
if err != nil {
log.Printf("volume button: %v", err)
continue
}
lights.showVolume(level)
go playVolumeTone(playback, level)
}
}
}
log.Print("volume buttons input device unavailable")
}
func volumeStep(event [24]byte) int {
eventType := binary.LittleEndian.Uint16(event[16:18])
code := binary.LittleEndian.Uint16(event[18:20])
value := binary.LittleEndian.Uint32(event[20:24])
if eventType != 1 || (value != 1 && value != 2) {
return 0
}
switch code {
case 114: // KEY_VOLUMEDOWN
return -10
case 115: // KEY_VOLUMEUP
return 10
}
return 0
}
func changeVolume(delta int) (int, error) {
volumeMu.Lock()
defer volumeMu.Unlock()
+17
View File
@@ -20,6 +20,23 @@ test.after(() => server.close());
const event = (delta, finish_reason = null) => ({ choices: [{ delta, finish_reason }] });
const settings = { systemPrompt: 'test', llmModel: 'test' };
test('chooses delivery in the answer request and keeps audio tags out of history', async () => {
requests.length = 0;
responder = () => [event({ content: '[exci' }), event({ content: 'ted] Ура! [shouts] Поздравляю!' }, 'stop')];
const spoken = [];
const expressive = { ...settings, ttsModel: 'eleven_v4_turbo' };
assert.equal(await streamAnswer(expressive, [], 'Я выиграл!', AbortSignal.timeout(2000), part => spoken.push(part)), 'Ура! Поздравляю!');
assert.deepEqual(spoken, ['[excited] Ура! Поздравляю!']);
assert.equal(requests.length, 1);
assert.match(requests[0].messages[0].content, /\[curious\]/);
const neutral = { ...settings, ttsModel: 'eleven_flash_v2_5' };
spoken.length = 0;
assert.equal(await streamAnswer(neutral, [], 'Я выиграл!', AbortSignal.timeout(2000), part => spoken.push(part)), 'Ура! Поздравляю!');
assert.deepEqual(spoken, ['Ура! Поздравляю!']);
assert.doesNotMatch(requests[1].messages[0].content, /\[curious\]/);
});
test('assembles fragmented calls before executing and streams text', async () => {
requests.length = 0;
responder = n => n === 1 ? [event({ content: 'Сейчас включу.', tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'spotify_play', arguments: '{"query":"' } }] }), event({ tool_calls: [{ index: 0, function: { arguments: 'Queen"}' } }] }, 'tool_calls')] : [event({ content: 'Готово' }, 'stop')];