From 00e2c3cd99cb752241ff713ee160b036c005ff98 Mon Sep 17 00:00:00 2001 From: chexit Date: Sat, 14 Nov 2026 15:08:18 +0300 Subject: [PATCH] Add a local Scream receiver for Mini 2 --- station/README.md | 22 ++++ station/cmd/scream-receiver/main.go | 185 ++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 station/cmd/scream-receiver/main.go diff --git a/station/README.md b/station/README.md index bcd7337..7875428 100644 --- a/station/README.md +++ b/station/README.md @@ -79,3 +79,25 @@ mount -o remount,ro / ``` Логи текущего запуска находятся в `/tmp/alice-station.log`, `/tmp/alice-pulse.log` и `/tmp/alice-soloist.log`. + +## Звук с Windows по локальной сети + +Для пробного запуска собери приёмник на Mini 2 тем же Go toolchain, что и основной клиент. Из каталога `station` выполни: + +```sh +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o scream-receiver ./cmd/scream-receiver +``` + +Скопируй бинарник на станцию в `/data/opt/alice-backend` и запусти из этого каталога. Укажи адрес станции и адрес Windows. Приёмник работает с запущенным PulseAudio, принимает моно или стерео PCM в пакетах Scream и воспроизводит их через вход `music`. На этот вход действуют Room EQ и приглушение на время разговора. + +```sh +./scream-receiver -listen 192.168.1.135:4010 -source 192.168.1.170 +``` + +На Windows распакуй [Scream Sender](https://github.com/netham45/windows-scream-sender/releases/tag/7508fb7). Запусти из папки с EXE и включи звук на обычном системном выходе. Программа передаёт копию звука этого выхода. + +```powershell +.\ScreamSender.exe 192.168.1.135 4010 +``` + +Приёмник пишет в журнал формат при первом пакете и освобождает вход PulseAudio через две секунды без звука. Базовый запрос к буферу PulseAudio составляет 30 мс. Полная задержка также зависит от Windows, сети и буфера аппаратного выхода. Остановить приёмник в терминале можно через Ctrl+C. diff --git a/station/cmd/scream-receiver/main.go b/station/cmd/scream-receiver/main.go new file mode 100644 index 0000000..07eed49 --- /dev/null +++ b/station/cmd/scream-receiver/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "errors" + "flag" + "log" + "net" + "net/netip" + "os" + "os/exec" + "os/signal" + "strconv" + "syscall" + "time" +) + +const ( + packetBytes = 1157 + headerBytes = 5 + idleTimeout = 2 * time.Second +) + +type format struct { + rate int + channels byte + sample string +} + +func packetFormat(packet []byte) (format, bool) { + if len(packet) != packetBytes || packet[2] < 1 || packet[2] > 2 { + return format{}, false + } + rate := 48000 + if packet[0]&128 != 0 { + rate = 44100 + } + multiple := int(packet[0] & 127) + if multiple < 1 || multiple > 2 { + return format{}, false + } + sample := "" + switch packet[1] { + case 16: + sample = "s16le" + case 24: + sample = "s24le" + case 32: + sample = "s32le" + default: + return format{}, false + } + return format{rate * multiple, packet[2], sample}, true +} + +const pulse = "/data/opt/alice-backend/spotify" + +type playback struct { + cmd *exec.Cmd + input *os.File + format format +} + +func startPlayback(f format, latency int) (*playback, error) { + cmd := exec.Command(pulse+"/run", pulse+"/bin/pacat", "--playback", "--raw", + "--device=music", "--format="+f.sample, "--rate="+strconv.Itoa(f.rate), + "--channels="+strconv.Itoa(int(f.channels)), "--latency-msec="+strconv.Itoa(latency), + "--client-name=PC Audio", "--stream-name=Scream") + cmd.Env = append(os.Environ(), "PULSE_SERVER=unix:/tmp/alice-pulse/native", + "PULSE_RUNTIME_PATH=/tmp/alice-pulse", "PULSE_COOKIE=/tmp/alice-pulse/cookie") + cmd.Stderr = os.Stderr + r, w, err := os.Pipe() + if err != nil { + return nil, err + } + defer r.Close() + cmd.Stdin = r + if err := cmd.Start(); err != nil { + w.Close() + return nil, err + } + return &playback{cmd, w, f}, nil +} + +func (p *playback) stop() { + p.input.Close() + _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() +} + +func receive(conn *net.UDPConn, peer netip.Addr, latency int) error { + if err := conn.SetReadBuffer(16 * 1024); err != nil { + return err + } + var current *playback + stop := func() { + if current != nil { + current.stop() + current = nil + log.Print("PC audio ended") + } + } + defer stop() + packet := make([]byte, packetBytes+1) + var lastSeen time.Time + var retryAfter time.Time + for { + if current != nil { + if err := conn.SetReadDeadline(lastSeen.Add(idleTimeout)); err != nil { + return err + } + } else { + if err := conn.SetReadDeadline(time.Time{}); err != nil { + return err + } + } + n, sender, err := conn.ReadFromUDPAddrPort(packet) + if errors.Is(err, os.ErrDeadlineExceeded) { + stop() + continue + } + if err != nil { + return err + } + if sender.Addr() != peer { + continue + } + f, valid := packetFormat(packet[:n]) + if !valid { + continue + } + lastSeen = time.Now() + if current == nil || current.format != f { + if lastSeen.Before(retryAfter) { + continue + } + stop() + current, err = startPlayback(f, latency) + if err != nil { + log.Print(err) + retryAfter = lastSeen.Add(time.Second) + continue + } + log.Printf("PC audio from %s: %d Hz, %d channel(s), %s", peer, f.rate, f.channels, f.sample) + } + if err := current.input.SetWriteDeadline(lastSeen.Add(60 * time.Millisecond)); err != nil { + return err + } + if _, err := current.input.Write(packet[headerBytes:n]); err != nil { + log.Printf("PulseAudio stalled: %v", err) + stop() + retryAfter = time.Now().Add(time.Second) + } + } +} + +func main() { + address := flag.String("listen", "", "local IPv4 and UDP port") + source := flag.String("source", "", "IPv4 of the sender") + latency := flag.Int("latency-ms", 30, "PulseAudio playback latency request, in milliseconds") + flag.Parse() + peer, err := netip.ParseAddr(*source) + if err != nil || !peer.Is4() || *latency < 10 || *latency > 500 { + log.Fatal("provide a sender IPv4 and a latency between 10 and 500 ms") + } + local, err := netip.ParseAddrPort(*address) + if err != nil || !local.Addr().Is4() || local.Addr().IsUnspecified() || local.Port() == 0 { + log.Fatal("provide an explicit local IPv4 and UDP port") + } + conn, err := net.ListenUDP("udp4", net.UDPAddrFromAddrPort(local)) + if err != nil { + log.Fatal(err) + } + defer conn.Close() + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + go func() { + <-signals + _ = conn.Close() + }() + log.Printf("listening on %s for %s", conn.LocalAddr(), peer) + if err := receive(conn, peer, *latency); err != nil && !errors.Is(err, net.ErrClosed) { + log.Fatalf("receive audio: %v", err) + } +}