From 3e83d25410a9aeed8e0ecc20d7aab683c7f7c809 Mon Sep 17 00:00:00 2001 From: chexit Date: Sat, 14 Nov 2026 15:26:40 +0300 Subject: [PATCH] Control Windows audio streaming from Alice --- server/quick-actions.mjs | 11 ++ server/tools.mjs | 3 + station/README.md | 14 ++ station/cmd/pc-audio-agent/main_windows.go | 209 +++++++++++++++++++++ station/device.go | 3 + station/go.mod | 2 + station/go.sum | 2 + station/main.go | 1 + station/pc_audio.go | 178 ++++++++++++++++++ station/pc_audio_test.go | 83 ++++++++ station/pcaudio/protocol.go | 13 ++ station/station-supervisor.sh | 18 +- 12 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 station/cmd/pc-audio-agent/main_windows.go create mode 100644 station/pc_audio.go create mode 100644 station/pc_audio_test.go create mode 100644 station/pcaudio/protocol.go diff --git a/server/quick-actions.mjs b/server/quick-actions.mjs index d6c4187..deedc51 100644 --- a/server/quick-actions.mjs +++ b/server/quick-actions.mjs @@ -16,6 +16,17 @@ function volumeLevel(text) { export function quickAction(transcript) { const text = transcript.toLowerCase().replace(/[.,!?;:…\s]+/gu, ' ').trim() .replace(/^алиса\s+/, '').replace(/^пожалуйста\s+|\s+пожалуйста$/g, ''); + const pc = /^(включи|запусти|выключи|отключи|останови) (?:звук|трансляцию(?: звука)?) (?:с |из )?(?:пк|компьютера|компа)(?: на колонку| на колонке)?$/u.exec(text); + if (pc) { + const enabled = pc[1] === 'включи' || pc[1] === 'запусти'; + return { + name: 'pc_audio', + args: { action: enabled ? 'start' : 'stop' }, + done: enabled ? 'Звук с компьютера включён.' : 'Звук с компьютера выключен.', + failure: 'Не удалось переключить звук с компьютера. Проверь агент на Windows.', + success: result => result?.ok === true && result.enabled === enabled, + }; + } if (/^(?:пауза|на паузу|(?:поставь|поставить)(?: музыку| воспроизведение)? на паузу|(?:приостанови|выключи|останови) (?:музыку|воспроизведение))$/.test(text)) { return { name: 'spotify_control', diff --git a/server/tools.mjs b/server/tools.mjs index f864a7a..71d7b40 100644 --- a/server/tools.mjs +++ b/server/tools.mjs @@ -11,6 +11,7 @@ const actions = { export const tools = [ { type: 'openrouter:web_search' }, + { type: 'function', function: { name: 'pc_audio', description: 'Start, stop or check the Windows PC audio mirror on the speaker. Use for explicit requests about sound from the PC, independently of Spotify.', parameters: { type: 'object', properties: { action: { type: 'string', enum: ['start', 'stop', 'status'] } }, required: ['action'], 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: { @@ -44,6 +45,8 @@ export function validateTool(name, args) { 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 === 'pc_audio') { + if (keys.length !== 1 || !['start', 'stop', 'status'].includes(args.action)) throw new Error('invalid_arguments'); } else if (name === 'station_volume') { 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'); diff --git a/station/README.md b/station/README.md index 401c199..6a5c240 100644 --- a/station/README.md +++ b/station/README.md @@ -101,3 +101,17 @@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o scr ``` Приёмник пишет в журнал формат при первом пакете и освобождает вход PulseAudio через две секунды без звука. Базовый запрос к буферу PulseAudio составляет 30 мс. Полная задержка также зависит от Windows, сети и буфера аппаратного выхода. Остановить приёмник в терминале можно через Ctrl+C. + +### Голосовое управление с Windows + +Команды «Алиса, включи звук с компьютера» и «Алиса, выключи звук с компьютера» управляют отправителем. Агент работает в сеансе пользователя Windows и запускает Scream Sender по команде. При выходе из агента или потере связи со станцией отправитель завершается. + +На Mini 2 положи в `/data/opt/alice-backend` файл `pc-audio-receiver.conf` с адресом приёмника `192.168.1.135:4010` на первой строке и IP компьютера `192.168.1.170` на второй. Соседний файл `pc-audio.json` содержит поля `listen` (адрес станции с TCP-портом, например `192.168.1.135:4011`), `peer` (IP компьютера) и `token` (случайные 32 байта в hex). Поставь файлам права `0600` и перезапусти `S99alice`. + +Из каталога `station` собери агент командой: + +```sh +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags='-s -w -H windowsgui' -o AlicePCAudio.exe ./cmd/pc-audio-agent +``` + +Положи рядом с EXE файл `ScreamSender.exe` и `agent.json` с полями `station` (TCP-адрес из `listen`), `port` (UDP-порт приёмника) и `token` (тот же ключ). Запусти `AlicePCAudio.exe` из сеанса Windows. Для автозапуска добавь ярлык в `shell:startup`. Журнал агента находится рядом с EXE в файле `agent.log`. Если отправитель ранее запускался вручную, закрой этот экземпляр перед первым запуском агента. diff --git a/station/cmd/pc-audio-agent/main_windows.go b/station/cmd/pc-audio-agent/main_windows.go new file mode 100644 index 0000000..fc52893 --- /dev/null +++ b/station/cmd/pc-audio-agent/main_windows.go @@ -0,0 +1,209 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/netip" + "os" + "path/filepath" + "strconv" + "time" + "unsafe" + + "alice-back/station/pcaudio" + "golang.org/x/sys/windows" +) + +const agentLease = 5 * time.Second + +type sender struct { + process windows.Handle + job windows.Handle +} + +func startSender(path, host string, port uint16) (*sender, error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, err + } + var limits windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION + limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil { + windows.CloseHandle(job) + return nil, err + } + + program, _ := windows.UTF16PtrFromString(path) + args, _ := windows.UTF16PtrFromString(windows.ComposeCommandLine([]string{path, host, strconv.Itoa(int(port))})) + dir, _ := windows.UTF16PtrFromString(filepath.Dir(path)) + info := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{}))} + var child windows.ProcessInformation + if err := windows.CreateProcess(program, args, nil, nil, false, windows.CREATE_SUSPENDED|windows.CREATE_NO_WINDOW, nil, dir, &info, &child); err != nil { + windows.CloseHandle(job) + return nil, err + } + defer windows.CloseHandle(child.Thread) + err = windows.AssignProcessToJobObject(job, child.Process) + if err == nil { + _, err = windows.ResumeThread(child.Thread) + } + if err != nil { + _ = windows.TerminateProcess(child.Process, 1) + windows.CloseHandle(child.Process) + windows.CloseHandle(job) + return nil, err + } + return &sender{process: child.Process, job: job}, nil +} + +func (s *sender) stop() { + windows.CloseHandle(s.job) + _, _ = windows.WaitForSingleObject(s.process, 1000) + windows.CloseHandle(s.process) +} + +func (s *sender) exited() bool { + status, err := windows.WaitForSingleObject(s.process, 0) + return err != nil || status != uint32(windows.WAIT_TIMEOUT) +} + +func poll(client *http.Client, url, token string, state pcaudio.State) (pcaudio.Command, error) { + data, _ := json.Marshal(state) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return pcaudio.Command{}, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return pcaudio.Command{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return pcaudio.Command{}, fmt.Errorf("station returned HTTP %d", resp.StatusCode) + } + var command pcaudio.Command + err = json.NewDecoder(io.LimitReader(resp.Body, 1024)).Decode(&command) + if err == nil && len(command.Revision) != 32 { + err = errors.New("invalid station command") + } + return command, err +} + +func run() error { + exe, err := os.Executable() + if err != nil { + return err + } + dir := filepath.Dir(exe) + path := filepath.Join(dir, "ScreamSender.exe") + if _, err := os.Stat(path); err != nil { + return err + } + data, err := os.ReadFile(filepath.Join(dir, "agent.json")) + if err != nil { + return err + } + var config struct { + Station string + Port uint16 + Token string + } + if err := json.Unmarshal(data, &config); err != nil { + return err + } + host, err := netip.ParseAddrPort(config.Station) + if err != nil || !host.Addr().Is4() || config.Port == 0 || len(config.Token) != 64 { + return errors.New("invalid agent configuration") + } + name, _ := windows.UTF16PtrFromString("Local\\AlicePCAudio") + mutex, err := windows.CreateMutex(nil, false, name) + if mutex != 0 { + defer windows.CloseHandle(mutex) + } + if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return nil + } + if err != nil { + return err + } + client := &http.Client{ + Timeout: 900 * time.Millisecond, + Transport: &http.Transport{DialContext: (&net.Dialer{}).DialContext}, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + url := "http://" + host.String() + pcaudio.Path + var state pcaudio.State + var child *sender + var lastSuccess time.Time + var previousError string + for { + if child != nil && child.exited() { + child.stop() + child = nil + state.Enabled = false + state.Error = "sender_exited" + log.Print("Scream Sender exited") + } + command, err := poll(client, url, config.Token, state) + now := time.Now() + if err != nil { + if err.Error() != previousError { + log.Printf("Station: %v", err) + previousError = err.Error() + } + if child != nil && now.Sub(lastSuccess) >= agentLease { + child.stop() + child = nil + state = pcaudio.State{} + log.Print("Station connection lost. Streaming stopped") + } + } else { + if lastSuccess.IsZero() || previousError != "" { + log.Print("Connected to Mini 2") + } + lastSuccess, previousError = now, "" + if command.Revision != state.Revision { + // Stop any sender from the previous command before applying a new one. + if child != nil && !command.Enabled { + child.stop() + child = nil + log.Print("Streaming stopped") + } + state = pcaudio.State{Command: command} + if command.Enabled && child == nil { + child, err = startSender(path, host.Addr().String(), config.Port) + if err != nil { + state.Enabled = false + state.Error = "sender_start_failed" + log.Printf("Scream Sender: %v", err) + } else { + log.Print("Streaming started") + } + } + } + } + time.Sleep(650 * time.Millisecond) + } +} + +func main() { + if exe, err := os.Executable(); err == nil { + path := filepath.Join(filepath.Dir(exe), "agent.log") + if logs, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600); err == nil { + defer logs.Close() + log.SetOutput(logs) + } + } + if err := run(); err != nil { + log.Print(err) + os.Exit(1) + } +} diff --git a/station/device.go b/station/device.go index 0a9a807..44cab7f 100644 --- a/station/device.go +++ b/station/device.go @@ -20,6 +20,9 @@ type deviceTool struct { } func runDeviceTool(ctx context.Context, req deviceTool) json.RawMessage { + if req.Name == "pc_audio" { + return pcAudioTool(ctx, req.Arguments) + } if req.Name != "station_volume" { return musicCallContext(ctx, req.Name, req.Arguments) } diff --git a/station/go.mod b/station/go.mod index 17ca92c..8c5ab52 100644 --- a/station/go.mod +++ b/station/go.mod @@ -3,3 +3,5 @@ module alice-back/station go 1.21 require github.com/gorilla/websocket v1.5.3 + +require golang.org/x/sys v0.30.0 diff --git a/station/go.sum b/station/go.sum index 25a9fc4..01da6ad 100644 --- a/station/go.sum +++ b/station/go.sum @@ -1,2 +1,4 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/station/main.go b/station/main.go index 198634d..3ab564d 100644 --- a/station/main.go +++ b/station/main.go @@ -541,6 +541,7 @@ func main() { dialog := &conversation{commands: make(chan byte, 4), panelWake: make(chan struct{}, 1)} go dialog.localCommands() if *mode == "wake" { + loadPCControl() go reportPlayback(*url, token) } live, stopCapture, e := capture(*device, *vqeHelper, *spotterModel, dialog.command) diff --git a/station/pc_audio.go b/station/pc_audio.go new file mode 100644 index 0000000..d112c47 --- /dev/null +++ b/station/pc_audio.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "log" + "net" + "net/http" + "net/netip" + "os" + "strings" + "sync" + "time" + + "alice-back/station/pcaudio" +) + +const pcAudioConfigPath = "/data/opt/alice-backend/pc-audio.json" +const pcAudioLease = 5 * time.Second + +var windowsAudio *pcAudioControl + +type pcAudioControl struct { + mu sync.Mutex + token string + peer netip.Addr + desired pcaudio.Command + actual pcaudio.State + last time.Time +} + +func loadPCControl() { + data, err := os.ReadFile(pcAudioConfigPath) + if os.IsNotExist(err) { + return + } + if err != nil { + log.Printf("PC audio configuration: %v", err) + return + } + var config struct{ Listen, Peer, Token string } + if json.Unmarshal(data, &config) != nil || len(config.Token) != 64 { + log.Print("Invalid PC audio configuration") + return + } + local, localErr := netip.ParseAddrPort(config.Listen) + peer, peerErr := netip.ParseAddr(config.Peer) + if localErr != nil || peerErr != nil || !local.Addr().Is4() || local.Addr().IsUnspecified() || local.Port() == 0 || !peer.Is4() { + log.Print("Invalid addresses for PC audio control") + return + } + listener, err := net.Listen("tcp4", local.String()) + if err != nil { + log.Printf("PC audio control: %v", err) + return + } + windowsAudio = &pcAudioControl{token: config.Token, peer: peer, desired: newPCCommand(false)} + server := &http.Server{Handler: windowsAudio, ReadTimeout: 2 * time.Second, WriteTimeout: 2 * time.Second, MaxHeaderBytes: 4096} + go func() { + if err := server.Serve(listener); err != nil { + log.Printf("PC audio control: %v", err) + } + }() + log.Printf("PC audio control listening on %s", listener.Addr()) +} + +func newPCCommand(enabled bool) pcaudio.Command { + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + panic(err) + } + return pcaudio.Command{Revision: hex.EncodeToString(id[:]), Enabled: enabled} +} + +func (c *pcAudioControl) expire(now time.Time) { + if c.desired.Enabled && now.Sub(c.last) >= pcAudioLease { + c.desired = newPCCommand(false) + } +} + +func (c *pcAudioControl) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != pcaudio.Path { + http.NotFound(w, r) + return + } + remote, err := netip.ParseAddrPort(r.RemoteAddr) + token, bearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if err != nil || !bearer || remote.Addr().Unmap() != c.peer || subtle.ConstantTimeCompare([]byte(token), []byte(c.token)) != 1 { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + var state pcaudio.State + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&state); err != nil { + http.Error(w, "invalid state", http.StatusBadRequest) + return + } + c.mu.Lock() + now := time.Now() + c.expire(now) + c.last = now + c.actual = state + desired := c.desired + c.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(desired) +} + +func (c *pcAudioControl) run(ctx context.Context, action string) json.RawMessage { + c.mu.Lock() + now := time.Now() + c.expire(now) + if action == "stop" && c.desired.Enabled { + c.desired = newPCCommand(false) + } + if now.Sub(c.last) >= pcAudioLease { + c.mu.Unlock() + return json.RawMessage(`{"error":"pc_agent_offline","hint":"Запусти агент Алисы на компьютере Windows."}`) + } + if action == "status" { + data, _ := json.Marshal(map[string]any{"ok": true, "enabled": c.actual.Enabled, "error": c.actual.Error}) + c.mu.Unlock() + return data + } + if c.desired.Enabled != (action == "start") || c.actual.Error != "" { + c.desired = newPCCommand(action == "start") + } + command := c.desired + c.mu.Unlock() + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + c.mu.Lock() + current, actual := c.desired, c.actual + c.mu.Unlock() + if current != command { + return json.RawMessage(`{"error":"pc_audio_command_replaced"}`) + } + if actual.Revision == command.Revision { + if actual.Error != "" || actual.Enabled != command.Enabled { + c.mu.Lock() + if command.Enabled && c.desired == command { + c.desired = newPCCommand(false) + } + c.mu.Unlock() + return json.RawMessage(`{"error":"pc_audio_sender_failed"}`) + } + data, _ := json.Marshal(map[string]any{"ok": true, "enabled": actual.Enabled}) + return data + } + select { + case <-ctx.Done(): + c.mu.Lock() + if command.Enabled && c.desired == command { + c.desired = newPCCommand(false) + } + c.mu.Unlock() + return json.RawMessage(`{"error":"pc_audio_timeout"}`) + case <-ticker.C: + } + } +} + +func pcAudioTool(ctx context.Context, arguments json.RawMessage) json.RawMessage { + var args struct{ Action string } + if json.Unmarshal(arguments, &args) != nil || (args.Action != "start" && args.Action != "stop" && args.Action != "status") { + return json.RawMessage(`{"error":"invalid_pc_audio_action"}`) + } + if windowsAudio == nil { + return json.RawMessage(`{"error":"pc_audio_unconfigured"}`) + } + return windowsAudio.run(ctx, args.Action) +} diff --git a/station/pc_audio_test.go b/station/pc_audio_test.go new file mode 100644 index 0000000..ccffc01 --- /dev/null +++ b/station/pc_audio_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "testing" + "time" + + "alice-back/station/pcaudio" +) + +func TestPCAudioWaitsForAgentAndExpires(t *testing.T) { + const token = "test-token" + control := &pcAudioControl{ + token: token, peer: netip.MustParseAddr("192.0.2.10"), desired: newPCCommand(false), + } + poll := func(state pcaudio.State, key string) (pcaudio.Command, int) { + data, _ := json.Marshal(state) + req := httptest.NewRequest(http.MethodPost, pcaudio.Path, bytes.NewReader(data)) + req.RemoteAddr = "192.0.2.10:42000" + req.Header.Set("Authorization", "Bearer "+key) + response := httptest.NewRecorder() + control.ServeHTTP(response, req) + var cmd pcaudio.Command + if response.Code == http.StatusOK { + if err := json.Unmarshal(response.Body.Bytes(), &cmd); err != nil { + t.Fatal(err) + } + } + return cmd, response.Code + } + if _, code := poll(pcaudio.State{}, "wrong"); code != http.StatusForbidden { + t.Fatalf("accepted unauthorized request: %d", code) + } + if result := control.run(context.Background(), "start"); !strings.Contains(string(result), "offline") { + t.Fatalf("started while the PC was offline: %s", result) + } + initial, _ := poll(pcaudio.State{}, token) + if initial.Enabled { + t.Fatal("initial command enabled audio") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + done := make(chan json.RawMessage, 1) + go func() { done <- control.run(ctx, "start") }() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + seen := initial + for !seen.Enabled { + select { + case <-ctx.Done(): + t.Fatal("start command did not reach the agent") + case <-ticker.C: + seen, _ = poll(pcaudio.State{Command: initial}, token) + } + } + select { + case result := <-done: + t.Fatalf("start reported success before acknowledgment: %s", result) + default: + } + poll(pcaudio.State{Command: seen}, token) + select { + case result := <-done: + if string(result) != `{"enabled":true,"ok":true}` { + t.Fatalf("start result: %s", result) + } + case <-ctx.Done(): + t.Fatal("start acknowledgment was not returned") + } + control.mu.Lock() + control.last = time.Now().Add(-pcAudioLease) + control.mu.Unlock() + expired, _ := poll(pcaudio.State{Command: seen}, token) + if expired.Enabled || expired.Revision == seen.Revision { + t.Fatal("reconnecting agent would automatically restart streaming") + } +} diff --git a/station/pcaudio/protocol.go b/station/pcaudio/protocol.go new file mode 100644 index 0000000..74a7713 --- /dev/null +++ b/station/pcaudio/protocol.go @@ -0,0 +1,13 @@ +package pcaudio + +const Path = "/v1/pc-audio" + +type Command struct { + Revision string `json:"revision"` + Enabled bool `json:"enabled"` +} + +type State struct { + Command + Error string `json:"error,omitempty"` +} diff --git a/station/station-supervisor.sh b/station/station-supervisor.sh index 23f1734..991e02c 100755 --- a/station/station-supervisor.sh +++ b/station/station-supervisor.sh @@ -15,11 +15,12 @@ if [ ! -d /sys/class/gpio/gpio414 ]; then fi fi child= +pc_receiver= timer= stopping=0 stop() { stopping=1 - for p in "$child" "$timer"; do [ -z "$p" ] || kill -TERM "$p" 2>/dev/null || :; done + for p in "$child" "$pc_receiver" "$timer"; do [ -z "$p" ] || kill -TERM "$p" 2>/dev/null || :; done } trap stop TERM INT HUP reset_duck() { @@ -37,17 +38,32 @@ while [ "$stopping" -eq 0 ]; do fi DEVICE_TOKEN=$(cat device-token) export DEVICE_TOKEN + if [ -x ./scream-receiver ] && [ -r pc-audio-receiver.conf ]; then + : >/tmp/alice-pc-audio.log + ( + read -r listen + read -r source + exec ./scream-receiver -listen "$listen" -source "$source" + ) >/tmp/alice-pc-audio.log 2>&1 9>&- & + pc_receiver=$! + fi : > /tmp/alice-station.log ./alice-station -mode wake -voice-threshold 12 -vqe-helper /data/opt/alice-backend/vqe-capture.sh -vqe-wake-model /system/vendor/quasar/spotter_models/activation/alisa >> /tmp/alice-station.log 2>&1 9>&- & child=$! while [ "$stopping" -eq 0 ] && kill -0 "$child" 2>/dev/null; do if [ "$(wc -c < /tmp/alice-station.log)" -gt 524288 ]; then : >/tmp/alice-station.log; fi + if [ -n "$pc_receiver" ] && [ "$(wc -c < /tmp/alice-pc-audio.log)" -gt 524288 ]; then : >/tmp/alice-pc-audio.log; fi sleep 3 & timer=$!; wait "$timer" || :; timer= done wait "$child" || : # A signal interrupts wait; reap the station before releasing the microphone lock. [ "$stopping" -eq 0 ] || wait "$child" 2>/dev/null || : child= + if [ -n "$pc_receiver" ]; then + kill -TERM "$pc_receiver" 2>/dev/null || : + wait "$pc_receiver" 2>/dev/null || : + pc_receiver= + fi [ "$stopping" -ne 0 ] && break sleep 5 & timer=$!; wait "$timer" || :; timer= done