From bc7d21603a0b9744ae0e7594f4df60102f2190d8 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:46:45 -0500 Subject: [PATCH 1/3] feat(skychat): voice audio backend (PulseAudio) + terminal spectrogram demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real audio for voice calls on Linux, cgo-free, plus a "see the audio" CLI view. pkg/skychat/voice/pulse_linux.go — capture (Source) + playback (Sink) via github.com/jfreymuth/pulse (pure-Go PulseAudio/PipeWire, no cgo). NewMicSource captures the mic, or the default sink's MONITOR (system output) so a call/demo can carry internal audio with no mic, like a PulseAudio monitor source. Sample rate is a parameter (calls: 48 kHz; spectrogram: 24 kHz). A blocking sample-ring paces the reader at the capture rate; non-linux stub returns ErrAudioUnsupported (malgo Windows/macOS is a follow-up). pkg/skychat/voice/spectrogram — the FFT/magnitude/color core, vendored+adapted from the operator's audioprism-go (pkg/spectrogram). Copied, not imported as a module, because audioprism-go back-imports skywire-utilities (a cycle); its go-dsp fork also drags a broken renamed transitive dep into `go mod tidy`, so the FFT + window functions are reimplemented locally (dsp.go, radix-2) — the package now needs only the stdlib. No replace directives. Unit-tested (sine → correct peak bin). cmd/skywire-cli/.../voice_spectrogram.go — `cli skychat voice spectrogram [--monitor]`: a live scrolling tcell spectrogram of local audio. Deliberately MIRRORS the audioprism-go tcell UI so it looks the same — 24 kHz capture, overlap-stepped FFT columns (one per StepSize), a 2048x1024 circular history downscaled to the terminal, vertical axis 0-12 kHz linear with low freq at the bottom, same MagnitudeToPixel colormap. Test asserts a 3 kHz tone peaks at the matching row. tcell/v2 already vendored. Only new dep: jfreymuth/pulse (pure Go). Validated live: monitor capture of a tone → FFT peak at the right bin, and the tcell view renders (screenshot). Build + lint + tests clean. --- .gitignore | 1 + .../commands/skychat/voice_spectrogram.go | 261 +++++ .../skychat/voice_spectrogram_test.go | 44 + go.mod | 1 + go.sum | 2 + pkg/skychat/voice/pulse_linux.go | 203 ++++ pkg/skychat/voice/pulse_other.go | 20 + pkg/skychat/voice/spectrogram/dsp.go | 108 ++ pkg/skychat/voice/spectrogram/settings.go | 314 ++++++ pkg/skychat/voice/spectrogram/spectrogram.go | 154 +++ .../voice/spectrogram/spectrogram_test.go | 58 + vendor/github.com/jfreymuth/pulse/LICENSE | 21 + vendor/github.com/jfreymuth/pulse/README.md | 18 + vendor/github.com/jfreymuth/pulse/client.go | 157 +++ vendor/github.com/jfreymuth/pulse/doc.go | 2 + vendor/github.com/jfreymuth/pulse/format.go | 183 ++++ vendor/github.com/jfreymuth/pulse/playback.go | 306 ++++++ .../jfreymuth/pulse/proto/client.go | 280 +++++ .../jfreymuth/pulse/proto/connect.go | 155 +++ .../github.com/jfreymuth/pulse/proto/error.go | 93 ++ vendor/github.com/jfreymuth/pulse/proto/op.go | 996 ++++++++++++++++++ .../jfreymuth/pulse/proto/reader.go | 275 +++++ .../github.com/jfreymuth/pulse/proto/types.go | 184 ++++ .../jfreymuth/pulse/proto/version.go | 14 + .../jfreymuth/pulse/proto/writer.go | 208 ++++ vendor/github.com/jfreymuth/pulse/record.go | 225 ++++ vendor/github.com/jfreymuth/pulse/sink.go | 68 ++ vendor/github.com/jfreymuth/pulse/source.go | 68 ++ vendor/github.com/jfreymuth/pulse/stream.go | 11 + vendor/modules.txt | 4 + 30 files changed, 4434 insertions(+) create mode 100644 cmd/skywire-cli/commands/skychat/voice_spectrogram.go create mode 100644 cmd/skywire-cli/commands/skychat/voice_spectrogram_test.go create mode 100644 pkg/skychat/voice/pulse_linux.go create mode 100644 pkg/skychat/voice/pulse_other.go create mode 100644 pkg/skychat/voice/spectrogram/dsp.go create mode 100644 pkg/skychat/voice/spectrogram/settings.go create mode 100644 pkg/skychat/voice/spectrogram/spectrogram.go create mode 100644 pkg/skychat/voice/spectrogram/spectrogram_test.go create mode 100644 vendor/github.com/jfreymuth/pulse/LICENSE create mode 100644 vendor/github.com/jfreymuth/pulse/README.md create mode 100644 vendor/github.com/jfreymuth/pulse/client.go create mode 100644 vendor/github.com/jfreymuth/pulse/doc.go create mode 100644 vendor/github.com/jfreymuth/pulse/format.go create mode 100644 vendor/github.com/jfreymuth/pulse/playback.go create mode 100644 vendor/github.com/jfreymuth/pulse/proto/client.go create mode 100644 vendor/github.com/jfreymuth/pulse/proto/connect.go create mode 100644 vendor/github.com/jfreymuth/pulse/proto/error.go create mode 100644 vendor/github.com/jfreymuth/pulse/proto/op.go create mode 100644 vendor/github.com/jfreymuth/pulse/proto/reader.go create mode 100644 vendor/github.com/jfreymuth/pulse/proto/types.go create mode 100644 vendor/github.com/jfreymuth/pulse/proto/version.go create mode 100644 vendor/github.com/jfreymuth/pulse/proto/writer.go create mode 100644 vendor/github.com/jfreymuth/pulse/record.go create mode 100644 vendor/github.com/jfreymuth/pulse/sink.go create mode 100644 vendor/github.com/jfreymuth/pulse/source.go create mode 100644 vendor/github.com/jfreymuth/pulse/stream.go diff --git a/.gitignore b/.gitignore index 4e662c6e37..17dd6d2bc9 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,4 @@ skysocks-client !cmd/wasm-visor/cacert.pem +/skywire-cli diff --git a/cmd/skywire-cli/commands/skychat/voice_spectrogram.go b/cmd/skywire-cli/commands/skychat/voice_spectrogram.go new file mode 100644 index 0000000000..baaa660cc0 --- /dev/null +++ b/cmd/skywire-cli/commands/skychat/voice_spectrogram.go @@ -0,0 +1,261 @@ +// Package cliskychat cmd/skywire-cli/commands/skychat/voice_spectrogram.go c4-vis-cli +// +// `skywire-cli skychat voice spectrogram` renders a live audio spectrogram in +// the terminal — the CLI-side "see the audio" view for voice chat (stands in for +// video). It captures local audio via the pure-Go PulseAudio backend (mic, or +// --monitor for whatever the system is playing, no mic needed) and feeds it to +// the vendored audioprism DSP core. Linux only; press q / Esc / Ctrl-C to quit. +// +// The rendering deliberately MIRRORS the operator's audioprism-go tcell UI +// (github.com/0magnet/audioprism-go/pkg/ui/tcell) so this view looks the same: +// capture at spectrogram.SampleRate, overlap-stepped FFT columns (one per +// StepSize samples), a fixed 2048×1024 circular history downscaled to the +// terminal, the vertical axis mapped linearly to 0–12 kHz with low frequencies +// at the bottom, and the same MagnitudeToPixel colormap. The same renderer feeds +// the two-panel sent/received view during an actual call (follow-up). +package cliskychat + +import ( + "image/color" + "sync" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/spf13/cobra" + + "github.com/skycoin/skywire/cmd/skywire-cli/cliutil" + skyvoice "github.com/skycoin/skywire/pkg/skychat/voice" + "github.com/skycoin/skywire/pkg/skychat/voice/spectrogram" +) + +// Fixed history resolution (independent of terminal size), matching audioprism. +const ( + specBufferWidth = 2048 + specBufferHeight = 1024 + specMaxFreqHz = 12000 // vertical axis spans 0..12 kHz, like audioprism +) + +var voiceSpectrogramMonitor bool + +func init() { + voiceSpectrogramCmd.Flags().BoolVar(&voiceSpectrogramMonitor, "monitor", false, + "capture the system output (default sink monitor) instead of the microphone — visualize whatever audio is playing, no mic needed") + voiceCmd.AddCommand(voiceSpectrogramCmd) +} + +var voiceSpectrogramCmd = &cobra.Command{ + Use: "spectrogram", + Short: "Live audio spectrogram in the terminal (mic, or --monitor for system audio)", + Long: `Render a live audio spectrogram in the terminal. + +Captures local audio (the microphone by default, or the system output with +--monitor) and draws a scrolling spectrogram — the CLI stand-in for a video +feed on a voice call. Matches the audioprism-go tcell view. Linux only +(PulseAudio/PipeWire). Press q / Esc / Ctrl-C to quit.`, + Run: func(cmd *cobra.Command, _ []string) { + // Capture at the spectrogram's native rate so the frequency mapping and + // magnitude scaling match audioprism-go exactly. + src, err := skyvoice.NewMicSource(voiceSpectrogramMonitor, spectrogram.SampleRate) + if err != nil { + cliutil.PrintFatalError(cmd.Flags(), err) + } + defer func() { _ = closeSource(src) }() //nolint:errcheck + if err := runSpectrogramTUI(src); err != nil { + cliutil.PrintFatalError(cmd.Flags(), err) + } + }, +} + +// specView holds the scrolling spectrogram state (audioprism's model). +type specView struct { + mu sync.RWMutex + history [][]color.Color // [specBufferWidth][specBufferHeight] circular buffer + head int // next write index + queueMu sync.Mutex + queue [][]color.Color // columns computed but not yet folded into history + overlap []float32 // sliding FFT window + pending []float32 // samples not yet stepped through + dftSize int +} + +func newSpecView() *specView { + v := &specView{dftSize: spectrogram.S.GetDFTSize()} + v.overlap = make([]float32, v.dftSize) + v.history = make([][]color.Color, specBufferWidth) + for i := range v.history { + v.history[i] = make([]color.Color, specBufferHeight) + for j := range v.history[i] { + v.history[i][j] = color.Black + } + } + return v +} + +// push accumulates captured samples and emits a spectrogram column every +// StepSize samples (overlap-stepped FFT), matching audioprism's processAudioThread. +func (v *specView) push(samples []float32) { + v.pending = append(v.pending, samples...) + step := spectrogram.S.StepSize() + if step <= 0 { + step = v.dftSize + } + for len(v.pending) >= step && len(v.pending) >= v.dftSize-step { + // Slide the FFT window: shift left by step, append the next samples — + // exactly audioprism-go's processAudioThread overlap step. + copy(v.overlap, v.overlap[step:]) + copy(v.overlap[step:], v.pending[:v.dftSize-step]) + v.pending = v.pending[step:] + + mags := spectrogram.ComputeFFT(v.overlap) + col := make([]color.Color, specBufferHeight) + for y := 0; y < specBufferHeight; y++ { + freq := float64(y) / float64(specBufferHeight) * specMaxFreqHz + bin := int(freq * float64(v.dftSize) / float64(spectrogram.SampleRate)) + if bin < len(mags) { + col[y] = spectrogram.MagnitudeToPixel(mags[bin]) + } else { + col[y] = color.Black + } + } + v.queueMu.Lock() + v.queue = append(v.queue, col) + if len(v.queue) > 200 { + v.queue = v.queue[len(v.queue)-100:] + } + v.queueMu.Unlock() + } +} + +// drainInto folds all queued columns into the circular history. +func (v *specView) drainInto() { + v.queueMu.Lock() + q := v.queue + v.queue = nil + v.queueMu.Unlock() + if len(q) == 0 { + return + } + v.mu.Lock() + for _, col := range q { + v.history[v.head] = col + v.head = (v.head + 1) % specBufferWidth + } + v.mu.Unlock() +} + +// draw renders the most recent w columns, scaling specBufferHeight to h with low +// frequencies at the bottom (audioprism's drawSpectrogram). +func (v *specView) draw(screen tcell.Screen, w, h int) { + v.mu.RLock() + defer v.mu.RUnlock() + start := (v.head - w + specBufferWidth) % specBufferWidth + for x := 0; x < w; x++ { + idx := (start + x) % specBufferWidth + for y := 0; y < h; y++ { + by := int(float64(y) / float64(h) * float64(specBufferHeight)) + if by >= specBufferHeight { + continue + } + r, g, b, _ := v.history[idx][by].RGBA() + style := tcell.StyleDefault.Background(tcell.NewRGBColor(int32(r>>8), int32(g>>8), int32(b>>8))) + screen.SetContent(x, h-1-y, ' ', nil, style) + } + } +} + +// runSpectrogramTUI drives the tcell screen: an audio goroutine steps FFT columns +// into a queue; the render loop (~60fps) folds them into history and repaints. +func runSpectrogramTUI(src skyvoice.Source) error { + screen, err := tcell.NewScreen() + if err != nil { + return err + } + if err := screen.Init(); err != nil { + return err + } + defer screen.Fini() + screen.Clear() + + view := newSpecView() + quit := make(chan struct{}) + + // Audio reader → step columns. + go func() { + frame := make([]int16, spectrogram.S.StepSize()) + fbuf := make([]float32, len(frame)) + for { + select { + case <-quit: + return + default: + } + n, rerr := src.Read(frame) + if rerr != nil { + return + } + for i := 0; i < n; i++ { + fbuf[i] = float32(frame[i]) / 32768.0 + } + view.push(fbuf[:n]) + } + }() + + // Event reader. + go func() { + for { + switch ev := screen.PollEvent().(type) { + case *tcell.EventKey: + if ev.Key() == tcell.KeyEscape || ev.Key() == tcell.KeyCtrlC || + ev.Rune() == 'q' || ev.Rune() == 'Q' { + close(quit) + return + } + case *tcell.EventResize: + screen.Sync() + } + } + }() + + ticker := time.NewTicker(time.Second / 60) + defer ticker.Stop() + for { + select { + case <-quit: + return nil + case <-ticker.C: + } + w, h := screen.Size() + if w <= 0 || h <= 1 { + continue + } + specH := h - 1 // bottom row = hint + view.drainInto() + screen.Clear() + view.draw(screen, w, specH) + drawHint(screen, h-1, w, voiceSpectrogramMonitor) + screen.Show() + } +} + +func closeSource(src skyvoice.Source) error { + if c, ok := src.(interface{ Close() error }); ok { + return c.Close() + } + return nil +} + +func drawHint(screen tcell.Screen, y, w int, monitor bool) { + src := "mic" + if monitor { + src = "system audio (monitor)" + } + hint := []rune(" skychat voice spectrogram — source: " + src + " — q/Esc to quit ") + st := tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) + for x := 0; x < w; x++ { + r := ' ' + if x < len(hint) { + r = hint[x] + } + screen.SetContent(x, y, r, nil, st) + } +} diff --git a/cmd/skywire-cli/commands/skychat/voice_spectrogram_test.go b/cmd/skywire-cli/commands/skychat/voice_spectrogram_test.go new file mode 100644 index 0000000000..4b9a96721b --- /dev/null +++ b/cmd/skywire-cli/commands/skychat/voice_spectrogram_test.go @@ -0,0 +1,44 @@ +// Package cliskychat cmd/skywire-cli/commands/skychat/voice_spectrogram_test.go c4-vis-cli +package cliskychat + +import ( + "math" + "testing" + + "github.com/skycoin/skywire/pkg/skychat/voice/spectrogram" +) + +// TestSpectrogramColumnFreqPosition verifies the column mapping matches +// audioprism-go's tcell model: the vertical axis is 0..12 kHz linear (bin = +// freq*fftSize/SampleRate), so a pure tone lights up the row at +// freq/12000 * bufferHeight. A 3 kHz tone (at the 24 kHz spectrogram rate) must +// peak ~1/4 of the way up the fixed buffer. +func TestSpectrogramColumnFreqPosition(t *testing.T) { + v := newSpecView() + const ( + rate = spectrogram.SampleRate // 24000 + freq = 3000.0 + ) + samp := make([]float32, 4096) + for i := range samp { + samp[i] = float32(math.Sin(2 * math.Pi * freq * float64(i) / rate)) + } + v.push(samp) + v.drainInto() + + v.mu.RLock() + col := v.history[(v.head-1+specBufferWidth)%specBufferWidth] + v.mu.RUnlock() + + peak, best := 0, -1.0 + for y, c := range col { + r, g, b, _ := c.RGBA() + if lum := float64(r) + float64(g) + float64(b); lum > best { + best, peak = lum, y + } + } + want := int(freq / specMaxFreqHz * specBufferHeight) // 3000/12000 * 1024 = 256 + if d := peak - want; d < -specBufferHeight/20 || d > specBufferHeight/20 { + t.Fatalf("brightest row %d, want ~%d (freq→row mapping doesn't match audioprism)", peak, want) + } +} diff --git a/go.mod b/go.mod index 56f9401d61..56e2f79d22 100644 --- a/go.mod +++ b/go.mod @@ -73,6 +73,7 @@ require ( github.com/gizak/termui/v3 v3.1.0 github.com/hanwen/go-fuse/v2 v2.10.1 github.com/itchyny/gojq v0.12.19 + github.com/jfreymuth/pulse v0.1.1 github.com/kr/pretty v0.3.1 github.com/peterh/liner v1.2.2 github.com/pgavlin/femto v0.0.0-20201224065653-0c9d20f9cac4 diff --git a/go.sum b/go.sum index a236c024dd..9a551d8446 100644 --- a/go.sum +++ b/go.sum @@ -506,6 +506,8 @@ github.com/jaypipes/ghw v0.24.0/go.mod h1:Qk3UjdH8Xu/OiVyb/eDJqnDsUc+awHU75y23Er github.com/jaypipes/pcidb v1.1.1 h1:QmPhpsbmmnCwZmHeYAATxEaoRuiMAJusKYkUncMC0ro= github.com/jaypipes/pcidb v1.1.1/go.mod h1:x27LT2krrUgjf875KxQXKB0Ha/YXLdZRVmw6hH0G7g8= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jfreymuth/pulse v0.1.1 h1:9WLNBNCijmtZ14ZJpatgJPu/NjwAl3TIKItSFnTh+9A= +github.com/jfreymuth/pulse v0.1.1/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= diff --git a/pkg/skychat/voice/pulse_linux.go b/pkg/skychat/voice/pulse_linux.go new file mode 100644 index 0000000000..b479cbabeb --- /dev/null +++ b/pkg/skychat/voice/pulse_linux.go @@ -0,0 +1,203 @@ +//go:build linux + +// Package voice pkg/skychat/voice/pulse_linux.go c2-app-chat +// +// PulseAudio/PipeWire capture + playback for skychat voice, via +// github.com/jfreymuth/pulse — a PURE-GO PulseAudio client (no cgo, no system +// libs to link; it just needs a running PulseAudio/PipeWire at runtime). This is +// the Linux audio backend; a cgo malgo backend for Windows/macOS is a follow-up. +// +// NewMicSource(monitor=false) captures the default microphone. NewMicSource( +// monitor=true) captures the default sink's MONITOR — i.e. whatever audio the +// system is playing — so a call (or the spectrogram demo) can carry internal +// audio with no mic plugged in, the way `pactl`/PulseAudio monitor sources work. +package voice + +import ( + "fmt" + "io" + "sync" + + "github.com/jfreymuth/pulse" +) + +// sampleRing is a bounded, thread-safe int16 FIFO bridging pulse's push-model +// capture callback (and pull-model playback callback) to the voice package's +// pull-model Source / push-model Sink. On overflow it keeps the NEWEST samples +// (drop-oldest) so latency can't grow unbounded. +// +// popBlocking (capture) waits for a full frame so a reader is naturally paced by +// the capture rate rather than spinning and draining silence. popSilence +// (playback) never blocks — pulse needs samples immediately and gets silence on +// underrun. +type sampleRing struct { + mu sync.Mutex + cond *sync.Cond + buf []int16 + max int + closed bool +} + +func newSampleRing(max int) *sampleRing { + r := &sampleRing{max: max} + r.cond = sync.NewCond(&r.mu) + return r +} + +func (r *sampleRing) push(s []int16) { + r.mu.Lock() + r.buf = append(r.buf, s...) + if len(r.buf) > r.max { + r.buf = append(r.buf[:0], r.buf[len(r.buf)-r.max:]...) + } + r.cond.Broadcast() + r.mu.Unlock() +} + +// popBlocking fills dst, waiting until a full frame is buffered (or the ring is +// closed, after which it pads silence and returns). +func (r *sampleRing) popBlocking(dst []int16) int { + r.mu.Lock() + defer r.mu.Unlock() + for len(r.buf) < len(dst) && !r.closed { + r.cond.Wait() + } + return r.drainLocked(dst) +} + +// popSilence fills dst with whatever is buffered, padding the tail with silence, +// never blocking. +func (r *sampleRing) popSilence(dst []int16) int { + r.mu.Lock() + defer r.mu.Unlock() + return r.drainLocked(dst) +} + +func (r *sampleRing) drainLocked(dst []int16) int { + n := copy(dst, r.buf) + r.buf = append(r.buf[:0], r.buf[n:]...) + for i := n; i < len(dst); i++ { + dst[i] = 0 + } + return len(dst) +} + +func (r *sampleRing) close() { + r.mu.Lock() + r.closed = true + r.cond.Broadcast() + r.mu.Unlock() +} + +// pulseSource is a voice.Source backed by a PulseAudio record stream. +type pulseSource struct { + client *pulse.Client + stream *pulse.RecordStream + ring *sampleRing +} + +// NewMicSource opens a capture Source at the package sample rate (mono). When +// monitor is true it records the default sink's monitor (system output) instead +// of the microphone. The returned Source also implements io.Closer. +func NewMicSource(monitor bool, rate int) (Source, error) { + if rate <= 0 { + rate = sampleRate + } + c, err := pulse.NewClient() + if err != nil { + return nil, fmt.Errorf("voice: pulse connect: %w", err) + } + ring := newSampleRing(rate) // ~1s cap + opts := []pulse.RecordOption{pulse.RecordSampleRate(rate), pulse.RecordLatency(0.05)} + if monitor { + sink, serr := c.DefaultSink() + if serr != nil { + c.Close() + return nil, fmt.Errorf("voice: default sink for monitor capture: %w", serr) + } + opts = append(opts, pulse.RecordMonitor(sink)) + } + st, err := c.NewRecord(pulse.Int16Writer(func(s []int16) (int, error) { + ring.push(s) + return len(s), nil + }), opts...) + if err != nil { + c.Close() + return nil, fmt.Errorf("voice: open record stream: %w", err) + } + st.Start() + return &pulseSource{client: c, stream: st, ring: ring}, nil +} + +// Read implements Source (pulls the next PCM frame; silence when quiet). +func (p *pulseSource) Read(pcm []int16) (int, error) { return p.ring.popBlocking(pcm), nil } + +// Close stops and releases the capture stream. +func (p *pulseSource) Close() error { + p.ring.close() // unblock any waiting Read + if p.stream != nil { + p.stream.Stop() + p.stream.Close() + } + if p.client != nil { + p.client.Close() + } + return nil +} + +// pulseSink is a voice.Sink backed by a PulseAudio playback stream. +type pulseSink struct { + client *pulse.Client + stream *pulse.PlaybackStream + ring *sampleRing +} + +// NewSpeakerSink opens a playback Sink at the package sample rate (mono). The +// returned Sink also implements io.Closer. +func NewSpeakerSink(rate int) (Sink, error) { + if rate <= 0 { + rate = sampleRate + } + c, err := pulse.NewClient() + if err != nil { + return nil, fmt.Errorf("voice: pulse connect: %w", err) + } + ring := newSampleRing(rate) + st, err := c.NewPlayback(pulse.Int16Reader(func(s []int16) (int, error) { + ring.popSilence(s) + return len(s), nil + }), pulse.PlaybackSampleRate(sampleRate), pulse.PlaybackLatency(0.05)) + if err != nil { + c.Close() + return nil, fmt.Errorf("voice: open playback stream: %w", err) + } + st.Start() + return &pulseSink{client: c, stream: st, ring: ring}, nil +} + +// Write implements Sink (queues a PCM frame for playback). +func (p *pulseSink) Write(pcm []int16) (int, error) { + p.ring.push(pcm) + return len(pcm), nil +} + +// Close drains and releases the playback stream. +func (p *pulseSink) Close() error { + if p.stream != nil { + p.stream.Drain() + p.stream.Stop() + p.stream.Close() + } + if p.client != nil { + p.client.Close() + } + return nil +} + +// compile-time interface checks. +var ( + _ Source = (*pulseSource)(nil) + _ Sink = (*pulseSink)(nil) + _ io.Closer = (*pulseSource)(nil) + _ io.Closer = (*pulseSink)(nil) +) diff --git a/pkg/skychat/voice/pulse_other.go b/pkg/skychat/voice/pulse_other.go new file mode 100644 index 0000000000..fcd8d01f1e --- /dev/null +++ b/pkg/skychat/voice/pulse_other.go @@ -0,0 +1,20 @@ +//go:build !linux + +// Package voice pkg/skychat/voice/pulse_other.go c2-app-chat +// +// Non-Linux stub for the PulseAudio backend (pulse_linux.go). Real audio +// capture/playback on Windows/macOS is a cgo malgo follow-up; until then these +// return an error so callers degrade gracefully to silent audio. +package voice + +import "errors" + +// ErrAudioUnsupported is returned by the audio-device constructors on platforms +// without a native backend yet. +var ErrAudioUnsupported = errors.New("voice: live audio capture/playback not supported on this platform yet (Linux/PulseAudio only)") + +// NewMicSource is unavailable off Linux. +func NewMicSource(_ bool, _ int) (Source, error) { return nil, ErrAudioUnsupported } + +// NewSpeakerSink is unavailable off Linux. +func NewSpeakerSink(_ int) (Sink, error) { return nil, ErrAudioUnsupported } diff --git a/pkg/skychat/voice/spectrogram/dsp.go b/pkg/skychat/voice/spectrogram/dsp.go new file mode 100644 index 0000000000..d04cf0e2a4 --- /dev/null +++ b/pkg/skychat/voice/spectrogram/dsp.go @@ -0,0 +1,108 @@ +// Package spectrogram pkg/skychat/voice/spectrogram/dsp.go c2-app-chat +// +// Self-contained DSP so the spectrogram core needs only the standard library — +// no external FFT/window module. This replaces the github.com/0magnet/go-dsp +// fft + window packages the upstream audioprism-go uses; that fork pulls a broken +// renamed transitive dep (mjibson→madelynnblue go-dsp) into `go mod tidy`, and +// skywire avoids replace directives. The window functions and the radix-2 FFT +// below are textbook and produce the same magnitude spectrum for the power-of-two +// DFT sizes the spectrogram uses. +package spectrogram + +import ( + "math" + "math/cmplx" +) + +// hann / hamming / bartlett / rectangular return length-n window coefficients. +func hann(n int) []float64 { + w := make([]float64, n) + if n == 1 { + w[0] = 1 + return w + } + for i := range w { + w[i] = 0.5 - 0.5*math.Cos(2*math.Pi*float64(i)/float64(n-1)) + } + return w +} + +func hamming(n int) []float64 { + w := make([]float64, n) + if n == 1 { + w[0] = 1 + return w + } + for i := range w { + w[i] = 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1)) + } + return w +} + +func bartlett(n int) []float64 { + w := make([]float64, n) + if n == 1 { + w[0] = 1 + return w + } + half := float64(n-1) / 2 + for i := range w { + w[i] = 1 - math.Abs((float64(i)-half)/half) + } + return w +} + +func rectangular(n int) []float64 { + w := make([]float64, n) + for i := range w { + w[i] = 1 + } + return w +} + +// fftReal computes the DFT of a real signal via iterative radix-2 Cooley-Tukey, +// zero-padding to the next power of two. Returns the complex spectrum (length = +// the padded size); the caller uses the first half for magnitudes. +func fftReal(x []float64) []complex128 { + m := 1 + for m < len(x) { + m <<= 1 + } + a := make([]complex128, m) + for i, v := range x { + a[i] = complex(v, 0) + } + fftInPlace(a) + return a +} + +// fftInPlace runs an in-place radix-2 FFT on a (len must be a power of two). +func fftInPlace(a []complex128) { + n := len(a) + // Bit-reversal permutation. + for i, j := 1, 0; i < n; i++ { + bit := n >> 1 + for ; j&bit != 0; bit >>= 1 { + j ^= bit + } + j ^= bit + if i < j { + a[i], a[j] = a[j], a[i] + } + } + // Butterfly stages. + for length := 2; length <= n; length <<= 1 { + ang := -2 * math.Pi / float64(length) + wl := cmplx.Rect(1, ang) + for i := 0; i < n; i += length { + w := complex(1, 0) + for k := 0; k < length/2; k++ { + u := a[i+k] + v := a[i+k+length/2] * w + a[i+k] = u + v + a[i+k+length/2] = u - v + w *= wl + } + } + } +} diff --git a/pkg/skychat/voice/spectrogram/settings.go b/pkg/skychat/voice/spectrogram/settings.go new file mode 100644 index 0000000000..8bbc339047 --- /dev/null +++ b/pkg/skychat/voice/spectrogram/settings.go @@ -0,0 +1,314 @@ +package spectrogram + +import "sync" + +// ColorScheme selects the colormap for spectrogram rendering +type ColorScheme int + +const ( + ColorHeat ColorScheme = iota + ColorBlue + ColorGrayscale +) + +// WindowFunc selects the FFT window function +type WindowFunc int + +const ( + WindowHann WindowFunc = iota + WindowHamming + WindowBartlett + WindowRectangular +) + +// Scale selects the magnitude scale +type Scale int + +const ( + ScaleLog Scale = iota + ScaleLinear +) + +// Settings holds all configurable spectrogram parameters +type Settings struct { + mu sync.RWMutex + Color ColorScheme + Window WindowFunc + Mag Scale + MagMin float64 + MagMax float64 + DFTSize int + Overlap float64 +} + +// S is the package-level settings instance used by all UIs +var S = DefaultSettings() + +// DefaultSettings returns settings matching the original audioprism defaults +func DefaultSettings() *Settings { + return &Settings{ + Color: ColorHeat, + Window: WindowHann, + Mag: ScaleLog, + MagMin: 0.0, + MagMax: 45.0, + DFTSize: 1024, + Overlap: 0.50, + } +} + +// StepSize returns the number of samples to advance per FFT frame +func (s *Settings) StepSize() int { + s.mu.RLock() + defer s.mu.RUnlock() + return int(float64(s.DFTSize) * (1.0 - s.Overlap)) +} + +// GetDFTSize returns the current DFT size +func (s *Settings) GetDFTSize() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.DFTSize +} + +// GetOverlap returns the current overlap ratio +func (s *Settings) GetOverlap() float64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.Overlap +} + +// SetColorByName sets the color scheme from a string +func (s *Settings) SetColorByName(name string) { + s.mu.Lock() + defer s.mu.Unlock() + switch name { + case "heat": + s.Color = ColorHeat + case "blue": + s.Color = ColorBlue + case "grayscale", "gray": + s.Color = ColorGrayscale + } +} + +// SetWindowByName sets the window function from a string +func (s *Settings) SetWindowByName(name string) { + s.mu.Lock() + defer s.mu.Unlock() + switch name { + case "hann": + s.Window = WindowHann + case "hamming": + s.Window = WindowHamming + case "bartlett": + s.Window = WindowBartlett + case "rectangular", "rect": + s.Window = WindowRectangular + } +} + +// SetScaleByName sets the magnitude scale from a string +func (s *Settings) SetScaleByName(name string) { + s.mu.Lock() + defer s.mu.Unlock() + switch name { + case "log", "logarithmic": + s.Mag = ScaleLog + case "linear": + s.Mag = ScaleLinear + } +} + +// SetMagMin sets the magnitude minimum +func (s *Settings) SetMagMin(v float64) { + s.mu.Lock() + defer s.mu.Unlock() + s.MagMin = v +} + +// SetMagMax sets the magnitude maximum +func (s *Settings) SetMagMax(v float64) { + s.mu.Lock() + defer s.mu.Unlock() + s.MagMax = v +} + +// SetDFTSize sets the DFT size (clamped to power of 2, 64-8192) +func (s *Settings) SetDFTSize(n int) { + s.mu.Lock() + defer s.mu.Unlock() + if n < 64 { + n = 64 + } + if n > 8192 { + n = 8192 + } + // Round to nearest power of 2 + p := 64 + for p < n { + p *= 2 + } + s.DFTSize = p +} + +// SetOverlap sets the overlap ratio (clamped to 0.05-0.95) +func (s *Settings) SetOverlap(v float64) { + s.mu.Lock() + defer s.mu.Unlock() + if v < 0.05 { + v = 0.05 + } + if v > 0.95 { + v = 0.95 + } + s.Overlap = v +} + +// CycleColor cycles to the next color scheme +func (s *Settings) CycleColor() { + s.mu.Lock() + defer s.mu.Unlock() + s.Color = (s.Color + 1) % 3 +} + +// CycleWindow cycles to the next window function +func (s *Settings) CycleWindow() { + s.mu.Lock() + defer s.mu.Unlock() + s.Window = (s.Window + 1) % 4 +} + +// ToggleScale toggles between log and linear magnitude scale +func (s *Settings) ToggleScale() { + s.mu.Lock() + defer s.mu.Unlock() + if s.Mag == ScaleLog { + s.Mag = ScaleLinear + s.MagMin = 0.0 + s.MagMax = 1000.0 + } else { + s.Mag = ScaleLog + s.MagMin = 0.0 + s.MagMax = 45.0 + } +} + +// AdjustMin adjusts the magnitude minimum +func (s *Settings) AdjustMin(delta float64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.Mag == ScaleLog { + s.MagMin += delta * 5.0 + if s.MagMin < -80 { + s.MagMin = -80 + } + if s.MagMin > 80 { + s.MagMin = 80 + } + } else { + s.MagMin += delta * 25.0 + if s.MagMin < 0 { + s.MagMin = 0 + } + if s.MagMin > 1000 { + s.MagMin = 1000 + } + } +} + +// AdjustMax adjusts the magnitude maximum +func (s *Settings) AdjustMax(delta float64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.Mag == ScaleLog { + s.MagMax += delta * 5.0 + if s.MagMax < -80 { + s.MagMax = -80 + } + if s.MagMax > 80 { + s.MagMax = 80 + } + } else { + s.MagMax += delta * 25.0 + if s.MagMax < 0 { + s.MagMax = 0 + } + if s.MagMax > 1000 { + s.MagMax = 1000 + } + } +} + +// DoubleFFTSize doubles the DFT size (up to 8192) +func (s *Settings) DoubleFFTSize() { + s.mu.Lock() + defer s.mu.Unlock() + if s.DFTSize < 8192 { + s.DFTSize *= 2 + } + s.Overlap = 0.50 +} + +// HalveFFTSize halves the DFT size (down to 64) +func (s *Settings) HalveFFTSize() { + s.mu.Lock() + defer s.mu.Unlock() + if s.DFTSize > 64 { + s.DFTSize /= 2 + } + s.Overlap = 0.50 +} + +// AdjustOverlap adjusts overlap by a delta (clamped to 0.05-0.95) +func (s *Settings) AdjustOverlap(delta float64) { + s.mu.Lock() + defer s.mu.Unlock() + s.Overlap += delta + if s.Overlap < 0.05 { + s.Overlap = 0.05 + } + if s.Overlap > 0.95 { + s.Overlap = 0.95 + } +} + +// ColorName returns the name of the current color scheme +func (s *Settings) ColorName() string { + s.mu.RLock() + defer s.mu.RUnlock() + switch s.Color { + case ColorBlue: + return "blue" + case ColorGrayscale: + return "grayscale" + default: + return "heat" + } +} + +// WindowName returns the name of the current window function +func (s *Settings) WindowName() string { + s.mu.RLock() + defer s.mu.RUnlock() + switch s.Window { + case WindowHamming: + return "hamming" + case WindowBartlett: + return "bartlett" + case WindowRectangular: + return "rectangular" + default: + return "hann" + } +} + +// ScaleName returns the name of the current magnitude scale +func (s *Settings) ScaleName() string { + s.mu.RLock() + defer s.mu.RUnlock() + if s.Mag == ScaleLinear { + return "linear" + } + return "logarithmic" +} diff --git a/pkg/skychat/voice/spectrogram/spectrogram.go b/pkg/skychat/voice/spectrogram/spectrogram.go new file mode 100644 index 0000000000..dbeafb579b --- /dev/null +++ b/pkg/skychat/voice/spectrogram/spectrogram.go @@ -0,0 +1,154 @@ +// Package spectrogram pkg/skychat/voice/spectrogram/spectrogram.go c2-app-chat +// +// Spectrogram DSP core — computes an FFT magnitude spectrum from PCM audio and +// maps magnitudes to colors, for visualizing skychat voice audio (sent/received) +// in a terminal or the browser, in lieu of video. +// +// Vendored (adapted) from the operator's audioprism-go project: +// +// github.com/0magnet/audioprism-go/pkg/spectrogram +// +// Copied rather than imported as a module because audioprism-go's module graph +// back-imports skywire-utilities (a cycle). The upstream FFT/window come from a +// go-dsp fork that drags a broken renamed transitive dep into `go mod tidy`, so +// those are reimplemented locally in dsp.go — this package now needs only the +// standard library. Keep the magnitude/color logic in sync with upstream. +package spectrogram + +import ( + "image/color" + "math" + "math/cmplx" +) + +// Default constants (kept for backward compatibility) +const FFTSize = 1024 +const SampleRate = 24000 +const Overlap = 0.50 +const StepSize = int(FFTSize * (1.0 - Overlap)) + +// SetSingleThreaded is a no-op: the local radix-2 FFT (dsp.go) is already +// synchronous. Kept for API compatibility with upstream audioprism-go. +func SetSingleThreaded() {} + +// Normalize normalizes the value between the given range. +func Normalize(value, minVal, maxVal float64) float64 { + return (math.Max(math.Min(value, maxVal), minVal) - minVal) / (maxVal - minVal) +} + +// ValueToPixelHeat converts a normalized value to a color based on a heatmap. +func ValueToPixelHeat(value float64) color.Color { + var r, g, b uint8 + + if value < 1.0/5.0 { + b = uint8(255.0 * Normalize(value, 0.0, 1.0/5.0)) + } else if value < 2.0/5.0 { + c := uint8(255.0 * Normalize(value, 1.0/5.0, 2.0/5.0)) + r = 0 + g = c + b = 255 - c + } else if value < 3.0/5.0 { + r = uint8(255.0 * Normalize(value, 2.0/5.0, 3.0/5.0)) + g = 255 + b = 0 + } else if value < 4.0/5.0 { + r = 255 + g = uint8(255 - 255.0*Normalize(value, 3.0/5.0, 4.0/5.0)) + b = 0 + } else { + c := uint8(255.0 * Normalize(value, 4.0/5.0, 1.0)) + r = 255 + g = c + b = c + } + + return color.RGBA{r, g, b, 255} +} + +// ValueToPixelBlue converts a normalized value to a color based on a blue gradient. +func ValueToPixelBlue(value float64) color.Color { + var r, g, b uint8 + + if value < 0.5 { + b = uint8(255.0 * Normalize(value, 0.0, 0.5)) + } else { + c := uint8(255.0 * Normalize(value, 0.5, 1.0)) + r = c + g = c + b = 255 + } + + return color.RGBA{r, g, b, 255} +} + +// ValueToPixelGrayscale converts a normalized value to a grayscale color. +func ValueToPixelGrayscale(value float64) color.Color { + c := uint8(255.0 * value) + return color.RGBA{c, c, c, 255} +} + +// MagnitudeToPixel converts a magnitude value to a pixel color using default settings. +func MagnitudeToPixel(value float64) color.Color { + return MagnitudeToPixelWith(value, S) +} + +// MagnitudeToPixelWith converts a magnitude value to a pixel color using the given settings. +func MagnitudeToPixelWith(value float64, s *Settings) color.Color { + s.mu.RLock() + scale := s.Mag + magMin := s.MagMin + magMax := s.MagMax + cs := s.Color + s.mu.RUnlock() + + if scale == ScaleLog { + value = 20 * math.Log10(value+1e-10) + } + + normalized := Normalize(value, magMin, magMax) + + switch cs { + case ColorBlue: + return ValueToPixelBlue(normalized) + case ColorGrayscale: + return ValueToPixelGrayscale(normalized) + default: + return ValueToPixelHeat(normalized) + } +} + +// ComputeFFT computes the FFT of the input and returns the magnitudes using default settings. +func ComputeFFT(input []float32) []float64 { + return ComputeFFTWith(input, S) +} + +// ComputeFFTWith computes the FFT using the window function from the given settings. +func ComputeFFTWith(input []float32, s *Settings) []float64 { + s.mu.RLock() + wf := s.Window + s.mu.RUnlock() + + var win []float64 + switch wf { + case WindowHamming: + win = hamming(len(input)) + case WindowBartlett: + win = bartlett(len(input)) + case WindowRectangular: + win = rectangular(len(input)) + default: + win = hann(len(input)) + } + + windowedBuffer := make([]float64, len(input)) + for i := 0; i < len(input); i++ { + windowedBuffer[i] = float64(input[i]) * win[i] + } + + spectrum := fftReal(windowedBuffer) + magnitudes := make([]float64, len(spectrum)/2) + for i := 0; i < len(magnitudes); i++ { + magnitudes[i] = cmplx.Abs(spectrum[i]) + } + return magnitudes +} diff --git a/pkg/skychat/voice/spectrogram/spectrogram_test.go b/pkg/skychat/voice/spectrogram/spectrogram_test.go new file mode 100644 index 0000000000..250aa10836 --- /dev/null +++ b/pkg/skychat/voice/spectrogram/spectrogram_test.go @@ -0,0 +1,58 @@ +// Package spectrogram pkg/skychat/voice/spectrogram/spectrogram_test.go c2-app-chat +package spectrogram + +import ( + "math" + "testing" +) + +// TestFFTPeakBin verifies the self-contained radix-2 FFT (dsp.go) is correct: +// a pure sine at a bin-aligned frequency must peak at exactly that FFT bin. +func TestFFTPeakBin(t *testing.T) { + const ( + n = 1024 + sr = 24000.0 + k = 64 // target bin + ) + f := float64(k) * sr / n // bin-aligned frequency + + in := make([]float32, n) + for i := range in { + in[i] = float32(math.Sin(2 * math.Pi * f * float64(i) / sr)) + } + + s := DefaultSettings() + s.SetWindowByName("rectangular") // no window spread → sharp single-bin peak + mags := ComputeFFTWith(in, s) + + if len(mags) != n/2 { + t.Fatalf("magnitudes len = %d, want %d", len(mags), n/2) + } + peak := 0 + for i, m := range mags { + if m > mags[peak] { + peak = i + } + } + if peak != k { + t.Fatalf("FFT peak at bin %d, want %d (radix-2 FFT wrong?)", peak, k) + } +} + +// TestWindows sanity-checks the window coefficient generators. +func TestWindows(t *testing.T) { + const n = 8 + for _, w := range [][]float64{hann(n), hamming(n), bartlett(n)} { + if len(w) != n { + t.Fatalf("window len = %d, want %d", len(w), n) + } + // endpoints small-ish, middle near 1 for these tapering windows. + mid := w[n/2] + if mid < 0.5 { + t.Errorf("window midpoint %.3f unexpectedly low", mid) + } + } + if r := rectangular(n); r[0] != 1 || r[n-1] != 1 { + t.Errorf("rectangular window not all ones: %v", r) + } +} diff --git a/vendor/github.com/jfreymuth/pulse/LICENSE b/vendor/github.com/jfreymuth/pulse/LICENSE new file mode 100644 index 0000000000..9b32798cde --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Johann Freymuth + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/jfreymuth/pulse/README.md b/vendor/github.com/jfreymuth/pulse/README.md new file mode 100644 index 0000000000..283e5531d8 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/README.md @@ -0,0 +1,18 @@ +# pulse +[![GoDoc](https://godocs.io/github.com/jfreymuth/pulse?status.svg)](https://godocs.io/github.com/jfreymuth/pulse) + +PulseAudio client implementation in pure Go. + +Based on [github.com/yobert/pulse](https://github.com/yobert/pulse), which provided a very useful starting point. + +Uses the pulseaudio native protocol to play audio without any CGO. The `proto` package exposes a very low-level API while the `pulse` package is more convenient to use. + +# status + +- `proto` supports almost all of the protocol, shm support is still missing. + +- `pulse` implements sufficient functionality for most audio playing/recording applications. + +# examples + +see [demo/play](demo/play/main.go) and [demo/record](demo/record/main.go) diff --git a/vendor/github.com/jfreymuth/pulse/client.go b/vendor/github.com/jfreymuth/pulse/client.go new file mode 100644 index 0000000000..1a30070011 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/client.go @@ -0,0 +1,157 @@ +package pulse + +import ( + "fmt" + "net" + "os" + "path" + "sync" + + "github.com/jfreymuth/pulse/proto" +) + +// The Client is the connection to the pulseaudio server. An application typically only uses a single client. +type Client struct { + conn net.Conn + c *proto.Client + + mu sync.Mutex + playback map[uint32]*PlaybackStream + record map[uint32]*RecordStream + + server string + props proto.PropList +} + +// NewClient connects to the server. +func NewClient(opts ...ClientOption) (*Client, error) { + c := &Client{ + props: proto.PropList{ + "media.name": proto.PropListString("go audio"), + "application.name": proto.PropListString(path.Base(os.Args[0])), + "application.icon_name": proto.PropListString("audio-x-generic"), + "application.process.id": proto.PropListString(fmt.Sprintf("%d", os.Getpid())), + "application.process.binary": proto.PropListString(os.Args[0]), + "window.x11.display": proto.PropListString(os.Getenv("DISPLAY")), + }, + } + for _, opt := range opts { + opt(c) + } + + var err error + c.c, c.conn, err = proto.Connect(c.server) + if err != nil { + return nil, err + } + + err = c.c.Request(&proto.SetClientName{Props: c.props}, &proto.SetClientNameReply{}) + if err != nil { + c.conn.Close() + return nil, err + } + + c.playback = make(map[uint32]*PlaybackStream) + c.record = make(map[uint32]*RecordStream) + c.c.Callback = func(msg interface{}) { + switch msg := msg.(type) { + case *proto.Request: + c.mu.Lock() + stream, ok := c.playback[msg.StreamIndex] + c.mu.Unlock() + if ok { + stream.request <- int(msg.Length) + } + case *proto.DataPacket: + c.mu.Lock() + stream, ok := c.record[msg.StreamIndex] + c.mu.Unlock() + if ok { + stream.write(msg.Data) + } + case *proto.Started: + c.mu.Lock() + stream, ok := c.playback[msg.StreamIndex] + c.mu.Unlock() + if ok && stream.state == running && !stream.underflow { + stream.started <- true + } + case *proto.Underflow: + c.mu.Lock() + stream, ok := c.playback[msg.StreamIndex] + c.mu.Unlock() + if ok { + if stream.state == running { + stream.underflow = true + } + } + case *proto.ConnectionClosed: + c.mu.Lock() + for _, p := range c.playback { + close(p.request) + p.err = ErrConnectionClosed + p.state = serverLost + } + for _, r := range c.record { + r.err = ErrConnectionClosed + r.state = serverLost + } + c.playback = make(map[uint32]*PlaybackStream) + c.record = make(map[uint32]*RecordStream) + c.mu.Unlock() + c.conn.Close() + default: + //fmt.Printf("%#v\n", msg) + } + } + + return c, nil +} + +// Close closes the client. Calling methods on a closed client may panic. +func (c *Client) Close() { + c.conn.Close() +} + +// A ClientOption supplies configuration when creating the client. +type ClientOption func(*Client) + +// ClientApplicationName sets the application name. +// This will e.g. be displayed by a volume control application to identity the application. +// It should be human-readable and localized. +func ClientApplicationName(name string) ClientOption { + return func(c *Client) { c.props["application.name"] = proto.PropListString(name) } +} + +// ClientApplicationIconName sets the application icon using an xdg icon name. +// This will e.g. be displayed by a volume control application to identity the application. +func ClientApplicationIconName(name string) ClientOption { + return func(c *Client) { c.props["application.icon_name"] = proto.PropListString(name) } +} + +// ClientServerString will override the default server strings. +// Server strings are used to connect to the server. For the server string format see +// https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/ServerStrings/ +func ClientServerString(s string) ClientOption { + return func(c *Client) { c.server = s } +} + +// RawRequest can be used to send arbitrary requests. +// +// req should be one of the request types defined by the proto package. +// +// rpl must be a pointer to the correct reply type or nil. This funcion will panic if rpl has the wrong type. +// +// The returned error can be compared against errors defined by the proto package to check for specific errors. +// +// The function will always block until the server has replied, even if rpl is nil. +func (c *Client) RawRequest(req proto.RequestArgs, rpl proto.Reply) error { + return c.c.Request(req, rpl) +} + +// ErrConnectionClosed is a special error value indicating that the server closed the connection. +const ErrConnectionClosed = pulseError("pulseaudio: connection closed") + +type pulseError string + +func (e pulseError) Error() string { return string(e) } diff --git a/vendor/github.com/jfreymuth/pulse/doc.go b/vendor/github.com/jfreymuth/pulse/doc.go new file mode 100644 index 0000000000..9617868d86 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/doc.go @@ -0,0 +1,2 @@ +// Package pulse implements the pulseaudio protocol in pure go. +package pulse diff --git a/vendor/github.com/jfreymuth/pulse/format.go b/vendor/github.com/jfreymuth/pulse/format.go new file mode 100644 index 0000000000..38d8c9d8ba --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/format.go @@ -0,0 +1,183 @@ +package pulse + +import ( + "io" + "reflect" + "unsafe" + + "github.com/jfreymuth/pulse/proto" +) + +// A Reader provides audio data in a specific format. +type Reader interface { + io.Reader + Format() byte // Format should return one of the format constants defined by the proto package +} + +// A Writer accepts audio data in a specific format. +type Writer interface { + io.Writer + Format() byte // Format should return one of the format constants defined by the proto package +} + +// Uint8Reader implements the Reader interface. +// The semantics are the same as io.Reader's Read. +type Uint8Reader func([]byte) (int, error) + +// Int16Reader implements the Reader interface. +// The semantics are the same as io.Reader's Read, but it returns +// the number of int16 values read, not the number of bytes. +type Int16Reader func([]int16) (int, error) + +// Int32Reader implements the Reader interface. +// The semantics are the same as io.Reader's Read, but it returns +// the number of int32 values read, not the number of bytes. +type Int32Reader func([]int32) (int, error) + +// Float32Reader implements the Reader interface. +// The semantics are the same as io.Reader's Read, but it returns +// the number of float32 values read, not the number of bytes. +type Float32Reader func([]float32) (int, error) + +// NewReader creates a reader from an io.Reader and a format. +// The format must be one of the constants defined in the proto package. +func NewReader(r io.Reader, format byte) Reader { + check(format) + return &reader{r, format} +} + +// Uint8Writer implements the Writer interface. +// The semantics are the same as io.Writer's Write. +type Uint8Writer func([]byte) (int, error) + +// Int16Writer implements the Writer interface. +// The semantics are the same as io.Writer's Write, but it returns +// the number of int16 values written, not the number of bytes. +type Int16Writer func([]int16) (int, error) + +// Int32Writer implements the Writer interface. +// The semantics are the same as io.Writer's Write, but it returns +// the number of int32 values written, not the number of bytes. +type Int32Writer func([]int32) (int, error) + +// Float32Writer implements the Writer interface. +// The semantics are the same as io.Writer's Write, but it returns +// the number of float32 values written, not the number of bytes. +type Float32Writer func([]float32) (int, error) + +// NewWriter creates a writer from an io.Writer and a format. +// The format must be one of the constants defined in the proto package. +func NewWriter(w io.Writer, format byte) Writer { + check(format) + return &writer{w, format} +} + +func (c Uint8Reader) Read(buf []byte) (int, error) { return c(buf) } +func (c Uint8Reader) Format() byte { return proto.FormatUint8 } + +func (c Int16Reader) Read(buf []byte) (int, error) { + n, err := c(int16Slice(buf)) + return n * 2, err +} +func (c Int16Reader) Format() byte { return formatI16 } + +func (c Int32Reader) Read(buf []byte) (int, error) { + n, err := c(int32Slice(buf)) + return n * 4, err +} +func (c Int32Reader) Format() byte { return formatI32 } + +func (c Float32Reader) Read(buf []byte) (int, error) { + n, err := c(float32Slice(buf)) + return n * 4, err +} +func (c Float32Reader) Format() byte { return formatF32 } + +func (c Uint8Writer) Write(buf []byte) (int, error) { return c(buf) } +func (c Uint8Writer) Format() byte { return proto.FormatUint8 } + +func (c Int16Writer) Write(buf []byte) (int, error) { + n, err := c(int16Slice(buf)) + return n * 2, err +} +func (c Int16Writer) Format() byte { return formatI16 } + +func (c Int32Writer) Write(buf []byte) (int, error) { + n, err := c(int32Slice(buf)) + return n * 4, err +} +func (c Int32Writer) Format() byte { return formatI32 } + +func (c Float32Writer) Write(buf []byte) (int, error) { + n, err := c(float32Slice(buf)) + return n * 4, err +} +func (c Float32Writer) Format() byte { return formatF32 } + +type reader struct { + r io.Reader + f byte +} + +func (r *reader) Read(buf []byte) (int, error) { return r.r.Read(buf) } +func (r *reader) Format() byte { return r.f } + +type writer struct { + w io.Writer + f byte +} + +func (w *writer) Write(buf []byte) (int, error) { return w.w.Write(buf) } +func (w *writer) Format() byte { return w.f } + +func bytes(f byte) int { + switch f { + case proto.FormatUint8: + return 1 + case proto.FormatInt16LE, proto.FormatInt16BE: + return 2 + case proto.FormatInt32LE, proto.FormatInt32BE, proto.FormatFloat32LE, proto.FormatFloat32BE: + return 4 + } + panic("pulse: invalid format") +} + +func check(f byte) { + switch f { + case proto.FormatUint8, proto.FormatInt16LE, proto.FormatInt16BE, + proto.FormatInt32LE, proto.FormatInt32BE, proto.FormatFloat32LE, proto.FormatFloat32BE: + return + } + panic("pulse: invalid format") +} + +var formatI16, formatI32, formatF32 byte + +func init() { + i := uint16(1) + littleEndian := *(*byte)(unsafe.Pointer(&i)) == 1 + if littleEndian { + formatI16 = proto.FormatInt16LE + formatI32 = proto.FormatInt32LE + formatF32 = proto.FormatFloat32LE + } else { + formatI16 = proto.FormatInt16BE + formatI32 = proto.FormatInt32BE + formatF32 = proto.FormatFloat32BE + } +} + +func int16Slice(s []byte) []int16 { + h := *(*reflect.SliceHeader)(unsafe.Pointer(&s)) + return *(*[]int16)(unsafe.Pointer(&reflect.SliceHeader{Data: h.Data, Len: h.Len / 2, Cap: h.Len / 2})) +} + +func int32Slice(s []byte) []int32 { + h := *(*reflect.SliceHeader)(unsafe.Pointer(&s)) + return *(*[]int32)(unsafe.Pointer(&reflect.SliceHeader{Data: h.Data, Len: h.Len / 4, Cap: h.Len / 4})) +} + +func float32Slice(s []byte) []float32 { + h := *(*reflect.SliceHeader)(unsafe.Pointer(&s)) + return *(*[]float32)(unsafe.Pointer(&reflect.SliceHeader{Data: h.Data, Len: h.Len / 4, Cap: h.Len / 4})) +} diff --git a/vendor/github.com/jfreymuth/pulse/playback.go b/vendor/github.com/jfreymuth/pulse/playback.go new file mode 100644 index 0000000000..9186640cad --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/playback.go @@ -0,0 +1,306 @@ +package pulse + +import "github.com/jfreymuth/pulse/proto" + +// A PlaybackStream is used for playing audio. +// When creating a stream, the user must provide a callback that will be used to buffer audio data. +type PlaybackStream struct { + c *Client + + index uint32 + state streamState + underflow bool + err error + + front, back []byte + requested int + request chan int + started chan bool + + r Reader + + createRequest proto.CreatePlaybackStream + createReply proto.CreatePlaybackStreamReply + bytesPerSample int +} + +// EndOfData is a special error value that can be returned by a reader to stop the stream. +const EndOfData endOfData = false + +// NewPlayback creates a playback stream. +// The created stream wil not be running, it must be started with Start(). +// If the reader returns any error, the stream will be stopped. The special error value EndOfData +// can be used to intentionally stop the stream from within the callback. +// The order of options is important in some cases, see the documentation of the individual PlaybackOptions. +func (c *Client) NewPlayback(r Reader, opts ...PlaybackOption) (*PlaybackStream, error) { + p := &PlaybackStream{ + c: c, + createRequest: proto.CreatePlaybackStream{ + SinkIndex: proto.Undefined, + ChannelMap: proto.ChannelMap{proto.ChannelMono}, + SampleSpec: proto.SampleSpec{Format: r.Format(), Channels: 1, Rate: 44100}, + BufferMaxLength: proto.Undefined, + Corked: true, + BufferTargetLength: proto.Undefined, + BufferPrebufferLength: proto.Undefined, + BufferMinimumRequest: proto.Undefined, + Properties: proto.PropList{}, + }, + bytesPerSample: bytes(r.Format()), + r: r, + } + + for _, opt := range opts { + opt(p) + } + + if p.createRequest.ChannelVolumes == nil { + cvol := make(proto.ChannelVolumes, len(p.createRequest.ChannelMap)) + for i := range cvol { + cvol[i] = 0x100 + } + p.createRequest.ChannelVolumes = cvol + } + + err := c.c.Request(&p.createRequest, &p.createReply) + if err != nil { + return nil, err + } + p.index = p.createReply.StreamIndex + p.front = make([]byte, p.createReply.BufferMaxLength) + p.back = make([]byte, p.createReply.BufferMaxLength) + p.request = make(chan int) + p.started = make(chan bool) + c.mu.Lock() + c.playback[p.index] = p + c.mu.Unlock() + go p.run() + return p, nil +} + +func (p *PlaybackStream) run() { + for n := range p.request { + if p.state != running { + continue + } + p.requested += n + for p.requested > 0 { + n, err := p.r.Read(p.front[:p.requested]) + if n > 0 { + p.c.c.Send(p.index, p.front[:n]) + p.requested -= n + p.front, p.back = p.back, p.front + } + if err != nil { + if err != EndOfData { + p.err = err + } + p.state = idle + break + } + select { + case n = <-p.request: + p.requested += n + default: + } + } + } +} + +// Start starts playing audio. +func (p *PlaybackStream) Start() { + if p.state == idle { + p.c.c.Request(&proto.FlushPlaybackStream{StreamIndex: p.index}, nil) + p.state = running + p.err = nil + p.request <- int(p.createReply.BufferTargetLength) + p.underflow = false + p.c.c.Request(&proto.CorkPlaybackStream{StreamIndex: p.index, Corked: false}, nil) + <-p.started + } +} + +// Stop stops playing audio; the callback will no longer be called. +// If the buffer size/latency is large, audio may continue to play for some time after the call to Stop. +func (p *PlaybackStream) Stop() { + if p.state == running || p.state == paused { + p.state = idle + } +} + +// Pause stops playing audio immediately. +func (p *PlaybackStream) Pause() { + if p.state == running { + p.c.c.Request(&proto.CorkPlaybackStream{StreamIndex: p.index, Corked: true}, nil) + p.state = paused + } +} + +// Resume resumes a paused stream. +func (p *PlaybackStream) Resume() { + if p.state == paused { + p.c.c.Request(&proto.CorkPlaybackStream{StreamIndex: p.index, Corked: false}, nil) + p.state = running + p.underflow = false + } +} + +// Drain waits until the playback has ended. +// Drain does not return when the stream is paused. +func (p *PlaybackStream) Drain() { + if p.state == running { + p.c.c.Request(&proto.DrainPlaybackStream{StreamIndex: p.index}, nil) + } +} + +// Close closes the stream. +func (p *PlaybackStream) Close() { + if !p.Closed() { + p.c.c.Request(&proto.DeletePlaybackStream{StreamIndex: p.index}, nil) + p.state = closed + close(p.request) + p.c.mu.Lock() + delete(p.c.playback, p.index) + p.c.mu.Unlock() + } +} + +// Closed returns wether the stream was closed. +func (p *PlaybackStream) Closed() bool { return p.state == closed || p.state == serverLost } + +// Running returns wether the stream is currently playing. +func (p *PlaybackStream) Running() bool { return p.state == running } + +// Underflow returns true if any underflows happend since the last call to Start or Resume. +// Underflows usually happen because the latency/buffer size is too low or because the callback +// takes too long to run. +func (p *PlaybackStream) Underflow() bool { return p.underflow } + +// Error returns the last error returned by the stream's reader. +func (p *PlaybackStream) Error() error { return p.err } + +// SampleRate returns the stream's sample rate (samples per second). +func (p *PlaybackStream) SampleRate() int { + return int(p.createReply.Rate) +} + +// Channels returns the number of channels. +func (p *PlaybackStream) Channels() int { + return int(p.createReply.Channels) +} + +// BufferSize returns the size of the server-side buffer in samples. +func (p *PlaybackStream) BufferSize() int { + s := int(p.createReply.BufferTargetLength) / int(p.createReply.Channels) + return s / p.bytesPerSample +} + +// BufferSizeBytes returns the size of the server-side buffer in bytes. +func (p *PlaybackStream) BufferSizeBytes() int { + return int(p.createReply.BufferTargetLength) +} + +// StreamIndex returns the stream index. +// This should only be used together with (*Cient).RawRequest. +func (p *PlaybackStream) StreamIndex() uint32 { + return p.index +} + +func (p *PlaybackStream) StreamInputIndex() uint32 { + return p.createReply.SinkInputIndex +} + +// A PlaybackOption supplies configuration when creating streams. +type PlaybackOption func(*PlaybackStream) + +// PlaybackMono sets a stream to a single channel. +var PlaybackMono PlaybackOption = func(p *PlaybackStream) { + p.createRequest.ChannelMap = proto.ChannelMap{proto.ChannelMono} + p.createRequest.Channels = 1 +} + +// PlaybackStereo sets a stream to two channels. +var PlaybackStereo PlaybackOption = func(p *PlaybackStream) { + p.createRequest.ChannelMap = proto.ChannelMap{proto.ChannelLeft, proto.ChannelRight} + p.createRequest.Channels = 2 +} + +// PlaybackChannels sets a stream to use a custom channel map. +func PlaybackChannels(m proto.ChannelMap) PlaybackOption { + if len(m) == 0 { + panic("pulse: invalid channel map") + } + return func(p *PlaybackStream) { + p.createRequest.ChannelMap = m + p.createRequest.Channels = byte(len(m)) + } +} + +// PlaybackSampleRate sets the stream's sample rate. +func PlaybackSampleRate(rate int) PlaybackOption { + return func(p *PlaybackStream) { + p.createRequest.Rate = uint32(rate) + } +} + +// PlaybackBufferSize sets the size of the server-side buffer. +// Setting the buffer size too small causes underflows, resulting in audible artifacts. +// +// Buffer size and latency should not be set at the same time. +func PlaybackBufferSize(samples int) PlaybackOption { + return func(p *PlaybackStream) { + p.createRequest.BufferTargetLength = uint32(samples * p.bytesPerSample) + p.createRequest.AdjustLatency = false + } +} + +// PlaybackLatency sets the stream's latency in seconds. +// Setting the latency too low causes underflows, resulting in audible artifacts. +// Applications should generally use the highest acceptable latency. +// +// This should be set after sample rate and channel options. +// +// Buffer size and latency should not be set at the same time. +func PlaybackLatency(seconds float64) PlaybackOption { + return func(p *PlaybackStream) { + p.createRequest.BufferTargetLength = uint32(seconds*float64(p.createRequest.Rate)) * uint32(p.createRequest.Channels) * uint32(p.bytesPerSample) + p.createRequest.BufferMaxLength = 2 * p.createRequest.BufferTargetLength + p.createRequest.AdjustLatency = true + } +} + +// PlaybackSink sets the sink the stream should send audio to. +func PlaybackSink(sink *Sink) PlaybackOption { + return func(p *PlaybackStream) { + p.createRequest.SinkIndex = sink.info.SinkIndex + } +} + +// PlaybackMediaName sets the streams media name. +// This will e.g. be displayed by a volume control application to identity the stream. +func PlaybackMediaName(name string) PlaybackOption { + return func(p *PlaybackStream) { + p.createRequest.Properties["media.name"] = proto.PropListString(name) + } +} + +// PlaybackMediaIconName sets the streams media icon using an xdg icon name. +// This will e.g. be displayed by a volume control application to identity the stream. +func PlaybackMediaIconName(name string) PlaybackOption { + return func(p *PlaybackStream) { + p.createRequest.Properties["media.icon_name"] = proto.PropListString(name) + } +} + +// PlaybackRawOption can be used to create custom options. +// +// This is an advanced function, similar to (*Client).RawRequest. +func PlaybackRawOption(o func(*proto.CreatePlaybackStream)) PlaybackOption { + return func(p *PlaybackStream) { + o(&p.createRequest) + } +} + +type endOfData bool + +func (endOfData) Error() string { return "end of data" } diff --git a/vendor/github.com/jfreymuth/pulse/proto/client.go b/vendor/github.com/jfreymuth/pulse/proto/client.go new file mode 100644 index 0000000000..016edef191 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/proto/client.go @@ -0,0 +1,280 @@ +package proto + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "reflect" + "sync" + "time" +) + +type Client struct { + r ProtocolReader + w ProtocolWriter + v Version + + replyM sync.Mutex + writeM sync.Mutex + nextID uint32 // protected by replyM + awaitReply map[uint32]AwaitReply // protected by replyM + timeout time.Duration + err error // protected by replyM and writeM (hold one to read, hold both to write) + + Callback func(interface{}) +} + +type send struct { + index uint32 + data []byte +} + +func (c *Client) Version() Version { + return c.v +} + +func (c *Client) SetVersion(v Version) { + c.v = c.v.Min(v) +} + +func (c *Client) SetTimeout(t time.Duration) { + c.timeout = t +} + +func (c *Client) Open(rw io.ReadWriter) { + //debug, _ := os.Create("debug") + //c.r.r = io.TeeReader(rw, debug) + c.r.r = rw + c.w.w = rw + c.v = Version(32) + + c.awaitReply = make(map[uint32]AwaitReply) + go c.readLoop() +} + +type AwaitReply struct { + value interface{} + reply chan<- error +} + +func (c *Client) Request(req RequestArgs, rpl Reply) error { + if rpl != nil && req.command() != rpl.IsReplyTo() { + panic("pulse: wrong reply type") + } + + ctx, cancel := context.WithTimeout(context.Background(), c.timeout) + defer cancel() + + reply := make(chan error, 1) + c.replyM.Lock() + if c.err != nil { + c.replyM.Unlock() + return c.err + } + tag := c.nextID + c.nextID++ + c.awaitReply[tag] = AwaitReply{rpl, reply} + c.replyM.Unlock() + + var buf bytes.Buffer + w := ProtocolWriter{w: &buf} + w.byte('L') + w.uint32(req.command()) + w.byte('L') + w.uint32(tag) + w.value(req, c.v) + w.flush() + + err := c.Send(0xFFFFFFFF, buf.Bytes()) + if err != nil { + return err + } + + select { + case err := <-reply: + return err + case <-ctx.Done(): + return ctx.Err() + } + +} + +func (c *Client) Send(index uint32, data []byte) error { + c.writeM.Lock() + if c.err != nil { + c.writeM.Unlock() + return c.err + } + c.w.uint32(uint32(len(data))) + c.w.uint32(index) + c.w.uint64(0) + c.w.uint32(0) + c.w.flush() + c.w.w.Write(data) + c.writeM.Unlock() + return nil +} + +func (c *Client) readLoop() { + for { + length := c.r.uint32() + index := c.r.uint32() + offset := c.r.uint64() + flags := c.r.uint32() + _, _ = offset, flags + if c.r.err != nil { + c.error(c.r.err) + return + } + if index == 0xFFFFFFFF { + c.r.byte() // L + op := c.r.uint32() + c.r.byte() // L + tag := c.r.uint32() + var message interface{} + switch op { + case OpError: + c.r.byte() + err := Error(c.r.uint32()) + c.replyM.Lock() + a, ok := c.awaitReply[tag] + delete(c.awaitReply, tag) + c.replyM.Unlock() + if ok { + a.reply <- err + } + case OpReply: + c.replyM.Lock() + a, ok := c.awaitReply[tag] + delete(c.awaitReply, tag) + c.replyM.Unlock() + if ok { + if a.value != nil { + if reflect.TypeOf(a.value).Elem().Kind() == reflect.Slice { + c.parseInfoList(a.value, int(length)-10) + } else { + c.r.value(a.value, c.v) + } + } else { + c.r.advance(int(length) - 10) + } + a.reply <- nil + } else { + c.r.advance(int(length) - 10) + } + case OpRequest: + message = &Request{} + case OpOverflow: + message = &Overflow{} + case OpUnderflow: + message = &Underflow{} + case OpPlaybackStreamKilled: + message = &PlaybackStreamKilled{} + case OpRecordStreamKilled: + message = &RecordStreamKilled{} + case OpSubscribeEvent: + message = &SubscribeEvent{} + case OpPlaybackStreamSuspended: + message = &PlaybackStreamSuspended{} + case OpRecordStreamSuspended: + message = &RecordStreamSuspended{} + case OpPlaybackStreamMoved: + message = &PlaybackStreamMoved{} + case OpRecordStreamMoved: + message = &RecordStreamMoved{} + case OpClientEvent: + message = &ClientEvent{} + case OpPlaybackStreamEvent: + message = &PlaybackStreamEvent{} + case OpRecordStreamEvent: + message = &RecordStreamEvent{} + case OpStarted: + message = &Started{} + case OpPlaybackBufferAttrChanged: + message = &PlaybackBufferAttrChanged{} + default: + fmt.Println(op) + c.r.advance(int(length) - 10) + } + if message != nil { + c.r.value(message, c.v) + if c.Callback != nil { + c.Callback(message) + } + } + } else { + if c.Callback != nil { + buf := c.r.tmpbytes(int(length)) + c.Callback(&DataPacket{index, buf}) + } + c.r.advance(int(length)) + } + } +} + +func (c *Client) error(err error) { + c.replyM.Lock() + c.writeM.Lock() + c.err = err + c.writeM.Unlock() + r := c.awaitReply + c.awaitReply = make(map[uint32]AwaitReply) + c.replyM.Unlock() + for _, r := range r { + r.reply <- err + } + if errors.Is(err, io.EOF) { + c.Callback(&ConnectionClosed{}) + } +} + +func (c *Client) parseInfoList(value interface{}, length int) { + start := c.r.pos + for c.r.pos-start < length { + switch value := value.(type) { + case *GetSinkInfoListReply: + var v GetSinkInfoReply + c.r.value(&v, c.v) + *value = append(*value, &v) + case *GetSourceInfoListReply: + var v GetSourceInfoReply + c.r.value(&v, c.v) + *value = append(*value, &v) + case *GetModuleInfoListReply: + var v GetModuleInfoReply + c.r.value(&v, c.v) + *value = append(*value, &v) + case *GetClientInfoListReply: + var v GetClientInfoReply + c.r.value(&v, c.v) + *value = append(*value, &v) + case *GetCardInfoListReply: + var v GetCardInfoReply + c.r.value(&v, c.v) + *value = append(*value, &v) + case *GetSinkInputInfoListReply: + var v GetSinkInputInfoReply + c.r.value(&v, c.v) + *value = append(*value, &v) + case *GetSourceOutputInfoListReply: + var v GetSourceOutputInfoReply + c.r.value(&v, c.v) + *value = append(*value, &v) + case *GetSampleInfoListReply: + var v GetSampleInfoReply + c.r.value(&v, c.v) + *value = append(*value, &v) + default: + panic("wrong type") + } + } +} + +type DataPacket struct { + StreamIndex uint32 + Data []byte +} + +type ConnectionClosed struct{} diff --git a/vendor/github.com/jfreymuth/pulse/proto/connect.go b/vendor/github.com/jfreymuth/pulse/proto/connect.go new file mode 100644 index 0000000000..1b0acbe7c9 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/proto/connect.go @@ -0,0 +1,155 @@ +package proto + +import ( + "errors" + "fmt" + "io/ioutil" + "net" + "os" + "os/user" + "path" + "runtime" + "strings" + "time" +) + +// Connect connects to the pulse server. +// +// For the server string format see +// https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/ServerStrings/ +// If the server string is empty, the environment variable PULSE_SERVER will be used. +func Connect(server string) (*Client, net.Conn, error) { + var sstr []serverString + if server != "" { + sstr = parseServerString(server) + } else if serverRaw, ok := os.LookupEnv("PULSE_SERVER"); ok { + sstr = parseServerString(serverRaw) + } else { + sstr = defaultServerStrings() + } + if len(sstr) == 0 { + return nil, nil, errors.New("pulseaudio: no valid server") + } + c := &Client{ + timeout: 1 * time.Second, + } + + localname, err := os.Hostname() + if err != nil { + return nil, nil, err + } + + var lastErr error + for _, s := range sstr { + if s.localname != "" && localname != s.localname { + continue + } + conn, err := net.Dial(s.protocol, s.addr) + if err != nil { + lastErr = err + continue + } + c.Open(conn) + + cookiePath := os.Getenv("HOME") + "/.config/pulse/cookie" + if path, ok := os.LookupEnv("PULSE_COOKIE"); ok { + cookiePath = path + } + + cookie, err := ioutil.ReadFile(cookiePath) + if err != nil { + if !os.IsNotExist(err) { + conn.Close() + lastErr = err + continue + } + // If the server is launched with auth-anonymous=1, + // any 256 bytes cookie will be accepted. + cookie = make([]byte, 256) + } + var authReply AuthReply + err = c.Request( + &Auth{ + Version: c.Version(), + Cookie: cookie, + }, &authReply) + if err != nil { + conn.Close() + lastErr = err + continue + } + c.SetVersion(authReply.Version) + + return c, conn, nil + } + + return nil, nil, lastErr +} + +type serverString struct { + localname string + protocol string + addr string +} + +func parseServerString(str string) []serverString { + s := strings.Fields(str) + var result []serverString + for _, s := range s { + var server serverString + if s[0] == '{' { + end := strings.IndexByte(s, '}') + server.localname = s[1:end] + s = s[end+1:] + } + switch { + case len(s) == 0: + // no server string + continue + case s[0] == '/': + server.protocol = "unix" + server.addr = s + case strings.HasPrefix(s, "unix:"): + server.protocol = "unix" + server.addr = s[5:] + case strings.HasPrefix(s, "tcp6:"): + server.protocol = "tcp6" + server.addr = s[5:] + case strings.HasPrefix(s, "tcp4:"): + server.protocol = "tcp4" + server.addr = s[5:] + case strings.HasPrefix(s, "tcp:"): + server.protocol = "tcp" + server.addr = s[4:] + default: + // invalid server string + continue + } + result = append(result, server) + } + return result +} + +func defaultServerStrings() []serverString { + switch runtime.GOOS { + case "linux": + return []serverString{{protocol: "unix", + addr: path.Join(os.Getenv("XDG_RUNTIME_DIR"), "pulse/native"), + }} + case "darwin": + u, err := user.Current() + if err != nil { + return nil + } + + h, err := os.Hostname() + if err != nil { + return nil + } + + return []serverString{{protocol: "unix", + addr: fmt.Sprintf("%s/.config/pulse/%s-runtime/native", u.HomeDir, h), + }} + } + return nil +} diff --git a/vendor/github.com/jfreymuth/pulse/proto/error.go b/vendor/github.com/jfreymuth/pulse/proto/error.go new file mode 100644 index 0000000000..bd6f8d2b6a --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/proto/error.go @@ -0,0 +1,93 @@ +package proto + +type Error uint32 + +const ( + ok Error = iota + ErrAccessDenied + ErrUnknownCommand + ErrInvalidArgument + ErrEntityExists + ErrNoSuchEntity + ErrConnectionRefused + ErrProtocolError + ErrTimeout + ErrNoAuthenticationKey + ErrInternalError + ErrConnectionTerminated + ErrEntityKilled + ErrInvalidServer + ErrModuleInitializationFailed + ErrBadState + ErrNoData + ErrIncompatibleProtocolVersion + ErrTooLarge + ErrNotSupported + ErrUnknownErrorCode + ErrNoSuchExtension + ErrObsoleteFunctionality + ErrMissingImplementation + ErrClientForked + ErrInputOutputError + ErrDeviceOrEesourceBusy +) + +func (e Error) Error() string { + switch e { + case ok: + return "pulseaudio: ok" + case ErrAccessDenied: + return "pulseaudio: access denied" + case ErrUnknownCommand: + return "pulseaudio: unknown command" + case ErrInvalidArgument: + return "pulseaudio: invalid argument" + case ErrEntityExists: + return "pulseaudio: entity exists" + case ErrNoSuchEntity: + return "pulseaudio: no such entity" + case ErrConnectionRefused: + return "pulseaudio: connection refused" + case ErrProtocolError: + return "pulseaudio: protocol error" + case ErrTimeout: + return "pulseaudio: timeout" + case ErrNoAuthenticationKey: + return "pulseaudio: no authentication key" + case ErrInternalError: + return "pulseaudio: internal error" + case ErrConnectionTerminated: + return "pulseaudio: connection terminated" + case ErrEntityKilled: + return "pulseaudio: entity killed" + case ErrInvalidServer: + return "pulseaudio: invalid server" + case ErrModuleInitializationFailed: + return "pulseaudio: module initialization failed" + case ErrBadState: + return "pulseaudio: bad state" + case ErrNoData: + return "pulseaudio: no data" + case ErrIncompatibleProtocolVersion: + return "pulseaudio: incompatible protocol version" + case ErrTooLarge: + return "pulseaudio: too large" + case ErrNotSupported: + return "pulseaudio: not supported" + case ErrUnknownErrorCode: + return "pulseaudio: unknown error code" + case ErrNoSuchExtension: + return "pulseaudio: no such extension" + case ErrObsoleteFunctionality: + return "pulseaudio: obsolete functionality" + case ErrMissingImplementation: + return "pulseaudio: missing implementation" + case ErrClientForked: + return "pulseaudio: client forked" + case ErrInputOutputError: + return "pulseaudio: input/output error" + case ErrDeviceOrEesourceBusy: + return "pulseaudio: device or resource busy" + } + return "pulseaudio: invalid error code" +} diff --git a/vendor/github.com/jfreymuth/pulse/proto/op.go b/vendor/github.com/jfreymuth/pulse/proto/op.go new file mode 100644 index 0000000000..2054273c72 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/proto/op.go @@ -0,0 +1,996 @@ +package proto + +const ( + OpError = 0 + OpTimeout = 1 + OpReply = 2 + + OpCreatePlaybackStream = 3 + OpDeletePlaybackStream = 4 + OpCreateRecordStream = 5 + OpDeleteRecordStream = 6 + + OpExit = 7 + OpAuth = 8 + OpSetClientName = 9 + + OpLookupSink = 10 + OpLookupSource = 11 + OpDrainPlaybackStream = 12 + OpStat = 13 + OpGetPlaybackLatency = 14 + OpCreateUploadStream = 15 + OpDeleteUploadStream = 16 + OpFinishUploadStream = 17 + OpPlaySample = 18 + OpRemoveSample = 19 + + OpGetServerInfo = 20 + OpGetSinkInfo = 21 + OpGetSinkInfoList = 22 + OpGetSourceInfo = 23 + OpGetSourceInfoList = 24 + OpGetModuleInfo = 25 + OpGetModuleInfoList = 26 + OpGetClientInfo = 27 + OpGetClientInfoList = 28 + OpGetSinkInputInfo = 29 + OpGetSinkInputInfoList = 30 + OpGetSourceOutputInfo = 31 + OpGetSourceOutputInfoList = 32 + OpGetSampleInfo = 33 + OpGetSampleInfoList = 34 + OpSubscribe = 35 + + OpSetSinkVolume = 36 + OpSetSinkInputVolume = 37 + OpSetSourceVolume = 38 + OpSetSinkMute = 39 + OpSetSourceMute = 40 + OpCorkPlaybackStream = 41 + OpFlushPlaybackStream = 42 + OpTriggerPlaybackStream = 43 + + OpSetDefaultSink = 44 + OpSetDefaultSource = 45 + OpSetPlaybackStreamName = 46 + OpSetRecordStreamName = 47 + OpKillClient = 48 + OpKillSinkInput = 49 + OpKillSourceOutput = 50 + + OpLoadModule = 51 + OpUnloadModule = 52 + + // 4 obsolete commands + + OpGetRecordLatency = 57 + OpCorkRecordStream = 58 + OpFlushRecordStream = 59 + OpPrebufPlaybackStream = 60 + + OpRequest = 61 // server -> client + OpOverflow = 62 // server -> client + OpUnderflow = 63 // server -> client + OpPlaybackStreamKilled = 64 // server -> client + OpRecordStreamKilled = 65 // server -> client + OpSubscribeEvent = 66 // server -> client + + OpMoveSinkInput = 67 + OpMoveSourceOutput = 68 + OpSetSinkInputMute = 69 + OpSuspendSink = 70 + OpSuspendSource = 71 + OpSetPlaybackStreamBufferAttr = 72 + OpSetRecordStreamBufferAttr = 73 + OpUpdatePlaybackStreamSampleRate = 74 + OpUpdateRecordStreamSampleRate = 75 + + OpPlaybackStreamSuspended = 76 // server -> client + OpRecordStreamSuspended = 77 // server -> client + OpPlaybackStreamMoved = 78 // server -> client + OpRecordStreamMoved = 79 // server -> client + + OpUpdateRecordStreamProplist = 80 + OpUpdatePlaybackStreamProplist = 81 + OpUpdateClientProplist = 82 + OpRemoveRecordStreamProplist = 83 + OpRemovePlaybackStreamProplist = 84 + OpRemoveClientProplist = 85 + + OpStarted = 86 // server -> client + + OpExtension = 87 + + OpGetCardInfo = 88 + OpGetCardInfoList = 89 + OpSetCardProfile = 90 + + OpClientEvent = 91 // server -> client + OpPlaybackStreamEvent = 92 // server -> client + OpRecordStreamEvent = 93 // server -> client + OpPlaybackBufferAttrChanged = 94 // server -> client + OpRecordBufferAttrChanged = 95 // server -> client + + OpSetSinkPort = 96 + OpSetSourcePort = 97 + OpSetSourceOutputVolume = 98 + OpSetSourceOutputMute = 99 + + OpSetPortLatencyOffset = 100 + + OpEnableSRBChannel = 101 + OpDisableSRBChannel = 102 + + OpRegisterMemfdShmid = 103 +) + +type RequestArgs interface{ command() uint32 } +type Reply interface{ IsReplyTo() uint32 } + +type CreatePlaybackStream struct { + SampleSpec + ChannelMap ChannelMap + SinkIndex uint32 + SinkName string + + BufferMaxLength uint32 + Corked bool + BufferTargetLength uint32 + BufferPrebufferLength uint32 + BufferMinimumRequest uint32 + + SyncID uint32 + + ChannelVolumes ChannelVolumes + + NoRemap bool "12" + NoRemix bool "12" + FixFormat bool "12" + FixRate bool "12" + FixChannels bool "12" + NoMove bool "12" + VariableRate bool "12" + + Muted bool "13" + AdjustLatency bool "13" + Properties PropList "13" + + VolumeSet bool "14" + EarlyRequests bool "14" + + MutedSet bool "15" + DontInhibitAutoSuspend bool "15" + FailOnSuspend bool "15" + + RelativeVolume bool "17" + + Passthrough bool "18" + + Formats []FormatInfo "21" +} +type CreatePlaybackStreamReply struct { + StreamIndex uint32 + SinkInputIndex uint32 + Missing uint32 + + BufferMaxLength uint32 "9" + BufferTargetLength uint32 "9" + BufferPrebufferLength uint32 "9" + BufferMinimumRequest uint32 "9" + + SampleSpec "12" + ChannelMap []byte "12" + + SinkIndex uint32 "12" + SinkName string "12" + SinkSuspended bool "12" + + SinkLatency Microseconds "13" + + FormatInfo "21" +} + +type DeletePlaybackStream struct{ StreamIndex uint32 } + +type CreateRecordStream struct { + SampleSpec + ChannelMap ChannelMap + SourceIndex uint32 + SourceName string + BufferMaxLength uint32 + Corked bool + BufferFragSize uint32 + + NoRemap bool "12" + NoRemix bool "12" + FixFormat bool "12" + FixRate bool "12" + FixChannels bool "12" + NoMove bool "12" + VariableRate bool "12" + + PeakDetect bool "13" + AdjustLatency bool "13" + Properties PropList "13" + DirectOnInputIndex uint32 "13" + + EarlyRequests bool "14" + + DontInhibitAutoSuspend bool "15" + FailOnSuspend bool "15" + + Formats []FormatInfo "22" + ChannelVolumes ChannelVolumes "22" + Muted bool "22" + VolumeSet bool "22" + MutedSet bool "22" + RelativeVolume bool "22" + Passthrough bool "22" +} +type CreateRecordStreamReply struct { + StreamIndex uint32 + SourceOutputIndex uint32 + + BufferMaxLength uint32 "9" + BufferFragSize uint32 "9" + + SampleSpec "12" + ChannelMap ChannelMap "12" + SourceIndex uint32 "12" + SourceName string "12" + SourceSuspended bool "12" + + SourceLatency Microseconds "13" + + FormatInfo "22" +} + +type DeleteRecordStream struct{ StreamIndex uint32 } + +type Exit struct{} + +type Auth struct { + Version Version + Cookie []byte +} +type AuthReply struct { + Version Version +} + +type SetClientName struct { + Props PropList +} +type SetClientNameReply struct { + ClientIndex uint32 +} + +type LookupSink struct{ SinkName string } +type LookupSinkReply struct{ SinkIndex uint32 } + +type LookupSource struct{ SourceName string } +type LookupSourceReply struct{ SourceIndex uint32 } + +type DrainPlaybackStream struct { + StreamIndex uint32 +} + +type Stat struct{} +type StatReply struct { + NumAllocated uint32 + AllocatedSize uint32 + NumAccumulated uint32 + AccumulatedSize uint32 + SampleCacheSize uint32 +} + +type GetPlaybackLatency struct { + StreamIndex uint32 + Time Time +} +type GetPlaybackLatencyReply struct { + Latency Microseconds + Unused Microseconds // always 0 + Running bool + RequestTime Time + ReplyTime Time + WriteIndex int64 + ReadIndex int64 + + UnderrunFor uint64 "13" + PlayingFor uint64 "13" +} + +type GetRecordLatency struct { + StreamIndex uint32 + Time Time +} +type GetRecordLatencyReply struct { + MonitorLatency Microseconds + Latency Microseconds + Running bool + RequestTime Time + ReplyTime Time + WriteIndex int64 + ReadIndex int64 +} + +type CreateUploadStream struct { + Name string + SampleSpec + ChannelMap ChannelMap + Length uint32 + + Properties PropList "13" +} +type CreateUploadStreamReply struct { + StreamIndex uint32 + Length uint32 +} + +type DeleteUploadStream struct{ StreamIndex uint32 } + +type FinishUploadStream struct { + StreamIndex uint32 +} + +type PlaySample struct { + SinkIndex uint32 + SinkName string + Volume uint32 + Name string + + Properties PropList "13" +} + +type RemoveSample struct { + Name string +} + +type GetServerInfo struct{} +type GetServerInfoReply struct { + PackageName string + PackageVersion string + Username string + Hostname string + + DefaultSampleSpec SampleSpec + DefaultSinkName string + DefaultSourceName string + + Cookie uint32 + + DefaultChannelMap ChannelMap "15" +} + +type GetSinkInfo struct { + SinkIndex uint32 + SinkName string +} +type GetSinkInfoReply struct { + SinkIndex uint32 + SinkName string + Device string + SampleSpec + ChannelMap ChannelMap + ModuleIndex uint32 + ChannelVolumes ChannelVolumes + Mute bool + MonitorSourceIndex uint32 + MonitorSourceName string + Latency Microseconds + Driver string + Flags uint32 + + Properties PropList "13" + RequestedLatency Microseconds "13" + + BaseVolume Volume "15" + State uint32 "15" + NumVolumeSteps uint32 "15" + CardIndex uint32 "15" + + Ports []struct { + Name string + Description string + Priority uint32 + Available uint32 "24" + } "16" + ActivePortName string "16" + + Formats []FormatInfo "21" +} + +type GetSourceInfo struct { + SourceIndex uint32 + SourceName string +} +type GetSourceInfoReply struct { + SourceIndex uint32 + SourceName string + Device string + SampleSpec + ChannelMap ChannelMap + ModuleIndex uint32 + ChannelVolumes ChannelVolumes + Mute bool + MonitorSourceIndex uint32 + MonitorSourceName string + Latency Microseconds + Driver string + Flags uint32 + + Properties PropList "13" + RequestedLatency Microseconds "13" + + BaseVolume Volume "15" + State uint32 "15" + NumVolumeSteps uint32 "15" + CardIndex uint32 "15" + + Ports []struct { + Name string + Description string + Priority uint32 + Available uint32 "24" + } "16" + ActivePortName string "16" + + Formats []FormatInfo "21" +} + +type GetClientInfo struct{ ClientIndex uint32 } +type GetClientInfoReply struct { + ClientIndex uint32 + Application string + ModuleIndex uint32 + Driver string + + Properties PropList "13" +} + +type GetCardInfo struct{ CardIndex uint32 } +type GetCardInfoReply struct { + CardIndex uint32 + CardName string + ModuleIndex uint32 + Driver string + + Profiles []struct { + Name string + Description string + NumSinks uint32 + NumSources uint32 + Priority uint32 + Available uint32 "29" + } + ActiveProfileName string + Properties PropList + + Ports []struct { + Name string + Description string + Priority uint32 + Available uint32 + Direction byte + Properties PropList + Profiles []struct { + Name string + } + LatencyOffset int64 "27" + } "26" +} + +type GetModuleInfo struct{ ModuleIndex uint32 } +type GetModuleInfoReply struct { + ModuleIndex uint32 + ModuleName string + ModuleArgs string + Users uint32 + + Properties PropList "15" + AutoLoad bool "<15" +} + +type GetSinkInputInfo struct{ SinkInputIndex uint32 } +type GetSinkInputInfoReply struct { + SinkInputIndex uint32 + MediaName string + ModuleIndex uint32 + ClientIndex uint32 + SinkIndex uint32 + SampleSpec + ChannelMap ChannelMap + ChannelVolumes ChannelVolumes + + SinkInputLatency Microseconds + SinkLatency Microseconds + ResampleMethod string + Driver string + + Muted bool "11" + + Properties PropList "13" + + Corked bool "19" + + VolumeReadable bool "20" + VolumeWritable bool "20" + + FormatInfo "21" +} + +type GetSourceOutputInfo struct{ SourceOutpuIndex uint32 } +type GetSourceOutputInfoReply struct { + SourceOutpuIndex uint32 + MediaName string + ModuleIndex uint32 + ClientIndex uint32 + SourceIndex uint32 + SampleSpec + ChannelMap ChannelMap + + SourceOutpuLatency Microseconds + SourceLatency Microseconds + ResampleMethod string + Driver string + + Properties PropList "13" + + Corked bool "19" + + ChannelVolumes ChannelVolumes "22" + Muted bool "22" + VolumeReadable bool "22" + VolumeWritable bool "22" + FormatInfo "22" +} + +type GetSampleInfo struct { + SampleIndex uint32 + SampleName string +} +type GetSampleInfoReply struct { + SampleIndex uint32 + SampleName string + ChannelVolumes ChannelVolumes + Duration Microseconds + SampleSpec + ChannelMap ChannelMap + Length uint32 + Lazy bool + Filename string + + Properties PropList "13" +} + +type GetSinkInfoList struct{} +type GetSourceInfoList struct{} +type GetModuleInfoList struct{} +type GetClientInfoList struct{} +type GetCardInfoList struct{} +type GetSinkInputInfoList struct{} +type GetSourceOutputInfoList struct{} +type GetSampleInfoList struct{} + +type GetSinkInfoListReply []*GetSinkInfoReply +type GetSourceInfoListReply []*GetSourceInfoReply +type GetModuleInfoListReply []*GetModuleInfoReply +type GetClientInfoListReply []*GetClientInfoReply +type GetCardInfoListReply []*GetCardInfoReply +type GetSinkInputInfoListReply []*GetSinkInputInfoReply +type GetSourceOutputInfoListReply []*GetSourceOutputInfoReply +type GetSampleInfoListReply []*GetSampleInfoReply + +type Subscribe struct{ Mask SubscriptionMask } + +type SetSinkVolume struct { + SinkIndex uint32 + SinkName string + ChannelVolumes ChannelVolumes +} + +type SetSourceVolume struct { + SourceIndex uint32 + SourceName string + ChannelVolumes ChannelVolumes +} + +type SetSinkInputVolume struct { + SinkInputIndex uint32 + ChannelVolumes ChannelVolumes +} + +type SetSourceOutputVolume struct { + SourceOutputIndex uint32 + ChannelVolumes ChannelVolumes +} + +type SetSinkMute struct { + SinkIndex uint32 + SinkName string + Mute bool +} + +type SetSourceMute struct { + SourceIndex uint32 + SourceName string + Mute bool +} + +type SetSinkInputMute struct { + SinkInputIndex uint32 + Mute bool +} + +type SetSourceOutputMute struct { + SourceOutputIndex uint32 + Mute bool +} + +type CorkPlaybackStream struct { + StreamIndex uint32 + Corked bool +} + +type CorkRecordStream struct { + StreamIndex uint32 + Corked bool +} + +type FlushRecordStream struct{ StreamIndex uint32 } +type TriggerPlaybackStream struct{ StreamIndex uint32 } +type FlushPlaybackStream struct{ StreamIndex uint32 } +type PrebufPlaybackStream struct{ StreamIndex uint32 } + +type SetPlaybackStreamBufferAttr struct { + StreamIndex uint32 + BufferMaxLength uint32 + BufferTargetLength uint32 + BufferPrebufferLength uint32 + BufferMinimumRequest uint32 + + AdjustLatency bool "13" + + EarlyRequests bool "14" +} +type SetPlaybackStreamBufferAttrReply struct { + BufferMaxLength uint32 + BufferTargetLength uint32 + BufferPrebufferLength uint32 + BufferMinimumRequest uint32 + + SinkLatency Microseconds "13" +} + +type SetRecordStreamBufferAttr struct { + StreamIndex uint32 + BufferMaxLength uint32 + BufferFragSize uint32 + + AdjustLatency bool "13" + + EarlyRequests bool "14" +} +type SetRecordStreamBufferAttrReply struct { + BufferMaxLength uint32 + BufferFragSize uint32 + + SourceLatency Microseconds "13" +} + +type UpdatePlaybackStreamSampleRate struct { + StreamIndex uint32 + SampleRate uint32 +} + +type UpdateRecordStreamSampleRate struct { + StreamIndex uint32 + SampleRate uint32 +} + +type UpdatePlaybackStreamProplist struct { + StreamIndex uint32 + Mode uint32 + Properties PropList +} + +type UpdateRecordStreamProplist struct { + StreamIndex uint32 + Mode uint32 + Properties PropList +} + +type UpdateClientProplist struct { + Mode uint32 + Properties PropList +} + +type RemovePlaybackStreamProplist struct { + StreamIndex uint32 + Properties PropList // ignored +} +type RemoveRecordStreamProplist struct { + StreamIndex uint32 + Properties PropList // ignored +} +type RemoveClientProplist struct { + Properties PropList // ignored +} + +type SetDefaultSink struct{ SinkName string } +type SetDefaultSource struct{ SourceName string } + +type SetPlaybackStreamName struct { + StreamIndex uint32 + Name string +} + +type SetRecordStreamName struct { + StreamIndex uint32 + Name string +} + +type KillSinkInput struct{ SinkInputIndex uint32 } +type KillSourceOutput struct{ SourceOutputIndex uint32 } +type KillClient struct{ ClientIndex uint32 } + +type LoadModule struct { + Name string + Args string +} +type LoadModuleReply struct { + ModuleIndex uint32 +} + +type UnloadModule struct{ ModuleIndex uint32 } + +type MoveSinkInput struct { + SinkInputIndex uint32 + DeviceIndex uint32 + DeviceName string +} + +type MoveSourceOutput struct { + SourceOutputIndex uint32 + DeviceIndex uint32 + DeviceName string +} + +type SuspendSink struct { + SinkIndex uint32 + SinkName string + Suspend bool +} +type SuspendSource struct { + SourceIndex uint32 + SourceName string + Suspend bool +} + +// The reply type for this command is extension-specific +type Extension struct { + Index uint32 + Name string +} + +type SetCardProfile struct { + CardIndex uint32 + CardName string + ProfileName string +} + +type SetSinkPort struct { + SinkIndex uint32 + SinkName string + Port string +} + +type SetSourcePort struct { + SourceIndex uint32 + SourceName string + Port string +} + +type SetPortLatencyOffset struct { + CardIndex uint32 + CardName string + PortName string + Offset int64 +} + +func (*CreatePlaybackStream) command() uint32 { return OpCreatePlaybackStream } +func (*DeletePlaybackStream) command() uint32 { return OpDeletePlaybackStream } +func (*CreateRecordStream) command() uint32 { return OpCreateRecordStream } +func (*DeleteRecordStream) command() uint32 { return OpDeleteRecordStream } +func (*Exit) command() uint32 { return OpExit } +func (*Auth) command() uint32 { return OpAuth } +func (*SetClientName) command() uint32 { return OpSetClientName } +func (*LookupSink) command() uint32 { return OpLookupSink } +func (*LookupSource) command() uint32 { return OpLookupSource } +func (*DrainPlaybackStream) command() uint32 { return OpDrainPlaybackStream } +func (*Stat) command() uint32 { return OpStat } +func (*GetPlaybackLatency) command() uint32 { return OpGetPlaybackLatency } +func (*CreateUploadStream) command() uint32 { return OpCreateUploadStream } +func (*DeleteUploadStream) command() uint32 { return OpDeleteUploadStream } +func (*FinishUploadStream) command() uint32 { return OpFinishUploadStream } +func (*PlaySample) command() uint32 { return OpPlaySample } +func (*RemoveSample) command() uint32 { return OpRemoveSample } +func (*GetServerInfo) command() uint32 { return OpGetServerInfo } +func (*GetSinkInfo) command() uint32 { return OpGetSinkInfo } +func (*GetSinkInfoList) command() uint32 { return OpGetSinkInfoList } +func (*GetSourceInfo) command() uint32 { return OpGetSourceInfo } +func (*GetSourceInfoList) command() uint32 { return OpGetSourceInfoList } +func (*GetModuleInfo) command() uint32 { return OpGetModuleInfo } +func (*GetModuleInfoList) command() uint32 { return OpGetModuleInfoList } +func (*GetClientInfo) command() uint32 { return OpGetClientInfo } +func (*GetClientInfoList) command() uint32 { return OpGetClientInfoList } +func (*GetSinkInputInfo) command() uint32 { return OpGetSinkInputInfo } +func (*GetSinkInputInfoList) command() uint32 { return OpGetSinkInputInfoList } +func (*GetSourceOutputInfo) command() uint32 { return OpGetSourceOutputInfo } +func (*GetSourceOutputInfoList) command() uint32 { return OpGetSourceOutputInfoList } +func (*GetSampleInfo) command() uint32 { return OpGetSampleInfo } +func (*GetSampleInfoList) command() uint32 { return OpGetSampleInfoList } +func (*Subscribe) command() uint32 { return OpSubscribe } +func (*SetSinkVolume) command() uint32 { return OpSetSinkVolume } +func (*SetSinkInputVolume) command() uint32 { return OpSetSinkInputVolume } +func (*SetSourceVolume) command() uint32 { return OpSetSourceVolume } +func (*SetSinkMute) command() uint32 { return OpSetSinkMute } +func (*SetSourceMute) command() uint32 { return OpSetSourceMute } +func (*CorkPlaybackStream) command() uint32 { return OpCorkPlaybackStream } +func (*FlushPlaybackStream) command() uint32 { return OpFlushPlaybackStream } +func (*TriggerPlaybackStream) command() uint32 { return OpTriggerPlaybackStream } +func (*SetDefaultSink) command() uint32 { return OpSetDefaultSink } +func (*SetDefaultSource) command() uint32 { return OpSetDefaultSource } +func (*SetPlaybackStreamName) command() uint32 { return OpSetPlaybackStreamName } +func (*SetRecordStreamName) command() uint32 { return OpSetRecordStreamName } +func (*KillClient) command() uint32 { return OpKillClient } +func (*KillSinkInput) command() uint32 { return OpKillSinkInput } +func (*KillSourceOutput) command() uint32 { return OpKillSourceOutput } +func (*LoadModule) command() uint32 { return OpLoadModule } +func (*UnloadModule) command() uint32 { return OpUnloadModule } +func (*GetRecordLatency) command() uint32 { return OpGetRecordLatency } +func (*CorkRecordStream) command() uint32 { return OpCorkRecordStream } +func (*FlushRecordStream) command() uint32 { return OpFlushRecordStream } +func (*PrebufPlaybackStream) command() uint32 { return OpPrebufPlaybackStream } +func (*MoveSinkInput) command() uint32 { return OpMoveSinkInput } +func (*MoveSourceOutput) command() uint32 { return OpMoveSourceOutput } +func (*SetSinkInputMute) command() uint32 { return OpSetSinkInputMute } +func (*SuspendSink) command() uint32 { return OpSuspendSink } +func (*SuspendSource) command() uint32 { return OpSuspendSource } +func (*SetPlaybackStreamBufferAttr) command() uint32 { return OpSetPlaybackStreamBufferAttr } +func (*SetRecordStreamBufferAttr) command() uint32 { return OpSetRecordStreamBufferAttr } +func (*UpdatePlaybackStreamSampleRate) command() uint32 { return OpUpdatePlaybackStreamSampleRate } +func (*UpdateRecordStreamSampleRate) command() uint32 { return OpUpdateRecordStreamSampleRate } +func (*UpdateRecordStreamProplist) command() uint32 { return OpUpdateRecordStreamProplist } +func (*UpdatePlaybackStreamProplist) command() uint32 { return OpUpdatePlaybackStreamProplist } +func (*UpdateClientProplist) command() uint32 { return OpUpdateClientProplist } +func (*RemoveRecordStreamProplist) command() uint32 { return OpRemoveRecordStreamProplist } +func (*RemovePlaybackStreamProplist) command() uint32 { return OpRemovePlaybackStreamProplist } +func (*RemoveClientProplist) command() uint32 { return OpRemoveClientProplist } +func (*Extension) command() uint32 { return OpExtension } +func (*GetCardInfo) command() uint32 { return OpGetCardInfo } +func (*GetCardInfoList) command() uint32 { return OpGetCardInfoList } +func (*SetCardProfile) command() uint32 { return OpSetCardProfile } +func (*SetSinkPort) command() uint32 { return OpSetSinkPort } +func (*SetSourcePort) command() uint32 { return OpSetSourcePort } +func (*SetSourceOutputVolume) command() uint32 { return OpSetSourceOutputVolume } +func (*SetSourceOutputMute) command() uint32 { return OpSetSourceOutputMute } +func (*SetPortLatencyOffset) command() uint32 { return OpSetPortLatencyOffset } + +func (*CreatePlaybackStreamReply) IsReplyTo() uint32 { return OpCreatePlaybackStream } +func (*CreateRecordStreamReply) IsReplyTo() uint32 { return OpCreateRecordStream } +func (*AuthReply) IsReplyTo() uint32 { return OpAuth } +func (*SetClientNameReply) IsReplyTo() uint32 { return OpSetClientName } +func (*LookupSinkReply) IsReplyTo() uint32 { return OpLookupSink } +func (*LookupSourceReply) IsReplyTo() uint32 { return OpLookupSource } +func (*StatReply) IsReplyTo() uint32 { return OpStat } +func (*GetPlaybackLatencyReply) IsReplyTo() uint32 { return OpGetPlaybackLatency } +func (*CreateUploadStreamReply) IsReplyTo() uint32 { return OpCreateUploadStream } +func (*GetServerInfoReply) IsReplyTo() uint32 { return OpGetServerInfo } +func (*GetSinkInfoReply) IsReplyTo() uint32 { return OpGetSinkInfo } +func (*GetSinkInfoListReply) IsReplyTo() uint32 { return OpGetSinkInfoList } +func (*GetSourceInfoReply) IsReplyTo() uint32 { return OpGetSourceInfo } +func (*GetSourceInfoListReply) IsReplyTo() uint32 { return OpGetSourceInfoList } +func (*GetModuleInfoReply) IsReplyTo() uint32 { return OpGetModuleInfo } +func (*GetModuleInfoListReply) IsReplyTo() uint32 { return OpGetModuleInfoList } +func (*GetClientInfoReply) IsReplyTo() uint32 { return OpGetClientInfo } +func (*GetClientInfoListReply) IsReplyTo() uint32 { return OpGetClientInfoList } +func (*GetSinkInputInfoReply) IsReplyTo() uint32 { return OpGetSinkInputInfo } +func (*GetSinkInputInfoListReply) IsReplyTo() uint32 { return OpGetSinkInputInfoList } +func (*GetSourceOutputInfoReply) IsReplyTo() uint32 { return OpGetSourceOutputInfo } +func (*GetSourceOutputInfoListReply) IsReplyTo() uint32 { return OpGetSourceOutputInfoList } +func (*GetSampleInfoReply) IsReplyTo() uint32 { return OpGetSampleInfo } +func (*GetSampleInfoListReply) IsReplyTo() uint32 { return OpGetSampleInfoList } +func (*LoadModuleReply) IsReplyTo() uint32 { return OpLoadModule } +func (*GetRecordLatencyReply) IsReplyTo() uint32 { return OpGetRecordLatency } +func (*SetPlaybackStreamBufferAttrReply) IsReplyTo() uint32 { return OpSetPlaybackStreamBufferAttr } +func (*SetRecordStreamBufferAttrReply) IsReplyTo() uint32 { return OpSetRecordStreamBufferAttr } +func (*GetCardInfoReply) IsReplyTo() uint32 { return OpGetCardInfo } +func (*GetCardInfoListReply) IsReplyTo() uint32 { return OpGetCardInfoList } + +// SERVER -> CLIENT MESSAGES + +type Request struct { + StreamIndex uint32 + Length uint32 +} + +type Overflow struct { + StreamIndex uint32 +} + +type Underflow struct { + StreamIndex uint32 + Offset int64 "23" +} + +type PlaybackStreamKilled struct{ StreamIndex uint32 } +type RecordStreamKilled struct{ StreamIndex uint32 } + +type SubscribeEvent struct { + Event SubscriptionEventType + Index uint32 +} + +type PlaybackStreamSuspended struct { + StreamIndex uint32 + Suspended bool +} + +type RecordStreamSuspended struct { + StreamIndex uint32 + Suspended bool +} + +type PlaybackStreamMoved struct { + StreamIndex uint32 + DestIndex uint32 + DestName string + Suspended bool + + BufferMaxLength uint32 "13" + BufferTargetLength uint32 "13" + BufferPrebufferLength uint32 "13" + BufferMinimumRequest uint32 "13" + SinkLatency Microseconds "13" +} + +type RecordStreamMoved struct { + StreamIndex uint32 + DestIndex uint32 + DestName string + Suspended bool + + BufferMaxLength uint32 "13" + BufferFragSize uint32 "13" + SourceLatency Microseconds "13" +} + +type Started struct{ StreamIndex uint32 } + +type ClientEvent struct { + Event string + Properties PropList +} + +type PlaybackStreamEvent struct { + StreamIndex uint32 + Event string + Properties PropList +} + +type RecordStreamEvent struct { + StreamIndex uint32 + Event string + Properties PropList +} + +type PlaybackBufferAttrChanged struct { + StreamIndex uint32 + BufferMaxLength uint32 + BufferTargetLength uint32 + BufferPrebufferLength uint32 + BufferMinimumRequest uint32 + SinkLatency Microseconds +} diff --git a/vendor/github.com/jfreymuth/pulse/proto/reader.go b/vendor/github.com/jfreymuth/pulse/proto/reader.go new file mode 100644 index 0000000000..34ab111946 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/proto/reader.go @@ -0,0 +1,275 @@ +package proto + +import ( + "io" + "reflect" + "strconv" +) + +type ProtocolReader struct { + r io.Reader + buf []byte + buffered int + err error + pos int +} + +func (p *ProtocolReader) setErr(err error) { + if p.err != nil { + p.err = err + } +} + +func (p *ProtocolReader) fill(min int) { + const bufferSize = 1024 + if p.err != nil { + return + } + if len(p.buf) < min { + size := 2 * min + if size < bufferSize { + size = bufferSize + } + buf := make([]byte, size) + copy(buf, p.buf[:p.buffered]) + p.buf = buf + } + emptyReads := 0 + for p.buffered < min { + n, err := p.r.Read(p.buf[p.buffered:]) + p.buffered += n + if err != nil { + p.err = err + return + } + if n == 0 { + emptyReads++ + if emptyReads >= 100 { + p.err = io.ErrNoProgress + return + } + } + } +} + +func (p *ProtocolReader) advance(n int) { + p.fill(n) + if p.err != nil { + return + } + p.buf = p.buf[n:] + p.buffered -= n + p.pos += n +} + +func (p *ProtocolReader) byte() byte { + p.fill(1) + if p.err != nil { + return 0 + } + b := p.buf[0] + p.advance(1) + return b +} + +func (p *ProtocolReader) uint32() uint32 { + p.fill(4) + if p.err != nil { + return 0 + } + u := uint32(p.buf[0])<<24 | uint32(p.buf[1])<<16 | uint32(p.buf[2])<<8 | uint32(p.buf[3]) + p.advance(4) + return u +} + +func (p *ProtocolReader) uint64() uint64 { + p.fill(8) + if p.err != nil { + return 0 + } + u := uint64(p.buf[0])<<56 | uint64(p.buf[1])<<48 | uint64(p.buf[2])<<40 | uint64(p.buf[3])<<32 | uint64(p.buf[4])<<24 | uint64(p.buf[5])<<16 | uint64(p.buf[6])<<8 | uint64(p.buf[7]) + p.advance(8) + return u +} + +func (p *ProtocolReader) bool() bool { + return p.byte() == '1' +} + +func (p *ProtocolReader) string() string { + const maxLength = 1024 + for i := 0; i < maxLength; i++ { + if i >= p.buffered { + p.fill(p.buffered + 1) + } + if p.err != nil { + return "" + } + if p.buf[i] == 0 { + s := string(p.buf[:i]) + p.advance(i + 1) + return s + } + } + p.setErr(ErrProtocolError) + return "" +} + +func (p *ProtocolReader) bytes(out []byte) { + p.fill(len(out)) + if p.err != nil { + return + } + copy(out, p.buf) + p.advance(len(out)) +} + +func (p *ProtocolReader) tmpbytes(n int) []byte { + p.fill(n) + if p.err != nil { + return nil + } + return p.buf[:n] +} + +func (p *ProtocolReader) x() []byte { + l := p.uint32() + x := make([]byte, l) + p.fill(int(l)) + if p.err != nil { + return nil + } + copy(x, p.buf) + p.advance(int(l)) + return x +} + +func (p *ProtocolReader) propList(out PropList) { + for p.err == nil { + keyType := p.byte() + if keyType == 'N' { + break + } + if keyType != 't' { + p.setErr(ErrProtocolError) + return + } + key := p.string() + lenType := p.byte() + if lenType != 'L' { + p.setErr(ErrProtocolError) + return + } + l := p.uint32() + valueType := p.byte() + if valueType != 'x' { + p.setErr(ErrProtocolError) + return + } + value := p.x() + if len(value) != int(l) { + p.setErr(ErrProtocolError) + return + } + out[key] = PropListEntry(value) + } +} + +func (p *ProtocolReader) value(i interface{}, version Version) { + v := reflect.ValueOf(i).Elem() + t := v.Type() + for i := 0; i < v.NumField(); i++ { + f := v.Field(i) + + if tag := string(t.Field(i).Tag); tag != "" { + if ver, err := strconv.Atoi(tag); err == nil && ver > version.Version() { + continue + } + if tag[0] == '<' { + if ver, err := strconv.Atoi(tag[1:]); err == nil && ver <= version.Version() { + continue + } + } + } + + if f.Kind() == reflect.Slice && f.Type().Elem().Kind() == reflect.Struct { + if _, ok := f.Interface().([]FormatInfo); ok { + p.byte() // B + l := p.byte() + fi := make([]FormatInfo, l) + for i := range fi { + p.byte() // f + p.byte() // B + fi[i].Encoding = p.byte() + p.byte() // P + fi[i].Properties = make(PropList) + p.propList(fi[i].Properties) + } + } else { + p.byte() // L + l := int(p.uint32()) + fv := reflect.MakeSlice(f.Type(), l, l) + for i := 0; i < l; i++ { + p.value(fv.Index(i).Addr().Interface(), version) + } + f.Set(fv) + } + continue + } + typ := p.byte() + switch typ { + case 't': + f.SetString(p.string()) + case 'N': + f.SetString("") + case 'L': + f.SetUint(uint64(p.uint32())) + case 'B': + f.SetUint(uint64(p.byte())) + case 'R': + f.SetUint(p.uint64()) + case 'r': + f.SetInt(int64(p.uint64())) + case 'a': + f.Set(reflect.ValueOf(SampleSpec{p.byte(), p.byte(), p.uint32()})) + case 'x': + if f.Kind() == reflect.String { + x := p.x() + f.SetString(string(x[:len(x)-1])) + } else { + f.SetBytes(p.x()) + } + case '1': + f.SetBool(true) + case '0': + f.SetBool(false) + case 'T': + f.Set(reflect.ValueOf(Time{p.uint32(), p.uint32()})) + case 'U': + f.SetUint(p.uint64()) + case 'm': + b := make([]byte, p.byte()) + p.bytes(b) + f.SetBytes(b) + case 'v': + u := make([]uint32, p.byte()) + for i := range u { + u[i] = p.uint32() + } + f.Set(reflect.ValueOf(u)) + case 'P': + m := make(PropList) + p.propList(m) + f.Set(reflect.ValueOf(m)) + case 'V': + f.SetUint(uint64(p.uint32())) + case 'f': + m := make(PropList) + p.byte() // B + enc := p.byte() + p.byte() // P + p.propList(m) + f.Set(reflect.ValueOf(FormatInfo{enc, m})) + } + } +} diff --git a/vendor/github.com/jfreymuth/pulse/proto/types.go b/vendor/github.com/jfreymuth/pulse/proto/types.go new file mode 100644 index 0000000000..144f5d165e --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/proto/types.go @@ -0,0 +1,184 @@ +package proto + +import "math" + +const Undefined = 0xFFFFFFFF + +const ( + FormatUint8 = 0 + FormatInt16LE = 3 + FormatInt16BE = 4 + FormatFloat32LE = 5 + FormatFloat32BE = 6 + FormatInt32LE = 7 + FormatInt32BE = 8 +) + +const ( + ChannelMono = 0 + ChannelLeft = 1 + ChannelRight = 2 + ChannelCenter = 3 + ChannelFrontLeft = 1 + ChannelFrontRight = 2 + ChannelFrontCenter = 3 + ChannelRearCenter = 4 + ChannelRearLeft = 5 + ChannelRearRight = 6 + ChannelLFE = 7 + ChannelLeftCenter = 8 + ChannelRightCenter = 9 + ChannelLeftSide = 10 + ChannelRightSide = 11 + ChannelAux0 = 12 + ChannelAux31 = 43 + ChannelTopCenter = 44 + ChannelTopFrontLeft = 45 + ChannelTopFrontRight = 46 + ChannelTopFrontCenter = 47 + ChannelTopRearLeft = 48 + ChannelTopRearRight = 49 + ChannelTopRearCenter = 50 +) + +const ( + EncodingPCM = 1 +) + +type SampleSpec struct { + Format byte + Channels byte + Rate uint32 +} + +type Microseconds uint64 + +type ChannelMap []byte + +type ChannelVolumes []uint32 + +type Time struct { + Seconds uint32 + Microseconds uint32 +} + +type Volume uint32 + +const ( + // Muted (minimal valid) volume (0%, -inf dB) + VolumeMuted Volume = 0 + // Normal volume (100%, 0 dB) + VolumeNorm Volume = 0x10000 + // Maximum valid volume we can store. + VolumeMax Volume = math.MaxUint32 / 2 + // Special 'invalid' volume. + VolumeInvalid Volume = math.MaxUint32 +) + +type FormatInfo struct { + Encoding byte + Properties PropList +} + +type SubscriptionMask uint32 + +const ( + SubscriptionMaskSink SubscriptionMask = 1 << iota + SubscriptionMaskSource + SubscriptionMaskSinkInput + SubscriptionMaskSourceInput + SubscriptionMaskModule + SubscriptionMaskClient + SubscriptionMaskSampleCache + SubscriptionMaskServer + SubscriptionMaskAutoload + SubscriptionMaskCard + + SubscriptionMaskNull SubscriptionMask = 0 + SubscriptionMaskAll SubscriptionMask = 0x02ff +) + +type SubscriptionEventType uint32 + +const ( + EventSink SubscriptionEventType = iota + EventSource + EventSinkSinkInput + EventSinkSourceOutput + EventModule + EventClient + EventSampleCache + EventServer + EventAutoload + EventCard + EventFacilityMask SubscriptionEventType = 0xf + + EventNew SubscriptionEventType = 0x0000 + EventChange SubscriptionEventType = 0x0010 + EventRemove SubscriptionEventType = 0x0020 + EventTypeMask SubscriptionEventType = 0x0030 +) + +func (e SubscriptionEventType) GetFacility() SubscriptionEventType { + return e & EventFacilityMask +} + +func (e SubscriptionEventType) GetType() SubscriptionEventType { + return e & EventTypeMask +} + +func (e SubscriptionEventType) String() string { + var res string + switch e.GetType() { + case EventNew: + res += "new" + case EventChange: + res += "change" + case EventRemove: + res += "remove" + default: + return "" + } + res += " " + switch e.GetFacility() { + case EventSink: + res += "sink" + case EventSource: + res += "source" + case EventSinkSinkInput: + res += "sink input" + case EventSinkSourceOutput: + res += "source output" + case EventModule: + res += "module" + case EventClient: + res += "client" + case EventSampleCache: + res += "sample cache" + case EventServer: + res += "server" + case EventAutoload: + res += "autoload" + case EventCard: + res += "card" + default: + return "" + } + return res +} + +type PropList map[string]PropListEntry + +type PropListEntry []byte + +func PropListString(s string) PropListEntry { + e := make(PropListEntry, len(s)+1) + copy(e, s) + return e +} +func (e PropListEntry) String() string { + if len(e) == 0 || e[len(e)-1] != '\x00' { + return "" + } + return string(e[:len(e)-1]) +} diff --git a/vendor/github.com/jfreymuth/pulse/proto/version.go b/vendor/github.com/jfreymuth/pulse/proto/version.go new file mode 100644 index 0000000000..a2c209ec2e --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/proto/version.go @@ -0,0 +1,14 @@ +package proto + +type Version uint32 + +func (v Version) Version() int { return int(v & 0xFFFF) } + +func (v Version) Min(u Version) Version { + flags := v & u & 0xFFFF0000 + v &= 0xFFFF + if v > u&0xFFFF { + v = u & 0xFFFF + } + return v | flags +} diff --git a/vendor/github.com/jfreymuth/pulse/proto/writer.go b/vendor/github.com/jfreymuth/pulse/proto/writer.go new file mode 100644 index 0000000000..6c3ff9c0cb --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/proto/writer.go @@ -0,0 +1,208 @@ +package proto + +import ( + "io" + "reflect" + "strconv" +) + +type ProtocolWriter struct { + w io.Writer + buf []byte + pos int + err error +} + +func (p *ProtocolWriter) setErr(err error) { + if p.err != nil { + p.err = err + } +} + +func (p *ProtocolWriter) flush() { + if p.err != nil { + return + } + _, err := p.w.Write(p.buf[:p.pos]) + if err != nil { + p.err = err + } + p.pos = 0 +} + +func (p *ProtocolWriter) ensure(n int) { + if len(p.buf) < 1024 { + p.flush() + p.buf = make([]byte, 1024) + } + if len(p.buf)+p.pos < n { + p.flush() + } +} + +func (p *ProtocolWriter) byte(b byte) { + p.ensure(1) + p.buf[p.pos] = b + p.pos++ +} + +func (p *ProtocolWriter) uint32(u uint32) { + p.ensure(4) + p.buf[p.pos] = byte(u >> 24) + p.buf[p.pos+1] = byte(u >> 16) + p.buf[p.pos+2] = byte(u >> 8) + p.buf[p.pos+3] = byte(u) + p.pos += 4 +} + +func (p *ProtocolWriter) uint64(u uint64) { + p.ensure(8) + p.buf[p.pos] = byte(u >> 56) + p.buf[p.pos+1] = byte(u >> 48) + p.buf[p.pos+2] = byte(u >> 40) + p.buf[p.pos+3] = byte(u >> 32) + p.buf[p.pos+4] = byte(u >> 24) + p.buf[p.pos+5] = byte(u >> 16) + p.buf[p.pos+6] = byte(u >> 8) + p.buf[p.pos+7] = byte(u) + p.pos += 8 +} + +func (p *ProtocolWriter) string(s string) { + p.ensure(len(s) + 1) + copy(p.buf[p.pos:], s) + p.buf[p.pos+len(s)] = 0 + p.pos += len(s) + 1 +} + +func (p *ProtocolWriter) x(x []byte) { + p.uint32(uint32(len(x))) + p.ensure(len(x)) + copy(p.buf[p.pos:], x) + p.pos += len(x) +} + +func (p *ProtocolWriter) xstring(x string) { + p.uint32(uint32(len(x)) + 1) + p.ensure(len(x)) + copy(p.buf[p.pos:], x) + p.pos += len(x) + p.byte(0) +} + +func (p *ProtocolWriter) propList(list PropList) { + for k, v := range list { + p.byte('t') + p.string(k) + p.byte('L') + p.uint32(uint32(len(v))) + p.byte('x') + p.x(v) + } + p.byte('N') +} + +func (p *ProtocolWriter) value(i interface{}, version Version) { + if i == nil { + return + } + v := reflect.ValueOf(i).Elem() + t := v.Type() + for i := 0; i < v.NumField(); i++ { + f := v.Field(i) + + if tag := string(t.Field(i).Tag); tag != "" { + if ver, err := strconv.Atoi(tag); err == nil && ver > version.Version() { + continue + } + if tag[0] == '<' { + if ver, err := strconv.Atoi(tag[1:]); err == nil && ver <= version.Version() { + continue + } + } + } + + switch f := f.Interface().(type) { + case string: + if f == "" { + p.byte('N') + } else { + p.byte('t') + p.string(f) + } + case uint32: + p.byte('L') + p.uint32(f) + case Version: + p.byte('L') + p.uint32(uint32(f)) + case SubscriptionMask: + p.byte('L') + p.uint32(uint32(f)) + case byte: + p.byte('B') + p.byte(f) + case uint64: + p.byte('R') + p.uint64(f) + case int64: + p.byte('r') + p.uint64(uint64(f)) + case SampleSpec: + p.byte('a') + p.byte(f.Format) + p.byte(f.Channels) + p.uint32(f.Rate) + case []byte: + p.byte('x') + p.x(f) + case bool: + if f { + p.byte('1') + } else { + p.byte('0') + } + case Time: + p.byte('T') + p.uint32(f.Seconds) + p.uint32(f.Microseconds) + case Microseconds: + p.byte('U') + p.uint64(uint64(f)) + case ChannelMap: + p.byte('m') + p.byte(byte(len(f))) + for i := range f { + p.byte(f[i]) + } + case ChannelVolumes: + p.byte('v') + p.byte(byte(len(f))) + for i := range f { + p.uint32(f[i]) + } + case PropList: + p.byte('P') + p.propList(f) + case Volume: + p.byte('V') + p.uint32(uint32(f)) + case FormatInfo: + p.byte('f') + p.byte('B') + p.byte(f.Encoding) + p.byte('P') + p.propList(f.Properties) + case []FormatInfo: + p.byte('B') + p.byte(byte(len(f))) + for _, f := range f { + p.byte('f') + p.byte('B') + p.byte(f.Encoding) + p.byte('P') + p.propList(f.Properties) + } + } + } +} diff --git a/vendor/github.com/jfreymuth/pulse/record.go b/vendor/github.com/jfreymuth/pulse/record.go new file mode 100644 index 0000000000..5d925ae402 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/record.go @@ -0,0 +1,225 @@ +package pulse + +import "github.com/jfreymuth/pulse/proto" + +// A RecordStream is used for recording audio. +// When creating a stream, the user must provide a callback that will be called with the recorded audio data. +type RecordStream struct { + c *Client + + index uint32 + state streamState + err error + + w Writer + + createRequest proto.CreateRecordStream + createReply proto.CreateRecordStreamReply + bytesPerSample int +} + +// NewRecord creates a record stream. +// If the reader returns any error, the stream will be stopped. +// The created stream wil not be running, it must be started with Start(). +// The order of options is important in some cases, see the documentation of the individual RecordOptions. +func (c *Client) NewRecord(w Writer, opts ...RecordOption) (*RecordStream, error) { + r := &RecordStream{ + c: c, + createRequest: proto.CreateRecordStream{ + SourceIndex: proto.Undefined, + ChannelMap: proto.ChannelMap{proto.ChannelMono}, + SampleSpec: proto.SampleSpec{Format: w.Format(), Channels: 1, Rate: 44100}, + BufferMaxLength: proto.Undefined, + Corked: true, + BufferFragSize: proto.Undefined, + DirectOnInputIndex: proto.Undefined, + Properties: proto.PropList{}, + }, + bytesPerSample: bytes(w.Format()), + w: w, + } + + for _, opt := range opts { + opt(r) + } + + if r.createRequest.ChannelVolumes == nil { + cvol := make(proto.ChannelVolumes, len(r.createRequest.ChannelMap)) + for i := range cvol { + cvol[i] = 0x100 + } + r.createRequest.ChannelVolumes = cvol + } + + err := c.c.Request(&r.createRequest, &r.createReply) + if err != nil { + return nil, err + } + r.index = r.createReply.StreamIndex + c.mu.Lock() + c.record[r.index] = r + c.mu.Unlock() + return r, nil +} + +func (r *RecordStream) write(buf []byte) { + if r.err != nil { + return + } + _, err := r.w.Write(buf) + if err != nil { + r.err = err + go r.Stop() + } +} + +// Start starts recording audio. +func (r *RecordStream) Start() { + if r.state == idle { + r.err = nil + r.c.c.Request(&proto.FlushRecordStream{StreamIndex: r.index}, nil) + r.c.c.Request(&proto.CorkRecordStream{StreamIndex: r.index, Corked: false}, nil) + r.state = running + } +} + +// Stop stops recording audio; the callback will no longer be called. +func (r *RecordStream) Stop() { + if r.state == running { + r.c.c.Request(&proto.CorkRecordStream{StreamIndex: r.index, Corked: true}, nil) + r.state = idle + } +} + +// Close closes the stream. +func (r *RecordStream) Close() { + if !r.Closed() { + r.c.c.Request(&proto.DeleteRecordStream{StreamIndex: r.index}, nil) + r.state = closed + r.c.mu.Lock() + delete(r.c.record, r.index) + r.c.mu.Unlock() + } +} + +// Closed returns wether the stream was closed. +// Calling other methods on a closed stream may panic. +func (r *RecordStream) Closed() bool { return r.state == closed || r.state == serverLost } + +// Running returns wether the stream is currently recording. +func (r *RecordStream) Running() bool { return r.state == running } + +// Error returns the last error returned by the stream's writer. +func (r *RecordStream) Error() error { return r.err } + +// SampleRate returns the stream's sample rate (samples per second). +func (r *RecordStream) SampleRate() int { + return int(r.createReply.Rate) +} + +// Channels returns the number of channels. +func (r *RecordStream) Channels() int { + return int(r.createReply.Channels) +} + +// StreamIndex returns the stream index. +// This should only be used together with (*Cient).RawRequest. +func (r *RecordStream) StreamIndex() uint32 { + return r.index +} + +// A RecordOption supplies configuration when creating streams. +type RecordOption func(*RecordStream) + +// RecordMono sets a stream to a single channel. +var RecordMono RecordOption = func(r *RecordStream) { + r.createRequest.ChannelMap = proto.ChannelMap{proto.ChannelMono} + r.createRequest.Channels = 1 +} + +// RecordStereo sets a stream to two channels. +var RecordStereo RecordOption = func(r *RecordStream) { + r.createRequest.ChannelMap = proto.ChannelMap{proto.ChannelLeft, proto.ChannelRight} + r.createRequest.Channels = 2 +} + +// RecordChannels sets a stream to use a custom channel map. +func RecordChannels(m proto.ChannelMap) RecordOption { + if len(m) == 0 { + panic("pulse: invalid channel map") + } + return func(r *RecordStream) { + r.createRequest.ChannelMap = m + r.createRequest.Channels = byte(len(m)) + } +} + +// RecordSampleRate sets the stream's sample rate. +func RecordSampleRate(rate int) RecordOption { + return func(r *RecordStream) { + r.createRequest.Rate = uint32(rate) + } +} + +// RecordBufferFragmentSize sets the fragment size. This is the size (in bytes) of the buffer passed to the callback. +// Lower values reduce latency, at the cost of more overhead. +// +// Fragment size and latency should not be set at the same time. +func RecordBufferFragmentSize(size uint32) RecordOption { + return func(r *RecordStream) { + r.createRequest.BufferFragSize = size + r.createRequest.AdjustLatency = false + } +} + +// RecordLatency sets the stream's latency in seconds. +// +// This should be set after sample rate and channel options. +// +// Fragment size and latency should not be set at the same time. +func RecordLatency(seconds float64) RecordOption { + return func(r *RecordStream) { + r.createRequest.BufferFragSize = uint32(seconds*float64(r.createRequest.Rate)) * uint32(r.createRequest.Channels) * uint32(r.bytesPerSample) + r.createRequest.BufferMaxLength = 2 * r.createRequest.BufferFragSize + r.createRequest.AdjustLatency = true + } +} + +// RecordSource sets the source the stream should receive audio from. +func RecordSource(source *Source) RecordOption { + return func(r *RecordStream) { + r.createRequest.SourceIndex = source.info.SourceIndex + } +} + +// RecordMonitor sets the stream to receive audio sent to the sink. +func RecordMonitor(sink *Sink) RecordOption { + return func(r *RecordStream) { + r.createRequest.SourceIndex = sink.info.MonitorSourceIndex + } +} + +// RecordMediaName sets the streams media name. +// This will e.g. be displayed by a volume control application to identity the stream. +func RecordMediaName(name string) RecordOption { + return func(r *RecordStream) { + r.createRequest.Properties["media.name"] = proto.PropListString(name) + } +} + +// RecordMediaIconName sets the streams media icon using an xdg icon name. +// This will e.g. be displayed by a volume control application to identity the stream. +func RecordMediaIconName(name string) RecordOption { + return func(r *RecordStream) { + r.createRequest.Properties["media.icon_name"] = proto.PropListString(name) + } +} + +// RecordRawOption can be used to create custom options. +// +// This is an advanced function, similar to (*Client).RawRequest. +func RecordRawOption(o func(*proto.CreateRecordStream)) RecordOption { + return func(p *RecordStream) { + o(&p.createRequest) + } +} diff --git a/vendor/github.com/jfreymuth/pulse/sink.go b/vendor/github.com/jfreymuth/pulse/sink.go new file mode 100644 index 0000000000..6be2e76b08 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/sink.go @@ -0,0 +1,68 @@ +package pulse + +import "github.com/jfreymuth/pulse/proto" + +// A Sink is an output device. +type Sink struct { + info proto.GetSinkInfoReply +} + +// ListSinks returns a list of all available output devices. +func (c *Client) ListSinks() ([]*Sink, error) { + var reply proto.GetSinkInfoListReply + err := c.c.Request(&proto.GetSinkInfoList{}, &reply) + if err != nil { + return nil, err + } + sinks := make([]*Sink, len(reply)) + for i := range sinks { + sinks[i] = &Sink{info: *reply[i]} + } + return sinks, nil +} + +// DefaultSink returns the default output device. +func (c *Client) DefaultSink() (*Sink, error) { + var sink Sink + err := c.c.Request(&proto.GetSinkInfo{SinkIndex: proto.Undefined}, &sink.info) + if err != nil { + return nil, err + } + return &sink, nil +} + +// SinkByID looks up a sink id. +func (c *Client) SinkByID(name string) (*Sink, error) { + var sink Sink + err := c.c.Request(&proto.GetSinkInfo{SinkIndex: proto.Undefined, SinkName: name}, &sink.info) + if err != nil { + return nil, err + } + return &sink, nil +} + +// ID returns the sink name. Sink names are unique identifiers, but not necessarily human-readable. +func (s *Sink) ID() string { + return s.info.SinkName +} + +// Name is a human-readable name describing the sink. +func (s *Sink) Name() string { + return s.info.Device +} + +// Channels returns the default channel map. +func (s *Sink) Channels() proto.ChannelMap { + return s.info.ChannelMap +} + +// SampleRate returns the default sample rate. +func (s *Sink) SampleRate() int { + return int(s.info.Rate) +} + +// SinkIndex returns the sink index. +// This should only be used together with (*Cient).RawRequest. +func (s *Sink) SinkIndex() uint32 { + return s.info.SinkIndex +} diff --git a/vendor/github.com/jfreymuth/pulse/source.go b/vendor/github.com/jfreymuth/pulse/source.go new file mode 100644 index 0000000000..a2f4d8c653 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/source.go @@ -0,0 +1,68 @@ +package pulse + +import "github.com/jfreymuth/pulse/proto" + +// A Source is an input device. +type Source struct { + info proto.GetSourceInfoReply +} + +// ListSources returns a list of all available input devices. +func (c *Client) ListSources() ([]*Source, error) { + var reply proto.GetSourceInfoListReply + err := c.c.Request(&proto.GetSourceInfoList{}, &reply) + if err != nil { + return nil, err + } + sinks := make([]*Source, len(reply)) + for i := range sinks { + sinks[i] = &Source{info: *reply[i]} + } + return sinks, nil +} + +// DefaultSource returns the default input device. +func (c *Client) DefaultSource() (*Source, error) { + var source Source + err := c.c.Request(&proto.GetSourceInfo{SourceIndex: proto.Undefined}, &source.info) + if err != nil { + return nil, err + } + return &source, nil +} + +// SourceByID looks up a source id. +func (c *Client) SourceByID(name string) (*Source, error) { + var source Source + err := c.c.Request(&proto.GetSourceInfo{SourceIndex: proto.Undefined, SourceName: name}, &source.info) + if err != nil { + return nil, err + } + return &source, nil +} + +// ID returns the source name. Source names are unique identifiers, but not necessarily human-readable. +func (s *Source) ID() string { + return s.info.SourceName +} + +// Name is a human-readable name describing the source. +func (s *Source) Name() string { + return s.info.Device +} + +// Channels returns the default channel map. +func (s *Source) Channels() proto.ChannelMap { + return s.info.ChannelMap +} + +// SampleRate returns the default sample rate. +func (s *Source) SampleRate() int { + return int(s.info.Rate) +} + +// SourceIndex returns the source index. +// This should only be used together with (*Cient).RawRequest. +func (s *Source) SourceIndex() uint32 { + return s.info.SourceIndex +} diff --git a/vendor/github.com/jfreymuth/pulse/stream.go b/vendor/github.com/jfreymuth/pulse/stream.go new file mode 100644 index 0000000000..9975376443 --- /dev/null +++ b/vendor/github.com/jfreymuth/pulse/stream.go @@ -0,0 +1,11 @@ +package pulse + +type streamState int + +const ( + idle streamState = iota + running + paused + closed + serverLost +) diff --git a/vendor/modules.txt b/vendor/modules.txt index 4a37568fb6..ee6a73e100 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -577,6 +577,10 @@ github.com/jaypipes/ghw/pkg/util github.com/jaypipes/pcidb github.com/jaypipes/pcidb/internal github.com/jaypipes/pcidb/types +# github.com/jfreymuth/pulse v0.1.1 +## explicit; go 1.12 +github.com/jfreymuth/pulse +github.com/jfreymuth/pulse/proto # github.com/jinzhu/inflection v1.0.0 ## explicit github.com/jinzhu/inflection From 79bd9c9230d44afb73d90c33d6515be892e5b0a8 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:45:39 -0500 Subject: [PATCH 2/3] =?UTF-8?q?feat(skychat):=20wire=20real=20call=20audio?= =?UTF-8?q?=20into=20the=20visor=20=E2=80=94=20opt-in=20+=20explicit=20ans?= =?UTF-8?q?wer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes a real 1:1 call carry audio, gated safely. - Real audio is OPT-IN via SKYWIRE_VOICE_AUDIO=mic|monitor (default: silent media + auto-answer, unchanged — safe for headless visors, a call leaks no audio). When enabled, init_voice wires the pulse Source/Sink and switches the manager to EXPLICIT-ANSWER so a visor never streams its microphone to a caller without an explicit answer. Falls back to silence if no audio device. - voice.Manager: ManualAnswer mode + a ringing registry. An inbound invite RINGS (parks the conn) until Answer(id)/Decline(id) or a 45s ring timeout, instead of the immediate OnIncoming decision. Ring callback notifies (logs) the incoming call. Incoming() lists ringing calls. Unit-tested: ring→answer establishes the call; ring→decline fails the caller. - Visor RPC + CLI: VoiceAnswer / VoiceDecline / VoiceIncoming → `cli skychat voice answer|decline|incoming`. (API interface + rpc server + rpc_client + mock + proxy stubs.) Build + lint + tests clean. --- .../commands/skychat/voice_answer.go | 74 ++++++++++++ pkg/skychat/voice/manager.go | 111 +++++++++++++++-- pkg/skychat/voice/voice_test.go | 112 ++++++++++++++++++ pkg/visor/api.go | 3 + pkg/visor/init_voice.go | 55 +++++++-- pkg/visor/proxy_default_api.go | 11 ++ pkg/visor/rpc_client.go | 17 +++ pkg/visor/rpc_client_mock.go | 9 ++ pkg/visor/rpc_voice.go | 29 +++++ pkg/visor/voice.go | 30 +++++ 10 files changed, 431 insertions(+), 20 deletions(-) create mode 100644 cmd/skywire-cli/commands/skychat/voice_answer.go diff --git a/cmd/skywire-cli/commands/skychat/voice_answer.go b/cmd/skywire-cli/commands/skychat/voice_answer.go new file mode 100644 index 0000000000..d46df38be5 --- /dev/null +++ b/cmd/skywire-cli/commands/skychat/voice_answer.go @@ -0,0 +1,74 @@ +// Package cliskychat cmd/skywire-cli/commands/skychat/voice_answer.go c4-vis-cli +// +// Explicit-answer controls for inbound voice calls. When a visor runs with real +// audio (SKYWIRE_VOICE_AUDIO=mic|monitor) incoming calls RING instead of +// auto-answering, so the microphone is never streamed without an explicit +// answer. `voice incoming` lists ringing calls; `voice answer`/`voice decline` +// act on one by id. +package cliskychat + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/skycoin/skywire/cmd/skywire-cli/cliutil" + clirpc "github.com/skycoin/skywire/cmd/skywire-cli/commands/rpc" +) + +func init() { + voiceCmd.AddCommand(voiceIncomingCmd, voiceAnswerCmd, voiceDeclineCmd) +} + +var voiceIncomingCmd = &cobra.Command{ + Use: "incoming", + Short: "List ringing inbound calls awaiting an answer", + Run: func(cmd *cobra.Command, _ []string) { + rpcClient, err := clirpc.Client(cmd.Flags()) + if err != nil { + cliutil.PrintFatalError(cmd.Flags(), err) + } + ids, err := rpcClient.VoiceIncoming() + if err != nil { + cliutil.PrintFatalError(cmd.Flags(), err) + } + out := "no ringing calls\n" + if len(ids) > 0 { + out = "ringing calls:\n " + strings.Join(ids, "\n ") + "\n" + } + cliutil.PrintOutput(cmd.Flags(), ids, out) + }, +} + +var voiceAnswerCmd = &cobra.Command{ + Use: "answer ", + Short: "Accept a ringing inbound call", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + rpcClient, err := clirpc.Client(cmd.Flags()) + if err != nil { + cliutil.PrintFatalError(cmd.Flags(), err) + } + if err := rpcClient.VoiceAnswer(args[0]); err != nil { + cliutil.PrintFatalError(cmd.Flags(), err) + } + cliutil.PrintOutput(cmd.Flags(), args[0], fmt.Sprintf("answered: %s\n", args[0])) + }, +} + +var voiceDeclineCmd = &cobra.Command{ + Use: "decline ", + Short: "Reject a ringing inbound call", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + rpcClient, err := clirpc.Client(cmd.Flags()) + if err != nil { + cliutil.PrintFatalError(cmd.Flags(), err) + } + if err := rpcClient.VoiceDecline(args[0]); err != nil { + cliutil.PrintFatalError(cmd.Flags(), err) + } + cliutil.PrintOutput(cmd.Flags(), args[0], fmt.Sprintf("declined: %s\n", args[0])) + }, +} diff --git a/pkg/skychat/voice/manager.go b/pkg/skychat/voice/manager.go index ec0c264826..89455b7c2f 100644 --- a/pkg/skychat/voice/manager.go +++ b/pkg/skychat/voice/manager.go @@ -10,11 +10,16 @@ import ( "fmt" "net" "sync" + "time" "github.com/skycoin/skywire/pkg/cipher" "github.com/skycoin/skywire/pkg/logging" ) +// ringTimeout bounds how long an unanswered inbound call rings before it's +// auto-declined (in ManualAnswer mode). +const ringTimeout = 45 * time.Second + // Config wires a Manager. Dial + the listeners are supplied by the runtime // (native visor or wasm) so this package stays transport-agnostic (KG7): both a // dmsg client and a skynet networker satisfy them, and voice signals on the SAME @@ -32,11 +37,24 @@ type Config struct { // audio hardware; a real audio backend swaps these in. NewSource func() Source NewSink func() Sink - // OnIncoming decides whether to accept an inbound invite. nil => decline all - // (voice off). A UI wires this to a ring/accept prompt; a headless/test peer - // can return true to auto-answer. + // OnIncoming decides whether to accept an inbound invite IMMEDIATELY (used in + // auto-answer mode). nil => decline all (voice off). A headless/test peer + // returns true to auto-answer. Ignored when ManualAnswer is set. OnIncoming func(inv Sig) bool - Logger *logging.Logger + // ManualAnswer, when true, makes an inbound invite RING (park, pending) until + // Answer(callID) or Decline(callID) — never auto-accepting. This is the mode + // used once real mic capture is enabled, so a visor never streams its + // microphone to a caller without an explicit answer. Ring (optional) is + // notified when a call starts ringing. + ManualAnswer bool + Ring func(inv Sig) + Logger *logging.Logger +} + +// ringingCall is an inbound invite parked awaiting an explicit answer/decline. +type ringingCall struct { + inv Sig + decided chan bool // buffered(1): true=answer, false=decline } // Manager owns the local voice endpoint: it accepts inbound calls via the @@ -46,8 +64,9 @@ type Manager struct { sig *Signaler log *logging.Logger - mu sync.Mutex - calls map[string]*Session + mu sync.Mutex + calls map[string]*Session + ringing map[string]*ringingCall } // NewManager constructs a Manager. Call Serve to start accepting. @@ -64,7 +83,7 @@ func NewManager(cfg Config) *Manager { if cfg.NewSink == nil { cfg.NewSink = func() Sink { return NullSink{} } } - m := &Manager{cfg: cfg, log: cfg.Logger, calls: make(map[string]*Session)} + m := &Manager{cfg: cfg, log: cfg.Logger, calls: make(map[string]*Session), ringing: make(map[string]*ringingCall)} m.sig = NewSignaler(cfg.LocalPK, cfg.SignalPort, cfg.Dial, cfg.Logger) m.sig.SetInviteHandler(m.handleInvite) return m @@ -107,15 +126,54 @@ func (m *Manager) Call(ctx context.Context, peer cipher.PubKey) (*Session, error return sess, nil } -// handleInvite is the callee side: decide accept/decline, and on accept reply -// SigAccept and start a media Session over the same conn. +// handleInvite is the callee side. In auto-answer mode it decides immediately +// via OnIncoming; in ManualAnswer mode it RINGS (parks the conn) until an +// explicit Answer/Decline or the ring timeout. On accept it replies SigAccept +// and starts a media Session over the same conn. func (m *Manager) handleInvite(inv Sig, conn net.Conn) { - accept := m.cfg.OnIncoming != nil && m.cfg.OnIncoming(inv) - if !accept { + if m.cfg.ManualAnswer { + if !m.ringAndWait(inv) { + _ = writeSig(conn, Sig{Type: SigDecline, CallID: inv.CallID, FromPK: m.cfg.LocalPK, Reason: "no answer"}) //nolint:errcheck + _ = conn.Close() //nolint:errcheck + return + } + m.accept(inv, conn) + return + } + if m.cfg.OnIncoming == nil || !m.cfg.OnIncoming(inv) { _ = writeSig(conn, Sig{Type: SigDecline, CallID: inv.CallID, FromPK: m.cfg.LocalPK, Reason: "declined"}) //nolint:errcheck _ = conn.Close() //nolint:errcheck return } + m.accept(inv, conn) +} + +// ringAndWait parks the invite as a ringing call and blocks until it's answered, +// declined, or the ring timeout fires. Returns true only on an explicit answer. +func (m *Manager) ringAndWait(inv Sig) bool { + rc := &ringingCall{inv: inv, decided: make(chan bool, 1)} + m.mu.Lock() + m.ringing[inv.CallID] = rc + m.mu.Unlock() + if m.cfg.Ring != nil { + m.cfg.Ring(inv) + } + m.log.WithField("from", inv.FromPK.Hex()).WithField("call", inv.CallID). + Info("voice: incoming call RINGING — answer with `skychat voice answer `") + + var ok bool + select { + case ok = <-rc.decided: + case <-time.After(ringTimeout): + } + m.mu.Lock() + delete(m.ringing, inv.CallID) + m.mu.Unlock() + return ok +} + +// accept replies SigAccept and starts the media session over conn. +func (m *Manager) accept(inv Sig, conn net.Conn) { ack := Sig{Type: SigAccept, CallID: inv.CallID, FromPK: m.cfg.LocalPK, Codec: m.cfg.Codec.Name(), MediaPort: m.cfg.SignalPort} if err := writeSig(conn, ack); err != nil { _ = conn.Close() //nolint:errcheck @@ -125,6 +183,37 @@ func (m *Manager) handleInvite(inv Sig, conn net.Conn) { go func() { sess.Run(context.Background()); m.dropCall(inv.CallID) }() //nolint:gosec // session outlives the invite/request ctx by design } +// Answer accepts a ringing inbound call by id (ManualAnswer mode). +func (m *Manager) Answer(callID string) error { return m.decide(callID, true) } + +// Decline rejects a ringing inbound call by id. +func (m *Manager) Decline(callID string) error { return m.decide(callID, false) } + +func (m *Manager) decide(callID string, ok bool) error { + m.mu.Lock() + rc := m.ringing[callID] + m.mu.Unlock() + if rc == nil { + return errors.New("voice: no ringing call with that id") + } + select { + case rc.decided <- ok: + default: // already decided + } + return nil +} + +// Incoming returns the invites of calls currently ringing (awaiting answer). +func (m *Manager) Incoming() []Sig { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]Sig, 0, len(m.ringing)) + for _, rc := range m.ringing { + out = append(out, rc.inv) + } + return out +} + func (m *Manager) startSession(callID string, conn net.Conn, ssrc uint32) *Session { sess := NewSession(callID, conn, m.cfg.Codec, m.cfg.NewSource(), m.cfg.NewSink(), ssrc, m.log) m.mu.Lock() diff --git a/pkg/skychat/voice/voice_test.go b/pkg/skychat/voice/voice_test.go index 27758d590b..c44e05bcb4 100644 --- a/pkg/skychat/voice/voice_test.go +++ b/pkg/skychat/voice/voice_test.go @@ -189,3 +189,115 @@ func TestCallDeliversAudio(t *testing.T) { t.Fatalf("hangup: %v", err) } } + +// TestManualAnswerRing exercises the explicit-answer (ring) flow: the callee +// parks the invite until Answer, and the caller's Call returns only then. +func TestManualAnswerRing(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pkA, _ := cipher.GenerateKeyPair() + pkB, _ := cipher.GenerateKeyPair() + + lis := newMemListener() + defer lis.Close() //nolint:errcheck + + rang := make(chan Sig, 1) + mgrB := NewManager(Config{ + LocalPK: pkB, + Dial: func(context.Context, cipher.PubKey, uint16) (net.Conn, error) { return nil, io.EOF }, + ManualAnswer: true, + Ring: func(inv Sig) { rang <- inv }, + }) + go mgrB.Serve(ctx, lis) + + mgrA := NewManager(Config{ + LocalPK: pkA, + Dial: func(context.Context, cipher.PubKey, uint16) (net.Conn, error) { return lis.dial() }, + }) + + type res struct { + sess *Session + err error + } + resc := make(chan res, 1) + go func() { s, e := mgrA.Call(ctx, pkB); resc <- res{s, e} }() + + var inv Sig + select { + case inv = <-rang: + case <-time.After(2 * time.Second): + t.Fatal("call never rang") + } + if inc := mgrB.Incoming(); len(inc) != 1 || inc[0].CallID != inv.CallID { + t.Fatalf("Incoming() = %v, want the ringing call %s", inc, inv.CallID) + } + // Not answered yet → caller still blocked. + select { + case r := <-resc: + t.Fatalf("Call returned before answer: %+v", r) + case <-time.After(100 * time.Millisecond): + } + if err := mgrB.Answer(inv.CallID); err != nil { + t.Fatalf("Answer: %v", err) + } + select { + case r := <-resc: + if r.err != nil { + t.Fatalf("Call after answer: %v", r.err) + } + if r.sess == nil { + t.Fatal("nil session after answer") + } + case <-time.After(2 * time.Second): + t.Fatal("Call did not return after Answer") + } + if inc := mgrB.Incoming(); len(inc) != 0 { + t.Fatalf("still ringing after answer: %v", inc) + } +} + +// TestManualAnswerDecline: an explicit Decline makes the caller's Call fail. +func TestManualAnswerDecline(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pkA, _ := cipher.GenerateKeyPair() + pkB, _ := cipher.GenerateKeyPair() + + lis := newMemListener() + defer lis.Close() //nolint:errcheck + + rang := make(chan Sig, 1) + mgrB := NewManager(Config{ + LocalPK: pkB, + Dial: func(context.Context, cipher.PubKey, uint16) (net.Conn, error) { return nil, io.EOF }, + ManualAnswer: true, + Ring: func(inv Sig) { rang <- inv }, + }) + go mgrB.Serve(ctx, lis) + + mgrA := NewManager(Config{ + LocalPK: pkA, + Dial: func(context.Context, cipher.PubKey, uint16) (net.Conn, error) { return lis.dial() }, + }) + + errc := make(chan error, 1) + go func() { _, e := mgrA.Call(ctx, pkB); errc <- e }() + + var inv Sig + select { + case inv = <-rang: + case <-time.After(2 * time.Second): + t.Fatal("call never rang") + } + if err := mgrB.Decline(inv.CallID); err != nil { + t.Fatalf("Decline: %v", err) + } + select { + case e := <-errc: + if e == nil { + t.Fatal("Call should fail after decline") + } + case <-time.After(2 * time.Second): + t.Fatal("Call did not return after Decline") + } +} diff --git a/pkg/visor/api.go b/pkg/visor/api.go index df8a6f574d..ff1efd0774 100644 --- a/pkg/visor/api.go +++ b/pkg/visor/api.go @@ -290,6 +290,9 @@ type API interface { VoiceCall(peer cipher.PubKey) (string, error) VoiceHangup(callID string) error VoiceActive() ([]string, error) + VoiceAnswer(callID string) error + VoiceDecline(callID string) error + VoiceIncoming() ([]string, error) // Embedded Transport Setup Node (TPS) controls TPSStatus() (*TPSStatus, error) diff --git a/pkg/visor/init_voice.go b/pkg/visor/init_voice.go index 8d5ea56986..6d0e6bae60 100644 --- a/pkg/visor/init_voice.go +++ b/pkg/visor/init_voice.go @@ -10,6 +10,8 @@ package visor import ( "context" "net" + "os" + "strings" "time" "github.com/skycoin/skywire/pkg/app/appnet" @@ -42,19 +44,54 @@ func initVoice(_ context.Context, v *Visor, log *logging.Logger) error { return v.dmsgC.DialStream(dctx, dmsgpkg.Addr{PK: peer, Port: p}) } - mgr := skyvoice.NewManager(skyvoice.Config{ + cfg := skyvoice.Config{ LocalPK: localPK, Dial: dial, SignalPort: port, - // Headless auto-answer for now: media is silent/null until the audio - // backend lands, so accepting an inbound call leaks no audio. A UI - // ring/consent hook replaces this before real mic capture ships. - OnIncoming: func(inv skyvoice.Sig) bool { - log.WithField("from", inv.FromPK.Hex()).Info("voice: inbound call — auto-answering (headless)") + Logger: log, + } + + // Real audio is OPT-IN (SKYWIRE_VOICE_AUDIO=mic|monitor|1). Default: silent + // media + auto-answer — safe for headless visors, accepting a call leaks no + // audio. When enabled, capture live audio AND switch to explicit-answer + // (ring) so a visor never streams its microphone to a caller without an + // explicit `skychat voice answer`. + switch mode := strings.ToLower(strings.TrimSpace(os.Getenv("SKYWIRE_VOICE_AUDIO"))); mode { + case "", "0", "off", "false": + cfg.OnIncoming = func(inv skyvoice.Sig) bool { + log.WithField("from", inv.FromPK.Hex()).Info("voice: inbound call — auto-answering (silent, no audio)") return true - }, - Logger: log, - }) + } + default: + monitor := mode == "monitor" + cfg.ManualAnswer = true + cfg.NewSource = func() skyvoice.Source { + s, err := skyvoice.NewMicSource(monitor, 0) + if err != nil { + log.WithError(err).Warn("voice: audio capture unavailable — using silence") + return skyvoice.SilentSource{} + } + return s + } + cfg.NewSink = func() skyvoice.Sink { + s, err := skyvoice.NewSpeakerSink(0) + if err != nil { + return skyvoice.NullSink{} + } + return s + } + cfg.Ring = func(inv skyvoice.Sig) { + log.WithField("from", inv.FromPK.Hex()).WithField("call", inv.CallID). + Warn("voice: INCOMING CALL — `skywire cli skychat voice answer ` to accept") + } + srcKind := "mic" + if monitor { + srcKind = "system-audio monitor" + } + log.Infof("voice: real audio ENABLED (%s) — incoming calls ring; answer explicitly", srcKind) + } + + mgr := skyvoice.NewManager(cfg) v.voice = mgr // dmsg signaling listener on the shared port, now. diff --git a/pkg/visor/proxy_default_api.go b/pkg/visor/proxy_default_api.go index 81315d31d4..e2f7fe42d5 100644 --- a/pkg/visor/proxy_default_api.go +++ b/pkg/visor/proxy_default_api.go @@ -849,6 +849,17 @@ func (proxyDefaultAPI) VoiceActive() ([]string, error) { return nil, ErrProxyNotSupported } +// VoiceAnswer implements API (not proxied). +func (proxyDefaultAPI) VoiceAnswer(_ string) error { return ErrProxyNotSupported } + +// VoiceDecline implements API (not proxied). +func (proxyDefaultAPI) VoiceDecline(_ string) error { return ErrProxyNotSupported } + +// VoiceIncoming implements API (not proxied). +func (proxyDefaultAPI) VoiceIncoming() ([]string, error) { + return nil, ErrProxyNotSupported +} + func (proxyDefaultAPI) GroupHistory(_ string, _ int) ([]GroupMessage, error) { return nil, ErrProxyNotSupported } diff --git a/pkg/visor/rpc_client.go b/pkg/visor/rpc_client.go index 1ab04852f6..4512303bcc 100644 --- a/pkg/visor/rpc_client.go +++ b/pkg/visor/rpc_client.go @@ -1626,6 +1626,23 @@ func (rc *rpcClient) VoiceActive() ([]string, error) { return out, err } +// VoiceAnswer implements API. +func (rc *rpcClient) VoiceAnswer(callID string) error { + return rc.Call("VoiceAnswer", &callID, &struct{}{}) +} + +// VoiceDecline implements API. +func (rc *rpcClient) VoiceDecline(callID string) error { + return rc.Call("VoiceDecline", &callID, &struct{}{}) +} + +// VoiceIncoming implements API. +func (rc *rpcClient) VoiceIncoming() ([]string, error) { + var out []string + err := rc.Call("VoiceIncoming", &struct{}{}, &out) + return out, err +} + // GroupHistory implements API. Returns persisted group messages from // the visor's history store; returns the wrapped ErrGroupHistoryDisabled // when persistence is off. diff --git a/pkg/visor/rpc_client_mock.go b/pkg/visor/rpc_client_mock.go index d102b8ec1b..20e068f7cb 100644 --- a/pkg/visor/rpc_client_mock.go +++ b/pkg/visor/rpc_client_mock.go @@ -1320,6 +1320,15 @@ func (mc *mockRPCClient) VoiceHangup(_ string) error { return nil } // VoiceActive implements API. func (mc *mockRPCClient) VoiceActive() ([]string, error) { return nil, nil } +// VoiceAnswer implements API. +func (mc *mockRPCClient) VoiceAnswer(_ string) error { return nil } + +// VoiceDecline implements API. +func (mc *mockRPCClient) VoiceDecline(_ string) error { return nil } + +// VoiceIncoming implements API. +func (mc *mockRPCClient) VoiceIncoming() ([]string, error) { return nil, nil } + // GroupHistory implements API. func (mc *mockRPCClient) GroupHistory(_ string, _ int) ([]GroupMessage, error) { return nil, nil } diff --git a/pkg/visor/rpc_voice.go b/pkg/visor/rpc_voice.go index 8f31f44481..f306a3bec6 100644 --- a/pkg/visor/rpc_voice.go +++ b/pkg/visor/rpc_voice.go @@ -41,3 +41,32 @@ func (r *RPC) VoiceActive(_ *struct{}, out *[]string) (err error) { *out = ids return nil } + +// VoiceAnswer accepts a ringing inbound call by id. +func (r *RPC) VoiceAnswer(callID *string, _ *struct{}) (err error) { + defer rpcutil.LogCall(r.log, "VoiceAnswer", callID)(nil, &err) + if callID == nil { + return fmt.Errorf("nil request") + } + return r.visor.VoiceAnswer(*callID) +} + +// VoiceDecline rejects a ringing inbound call by id. +func (r *RPC) VoiceDecline(callID *string, _ *struct{}) (err error) { + defer rpcutil.LogCall(r.log, "VoiceDecline", callID)(nil, &err) + if callID == nil { + return fmt.Errorf("nil request") + } + return r.visor.VoiceDecline(*callID) +} + +// VoiceIncoming replies with the ringing inbound calls awaiting an answer. +func (r *RPC) VoiceIncoming(_ *struct{}, out *[]string) (err error) { + defer rpcutil.LogCall(r.log, "VoiceIncoming", nil)(out, &err) + ids, err := r.visor.VoiceIncoming() + if err != nil { + return err + } + *out = ids + return nil +} diff --git a/pkg/visor/voice.go b/pkg/visor/voice.go index ea9ff45298..e679eecc06 100644 --- a/pkg/visor/voice.go +++ b/pkg/visor/voice.go @@ -48,3 +48,33 @@ func (v *Visor) VoiceActive() ([]string, error) { } return v.voice.Active(), nil } + +// VoiceAnswer accepts a ringing inbound call by id (explicit-answer mode, when +// real audio is enabled). +func (v *Visor) VoiceAnswer(callID string) error { + if v.voice == nil { + return ErrVoiceDisabled + } + return v.voice.Answer(callID) +} + +// VoiceDecline rejects a ringing inbound call by id. +func (v *Visor) VoiceDecline(callID string) error { + if v.voice == nil { + return ErrVoiceDisabled + } + return v.voice.Decline(callID) +} + +// VoiceIncoming returns the ringing inbound calls awaiting an answer, each +// formatted as " from ". +func (v *Visor) VoiceIncoming() ([]string, error) { + if v.voice == nil { + return nil, ErrVoiceDisabled + } + var out []string + for _, inv := range v.voice.Incoming() { + out = append(out, inv.CallID+" from "+inv.FromPK.Hex()) + } + return out, nil +} From 44223fb199082024984cd5fc99f3c39193ee128b Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:10:52 -0500 Subject: [PATCH 3/3] feat(skychat): two-panel sent/received spectrogram during a call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the in-call "see the audio" view. Since call audio is captured/played on the visor (server-side), the visor taps it and the CLI polls it: - voice.Manager Visualize mode (pkg/skychat/voice/tap.go): tee the session's Source (sent) and Sink (received) into small ~1s rings; CallAudio(id) serves the recent PCM. Audio path untouched. Enabled when SKYWIRE_VOICE_AUDIO is set. Unit-tested (tap captures the sent tone). - Visor RPC VoiceCallAudio(id) → {Sent, Recv []int16} (+ API/rpc_client/mock/ proxy). - `cli skychat voice spectrogram --call `: polls the visor and draws two side-by-side spectrograms (left: sent, right: received) with the same audioprism-matched renderer, rate-aware (48 kHz call audio). Standalone `voice spectrogram [--monitor]` unchanged. Build + lint + tests clean. --- .../commands/skychat/voice_spectrogram.go | 164 ++++++++++++++++-- .../skychat/voice_spectrogram_test.go | 2 +- pkg/skychat/voice/manager.go | 33 +++- pkg/skychat/voice/tap.go | 68 ++++++++ pkg/skychat/voice/voice_test.go | 49 ++++++ pkg/visor/api.go | 1 + pkg/visor/init_voice.go | 1 + pkg/visor/proxy_default_api.go | 5 + pkg/visor/rpc_client.go | 9 + pkg/visor/rpc_client_mock.go | 5 + pkg/visor/rpc_voice.go | 20 +++ pkg/visor/voice.go | 10 ++ 12 files changed, 351 insertions(+), 16 deletions(-) create mode 100644 pkg/skychat/voice/tap.go diff --git a/cmd/skywire-cli/commands/skychat/voice_spectrogram.go b/cmd/skywire-cli/commands/skychat/voice_spectrogram.go index baaa660cc0..e5885d01f7 100644 --- a/cmd/skywire-cli/commands/skychat/voice_spectrogram.go +++ b/cmd/skywire-cli/commands/skychat/voice_spectrogram.go @@ -24,6 +24,7 @@ import ( "github.com/spf13/cobra" "github.com/skycoin/skywire/cmd/skywire-cli/cliutil" + clirpc "github.com/skycoin/skywire/cmd/skywire-cli/commands/rpc" skyvoice "github.com/skycoin/skywire/pkg/skychat/voice" "github.com/skycoin/skywire/pkg/skychat/voice/spectrogram" ) @@ -33,16 +34,27 @@ const ( specBufferWidth = 2048 specBufferHeight = 1024 specMaxFreqHz = 12000 // vertical axis spans 0..12 kHz, like audioprism + callAudioRate = 48000 // voice call PCM sample rate (pkg/skychat/voice) ) -var voiceSpectrogramMonitor bool +var ( + voiceSpectrogramMonitor bool + voiceSpectrogramCall string +) func init() { voiceSpectrogramCmd.Flags().BoolVar(&voiceSpectrogramMonitor, "monitor", false, "capture the system output (default sink monitor) instead of the microphone — visualize whatever audio is playing, no mic needed") + voiceSpectrogramCmd.Flags().StringVar(&voiceSpectrogramCall, "call", "", + "visualize an active call by id instead of local audio — two panels (sent | received), polled from the visor") voiceCmd.AddCommand(voiceSpectrogramCmd) } +// callAudioRPC is the visor RPC surface the call-spectrogram view needs. +type callAudioRPC interface { + VoiceCallAudio(callID string) (sent, recv []int16, err error) +} + var voiceSpectrogramCmd = &cobra.Command{ Use: "spectrogram", Short: "Live audio spectrogram in the terminal (mic, or --monitor for system audio)", @@ -53,8 +65,20 @@ Captures local audio (the microphone by default, or the system output with feed on a voice call. Matches the audioprism-go tcell view. Linux only (PulseAudio/PipeWire). Press q / Esc / Ctrl-C to quit.`, Run: func(cmd *cobra.Command, _ []string) { - // Capture at the spectrogram's native rate so the frequency mapping and - // magnitude scaling match audioprism-go exactly. + // --call: two-panel sent/received view of an active call, polled from the + // visor (audio is captured/played server-side). + if voiceSpectrogramCall != "" { + rpcClient, err := clirpc.Client(cmd.Flags()) + if err != nil { + cliutil.PrintFatalError(cmd.Flags(), err) + } + if err := runCallSpectrogramTUI(rpcClient, voiceSpectrogramCall); err != nil { + cliutil.PrintFatalError(cmd.Flags(), err) + } + return + } + // Default: local audio, captured at the spectrogram's native rate so the + // frequency mapping and magnitude scaling match audioprism-go exactly. src, err := skyvoice.NewMicSource(voiceSpectrogramMonitor, spectrogram.SampleRate) if err != nil { cliutil.PrintFatalError(cmd.Flags(), err) @@ -76,10 +100,14 @@ type specView struct { overlap []float32 // sliding FFT window pending []float32 // samples not yet stepped through dftSize int + rate int // sample rate of the fed audio (freq→bin mapping) } -func newSpecView() *specView { - v := &specView{dftSize: spectrogram.S.GetDFTSize()} +func newSpecView(rate int) *specView { + if rate <= 0 { + rate = spectrogram.SampleRate + } + v := &specView{dftSize: spectrogram.S.GetDFTSize(), rate: rate} v.overlap = make([]float32, v.dftSize) v.history = make([][]color.Color, specBufferWidth) for i := range v.history { @@ -110,7 +138,7 @@ func (v *specView) push(samples []float32) { col := make([]color.Color, specBufferHeight) for y := 0; y < specBufferHeight; y++ { freq := float64(y) / float64(specBufferHeight) * specMaxFreqHz - bin := int(freq * float64(v.dftSize) / float64(spectrogram.SampleRate)) + bin := int(freq * float64(v.dftSize) / float64(v.rate)) if bin < len(mags) { col[y] = spectrogram.MagnitudeToPixel(mags[bin]) } else { @@ -143,9 +171,10 @@ func (v *specView) drainInto() { v.mu.Unlock() } -// draw renders the most recent w columns, scaling specBufferHeight to h with low -// frequencies at the bottom (audioprism's drawSpectrogram). -func (v *specView) draw(screen tcell.Screen, w, h int) { +// draw renders the most recent w columns into the screen region starting at x0, +// scaling specBufferHeight to h with low frequencies at the bottom (audioprism's +// drawSpectrogram). +func (v *specView) draw(screen tcell.Screen, x0, w, h int) { v.mu.RLock() defer v.mu.RUnlock() start := (v.head - w + specBufferWidth) % specBufferWidth @@ -158,7 +187,7 @@ func (v *specView) draw(screen tcell.Screen, w, h int) { } r, g, b, _ := v.history[idx][by].RGBA() style := tcell.StyleDefault.Background(tcell.NewRGBColor(int32(r>>8), int32(g>>8), int32(b>>8))) - screen.SetContent(x, h-1-y, ' ', nil, style) + screen.SetContent(x0+x, h-1-y, ' ', nil, style) } } } @@ -176,7 +205,7 @@ func runSpectrogramTUI(src skyvoice.Source) error { defer screen.Fini() screen.Clear() - view := newSpecView() + view := newSpecView(spectrogram.SampleRate) quit := make(chan struct{}) // Audio reader → step columns. @@ -231,12 +260,123 @@ func runSpectrogramTUI(src skyvoice.Source) error { specH := h - 1 // bottom row = hint view.drainInto() screen.Clear() - view.draw(screen, w, specH) + view.draw(screen, 0, w, specH) drawHint(screen, h-1, w, voiceSpectrogramMonitor) screen.Show() } } +// runCallSpectrogramTUI polls the visor for an active call's sent/received PCM +// and draws two side-by-side spectrograms (left: sent, right: received). +func runCallSpectrogramTUI(rpc callAudioRPC, callID string) error { + screen, err := tcell.NewScreen() + if err != nil { + return err + } + if err := screen.Init(); err != nil { + return err + } + defer screen.Fini() + screen.Clear() + + sent := newSpecView(callAudioRate) + recv := newSpecView(callAudioRate) + quit := make(chan struct{}) + + go func() { + for { + switch ev := screen.PollEvent().(type) { + case *tcell.EventKey: + if ev.Key() == tcell.KeyEscape || ev.Key() == tcell.KeyCtrlC || + ev.Rune() == 'q' || ev.Rune() == 'Q' { + close(quit) + return + } + case *tcell.EventResize: + screen.Sync() + } + } + }() + + const pollMs = 60 + tailN := callAudioRate * pollMs / 1000 // ~one poll-interval of new audio + ticker := time.NewTicker(pollMs * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-quit: + return nil + case <-ticker.C: + } + s, r, aerr := rpc.VoiceCallAudio(callID) + if aerr != nil { + drawCentered(screen, "call "+callID+": "+aerr.Error()+" (q to quit)") + continue + } + sent.push(int16Tail(s, tailN)) + recv.push(int16Tail(r, tailN)) + sent.drainInto() + recv.drainInto() + + w, h := screen.Size() + if w < 4 || h < 2 { + continue + } + specH := h - 1 + mid := w / 2 + leftW := mid - 1 + if leftW < 1 { + leftW = 1 + } + screen.Clear() + sent.draw(screen, 0, leftW, specH) + recv.draw(screen, mid, w-mid, specH) + for y := 0; y < specH; y++ { // divider + screen.SetContent(mid-1, y, '│', nil, tcell.StyleDefault.Foreground(tcell.ColorGray)) + } + drawCallHint(screen, h-1, w, callID) + screen.Show() + } +} + +// int16Tail returns the last n samples of s as normalized float32. +func int16Tail(s []int16, n int) []float32 { + if len(s) > n { + s = s[len(s)-n:] + } + out := make([]float32, len(s)) + for i, v := range s { + out[i] = float32(v) / 32768.0 + } + return out +} + +func drawCallHint(screen tcell.Screen, y, w int, callID string) { + hint := []rune(" call " + callID + " — left: sent right: received — q/Esc to quit ") + st := tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) + for x := 0; x < w; x++ { + r := ' ' + if x < len(hint) { + r = hint[x] + } + screen.SetContent(x, y, r, nil, st) + } +} + +func drawCentered(screen tcell.Screen, msg string) { + w, h := screen.Size() + screen.Clear() + runes := []rune(msg) + x := (w - len(runes)) / 2 + if x < 0 { + x = 0 + } + for i, r := range runes { + screen.SetContent(x+i, h/2, r, nil, tcell.StyleDefault) + } + screen.Show() +} + func closeSource(src skyvoice.Source) error { if c, ok := src.(interface{ Close() error }); ok { return c.Close() diff --git a/cmd/skywire-cli/commands/skychat/voice_spectrogram_test.go b/cmd/skywire-cli/commands/skychat/voice_spectrogram_test.go index 4b9a96721b..0c885a5309 100644 --- a/cmd/skywire-cli/commands/skychat/voice_spectrogram_test.go +++ b/cmd/skywire-cli/commands/skychat/voice_spectrogram_test.go @@ -14,11 +14,11 @@ import ( // freq/12000 * bufferHeight. A 3 kHz tone (at the 24 kHz spectrogram rate) must // peak ~1/4 of the way up the fixed buffer. func TestSpectrogramColumnFreqPosition(t *testing.T) { - v := newSpecView() const ( rate = spectrogram.SampleRate // 24000 freq = 3000.0 ) + v := newSpecView(rate) samp := make([]float32, 4096) for i := range samp { samp[i] = float32(math.Sin(2 * math.Pi * freq * float64(i) / rate)) diff --git a/pkg/skychat/voice/manager.go b/pkg/skychat/voice/manager.go index 89455b7c2f..bd70343a1e 100644 --- a/pkg/skychat/voice/manager.go +++ b/pkg/skychat/voice/manager.go @@ -48,7 +48,10 @@ type Config struct { // notified when a call starts ringing. ManualAnswer bool Ring func(inv Sig) - Logger *logging.Logger + // Visualize, when true, taps each call's sent/received PCM into a small ring + // so CallAudio can serve it for a live spectrogram. The audio is unaffected. + Visualize bool + Logger *logging.Logger } // ringingCall is an inbound invite parked awaiting an explicit answer/decline. @@ -67,6 +70,7 @@ type Manager struct { mu sync.Mutex calls map[string]*Session ringing map[string]*ringingCall + taps map[string]*callTap } // NewManager constructs a Manager. Call Serve to start accepting. @@ -83,7 +87,7 @@ func NewManager(cfg Config) *Manager { if cfg.NewSink == nil { cfg.NewSink = func() Sink { return NullSink{} } } - m := &Manager{cfg: cfg, log: cfg.Logger, calls: make(map[string]*Session), ringing: make(map[string]*ringingCall)} + m := &Manager{cfg: cfg, log: cfg.Logger, calls: make(map[string]*Session), ringing: make(map[string]*ringingCall), taps: make(map[string]*callTap)} m.sig = NewSignaler(cfg.LocalPK, cfg.SignalPort, cfg.Dial, cfg.Logger) m.sig.SetInviteHandler(m.handleInvite) return m @@ -215,7 +219,17 @@ func (m *Manager) Incoming() []Sig { } func (m *Manager) startSession(callID string, conn net.Conn, ssrc uint32) *Session { - sess := NewSession(callID, conn, m.cfg.Codec, m.cfg.NewSource(), m.cfg.NewSink(), ssrc, m.log) + src := m.cfg.NewSource() + sink := m.cfg.NewSink() + if m.cfg.Visualize { + tap := &callTap{sent: newAudioRing(sampleRate), recv: newAudioRing(sampleRate)} // ~1s each + src = &teeSource{inner: src, ring: tap.sent} + sink = &teeSink{inner: sink, ring: tap.recv} + m.mu.Lock() + m.taps[callID] = tap + m.mu.Unlock() + } + sess := NewSession(callID, conn, m.cfg.Codec, src, sink, ssrc, m.log) m.mu.Lock() m.calls[callID] = sess m.mu.Unlock() @@ -223,10 +237,23 @@ func (m *Manager) startSession(callID string, conn net.Conn, ssrc uint32) *Sessi return sess } +// CallAudio returns the most recent buffered sent + received PCM for a call +// (Visualize mode). Used to drive a live spectrogram. +func (m *Manager) CallAudio(callID string) (sent, recv []int16, err error) { + m.mu.Lock() + tap := m.taps[callID] + m.mu.Unlock() + if tap == nil { + return nil, nil, errors.New("voice: no visualized audio for that call") + } + return tap.sent.snapshot(), tap.recv.snapshot(), nil +} + func (m *Manager) dropCall(callID string) { m.mu.Lock() sess := m.calls[callID] delete(m.calls, callID) + delete(m.taps, callID) m.mu.Unlock() if sess != nil { sess.Close() diff --git a/pkg/skychat/voice/tap.go b/pkg/skychat/voice/tap.go new file mode 100644 index 0000000000..eb92b991ef --- /dev/null +++ b/pkg/skychat/voice/tap.go @@ -0,0 +1,68 @@ +// Package voice pkg/skychat/voice/tap.go c2-app-chat +// +// Per-call audio taps for visualization. When a Manager has Visualize set, each +// session's Source (sent audio) and Sink (received audio) are wrapped so a copy +// of every frame lands in a small bounded ring. CallAudio(callID) returns the +// most recent ~1s of each direction, which the CLI polls to draw a two-panel +// sent/received spectrogram during a call. The audio itself is untouched — the +// tee just observes. +package voice + +import "sync" + +// audioRing keeps the most recent int16 samples (drop-oldest on overflow). +type audioRing struct { + mu sync.Mutex + buf []int16 + max int +} + +func newAudioRing(max int) *audioRing { return &audioRing{max: max} } + +func (r *audioRing) add(s []int16) { + r.mu.Lock() + r.buf = append(r.buf, s...) + if len(r.buf) > r.max { + r.buf = append(r.buf[:0], r.buf[len(r.buf)-r.max:]...) + } + r.mu.Unlock() +} + +func (r *audioRing) snapshot() []int16 { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]int16, len(r.buf)) + copy(out, r.buf) + return out +} + +// teeSource wraps a Source, copying each read frame into a ring (the SENT audio). +type teeSource struct { + inner Source + ring *audioRing +} + +func (t *teeSource) Read(pcm []int16) (int, error) { + n, err := t.inner.Read(pcm) + if n > 0 { + t.ring.add(pcm[:n]) + } + return n, err +} + +// teeSink wraps a Sink, copying each written frame into a ring (RECEIVED audio). +type teeSink struct { + inner Sink + ring *audioRing +} + +func (t *teeSink) Write(pcm []int16) (int, error) { + t.ring.add(pcm) + return t.inner.Write(pcm) +} + +// callTap holds the sent/received audio rings for one active call. +type callTap struct { + sent *audioRing + recv *audioRing +} diff --git a/pkg/skychat/voice/voice_test.go b/pkg/skychat/voice/voice_test.go index c44e05bcb4..d1aa9338fb 100644 --- a/pkg/skychat/voice/voice_test.go +++ b/pkg/skychat/voice/voice_test.go @@ -301,3 +301,52 @@ func TestManualAnswerDecline(t *testing.T) { t.Fatal("Call did not return after Decline") } } + +// TestCallAudioTap verifies Visualize mode taps a call's sent audio so +// CallAudio can serve it for the live spectrogram. +func TestCallAudioTap(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + pkA, _ := cipher.GenerateKeyPair() + pkB, _ := cipher.GenerateKeyPair() + + lis := newMemListener() + defer lis.Close() //nolint:errcheck + + mgrB := NewManager(Config{ + LocalPK: pkB, + Dial: func(context.Context, cipher.PubKey, uint16) (net.Conn, error) { return nil, io.EOF }, + OnIncoming: func(Sig) bool { return true }, + }) + go mgrB.Serve(ctx, lis) + + mgrA := NewManager(Config{ + LocalPK: pkA, + Dial: func(context.Context, cipher.PubKey, uint16) (net.Conn, error) { return lis.dial() }, + NewSource: func() Source { return &toneSource{} }, + Visualize: true, + }) + sess, err := mgrA.Call(ctx, pkB) + if err != nil { + t.Fatalf("Call: %v", err) + } + // Let a few frames of the tone flow through the tap. + time.Sleep(300 * time.Millisecond) + sent, _, err := mgrA.CallAudio(sess.CallID) + if err != nil { + t.Fatalf("CallAudio: %v", err) + } + if len(sent) == 0 { + t.Fatal("tap captured no sent audio") + } + nonzero := false + for _, s := range sent { + if s != 0 { + nonzero = true + break + } + } + if !nonzero { + t.Fatal("tapped sent audio is all silence") + } +} diff --git a/pkg/visor/api.go b/pkg/visor/api.go index ff1efd0774..ff69b9e059 100644 --- a/pkg/visor/api.go +++ b/pkg/visor/api.go @@ -293,6 +293,7 @@ type API interface { VoiceAnswer(callID string) error VoiceDecline(callID string) error VoiceIncoming() ([]string, error) + VoiceCallAudio(callID string) (sent, recv []int16, err error) // Embedded Transport Setup Node (TPS) controls TPSStatus() (*TPSStatus, error) diff --git a/pkg/visor/init_voice.go b/pkg/visor/init_voice.go index 6d0e6bae60..1a0faa2ca1 100644 --- a/pkg/visor/init_voice.go +++ b/pkg/visor/init_voice.go @@ -65,6 +65,7 @@ func initVoice(_ context.Context, v *Visor, log *logging.Logger) error { default: monitor := mode == "monitor" cfg.ManualAnswer = true + cfg.Visualize = true // tap call audio for `voice spectrogram --call` cfg.NewSource = func() skyvoice.Source { s, err := skyvoice.NewMicSource(monitor, 0) if err != nil { diff --git a/pkg/visor/proxy_default_api.go b/pkg/visor/proxy_default_api.go index e2f7fe42d5..b2b8abbbb7 100644 --- a/pkg/visor/proxy_default_api.go +++ b/pkg/visor/proxy_default_api.go @@ -860,6 +860,11 @@ func (proxyDefaultAPI) VoiceIncoming() ([]string, error) { return nil, ErrProxyNotSupported } +// VoiceCallAudio implements API (not proxied). +func (proxyDefaultAPI) VoiceCallAudio(_ string) (sent, recv []int16, err error) { + return nil, nil, ErrProxyNotSupported +} + func (proxyDefaultAPI) GroupHistory(_ string, _ int) ([]GroupMessage, error) { return nil, ErrProxyNotSupported } diff --git a/pkg/visor/rpc_client.go b/pkg/visor/rpc_client.go index 4512303bcc..6140f6b5d8 100644 --- a/pkg/visor/rpc_client.go +++ b/pkg/visor/rpc_client.go @@ -1643,6 +1643,15 @@ func (rc *rpcClient) VoiceIncoming() ([]string, error) { return out, err } +// VoiceCallAudio implements API. +func (rc *rpcClient) VoiceCallAudio(callID string) (sent, recv []int16, err error) { + var out VoiceAudioSnapshot + if err = rc.Call("VoiceCallAudio", &callID, &out); err != nil { + return nil, nil, err + } + return out.Sent, out.Recv, nil +} + // GroupHistory implements API. Returns persisted group messages from // the visor's history store; returns the wrapped ErrGroupHistoryDisabled // when persistence is off. diff --git a/pkg/visor/rpc_client_mock.go b/pkg/visor/rpc_client_mock.go index 20e068f7cb..4f9d6096ac 100644 --- a/pkg/visor/rpc_client_mock.go +++ b/pkg/visor/rpc_client_mock.go @@ -1329,6 +1329,11 @@ func (mc *mockRPCClient) VoiceDecline(_ string) error { return nil } // VoiceIncoming implements API. func (mc *mockRPCClient) VoiceIncoming() ([]string, error) { return nil, nil } +// VoiceCallAudio implements API. +func (mc *mockRPCClient) VoiceCallAudio(_ string) (sent, recv []int16, err error) { + return nil, nil, nil +} + // GroupHistory implements API. func (mc *mockRPCClient) GroupHistory(_ string, _ int) ([]GroupMessage, error) { return nil, nil } diff --git a/pkg/visor/rpc_voice.go b/pkg/visor/rpc_voice.go index f306a3bec6..89f1ebc9ff 100644 --- a/pkg/visor/rpc_voice.go +++ b/pkg/visor/rpc_voice.go @@ -70,3 +70,23 @@ func (r *RPC) VoiceIncoming(_ *struct{}, out *[]string) (err error) { *out = ids return nil } + +// VoiceAudioSnapshot is the recent sent/received PCM of an active call. +type VoiceAudioSnapshot struct { + Sent []int16 + Recv []int16 +} + +// VoiceCallAudio replies with the recent sent + received PCM of a call. +func (r *RPC) VoiceCallAudio(callID *string, out *VoiceAudioSnapshot) (err error) { + defer rpcutil.LogCall(r.log, "VoiceCallAudio", callID)(nil, &err) + if callID == nil { + return fmt.Errorf("nil request") + } + sent, recv, err := r.visor.VoiceCallAudio(*callID) + if err != nil { + return err + } + out.Sent, out.Recv = sent, recv + return nil +} diff --git a/pkg/visor/voice.go b/pkg/visor/voice.go index e679eecc06..6017f64692 100644 --- a/pkg/visor/voice.go +++ b/pkg/visor/voice.go @@ -78,3 +78,13 @@ func (v *Visor) VoiceIncoming() ([]string, error) { } return out, nil } + +// VoiceCallAudio returns the most recent buffered sent + received PCM for an +// active call (only when the visor runs with real audio, which taps call audio). +// The CLI polls this to draw a live two-panel spectrogram. +func (v *Visor) VoiceCallAudio(callID string) (sent, recv []int16, err error) { + if v.voice == nil { + return nil, nil, ErrVoiceDisabled + } + return v.voice.CallAudio(callID) +}