Skip to content

Commit 2c7b8be

Browse files
committed
Merge #16 branch 'diamondburned-pa-fix'
2 parents 88e8871 + 81e4318 commit 2c7b8be

File tree

9 files changed

+624
-42
lines changed

9 files changed

+624
-42
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.18
44

55
require (
66
github.com/integrii/flaggy v1.4.4
7-
github.com/lawl/pulseaudio v0.0.0-20210928141934-ed754c0c6618
7+
github.com/noisetorch/pulseaudio v0.0.0-20220603053345-9303200c3861
88
github.com/nsf/termbox-go v1.1.1
99
github.com/pkg/errors v0.9.1
1010
gonum.org/v1/gonum v0.11.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
github.com/integrii/flaggy v1.4.4 h1:8fGyiC14o0kxhTqm2VBoN19fDKPZsKipP7yggreTMDc=
22
github.com/integrii/flaggy v1.4.4/go.mod h1:tnTxHeTJbah0gQ6/K0RW0J7fMUBk9MCF5blhm43LNpI=
3-
github.com/lawl/pulseaudio v0.0.0-20210928141934-ed754c0c6618 h1:lktbhQBHluc1oWEDow4DEv13qkWJ8zm/dTUSKer2iKk=
4-
github.com/lawl/pulseaudio v0.0.0-20210928141934-ed754c0c6618/go.mod h1:9h36x4KH7r2V8DOCKoPMt87IXZ++X90y8D5nnuwq290=
53
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
64
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
5+
github.com/noisetorch/pulseaudio v0.0.0-20220603053345-9303200c3861 h1:Xng5X+MlNK7Y/Ede75B86wJgaFMFvuey1K4Suh9k2E4=
6+
github.com/noisetorch/pulseaudio v0.0.0-20220603053345-9303200c3861/go.mod h1:/zosM8PSkhuVyfJ9c/qzBhPSm3k06m9U4y4SDfH0jeA=
77
github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=
88
github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
99
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=

input/all/all.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ package all
44
import (
55
_ "github.com/noriah/catnip/input/ffmpeg"
66
_ "github.com/noriah/catnip/input/parec"
7+
_ "github.com/noriah/catnip/input/pipewire"
78
)

input/common/execread/execread.go

Lines changed: 83 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ import (
99
"os"
1010
"os/exec"
1111
"sync"
12+
"time"
1213

1314
"github.com/noriah/catnip/input"
1415
"github.com/pkg/errors"
1516
)
1617

1718
// Session is a session that reads floating-point audio values from a Cmd.
1819
type Session struct {
20+
// OnStart is called when the session starts. Nil by default.
21+
OnStart func(ctx context.Context, cmd *exec.Cmd) error
22+
1923
argv []string
2024
cfg input.SessionConfig
2125

@@ -26,38 +30,47 @@ type Session struct {
2630
}
2731

2832
// NewSession creates a new execread session. It never returns an error.
29-
func NewSession(argv []string, f32mode bool, cfg input.SessionConfig) (*Session, error) {
33+
func NewSession(argv []string, f32mode bool, cfg input.SessionConfig) *Session {
3034
if len(argv) < 1 {
31-
return nil, errors.New("argv has no arg0")
35+
panic("argv has no arg0")
3236
}
3337

3438
return &Session{
3539
argv: argv,
3640
cfg: cfg,
3741
f32mode: f32mode,
3842
samples: cfg.SampleSize * cfg.FrameSize,
39-
}, nil
43+
}
4044
}
4145

4246
func (s *Session) Start(ctx context.Context, dst [][]input.Sample, kickChan chan bool, mu *sync.Mutex) error {
4347
if !input.EnsureBufferLen(s.cfg, dst) {
4448
return errors.New("invalid dst length given")
4549
}
4650

47-
// Take argv and free it soon after, since we won't be needing it again.
4851
cmd := exec.CommandContext(ctx, s.argv[0], s.argv[1:]...)
4952
cmd.Stderr = os.Stderr
50-
s.argv = nil
5153

5254
o, err := cmd.StdoutPipe()
5355
if err != nil {
5456
return errors.Wrap(err, "failed to get stdout pipe")
5557
}
5658
defer o.Close()
5759

58-
bufsz := s.samples * 4
59-
if !s.f32mode {
60-
bufsz *= 2
60+
// We need o as an *os.File for SetWriteDeadline.
61+
of, ok := o.(*os.File)
62+
if !ok {
63+
return errors.New("stdout pipe is not an *os.File (bug)")
64+
}
65+
66+
if err := cmd.Start(); err != nil {
67+
return errors.Wrap(err, "failed to start "+s.argv[0])
68+
}
69+
70+
if s.OnStart != nil {
71+
if err := s.OnStart(ctx, cmd); err != nil {
72+
return err
73+
}
6174
}
6275

6376
framesz := s.cfg.FrameSize
@@ -66,60 +79,94 @@ func (s *Session) Start(ctx context.Context, dst [][]input.Sample, kickChan chan
6679
f64: !s.f32mode,
6780
}
6881

69-
// Allocate 4 times the buffer. We should ensure that we can read some of
70-
// the overflow.
71-
raw := make([]byte, bufsz)
72-
73-
if err := cmd.Start(); err != nil {
74-
return errors.Wrap(err, "failed to start ffmpeg")
82+
bufsz := s.samples
83+
if !s.f32mode {
84+
bufsz *= 2
7585
}
7686

77-
for {
78-
reader.reset(raw)
87+
raw := make([]byte, bufsz*4)
7988

80-
mu.Lock()
81-
for n := 0; n < s.samples; n++ {
82-
dst[n%framesz][n/framesz] = reader.next()
83-
}
84-
mu.Unlock()
89+
// We double this as a workaround because sampleDuration is less than the
90+
// actual time that ReadFull blocks for some reason, probably because the
91+
// process decides to discard audio when it overflows.
92+
sampleDuration := time.Duration(
93+
float64(s.cfg.SampleSize) / s.cfg.SampleRate * float64(time.Second))
94+
// We also keep track of whether the deadline was hit once so we can half
95+
// the sample duration. This smooths out the jitter.
96+
var readExpired bool
8597

86-
select {
87-
case <-ctx.Done():
88-
return ctx.Err()
89-
// default:
90-
case kickChan <- true:
98+
for {
99+
// Set us a read deadline. If the deadline is reached, we'll write zeros
100+
// to the buffer.
101+
timeout := sampleDuration
102+
if !readExpired {
103+
timeout *= 2
104+
}
105+
if err := of.SetReadDeadline(time.Now().Add(timeout)); err != nil {
106+
return errors.Wrap(err, "failed to set read deadline")
91107
}
92108

93109
_, err := io.ReadFull(o, raw)
94110
if err != nil {
95-
if errors.Is(err, io.EOF) {
111+
switch {
112+
case errors.Is(err, io.EOF):
96113
return nil
114+
case errors.Is(err, os.ErrDeadlineExceeded):
115+
readExpired = true
116+
default:
117+
return err
97118
}
98-
return err
119+
} else {
120+
readExpired = false
121+
}
122+
123+
if readExpired {
124+
mu.Lock()
125+
// We can write directly to dst just so we can avoid parsing zero
126+
// bytes to floats.
127+
for _, buf := range dst {
128+
// Go should optimize this to a memclr.
129+
for i := range buf {
130+
buf[i] = 0
131+
}
132+
}
133+
mu.Unlock()
134+
} else {
135+
reader.reset(raw)
136+
mu.Lock()
137+
for n := 0; n < s.samples; n++ {
138+
dst[n%framesz][n/framesz] = reader.next()
139+
}
140+
mu.Unlock()
141+
}
142+
143+
// Signal that we've written to dst.
144+
select {
145+
case <-ctx.Done():
146+
return ctx.Err()
147+
case kickChan <- true:
99148
}
100149
}
101150
}
102151

103152
type floatReader struct {
104153
order binary.ByteOrder
105154
buf []byte
106-
n int64
107155
f64 bool
108156
}
109157

110158
func (f *floatReader) reset(b []byte) {
111-
f.n = 0
112159
f.buf = b
113160
}
114161

115162
func (f *floatReader) next() float64 {
116-
n := f.n
117-
118163
if f.f64 {
119-
f.n += 8
120-
return math.Float64frombits(f.order.Uint64(f.buf[n:]))
164+
b := f.buf[:8]
165+
f.buf = f.buf[8:]
166+
return math.Float64frombits(f.order.Uint64(b))
121167
}
122168

123-
f.n += 4
124-
return float64(math.Float32frombits(f.order.Uint32(f.buf[n:])))
169+
b := f.buf[:4]
170+
f.buf = f.buf[4:]
171+
return float64(math.Float32frombits(f.order.Uint32(b)))
125172
}

input/ffmpeg/ffmpeg.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ func NewSession(b FFmpegBackend, cfg input.SessionConfig) (*execread.Session, er
2121
"-",
2222
)
2323

24-
return execread.NewSession(args, false, cfg)
24+
return execread.NewSession(args, false, cfg), nil
2525
}

input/parec/parec.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package parec
33
import (
44
"fmt"
55

6-
"github.com/lawl/pulseaudio"
6+
"github.com/noisetorch/pulseaudio"
77
"github.com/noriah/catnip/input"
88
"github.com/noriah/catnip/input/common/execread"
99
"github.com/pkg/errors"
@@ -83,5 +83,5 @@ func NewSession(cfg input.SessionConfig) (*execread.Session, error) {
8383
args = append(args, "-d", dv.String())
8484
}
8585

86-
return execread.NewSession(args, true, cfg)
86+
return execread.NewSession(args, true, cfg), nil
8787
}

input/pipewire/dump.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package pipewire
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"os/exec"
8+
9+
"github.com/pkg/errors"
10+
)
11+
12+
type pwObjects []pwObject
13+
14+
func pwDump(ctx context.Context) (pwObjects, error) {
15+
cmd := exec.CommandContext(ctx, "pw-dump")
16+
cmd.Stderr = os.Stderr
17+
18+
dumpOutput, err := cmd.Output()
19+
if err != nil {
20+
var execErr *exec.ExitError
21+
if errors.As(err, &execErr) {
22+
return nil, errors.Wrapf(err, "failed to run pw-dump: %s", execErr.Stderr)
23+
}
24+
return nil, errors.Wrap(err, "failed to run pw-dump")
25+
}
26+
27+
var dump pwObjects
28+
if err := json.Unmarshal(dumpOutput, &dump); err != nil {
29+
return nil, errors.Wrap(err, "failed to parse pw-dump output")
30+
}
31+
32+
return dump, nil
33+
}
34+
35+
// Filter filters for the devices that satisfies f.
36+
func (d pwObjects) Filter(fns ...func(pwObject) bool) pwObjects {
37+
filtered := make(pwObjects, 0, len(d))
38+
loop:
39+
for _, device := range d {
40+
for _, f := range fns {
41+
if !f(device) {
42+
continue loop
43+
}
44+
}
45+
filtered = append(filtered, device)
46+
}
47+
return filtered
48+
}
49+
50+
// Find returns the first object that satisfies f.
51+
func (d pwObjects) Find(f func(pwObject) bool) *pwObject {
52+
for i, device := range d {
53+
if f(device) {
54+
return &d[i]
55+
}
56+
}
57+
return nil
58+
}
59+
60+
// ResolvePorts returns all PipeWire port objects that belong to the given
61+
// object.
62+
func (d pwObjects) ResolvePorts(object *pwObject, dir pwPortDirection) pwObjects {
63+
return d.Filter(
64+
func(o pwObject) bool { return o.Type == pwInterfacePort },
65+
func(o pwObject) bool {
66+
return o.Info.Props.NodeID == object.ID && o.Info.Props.PortDirection == dir
67+
},
68+
)
69+
}
70+
71+
type pwObjectID int64
72+
73+
type pwObjectType string
74+
75+
const (
76+
pwInterfaceDevice pwObjectType = "PipeWire:Interface:Device"
77+
pwInterfaceNode pwObjectType = "PipeWire:Interface:Node"
78+
pwInterfacePort pwObjectType = "PipeWire:Interface:Port"
79+
pwInterfaceLink pwObjectType = "PipeWire:Interface:Link"
80+
)
81+
82+
type pwObject struct {
83+
ID pwObjectID `json:"id"`
84+
Type pwObjectType `json:"type"`
85+
Info struct {
86+
Props pwInfoProps `json:"props"`
87+
} `json:"info"`
88+
}
89+
90+
type pwInfoProps struct {
91+
pwDeviceProps
92+
pwNodeProps
93+
pwPortProps
94+
MediaClass string `json:"media.class"`
95+
96+
JSON json.RawMessage `json:"-"`
97+
}
98+
99+
func (p *pwInfoProps) UnmarshalJSON(data []byte) error {
100+
type Alias pwInfoProps
101+
if err := json.Unmarshal(data, (*Alias)(p)); err != nil {
102+
return err
103+
}
104+
p.JSON = append([]byte(nil), data...)
105+
return nil
106+
}
107+
108+
type pwDeviceProps struct {
109+
DeviceName string `json:"device.name"`
110+
}
111+
112+
// pwNodeProps is for Audio/Sink only.
113+
type pwNodeProps struct {
114+
NodeName string `json:"node.name"`
115+
NodeNick string `json:"node.nick"`
116+
NodeDescription string `json:"node.description"`
117+
}
118+
119+
// Constants for MediaClass.
120+
const (
121+
pwAudioDevice string = "Audio/Device"
122+
pwAudioSink string = "Audio/Sink"
123+
pwStreamOutputAudio string = "Stream/Output/Audio"
124+
)
125+
126+
type pwPortDirection string
127+
128+
const (
129+
pwPortIn = "in"
130+
pwPortOut = "out"
131+
)
132+
133+
type pwPortProps struct {
134+
PortID pwObjectID `json:"port.id"`
135+
PortName string `json:"port.name"`
136+
PortAlias string `json:"port.alias"`
137+
PortDirection pwPortDirection `json:"port.direction"`
138+
NodeID pwObjectID `json:"node.id"`
139+
ObjectPath string `json:"object.path"`
140+
}

0 commit comments

Comments
 (0)