Add a local Scream receiver for Mini 2

This commit is contained in:
2026-11-14 15:08:18 +03:00
parent 122562ea38
commit 00e2c3cd99
2 changed files with 207 additions and 0 deletions
+22
View File
@@ -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.
+185
View File
@@ -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)
}
}