diff --git a/README.md b/README.md index 99f4782..acf54b3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Mini 2 → VQE → WSS с PCM16 / 16 кГц → Scribe v3 Live ← WSS с PCM16 / 24 кГц ← Eleven v4 Turbo ← OpenRouter / Haiku 5.5 ``` -Модель Scribe можно переключить в пульте, включая Scribe v2 Realtime. Scribe выдаёт промежуточный текст, а запрос к LLM начинается после подтверждения конца команды. Текстовые фрагменты из OpenRouter сразу идут в ElevenLabs, звуковые фрагменты сразу уходят на колонку. Запрос и ответ в текстовом виде сохраняются после успешного хода. Сервер держит 50 последних ходов и использует до 10 из них как контекст. Аудио сервер не пишет на диск. Облачные провайдеры обрабатывают отправленные им аудио и текст. +Модель Scribe можно переключить в пульте, включая Scribe v2 Realtime. Scribe выдаёт промежуточный текст, а запрос к LLM начинается после подтверждения конца команды. После инструментов сервер озвучивает один короткий ответ. Звуковые фрагменты сразу уходят на колонку. Запрос и ответ в текстовом виде сохраняются после успешного хода. Сервер держит 50 последних ходов и использует до 10 из них как контекст. Аудио сервер не пишет на диск. Облачные провайдеры обрабатывают отправленные им аудио и текст. ## Запуск и проверка @@ -50,3 +50,10 @@ Caddy проксирует голосовое соединение и `/healthz` Клод вызывает `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/) + + +### Разговор и Spotify + +В «Пульте Алисы» можно включить сигнал активации и настроить окно продолжения разговора. По умолчанию сигнал отключён, а окно открывается на три секунды после голосового ответа. «Стоп» и «хватит» распознаются на колонке и прекращают текущий ответ. Spotify продолжает воспроизведение с обычным уровнем. + +Штатный локальный детектор также принимает «громче», «тише», «пауза», «продолжи», «дальше» и «назад». Голосовой ассистент может показать состояние Spotify Connect, назвать текущий трек, перемотать, включить повтор или перемешивание, посмотреть и дополнить очередь. Для изменения плейлистов нужно повторно авторизовать Spotify в Mini App. Пульт показывает состояние колонки и последний известный трек. Источник состояния воспроизведения — Spotify Connect. diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 50df6ac..361d4ea 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,6 +1,6 @@ # alice-backend alice.pepega.systems { - @voice path /v1/voice /healthz + @voice path /v1/voice /v1/device/status /healthz handle @voice { reverse_proxy alice-backend:3000 } diff --git a/music/main.go b/music/main.go index 5f4d83a..f2efac0 100644 --- a/music/main.go +++ b/music/main.go @@ -19,10 +19,13 @@ import ( type request struct { Name string `json:"name"` Arguments struct { - URI string `json:"uri"` - Action string `json:"action"` - Volume *int `json:"volume"` - Active bool `json:"active"` + URI string `json:"uri"` + Action string `json:"action"` + Volume *int `json:"volume"` + Active bool `json:"active"` + PositionMs *int `json:"positionMs"` + Enabled *bool `json:"enabled"` + Mode string `json:"mode"` } `json:"arguments"` } @@ -51,13 +54,47 @@ func execute(req request) (map[string]interface{}, error) { command["command"] = "play" command["uri"] = req.Arguments.URI } else if req.Name == "spotify_control" { - actions := map[string]string{"pause": "pause", "resume": "play", "next": "skip_next", "previous": "skip_prev", "volume": "set_volume"} + actions := map[string]string{ + "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", + } action, ok := actions[req.Arguments.Action] if !ok { return nil, errors.New("invalid_music_action") } command["command"] = action - if action == "set_volume" { + switch action { + case "get_queue": + command["limit"] = 6 + case "add_to_queue": + if !validURI.MatchString(req.Arguments.URI) || !strings.HasPrefix(req.Arguments.URI, "spotify:track:") { + return nil, errors.New("invalid_spotify_uri") + } + command["uri"] = req.Arguments.URI + case "seek": + if req.Arguments.PositionMs == nil || *req.Arguments.PositionMs < 0 || *req.Arguments.PositionMs > 86400000 { + return nil, errors.New("invalid_spotify_position") + } + command["position_ms"] = *req.Arguments.PositionMs + case "set_shuffle": + if req.Arguments.Enabled == nil { + return nil, errors.New("invalid_spotify_shuffle") + } + command["enabled"] = *req.Arguments.Enabled + case "set_repeat_context": + switch req.Arguments.Mode { + case "off": + command["enabled"] = false + case "context": + command["enabled"] = true + case "track": + command["command"] = "set_repeat_track" + command["enabled"] = true + default: + return nil, errors.New("invalid_spotify_repeat") + } + case "set_volume": if req.Arguments.Volume == nil || *req.Arguments.Volume < 0 || *req.Arguments.Volume > 100 { return nil, errors.New("invalid_music_volume") } @@ -81,8 +118,30 @@ func execute(req request) (map[string]interface{}, error) { LoggedIn bool `json:"logged_in"` } if err := conn.ReadJSON(&auth); err != nil || auth.Type != "auth_state" || !auth.LoggedIn { + if command["command"] == "get_state" && err == nil { + return map[string]interface{}{"connected": false, "status": "unpaired"}, nil + } return nil, errors.New("spotify_not_paired") } + // Soloist exposes context and track repeat as separate flags. + if req.Arguments.Action == "repeat" { + reset := "set_repeat_track" + if req.Arguments.Mode == "track" { + reset = "set_repeat_context" + } + if err := conn.WriteJSON(map[string]interface{}{"type": "command", "command": reset, "enabled": false}); err != nil { + return nil, errors.New("spotify_disconnected") + } + for { + var event struct{ Type, Command string } + if err := conn.ReadJSON(&event); err != nil || event.Type == "error" { + return nil, errors.New("spotify_repeat_failed") + } + if event.Type == "command_result" && event.Command == reset { + break + } + } + } if err := conn.WriteJSON(command); err != nil { return nil, errors.New("spotify_disconnected") } @@ -98,6 +157,10 @@ func execute(req request) (map[string]interface{}, error) { switch event["type"] { case "error": return nil, errors.New("spotify_command_failed") + case "playback_changed": + if accepted && req.Arguments.Action == "pause" && event["status"] == "paused" { + return map[string]interface{}{"accepted": true, "status": "paused"}, nil + } case "command_result": if event["command"] == command["command"] { accepted = true @@ -105,14 +168,43 @@ func execute(req request) (map[string]interface{}, error) { return nil, errors.New("spotify_disconnected") } } + case "queue_changed": + if command["command"] == "get_queue" { + entries, _ := event["upcoming"].([]interface{}) + items := make([]interface{}, 0, min(6, len(entries))) + for _, entry := range entries[:min(6, len(entries))] { + if entry, ok := entry.(map[string]interface{}); ok { + items = append(items, compactEntity(entry["item"])) + } + } + return map[string]interface{}{"upcoming": items}, nil + } case "playback_state": - if accepted { - return map[string]interface{}{"accepted": true, "status": event["status"], "item": event["item"], "volume": event["volume"]}, nil + if accepted || command["command"] == "get_state" { + if req.Arguments.Action == "pause" && event["status"] != "paused" && event["status"] != "idle" { + continue + } + result := map[string]interface{}{"accepted": accepted, "connected": true, "status": event["status"], "active": event["is_active"], + "item": compactEntity(event["item"]), "context": compactEntity(event["context"]), "position": event["position"], "options": event["options"]} + if req.Arguments.Action == "volume" { + result["volume"] = event["volume"] + } + return result, nil } } } } +func compactEntity(raw interface{}) interface{} { + entity, ok := raw.(map[string]interface{}) + if !ok { + return nil + } + decorations, _ := entity["decorations"].(map[string]interface{}) + identity, _ := decorations["identity"].(map[string]interface{}) + return map[string]interface{}{"uri": entity["uri"], "name": identity["name"], "artists": identity["artists"], "album": identity["album"]} +} + func main() { cacheDir := flag.String("cache-janitor", "", "maintain the dedicated Soloist cache directory") flag.Parse() diff --git a/server/admin.mjs b/server/admin.mjs index 0f1ded5..458d7f0 100644 --- a/server/admin.mjs +++ b/server/admin.mjs @@ -97,7 +97,7 @@ export async function handleAdmin(req, res, store) { json(res, 200, { settings: store.getSettings(), history: store.getHistory().reverse(), - spotify: spotifyStatus(), + spotify: { ...spotifyStatus(), playback: store.getDevice() }, }); } else if (route === '/api/admin/settings' && req.method === 'PUT') { const settings = await readSettings(req); diff --git a/server/device.mjs b/server/device.mjs index dd3ff5e..6204f88 100644 --- a/server/device.mjs +++ b/server/device.mjs @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import WebSocket from 'ws'; -import { searchSpotify } from './spotify.mjs'; +import { searchSpotify, playlistTool } from './spotify.mjs'; const spotifyUri = /^spotify:(track|artist|album|playlist|episode):[A-Za-z0-9]{22}$/; @@ -38,20 +38,22 @@ export function deviceTools(client, signal) { return true; }, async execute(name, args) { - if (name !== 'spotify_play') return request(name, args); + if (name === 'spotify_playlist') return playlistTool(args, signal); + const enqueue = name === 'spotify_control' && args.action === 'enqueue'; + if (name !== 'spotify_play' && !enqueue) return request(name, args); let selected; if (args.uri) { if (!spotifyUri.test(args.uri)) return { error: 'invalid_spotify_uri' }; selected = { uri: args.uri }; } else { try { - selected = (await searchSpotify(args.query, signal))[0]; + selected = (await searchSpotify(args.query, signal, enqueue ? 'track' : args.type))[0]; } catch { return { error: 'spotify_search_unavailable', hint: 'Подключи Spotify в пульте Telegram и повтори запрос.' }; } } if (!selected) return { error: 'spotify_not_found' }; - const result = await request(name, { uri: selected.uri }); + const result = await request(name, enqueue ? { action: 'enqueue', uri: selected.uri } : { uri: selected.uri }); return { ...result, selection: selected }; }, }; diff --git a/server/index.mjs b/server/index.mjs index ed6efb1..bd73691 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -13,16 +13,31 @@ const store = openStore(); const server = http.createServer(async (req, res) => { try { if (req.method === 'GET' && req.url === '/healthz') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end('{"ok":true}'); return; } + if (req.url === '/v1/device/status' && req.method === 'POST') { + if (!authorizedDevice(req)) { res.writeHead(401); res.end(); return; } + let body = ''; + for await (const chunk of req) { + body += chunk; + if (body.length > 14000) { res.writeHead(413); res.end(); return; } + } + const data = JSON.parse(body); + if (!data || typeof data !== 'object' || Array.isArray(data)) { res.writeHead(400); res.end(); return; } + store.recordDevice(data); + res.writeHead(204); res.end(); return; + } if (await handleAdmin(req, res, store)) return; res.writeHead(404); res.end(); } catch { if (!res.headersSent) res.writeHead(500); res.end(); } }); const sockets = new WebSocketServer({ noServer: true, maxPayload: 128 * 1024, perMessageDeflate: false }); let active = false; -server.on('upgrade', (req, socket, head) => { +function authorizedDevice(req) { const token = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : ''; const a = Buffer.from(token), b = Buffer.from(DEVICE_TOKEN); - if (req.url !== '/v1/voice' || a.length !== b.length || !timingSafeEqual(a, b)) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return; } + return a.length === b.length && timingSafeEqual(a, b); +} +server.on('upgrade', (req, socket, head) => { + if (req.url !== '/v1/voice' || !authorizedDevice(req)) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return; } if (active) { socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); return; } sockets.handleUpgrade(req, socket, head, ws => { active = true; diff --git a/server/providers.mjs b/server/providers.mjs index c1a6072..856497f 100644 --- a/server/providers.mjs +++ b/server/providers.mjs @@ -68,15 +68,32 @@ export async function openTts(settings, signal, onAudio) { return { ws, done, text(text) { if (ws.readyState !== WebSocket.OPEN) throw new Error('tts_closed'); ws.send(JSON.stringify(flash ? { text } : { inputs: [{ text, voice_id: settings.voiceId }] })); }, finish() { if (ws.readyState !== WebSocket.OPEN) throw new Error('tts_closed'); ws.send(JSON.stringify(flash ? { text: '' } : { close_socket: true })); } }; } +const sentenceSegments = new Intl.Segmenter('ru', { granularity: 'sentence' }); + +function shortReply(text) { + const compact = Array.from(sentenceSegments.segment(text.trim()), part => part.segment).slice(0, 2).join('').trim(); + if (compact.length <= 300) return compact; + const end = compact.lastIndexOf(' ', 297); + return compact.slice(0, end > 170 ? end : 297).trimEnd() + '…'; +} + export async function streamAnswer(settings, history, userText, signal, onText, executeDeviceTool) { if (env.LLM_MODE === 'echo') { onText(userText); return userText; } + const question = userText.toLowerCase().replace(/[?!.…]+$/u, '').trim(); + if (/^(?:какая (?:это|у тебя) модель|на какой модели ты работаешь|какая у тебя языковая модель)$/u.test(question)) { + const model = settings.llmModel === 'anthropic/claude-haiku-5.5' ? 'Claude Haiku 5.5' : settings.llmModel; + const answer = `Я работаю на ${model}.`; + onText(answer); + return answer; + } if (!env.OPENROUTER_API_KEY) throw new Error('llm_config'); const moscowDate = new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', dateStyle: 'long' }).format(new Date()); - const systemPrompt = `${settings.systemPrompt}\nСегодня ${moscowDate} (Москва). Используй веб-поиск для актуальных сведений и когда требуется проверить сомнительные факты. Вызывай инструменты без вступительных фраз. Подтверждай воспроизведение музыки только после успешного результата инструмента. Отвечай для озвучивания без Markdown и ссылок, если их не попросили.`; + 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 }]; - let answer = '', totalCalls = 0; + const detailed = /подробн|развернут|пошагов|детальн/iu.test(userText); + let totalCalls = 0; for (let round = 0; round < 4; round++) { - const response = await fetch(llmUrl, { method: 'POST', signal, headers: { Authorization: `Bearer ${env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: settings.llmModel, messages, tools, stream: true, max_tokens: 1200, ...(settings.llmModel === 'anthropic/claude-haiku-5.5' ? { reasoning: { enabled: false, effort: 'low' } } : {}) }) }); + const response = await fetch(llmUrl, { method: 'POST', signal, headers: { Authorization: `Bearer ${env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: settings.llmModel, messages, tools, stream: true, max_tokens: detailed ? 800 : 220, ...(settings.llmModel === 'anthropic/claude-haiku-5.5' ? { reasoning: { enabled: false, effort: 'low' } } : {}) }) }); if (!response.ok || !response.body) throw new Error('llm_error'); let buffer = '', content = '', done = false, finishReason; const calls = new Map(); @@ -92,10 +109,8 @@ export async function streamAnswer(settings, history, userText, signal, onText, if (choice.finish_reason) finishReason = choice.finish_reason; const delta = choice.delta || {}; if (typeof delta.content === 'string' && delta.content) { - const prefix = !content && answer && !/\s$/.test(answer) && !/^\s/.test(delta.content) ? ' ' : ''; - content += delta.content; answer += prefix + delta.content; - if (answer.length > 12000) throw new Error('llm_limit'); - onText(prefix + delta.content); + content += delta.content; + if (content.length > 12000) throw new Error('llm_limit'); } for (const part of delta.tool_calls || []) { if (!Number.isInteger(part.index) || part.index < 0 || part.index >= 4) throw new Error('llm_limit'); @@ -118,28 +133,47 @@ export async function streamAnswer(settings, history, userText, signal, onText, if (buffer.trim()) consume(buffer); if (!done || !finishReason) throw new Error('llm_incomplete'); if (!calls.size) { - if (finishReason !== 'stop' || !answer.trim()) throw new Error('llm_incomplete'); + if (!['stop', 'length'].includes(finishReason) || !content.trim()) throw new Error('llm_incomplete'); + const answer = detailed ? content.trim() : shortReply(content); + onText(answer); return answer; } if (finishReason !== 'tool_calls') throw new Error('llm_incomplete'); const ordered = [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, call]) => call); if ((totalCalls += ordered.length) > 4 || round === 3 || ordered.some(c => !c.id || !c.function.name)) throw new Error('llm_limit'); messages.push({ role: 'assistant', content: content || null, tool_calls: ordered }); - for (const call of ordered) { - if (signal.aborted) throw new Error('aborted'); - let result; - try { - const args = validateTool(call.function.name, JSON.parse(call.function.arguments)); - if (typeof executeDeviceTool !== 'function') throw new Error('device_unavailable'); - result = await executeDeviceTool(call.function.name, args); - if (signal.aborted) throw new Error('aborted'); - result = JSON.stringify(result ?? { ok: true }); - if (result.length > 8192) result = JSON.stringify({ error: 'result_too_large' }); - } catch (error) { - if (signal.aborted) throw error; - result = JSON.stringify({ error: 'tool_failed' }); + const results = new Map(); + const groups = [ + ordered.filter(call => call.function.name === 'station_volume'), + ordered.filter(call => call.function.name !== 'station_volume'), + ]; + await Promise.all(groups.map(async group => { + for (const call of group) { + signal.throwIfAborted(); + let result; + try { + const args = validateTool(call.function.name, JSON.parse(call.function.arguments)); + if (typeof executeDeviceTool !== 'function') throw new Error('device_unavailable'); + result = await executeDeviceTool(call.function.name, args); + signal.throwIfAborted(); + if (JSON.stringify(result ?? {}).length > 8192) result = { error: 'result_too_large' }; + } catch (error) { + if (signal.aborted) throw error; + result = { error: 'tool_failed' }; + } + results.set(call.id, result ?? { ok: true }); } - messages.push({ role: 'tool', tool_call_id: call.id, content: result }); + })); + if (ordered.length === 1 && ordered[0].function.name === 'spotify_play') { + const result = results.get(ordered[0].id); + const title = result.selection?.name?.trim().slice(0, 110); + let answer; + if (result.accepted === true) answer = title ? `Включаю «${title}».` : 'Включаю музыку.'; + if (result.error === 'spotify_not_found') answer = 'В Spotify ничего не нашла.'; + if (answer) { onText(answer); return answer; } + } + for (const call of ordered) { + messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(results.get(call.id)) }); } } throw new Error('llm_limit'); diff --git a/server/public/app.js b/server/public/app.js index e3b2156..f7fd0c9 100644 --- a/server/public/app.js +++ b/server/public/app.js @@ -65,8 +65,14 @@ showHistory.onclick = async () => { }; function renderSpotify(spotify) { spotifyStatus.textContent = !spotify.configured ? 'Spotify пока не настроен на сервере.' : spotify.connected ? 'Аккаунт подключён.' : 'Подключи аккаунт для управления музыкой.'; + const playback = spotify.playback; + if (playback) { + spotifyStatus.textContent += playback.online + ? ` Колонка онлайн${playback.active ? ', активна' : ', неактивна'}. ${playback.item?.name || ''}${playback.status === 'paused' ? ' — пауза' : ''}` + : ` Колонка офлайн.${playback.lastPlayback?.item?.name ? ' Последний трек — ' + playback.lastPlayback.item.name + '.' : ''}`; + } spotifyConnect.disabled = !spotify.configured; - spotifyConnect.textContent = spotify.connected ? 'Подключить другой аккаунт' : 'Подключить Spotify'; + spotifyConnect.textContent = spotify.connected ? 'Переподключить Spotify' : 'Подключить Spotify'; } showSettings.onclick = async () => { selectTab(false); @@ -88,7 +94,8 @@ form.onsubmit = async (event) => { const button = form.querySelector('button'); button.disabled = true; try { - const body = JSON.stringify(Object.fromEntries(new FormData(form))); + const values = Object.fromEntries(new FormData(form)); + const body = JSON.stringify({ ...values, followUpMs: Number(values.followUpMs), activationSound: form.elements.activationSound.checked }); await api('/settings', { method: 'PUT', body }); status.textContent = 'Сохранено. Настройки вступят в силу со следующего запроса.'; } catch (error) { @@ -119,7 +126,12 @@ if (!telegram?.initData) { } else { try { const data = await api(); - for (const [name, value] of Object.entries(data.settings)) form.elements.namedItem(name).value = value; + for (const [name, value] of Object.entries(data.settings)) { + const input = form.elements.namedItem(name); + if (!input) continue; + if (input.type === 'checkbox') input.checked = value === true; + else input.value = value; + } renderTurns(data.history); renderSpotify(data.spotify); status.textContent = ''; @@ -128,3 +140,9 @@ if (!telegram?.initData) { status.textContent = error.message; } } + + +setInterval(async () => { + if (settings.hidden || !telegram?.initData) return; + try { renderSpotify((await api()).spotify); } catch {} +}, 15000); diff --git a/server/public/index.html b/server/public/index.html index ac81dae..f68070c 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -38,6 +38,8 @@ + + diff --git a/server/public/style.css b/server/public/style.css index f517b02..da5be73 100644 --- a/server/public/style.css +++ b/server/public/style.css @@ -42,3 +42,5 @@ textarea { resize: vertical; } .spotify-card { background: var(--surface); padding: 16px; border-radius: 15px; margin-bottom: 24px; } .spotify-card p { color: var(--muted); margin: 5px 0 12px; } + +#form input[type="checkbox"] { width: auto; vertical-align: middle; } diff --git a/server/quick-actions.mjs b/server/quick-actions.mjs index aca2609..d6c4187 100644 --- a/server/quick-actions.mjs +++ b/server/quick-actions.mjs @@ -25,6 +25,15 @@ export function quickAction(transcript) { success: result => result?.accepted === true && ['paused', 'stopped'].includes(result.status), }; } + if (text === 'громче' || text === 'тише') return { + name: 'station_volume', args: { delta: text === 'громче' ? 10 : -10 }, done: 'Готово.', + failure: 'Не удалось изменить громкость.', success: result => result?.ok === true, + }; + const playback = { 'продолжи': 'resume', 'продолжай': 'resume', 'играй': 'resume', 'дальше':'next', 'следующий трек':'next', 'назад':'previous', 'предыдущий трек':'previous' }[text]; + if (playback) return { + name: 'spotify_control', args: { action: playback }, done: 'Готово.', + failure: 'Не удалось выполнить команду Spotify.', success: result => result?.accepted === true, + }; const match = text.match(/^(?:(?:поставь|установи|сделай) )?громкость(?: на| до)? (.+?)(?: ?%| процент(?:а|ов)?)?$/); if (!match) return null; const level = volumeLevel(match[1]); diff --git a/server/spotify.mjs b/server/spotify.mjs index 04ea058..8eb136f 100644 --- a/server/spotify.mjs +++ b/server/spotify.mjs @@ -43,7 +43,7 @@ export function startSpotify() { const verifier = base64url(randomBytes(64)); pending.set(state, { verifier, expires: now + 600_000 }); const url = new URL('https://accounts.spotify.com/authorize'); - url.search = new URLSearchParams({ client_id: clientId(), response_type: 'code', redirect_uri: redirectUri(), state, code_challenge_method: 'S256', code_challenge: base64url(createHash('sha256').update(verifier).digest()), scope: 'user-read-playback-state user-modify-playback-state' }); + url.search = new URLSearchParams({ client_id: clientId(), response_type: 'code', redirect_uri: redirectUri(), state, code_challenge_method: 'S256', code_challenge: base64url(createHash('sha256').update(verifier).digest()), scope: 'user-read-playback-state user-modify-playback-state playlist-read-private playlist-read-collaborative playlist-modify-private playlist-modify-public' }); return url.toString(); } async function tokenRequest(params, signal) { @@ -79,18 +79,60 @@ async function accessToken() { } return refreshInFlight; } -export async function searchSpotify(query, signal) { +export async function searchSpotify(query, signal, type) { if (typeof query !== 'string' || !query.trim() || query.length > 500) throw new Error('spotify_invalid_query'); const token = await accessToken(); if (signal?.aborted) throw new Error('aborted'); const url = new URL('https://api.spotify.com/v1/search'); - url.search = new URLSearchParams({ q: query.trim(), type: 'track,artist,album,playlist', limit: '5' }); + url.search = new URLSearchParams({ q: query.trim(), type: type || 'track,artist,album,playlist', limit: '5' }); const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal }); if (!response.ok) throw new Error('spotify_search_error'); const data = await response.json(); const results = ['tracks', 'artists', 'albums', 'playlists'].flatMap(key => (data[key]?.items || []).filter(item => item?.uri && item?.name).map(item => ({ uri: item.uri, name: item.name, type: item.type, artists: (item.artists || []).map(artist => artist.name), album: item.album?.name || null }))); const normalize = value => value.normalize('NFKC').trim().toLocaleLowerCase().replace(/\s+/g, ' '); + if (type) { + const words = normalize(query).match(/[\p{L}\p{N}]+/gu) || []; + return results.filter(item => item.type === type && words.every(word => normalize([item.name, ...item.artists].join(' ')).includes(word))); + } const exactArtist = results.find(item => item.type === 'artist' && normalize(item.name) === normalize(query)); if (exactArtist) return [exactArtist, ...results.filter(item => item !== exactArtist)]; return results; } + +export async function spotifyApi(path, { method = 'GET', body, signal } = {}) { + const token = await accessToken(); + const response = await fetch('https://api.spotify.com/v1/' + path, { + method, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(7000)]) : AbortSignal.timeout(7000), + headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (response.status === 403) return { error: 'spotify_permission_required', hint: 'Переподключи Spotify в пульте, чтобы разрешить работу с плейлистами.' }; + if (!response.ok) return { error: 'spotify_request_failed' }; + return response.status === 204 ? { ok: true } : response.json(); +} + +export async function playlistTool(args, signal) { + if (args.action === 'list') { + const data = await spotifyApi('me/playlists?limit=30', { signal }); + return data.error ? data : { playlists: (data.items || []).map(p => ({ name: p.name, uri: p.uri })) }; + } + if (args.action === 'create') { + const data = await spotifyApi('me/playlists', { method: 'POST', signal, body: { name: args.name, public: false } }); + return data.error ? data : { ok: true, name: data.name, uri: data.uri }; + } + const id = args.playlistUri.split(':').at(-1); + if (args.action === 'read') { + const data = await spotifyApi(`playlists/${id}/items?limit=15`, { signal }); + return data.error ? data : { tracks: (data.items || []).map(entry => { + const item = entry.item || entry.track; + return { name: item?.name, uri: item?.uri, artists: item?.artists?.map(a => a.name) }; + }) }; + } + let uri = args.trackUri; + if (!uri) uri = (await searchSpotify(args.trackQuery, signal, 'track'))[0]?.uri; + if (!uri) return { error: 'spotify_not_found' }; + return spotifyApi(`playlists/${id}/items`, { + method: args.action === 'add' ? 'POST' : 'DELETE', signal, + body: args.action === 'add' ? { uris: [uri] } : { items: [{ uri }] }, + }); +} diff --git a/server/store.mjs b/server/store.mjs index 938298f..1ddec02 100644 --- a/server/store.mjs +++ b/server/store.mjs @@ -4,6 +4,8 @@ import { join } from 'node:path'; export const defaults = Object.freeze({ llmModel: 'anthropic/claude-haiku-5.5', sttModel: 'scribe_v3_live', + followUpMs: 3000, + activationSound: false, ttsModel: 'eleven_v4_turbo', voiceId: '21m00Tcm4TlvDq8ikWAM', systemPrompt: 'Ты голосовой помощник Алиса. Отвечай по-русски, коротко и по делу. Пиши так, чтобы ответ хорошо звучал вслух: обычный текст, без Markdown. Если не знаешь ответа, скажи об этом прямо.', @@ -14,12 +16,13 @@ export const ttsModels = ['eleven_v4_turbo', 'eleven_v3_conversational', 'eleven export function validateSettings(input) { if (typeof input !== 'object' || input === null) return null; - const { llmModel, sttModel, ttsModel, voiceId, systemPrompt } = input; + const { llmModel, sttModel, ttsModel, voiceId, systemPrompt, followUpMs = defaults.followUpMs, activationSound = defaults.activationSound } = input; if (typeof llmModel !== 'string' || !/^[\w.~:-]+\/[\w.:~-]+$/.test(llmModel) || llmModel.length > 120) return null; if (!sttModels.includes(sttModel) || !ttsModels.includes(ttsModel)) return null; if (typeof voiceId !== 'string' || !/^[a-zA-Z0-9]{20}$/.test(voiceId)) return null; if (typeof systemPrompt !== 'string' || !systemPrompt.trim() || systemPrompt.length > 4000) return null; - return { llmModel, sttModel, ttsModel, voiceId, systemPrompt: systemPrompt.trim() }; + if (!Number.isInteger(followUpMs) || followUpMs < 0 || followUpMs > 10000 || typeof activationSound !== 'boolean') return null; + return { llmModel, sttModel, ttsModel, voiceId, systemPrompt: systemPrompt.trim(), followUpMs, activationSound }; } export function openStore(directory = process.env.DATA_DIR ?? './data') { @@ -30,7 +33,7 @@ export function openStore(directory = process.env.DATA_DIR ?? './data') { const saved = JSON.parse(readFileSync(path, 'utf8')); const settings = validateSettings({ ...defaults, ...saved.settings }); if (!settings || !Array.isArray(saved.history)) throw new Error('Invalid assistant state'); - state = { settings, history: saved.history }; + state = { settings, history: saved.history, device: saved.device }; } catch (error) { if (error.code !== 'ENOENT') throw error; } @@ -43,6 +46,17 @@ export function openStore(directory = process.env.DATA_DIR ?? './data') { } return { + getDevice: () => { + if (!state.device) return null; + return { ...state.device, online: Date.now() - state.device.updatedAt < 45000 }; + }, + recordDevice(device) { + const updatedAt = Date.now(); + const lastPlayback = device.item?.uri && device.active + ? { item: device.item, context: device.context, position: device.position, status: device.status, updatedAt } + : state.device?.lastPlayback; + commit({ ...state, device: { ...device, updatedAt, lastPlayback } }); + }, getSettings: () => ({ ...state.settings }), getHistory: () => state.history.map(turn => ({ ...turn })), updateSettings(settings) { diff --git a/server/tools.mjs b/server/tools.mjs index ab30b59..f864a7a 100644 --- a/server/tools.mjs +++ b/server/tools.mjs @@ -1,20 +1,51 @@ // OpenRouter executes web search itself; Spotify calls are handled by the device. +const spotifyTypes = ['track', 'artist', 'album', 'playlist']; +const actions = { + pause: {}, resume: {}, next: {}, previous: {}, status: {}, queue: {}, + volume: { volume: { type: 'integer', minimum: 0, maximum: 100 } }, + seek: { positionMs: { type: 'integer', minimum: 0, maximum: 86400000 } }, + shuffle: { enabled: { type: 'boolean' } }, + repeat: { mode: { enum: ['off', 'track', 'context'] } }, + enqueue: { query: { type: 'string', maxLength: 500 } }, +}; + 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 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 } } }, + { type: 'function', function: { name: 'spotify_play', description: 'Play a track, artist, album or playlist on Spotify. Pass type when the user names a kind of content. Normalize known transliterations.', parameters: { type: 'object', properties: { query: { type: 'string', maxLength: 500 }, uri: { type: 'string', maxLength: 500 }, type: { type: 'string', enum: spotifyTypes } }, additionalProperties: false } } }, + { type: 'function', function: { name: 'spotify_control', description: 'Control Spotify on the station. Use status for what is playing, queue to read upcoming tracks, enqueue to add a track by name, seek with an absolute position in milliseconds, and repeat with track, context (album or playlist), or off.', parameters: { type: 'object', properties: { action: { type: 'string', enum: Object.keys(actions) }, ...Object.assign({}, ...Object.values(actions)) }, required: ['action'], additionalProperties: false } } }, + { type: 'function', function: { name: 'spotify_playlist', description: 'List own playlists, read tracks, create a private playlist, or add/remove a track. Use playlistUri for an existing playlist, trackUri or trackQuery for a track, and name when creating.', parameters: { type: 'object', properties: { + action: { enum: ['list', 'read', 'create', 'add', 'remove'] }, name: { type: 'string', maxLength: 100 }, + playlistUri: { type: 'string' }, trackUri: { type: 'string' }, trackQuery: { type: 'string', maxLength: 500 }, + }, 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 }, delta: { type: 'integer', enum: [-10, 10] } }, additionalProperties: false } } }, ]; +function validValue(value, schema) { + if (schema.enum) return schema.enum.includes(value); + if (schema.type === 'integer') return Number.isInteger(value) && value >= schema.minimum && value <= schema.maximum; + if (schema.type === 'boolean') return typeof value === 'boolean'; + return typeof value === 'string' && !!value.trim() && value.length <= schema.maxLength; +} + export function validateTool(name, args) { if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('invalid_arguments'); const keys = Object.keys(args); if (name === 'spotify_play') { - 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'); + if (keys.some(k => !['query', 'uri', 'type'].includes(k)) || (!args.query && !args.uri) || + keys.some(k => k === 'type' ? !spotifyTypes.includes(args[k]) : !validValue(args[k], { maxLength: 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'); + if (!Object.hasOwn(actions, args.action)) throw new Error('invalid_arguments'); + const fields = Object.entries(actions[args.action]); + if (keys.length !== fields.length + 1 || fields.some(([key, schema]) => !validValue(args[key], schema))) throw new Error('invalid_arguments'); + } else if (name === 'spotify_playlist') { + const required = { list: [], read: ['playlistUri'], create: ['name'], add: ['playlistUri'], remove: ['playlistUri'] }[args.action]; + if (!required || keys.some(k => !['action', 'name', 'playlistUri', 'trackUri', 'trackQuery'].includes(k)) || + required.some(k => !args[k]) || keys.some(k => typeof args[k] !== 'string' || args[k].length > 500)) throw new Error('invalid_arguments'); + if (args.playlistUri && !/^spotify:playlist:[A-Za-z0-9]{22}$/.test(args.playlistUri)) throw new Error('invalid_arguments'); + if (args.trackUri && !/^spotify:track:[A-Za-z0-9]{22}$/.test(args.trackUri)) throw new Error('invalid_arguments'); + if (['add', 'remove'].includes(args.action) && !args.trackUri && !args.trackQuery) 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'); + if (keys.length !== 1 || ('delta' in args ? ![-10, 10].includes(args.delta) : !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 c75065d..46ace3a 100644 --- a/server/turns.mjs +++ b/server/turns.mjs @@ -14,7 +14,7 @@ export function handleTurn(client, store) { let state = 'waiting', stt, tts, bytes = 0, received = 0, messages = 0, ended = false, terminal = false, wake = false; let pending = []; let pendingBytes = 0; - const timeout = setTimeout(() => fail('timeout'), 120000); + let timeout = setTimeout(() => fail('timeout'), 120000); const send = obj => { if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify(obj)); }; function cleanup() { clearTimeout(timeout); @@ -47,7 +47,7 @@ export function handleTurn(client, store) { text = text.replace(/^[«"“]?алиса(?:[»"”.,!?…:;\s–—-]+|$)/iu, ''); if (!text) return complete('', ''); } - if (!text) return fail('empty_transcript'); + if (!text || /^(?:стоп|хватит)[.!?…]*$/iu.test(text)) return complete('', ''); send({ type: 'transcript', text }); try { const action = quickAction(text); @@ -103,6 +103,8 @@ export function handleTurn(client, store) { if (!pendingBytes) return fail('empty_audio'); ended = true; state = 'processing'; + clearTimeout(timeout); + timeout = setTimeout(() => fail('response_timeout'), 45000); sendChunk(Buffer.concat(pending, pendingBytes), true); pending = []; pendingBytes = 0; @@ -111,5 +113,6 @@ export function handleTurn(client, store) { }); client.on('error', () => { if (!terminal) { terminal = true; cleanup(); } }); client.on('close', () => { if (!terminal) { terminal = true; cleanup(); } }); - send({ type: 'ready' }); + const { followUpMs, activationSound } = store.getSettings(); + send({ type: 'ready', followUpMs, activationSound }); } diff --git a/station/capture.go b/station/capture.go index 69eeb50..dbf781c 100644 --- a/station/capture.go +++ b/station/capture.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/binary" "errors" "io" @@ -36,7 +37,7 @@ func pcm(samples []int16) []byte { return data } -func capture(device, vqeHelper, spotterModel string) (*liveReader, func(), error) { +func capture(device, vqeHelper, spotterModel string, localCommand func(byte)) (*liveReader, func(), error) { channels, period, buffer, block := "8", "512", "2048", rawBlock if vqeHelper != "" { device, channels, buffer, block = "plug:default", "6", "4096", pcmBlock @@ -83,7 +84,7 @@ func capture(device, vqeHelper, spotterModel string) (*liveReader, func(), error } processor, pipe = helper, output } - return newLiveReader(pipe.(*os.File), block), stop, nil + return newLiveReaderWithCommands(pipe.(*os.File), block, localCommand), stop, nil } // Keep draining ALSA during playback and network startup. When the queue is @@ -100,7 +101,7 @@ type audioFrame struct { wake, muted bool } -func newLiveReader(pipe *os.File, block int) *liveReader { +func newLiveReaderWithCommands(pipe *os.File, block int, localCommand func(byte)) *liveReader { live := &liveReader{chunks: make(chan audioFrame, 300)} go func() { defer close(live.chunks) @@ -122,12 +123,18 @@ func newLiveReader(pipe *os.File, block int) *liveReader { frame := audioFrame{seq: seq, pcm: samples} if block == spotterBlock { flags := buf[pcmBlock] - if flags > 3 { + if flags > 31 { live.err = errors.New("invalid VQE spotter flags") return } frame.wake = flags&1 != 0 frame.muted = flags&2 != 0 + if frame.muted && localCommand != nil { + localCommand(1) + } + if command := flags >> 2; command != 0 && localCommand != nil && !frame.muted && unmuted() && !frame.wake { + localCommand(command) + } } select { case live.chunks <- frame: @@ -144,7 +151,17 @@ func newLiveReader(pipe *os.File, block int) *liveReader { } func (l *liveReader) next() (audioFrame, error) { - frame, ok := <-l.chunks + return l.nextContext(context.Background()) +} + +func (l *liveReader) nextContext(ctx context.Context) (audioFrame, error) { + var frame audioFrame + var ok bool + select { + case <-ctx.Done(): + return frame, ctx.Err() + case frame, ok = <-l.chunks: + } if !ok { return audioFrame{}, l.err } diff --git a/station/conversation.go b/station/conversation.go new file mode 100644 index 0000000..58f36b1 --- /dev/null +++ b/station/conversation.go @@ -0,0 +1,124 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log" + "math" + "sync" + "time" +) + +type conversation struct { + mu sync.Mutex + cancel context.CancelFunc + commands chan byte +} + +func (c *conversation) begin() (context.Context, func()) { + ctx, cancel := context.WithCancel(context.Background()) + c.mu.Lock() + c.cancel = cancel + c.mu.Unlock() + return ctx, func() { + cancel() + c.mu.Lock() + c.cancel = nil + c.mu.Unlock() + } +} + +func (c *conversation) stop() { + c.mu.Lock() + defer c.mu.Unlock() + if c.cancel != nil { + c.cancel() + } +} + +func (c *conversation) command(cmd byte) { + if cmd == 1 { + c.stop() + return + } + c.mu.Lock() + defer c.mu.Unlock() + if c.cancel == nil { + select { + case c.commands <- cmd: + default: + } + } +} + +func (c *conversation) localCommands() { + for cmd := range c.commands { + if !unmuted() { + continue + } + if cmd == 2 || cmd == 3 { + delta := 10 + if cmd == 3 { + delta = -10 + } + level, err := changeVolume(delta) + if err != nil { + log.Printf("local volume: %v", err) + continue + } + lights.showVolume(level) + continue + } + action := [...]string{4: "pause", 5: "resume", 6: "next", 7: "previous"}[cmd] + args, _ := json.Marshal(map[string]string{"action": action}) + result := musicCall("spotify_control", args) + var reply struct { + Error string + Accepted bool + } + if json.Unmarshal(result, &reply) != nil || reply.Error != "" { + lights.flashError() + } else if reply.Accepted { + lights.confirm(action) + } + } +} + +type turnOptions struct { + FollowUpMs int `json:"followUpMs"` + ActivationSound bool `json:"activationSound"` + spoken bool +} + +func (c *conversation) dialogue(url, token string, live *liveReader, preroll []int16, threshold float64, max, silence time.Duration, playback string, once bool) error { + options := turnOptions{} + var followUp time.Duration + for { + ctx, finish := c.begin() + err := exchange(ctx, url, token, live, nil, preroll, threshold, max, silence, playback, followUp, &options) + finish() + if followUp > 0 && errors.Is(err, errNoSpeech) { + return nil + } + if err != nil || once || !options.spoken || options.FollowUpMs <= 0 { + return err + } + followUp = time.Duration(min(options.FollowUpMs, 10000)) * time.Millisecond + preroll = nil + } +} + +func activationTone(ctx context.Context, playback string) { + ctx, cancel := context.WithTimeout(ctx, 450*time.Millisecond) + defer cancel() + samples := make([]int16, 2400) + for i := range samples { + envelope := math.Sin(math.Pi * float64(i) / float64(len(samples))) + samples[i] = int16(1600 * envelope * envelope * math.Sin(2*math.Pi*660*float64(i)/24000)) + } + player := playbackCommand(ctx, playback) + player.Stdin = bytes.NewReader(pcm(samples)) + _ = player.Run() +} diff --git a/station/conversation_test.go b/station/conversation_test.go new file mode 100644 index 0000000..de38757 --- /dev/null +++ b/station/conversation_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestStopClosesResponseAndRestoresMusic(t *testing.T) { + dir := t.TempDir() + helper := filepath.Join(dir, "music") + logfile := filepath.Join(dir, "calls") + script := "#!/bin/sh\ncat >> '" + logfile + "'\nprintf '{\"ok\":true}'\n" + if err := os.WriteFile(helper, []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("ALICE_MUSIC_HELPER", helper) + player := filepath.Join(dir, "player") + spoken := filepath.Join(dir, "spoken") + if err := os.WriteFile(player, []byte("#!/bin/sh\nexec cat > '"+spoken+"'\n"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("ALICE_PLAYER", player) + mutePath = filepath.Join(dir, "mute") + if err := os.WriteFile(mutePath, []byte("1"), 0600); err != nil { + t.Fatal(err) + } + processing := make(chan struct{}) + closed := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ws, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + defer ws.Close() + ws.WriteJSON(message{Type: "ready"}) + for { + typ, data, err := ws.ReadMessage() + if err != nil { + close(closed) + return + } + if typ == websocket.TextMessage && strings.Contains(string(data), "end") { + ws.WriteMessage(websocket.BinaryMessage, make([]byte, 4800)) + close(processing) + } + } + })) + defer srv.Close() + live := &liveReader{chunks: make(chan audioFrame, 130)} + for i := 0; i < 130; i++ { + s := make([]int16, hop) + if i < 6 { + for j := range s { + s[j] = 60 + } + } + live.chunks <- audioFrame{seq: uint64(i + 1), pcm: s} + } + close(live.chunks) + dialog := &conversation{commands: make(chan byte, 4)} + ctx, finish := dialog.begin() + defer finish() + result := make(chan error, 1) + go func() { + result <- exchange(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), "test", live, nil, make([]int16, 800), 12, 4*time.Second, 1200*time.Millisecond, "", 0, nil) + }() + select { + case <-processing: + case <-time.After(2 * time.Second): + t.Fatal("no processing") + } + for deadline := time.Now().Add(time.Second); ; { + if info, err := os.Stat(spoken); err == nil && info.Size() > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("no audio written to player") + } + time.Sleep(5 * time.Millisecond) + } + start := time.Now() + dialog.command(1) + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("stop did not interrupt") + } + if time.Since(start) > time.Second { + t.Fatal("slow stop") + } + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("upstream not closed") + } + calls, _ := os.ReadFile(logfile) + if strings.Count(string(calls), "\"name\":\"duck\"") != 2 || !strings.Contains(string(calls), "\"active\":false") || strings.Contains(string(calls), "spotify_control") { + t.Fatalf("unexpected music calls %s", calls) + } +} + +func TestLocalStopDoesNotPauseMusic(t *testing.T) { + dialog := &conversation{commands: make(chan byte, 4)} + dialog.command(1) + select { + case <-dialog.commands: + t.Fatal("stop queued a Spotify command") + default: + } +} diff --git a/station/device.go b/station/device.go index 128b5d7..0a9a807 100644 --- a/station/device.go +++ b/station/device.go @@ -19,20 +19,30 @@ type deviceTool struct { Arguments json.RawMessage `json:"arguments"` } -func runDeviceTool(req deviceTool) json.RawMessage { +func runDeviceTool(ctx context.Context, req deviceTool) json.RawMessage { if req.Name != "station_volume" { - return musicCall(req.Name, req.Arguments) + return musicCallContext(ctx, 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 { + var args struct{ Level, Delta *int } + if json.Unmarshal(req.Arguments, &args) != nil || (args.Level == nil) == (args.Delta == nil) { return json.RawMessage(`{"error":"invalid_volume"}`) } - if err := setStationVolume(*args.Level); err != nil { + var level int + var err error + if args.Delta != nil && (*args.Delta == -10 || *args.Delta == 10) { + level, err = changeVolume(*args.Delta) + } else if args.Level != nil && *args.Level >= 0 && *args.Level <= 100 { + level = *args.Level + err = setStationVolume(level) + } else { + return json.RawMessage(`{"error":"invalid_volume"}`) + } + if 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)) + lights.showVolume(level) + return json.RawMessage(fmt.Sprintf(`{"ok":true,"level":%d}`, level)) } type limitedOutput struct { @@ -57,8 +67,14 @@ func (b *limitedOutput) Write(p []byte) (int, error) { var musicMu sync.Mutex func musicCall(name string, args json.RawMessage) json.RawMessage { - musicMu.Lock() - defer musicMu.Unlock() + return musicCallContext(context.Background(), name, args) +} + +func musicCallContext(ctx context.Context, name string, args json.RawMessage) json.RawMessage { + if name != "duck" { + musicMu.Lock() + defer musicMu.Unlock() + } fail := func(s string) json.RawMessage { b, _ := json.Marshal(map[string]string{"error": s}); return b } if name != "spotify_play" && name != "spotify_control" && name != "duck" { return fail("unsupported device tool") @@ -77,7 +93,7 @@ func musicCall(name string, args json.RawMessage) json.RawMessage { if path == "" { path = "/data/opt/alice-backend/music-control" } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) defer cancel() cmd := exec.CommandContext(ctx, path) terminateWithClient(cmd) @@ -94,13 +110,15 @@ 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 - volume int - volumeUntil time.Time + mu sync.Mutex + state string + done chan struct{} + finished chan struct{} + once sync.Once + volume int + volumeUntil time.Time + feedback string + feedbackUntil time.Time } var lights = &ring{state: "idle"} @@ -128,12 +146,24 @@ func volumeFrame(level int) [6][3]int { return frame } +func (r *ring) confirm(state string) { + r.mu.Lock() + defer r.mu.Unlock() + r.feedback, r.feedbackUntil = state, time.Now().Add(650*time.Millisecond) +} + func (r *ring) start() { r.done = make(chan struct{}) r.finished = make(chan struct{}) go func() { defer close(r.finished) - t := time.NewTicker(150 * time.Millisecond) + patterns := make(map[string][]ledStep) + for _, state := range []string{"listening", "processing", "speaking", "error", "pause", "resume", "next", "previous"} { + patterns[state] = loadLedPattern(state) + } + previousState := "" + changed := time.Now() + t := time.NewTicker(25 * time.Millisecond) defer t.Stop() tick := 0 var last [6][3]int @@ -141,14 +171,20 @@ func (r *ring) start() { for { r.mu.Lock() s, level, volumeUntil := r.state, r.volume, r.volumeUntil + if s == "idle" && time.Now().Before(r.feedbackUntil) { + s = r.feedback + } r.mu.Unlock() if !unmuted() { s = "mute" } + if s != previousState { + previousState, changed = s, time.Now() + } var rgb [3]int switch s { case "listening": - rgb = [3]int{12, 0, 70} + rgb = [3]int{80, 8, 125} case "processing": rgb = [3]int{8, 0, 18} case "speaking": @@ -164,6 +200,9 @@ func (r *ring) start() { for i := range frame { frame[i] = rgb } + if steps := patterns[s]; len(steps) > 0 { + frame = patternFrame(steps, time.Since(changed)) + } if time.Now().Before(volumeUntil) { frame = volumeFrame(level) } diff --git a/station/keyword.go b/station/keyword.go index 3c8dc44..5fc02ae 100644 --- a/station/keyword.go +++ b/station/keyword.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/binary" "errors" "io" @@ -131,7 +132,7 @@ func runKeywordWake(live *liveReader, detector *keywordDetector, activate func([ 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) { + if !errors.Is(err, errMuted) && !errors.Is(err, errNoSpeech) && !errors.Is(err, context.Canceled) { lights.flashError() } } else if once { diff --git a/station/ledpatterns.go b/station/ledpatterns.go new file mode 100644 index 0000000..338222c --- /dev/null +++ b/station/ledpatterns.go @@ -0,0 +1,71 @@ +package main + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type ledStep struct { + frame [6][3]int + duration time.Duration +} + +func loadLedPattern(state string) []ledStep { + names := map[string]string{"listening": "alice_listening", "processing": "alice_thinking", "speaking": "alice_speaking", "error": "error", "pause": "player_pause", "resume": "player_continue", "next": "player_next_track", "previous": "player_previous_track"} + name := names[state] + if name == "" { + return nil + } + file, err := os.Open(filepath.Join("/system/vendor/quasar/ledpatterns/oknix", name+".led")) + if err != nil { + return nil + } + defer file.Close() + var steps []ledStep + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 7 { + continue + } + ms, err := strconv.Atoi(fields[6]) + if err != nil || ms <= 0 || ms > 10000 { + return nil + } + step := ledStep{duration: time.Duration(ms) * time.Millisecond} + for i := range step.frame { + value, err := strconv.ParseUint(fields[i], 16, 24) + if err != nil { + return nil + } + step.frame[i] = [3]int{int(value >> 16), int(value>>8) & 255, int(value) & 255} + if state == "listening" { + step.frame[i][0] = min(255, step.frame[i][0]*3/2) + } + } + steps = append(steps, step) + } + return steps +} + +func patternFrame(steps []ledStep, elapsed time.Duration) [6][3]int { + var total time.Duration + for _, step := range steps { + total += step.duration + } + if total == 0 { + return [6][3]int{} + } + elapsed %= total + for _, step := range steps { + if elapsed < step.duration { + return step.frame + } + elapsed -= step.duration + } + return steps[len(steps)-1].frame +} diff --git a/station/main.go b/station/main.go index e70fc51..b8212d1 100644 --- a/station/main.go +++ b/station/main.go @@ -34,6 +34,7 @@ type message struct { Type string `json:"type"` Code string `json:"code,omitempty"` Wake bool `json:"wake,omitempty"` + turnOptions } var hann [frameSamples]float64 @@ -210,11 +211,11 @@ func enroll(r *liveReader, path string, count int, threshold float64) error { } return os.WriteFile(path, b, 0600) } -func connect(url, token string) (*websocket.Conn, error) { +func connect(ctx context.Context, url, token string, options *turnOptions) (*websocket.Conn, error) { h := http.Header{} h.Set("Authorization", "Bearer "+token) d := websocket.Dialer{HandshakeTimeout: 2 * time.Second, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}} - c, resp, e := d.Dial(url, h) + c, resp, e := d.DialContext(ctx, url, h) if e != nil { if resp != nil { return nil, fmt.Errorf("websocket handshake HTTP %d: %w", resp.StatusCode, e) @@ -233,6 +234,9 @@ func connect(url, token string) (*websocket.Conn, error) { return nil, fmt.Errorf("expected ready, got %q", m.Type) } _ = c.SetReadDeadline(time.Time{}) + if options != nil { + *options = m.turnOptions + } return c, nil } func playbackCommand(ctx context.Context, device string) *exec.Cmd { @@ -245,25 +249,47 @@ func playbackCommand(ctx context.Context, device string) *exec.Cmd { return player } -func exchange(url, token string, live *liveReader, fileAudio []byte, wakeAudio []int16, threshold float64, max, silence time.Duration, playback string) error { +func exchange(ctx context.Context, url, token string, live *liveReader, fileAudio []byte, wakeAudio []int16, threshold float64, max, silence time.Duration, playback string, followUp time.Duration, options *turnOptions) (result error) { + defer func() { + if ctx.Err() != nil { + result = ctx.Err() + } + }() + ducked := false + duck := func() { + if !ducked { + ducked = true + _ = musicCallContext(ctx, "duck", json.RawMessage(`{"active":true}`)) + } + } + defer func() { + lights.set("idle") + if ducked { + _ = musicCall("duck", json.RawMessage(`{"active":false}`)) + } + }() if live != nil { if !unmuted() { return errMuted } lights.set("listening") - _ = musicCall("duck", json.RawMessage(`{"active":true}`)) - defer func() { _ = musicCall("duck", json.RawMessage(`{"active":false}`)); lights.set("idle") }() + if followUp == 0 { + duck() + } if wakeAudio == nil { live.discardPending() } - } else { - defer lights.set("idle") } - c, e := connect(url, token) + c, e := connect(ctx, url, token, options) if e != nil { return e } defer c.Close() + stopClose := context.AfterFunc(ctx, func() { c.Close() }) + defer stopClose() + if wakeAudio != nil && options != nil && options.ActivationSound { + go activationTone(ctx, playback) + } if fileAudio == nil && !unmuted() { return errMuted } @@ -293,11 +319,15 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, wakeAudio [ lastVoice := -9 var peakRMS float64 heard := false + waitForSpeech := 4 * time.Second + if followUp > 0 { + waitForSpeech = followUp + } for ; frames < int(max/(10*time.Millisecond)); frames++ { - if !heard && frames >= 400 { + if !heard && time.Duration(frames)*10*time.Millisecond >= waitForSpeech { break } - audio, err := live.next() + audio, err := live.nextContext(ctx) if err != nil { return err } @@ -313,7 +343,7 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, wakeAudio [ // Short commands after activation can have quiet gaps between // syllables. Require two voiced frames within 80 ms or a strong // frame outside the immediate tail of the wake word. - voiceStart = voiced >= 3 || wakeAudio != nil && + voiceStart = voiced >= 3 || (wakeAudio != nil || followUp > 0) && (frames-lastVoice <= 8 || frames >= 3 && level >= 2*threshold) lastVoice = frames } else { @@ -328,8 +358,9 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, wakeAudio [ if !voiceStart { continue } + duck() _ = c.SetWriteDeadline(time.Now().Add(2 * time.Second)) - if e = c.WriteJSON(message{Type: "start", Wake: wakeAudio != nil}); e != nil { + if e = c.WriteJSON(message{Type: "start", Wake: wakeAudio != nil || followUp > 0}); e != nil { return e } heard = true @@ -362,6 +393,7 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, wakeAudio [ return e } lights.set("processing") + var toolWrites sync.Mutex var player *exec.Cmd var w io.WriteCloser defer func() { @@ -379,7 +411,7 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, wakeAudio [ } if typ == websocket.BinaryMessage { if player == nil { - cmd := playbackCommand(context.Background(), playback) + cmd := playbackCommand(ctx, playback) w, e = cmd.StdinPipe() if e != nil { return e @@ -407,11 +439,18 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, wakeAudio [ if e = json.Unmarshal(data, &req); e != nil { return e } - 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 - } + go func(req deviceTool) { + result := runDeviceTool(ctx, req) + toolWrites.Lock() + defer toolWrites.Unlock() + if ctx.Err() != nil { + return + } + _ = c.SetWriteDeadline(time.Now().Add(2 * time.Second)) + if err := c.WriteJSON(map[string]interface{}{"type": "device_tool_result", "id": req.ID, "result": result}); err != nil { + c.Close() + } + }(req) case "done": if player == nil { return nil @@ -421,6 +460,9 @@ func exchange(url, token string, live *liveReader, fileAudio []byte, wakeAudio [ } e = player.Wait() player = nil + if options != nil && e == nil { + options.spoken = true + } return e case "error": return fmt.Errorf("server error: %s", m.Code) @@ -485,7 +527,7 @@ func main() { if len(b) == 0 || len(b) > 30*rate*2 || len(b)%2 != 0 { log.Fatal("input must contain at most 30 seconds of PCM16LE and have an even nonzero size") } - if e = exchange(*url, token, nil, b, nil, *th, *max, *silence, *playback); e != nil { + if e = exchange(context.Background(), *url, token, nil, b, nil, *th, *max, *silence, *playback, 0, nil); e != nil { log.Fatal(e) } return @@ -496,7 +538,12 @@ func main() { log.Fatal(e) } } - live, stopCapture, e := capture(*device, *vqeHelper, *spotterModel) + dialog := &conversation{commands: make(chan byte, 4)} + go dialog.localCommands() + if *mode == "wake" { + go reportPlayback(*url, token) + } + live, stopCapture, e := capture(*device, *vqeHelper, *spotterModel, dialog.command) if e != nil { log.Fatal(e) } @@ -524,7 +571,7 @@ func main() { for !unmuted() { time.Sleep(100 * time.Millisecond) } - e = exchange(*url, token, live, nil, nil, *th, *max, *silence, *playback) + e = exchange(context.Background(), *url, token, live, nil, nil, *th, *max, *silence, *playback, 0, nil) if errors.Is(e, errMuted) { log.Println("microphone muted; waiting for unmute") continue @@ -537,7 +584,7 @@ func main() { } } - go listenVolumeButtons(*playback) + go listenVolumeButtons(*playback, dialog.stop) if *keywordModel != "" || *spotterModel != "" { var detector *keywordDetector if *keywordModel != "" { @@ -549,7 +596,7 @@ func main() { defer detector.close() } err := runKeywordWake(live, detector, func(preroll []int16) error { - return exchange(*url, token, live, nil, preroll, *th, *max, *silence, *playback) + return dialog.dialogue(*url, token, live, preroll, *th, *max, *silence, *playback, *oneTurn) }, *oneTurn) if err != nil { cleanup() @@ -607,7 +654,7 @@ func main() { log.Printf("wake candidate distance=%.3f", best) if best <= *match { log.Println("wake detected") - if e = exchange(*url, token, live, nil, nil, *th, *max, *silence, *playback); e != nil { + if e = exchange(context.Background(), *url, token, live, nil, nil, *th, *max, *silence, *playback, 0, nil); e != nil { log.Printf("session: %v", e) if !errors.Is(e, errMuted) && !errors.Is(e, errNoSpeech) { lights.flashError() diff --git a/station/main_test.go b/station/main_test.go index 45072f3..f122a20 100644 --- a/station/main_test.go +++ b/station/main_test.go @@ -50,7 +50,7 @@ func TestLiveReaderEOFStaysEOF(t *testing.T) { } defer in.Close() out.Close() - r := newLiveReader(in, pcmBlock) + r := newLiveReaderWithCommands(in, pcmBlock, nil) for i := 0; i < 2; i++ { if _, err := r.next(); err != io.EOF { t.Fatalf("read %d: %v", i, err) @@ -77,7 +77,7 @@ func TestLiveReaderSpotterFlags(t *testing.T) { } out.Close() - reader := newLiveReader(in, spotterBlock) + reader := newLiveReaderWithCommands(in, spotterBlock, nil) frame, err := reader.next() if err != nil || !frame.wake || frame.muted || len(frame.pcm) != hop || frame.pcm[0] != 123 { t.Fatalf("wake packet: %+v, %v", frame, err) diff --git a/station/telemetry.go b/station/telemetry.go new file mode 100644 index 0000000..052dc52 --- /dev/null +++ b/station/telemetry.go @@ -0,0 +1,40 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/url" + "time" +) + +func reportPlayback(address, token string) { + endpoint, err := url.Parse(address) + if err != nil { + return + } + if endpoint.Scheme == "wss" { + endpoint.Scheme = "https" + } else { + endpoint.Scheme = "http" + } + endpoint.Path = "/v1/device/status" + client := &http.Client{Timeout: 4 * time.Second} + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + state := musicCallContext(ctx, "spotify_control", json.RawMessage(`{"action":"status"}`)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(state)) + if err == nil { + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + if response, err := client.Do(req); err == nil { + response.Body.Close() + } + } + cancel() + <-ticker.C + } +} diff --git a/station/volume.go b/station/volume.go index 959ada6..a1a769a 100644 --- a/station/volume.go +++ b/station/volume.go @@ -24,7 +24,7 @@ const pulseControl = "/data/opt/alice-backend/spotify/pactl" var volumeMu sync.Mutex var toneMu sync.Mutex -func listenVolumeButtons(playback string) { +func listenVolumeButtons(playback string, stop func()) { names, _ := filepath.Glob("/sys/class/input/event*/device/name") for _, path := range names { name, err := os.ReadFile(path) @@ -45,6 +45,12 @@ func listenVolumeButtons(playback string) { 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 { diff --git a/station/vqe/capture.c b/station/vqe/capture.c index 43f1316..34e2821 100644 --- a/station/vqe/capture.c +++ b/station/vqe/capture.c @@ -2,7 +2,7 @@ // d1d0f12f8f8389c0ca72672c61530161b85353c9 (Quasar 3.21). // stdin: ALSA plug:default, 48 kHz S16_LE, four mics followed by stereo speaker feedback. // stdout: vqe_0, 16 kHz mono S16_LE. In spotter mode, each 10 ms block -// is followed by one byte of flags (bit 0: wake, bit 1: mute). +// is followed by one byte of flags (bits 0–1: wake and mute, bits 2–4: command). #include #include #include @@ -43,6 +43,7 @@ static int16_t pcm(float sample) { struct spotter { void *config, *model; + bool raw_channel; void *(*create)(void *, int, size_t, const int *, int, void **); void (*destroy)(void *, void **); void (*free_config)(void *, void **); @@ -64,7 +65,7 @@ static void start_spotter(struct spotter *spot) { // The bundled activation model consumes the cleaned stream and one raw mic. const int channels[] = {0, 4}; void *error = NULL; - spot->model = spot->create(spot->config, 16000, 2, channels, 0, &error); + spot->model = spot->create(spot->config, 16000, spot->raw_channel ? 2 : 1, channels, 0, &error); check_spotter(spot, error); if (!spot->model) exit(1); } @@ -77,7 +78,8 @@ static void stop_spotter(struct spotter *spot) { spot->model = NULL; } -static void load_spotter(struct spotter *spot, void *lib, const char *path) { +static void load_spotter(struct spotter *spot, void *lib, const char *path, bool raw_channel) { + spot->raw_channel = raw_channel; #define SPOT(name) symbol(lib, "spotter_" #name) void *(*read_config)(const char *, void **) = SPOT(conf_read); spot->create = SPOT(create_multichannel); @@ -96,24 +98,41 @@ static void load_spotter(struct spotter *spot, void *lib, const char *path) { start_spotter(spot); } -static bool detect(struct spotter *spot, const int16_t *clean, const float *raw) { +static uint8_t detect(struct spotter *spot, const int16_t *clean, const float *raw) { int16_t audio[OUTPUT][2]; - for (size_t i = 0; i < OUTPUT; ++i) { - audio[i][0] = clean[i]; - audio[i][1] = pcm(raw[i]); + const int16_t *input = clean; + size_t length = OUTPUT * sizeof(int16_t); + if (raw) { + for (size_t i = 0; i < OUTPUT; ++i) { + audio[i][0] = clean[i]; + audio[i][1] = pcm(raw[i]); + } + input = &audio[0][0]; + length = sizeof(audio); } void *error = NULL; - spot->submit(spot->model, &audio[0][0], sizeof(audio), &error); + spot->submit(spot->model, input, length, &error); check_spotter(spot, error); - bool detected = false; + uint8_t result = 0; for (;;) { int event = spot->next_event(spot->model, &error); check_spotter(spot, error); - if (event == 0) return detected; + if (event == 0) return result; if (event != 2) continue; const char *phrase = spot->phrase(spot->model, &error); check_spotter(spot, error); - if (phrase && strcmp(phrase, "алиса") == 0) detected = true; + if (!phrase) continue; + if (raw) { + if (strcmp(phrase, "алиса") == 0) result = WAKE; + } else { + if (strcmp(phrase, "стоп") == 0 || strcmp(phrase, "хватит") == 0) result = 1; + else if (strcmp(phrase, "громче") == 0) result = 2; + else if (strcmp(phrase, "тише") == 0) result = 3; + else if (strcmp(phrase, "пауза") == 0) result = 4; + else if (strcmp(phrase, "продолжи") == 0 || strcmp(phrase, "играй") == 0) result = 5; + else if (strcmp(phrase, "следующий") == 0 || strcmp(phrase, "дальше") == 0) result = 6; + else if (strcmp(phrase, "назад") == 0) result = 7; + } } } @@ -163,8 +182,11 @@ int main(int argc, char **argv) { check(create(config, "/system/vendor/quasar/vqe_models", 0, NULL, &vqe)); free_config(config); - struct spotter spot = {0}; - if (use_spotter) load_spotter(&spot, lib, argv[1]); + struct spotter spot = {0}, commands = {0}; + if (use_spotter) { + load_spotter(&spot, lib, argv[1], true); + load_spotter(&commands, lib, "/system/vendor/quasar/spotter_models/command", false); + } int16_t input[INPUT][MICS + SPEAKERS], output[OUTPUT]; float mics[INPUT][MICS], speakers[INPUT][SPEAKERS]; @@ -195,18 +217,20 @@ int main(int argc, char **argv) { if (fwrite(output, sizeof(output), 1, stdout) != 1 || fflush(stdout) != 0) return 1; continue; } - bool detected = false; + uint8_t detected = 0; bool chunk_muted = !mic_unmuted(argv[2]); if (!chunk_muted) { if (!spot.model) start_spotter(&spot); + if (!commands.model) start_spotter(&commands); size_t mic = 0; check(omni_mic(vqe, &mic)); check(channel(vqe, RAW_MIC_CHANNEL, mic, &samples, &count)); if (count != OUTPUT || !samples) return 1; // The last 10 ms packet in this chunk carries the spotter event. - detected = detect(&spot, output, samples); + detected = detect(&spot, output, samples) | (detect(&commands, output, NULL) << 2); } else { stop_spotter(&spot); + stop_spotter(&commands); memset(output, 0, sizeof(output)); } for (size_t i = 0; i < OUTPUT; ++i) { @@ -214,7 +238,7 @@ int main(int argc, char **argv) { if (packet_samples != HOP) continue; bool last = OUTPUT - i - 1 < HOP; packet[sizeof(packet) - 1] = - (last && detected ? WAKE : 0) | (chunk_muted || partial_muted ? MUTED : 0); + (last ? detected : 0) | (chunk_muted || partial_muted ? MUTED : 0); if (fwrite(packet, sizeof(packet), 1, stdout) != 1) return 1; packet_samples = 0; partial_muted = false; @@ -228,6 +252,9 @@ int main(int argc, char **argv) { void *error = NULL; spot.free_config(spot.config, &error); check_spotter(&spot, error); + stop_spotter(&commands); + commands.free_config(commands.config, &error); + check_spotter(&commands, error); } destroy(vqe); return ferror(stdin) ? 1 : 0; diff --git a/test/admin.test.mjs b/test/admin.test.mjs index 367096a..8917b92 100644 --- a/test/admin.test.mjs +++ b/test/admin.test.mjs @@ -40,6 +40,9 @@ test('settings and bounded history survive a restart', t => { assert.equal(reopened.getSettings().sttModel, 'scribe_v2_realtime'); assert.equal(reopened.getHistory().length, 50); assert.equal(reopened.getHistory()[0].user, '5'); + reopened.recordDevice({ active: true, item: { uri: 'spotify:track:test', name: 'Track' }, status: 'paused' }); + reopened.recordDevice({ connected: false }); + assert.equal(openStore(directory).getDevice().lastPlayback.item.name, 'Track'); reopened.clearHistory(); assert.deepEqual(openStore(directory).getHistory(), []); }); diff --git a/test/providers-tools.test.mjs b/test/providers-tools.test.mjs index bb9a914..488652e 100644 --- a/test/providers-tools.test.mjs +++ b/test/providers-tools.test.mjs @@ -22,7 +22,7 @@ const settings = { systemPrompt: 'test', llmModel: 'test' }; test('assembles fragmented calls before executing and streams text', async () => { requests.length = 0; - responder = n => n === 1 ? [event({ 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')]; + 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')]; const calls = [], text = []; const answer = await streamAnswer(settings, [], 'play', new AbortController().signal, part => text.push(part), async (name, args) => { calls.push([name, args]); return { ok: true }; }); assert.equal(answer, 'Готово'); assert.deepEqual(text, ['Готово']); @@ -31,6 +31,18 @@ test('assembles fragmented calls before executing and streams text', async () => assert.equal(requests[1].messages.at(-1).tool_call_id, 'call_1'); }); +test('confirms Spotify play once without reading the volume or an old track', async () => { + requests.length = 0; + responder = () => [event({ content: 'Громкость 40.', tool_calls: [{ index: 0, id: 'play', function: { name: 'spotify_play', arguments: '{"query":"Aphex Twin"}' } }] }, 'tool_calls')]; + const text = []; + const answer = await streamAnswer(settings, [], 'включи музыку', AbortSignal.timeout(2000), part => text.push(part), async () => ({ + accepted: true, status: 'pending', selection: { name: 'Aphex Twin' }, item: { name: 'Old track' }, volume: 40, + })); + assert.equal(answer, 'Включаю «Aphex Twin».'); + assert.deepEqual(text, [answer]); + assert.equal(requests.length, 1); +}); + test('does not execute incomplete tool calls', async () => { requests.length = 0; responder = () => [event({ tool_calls: [{ index: 0, id: 'x', function: { name: 'spotify_control', arguments: '{"action":"next"}' } }] }, 'length')];