Add a local Scream receiver for Mini 2
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user