mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 16:18:24 +02:00
@@ -0,0 +1,99 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Device is one physical/virtual audio sink (output) or source (input), as
|
||||
// listed under wpctl status's Sinks/Sources sections.
|
||||
type Device struct {
|
||||
ID string
|
||||
Name string
|
||||
Default bool
|
||||
Volume int // 0-100
|
||||
Muted bool
|
||||
}
|
||||
|
||||
// ListSinks returns the available audio output devices.
|
||||
func ListSinks() ([]Device, error) {
|
||||
return listDevices("Sinks:")
|
||||
}
|
||||
|
||||
// ListSources returns the available audio input devices.
|
||||
func ListSources() ([]Device, error) {
|
||||
return listDevices("Sources:")
|
||||
}
|
||||
|
||||
func listDevices(header string) ([]Device, error) {
|
||||
out, err := status()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := parseSection(out, header)
|
||||
devices := make([]Device, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
devices = append(devices, Device{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
Default: e.Default,
|
||||
Volume: e.Volume,
|
||||
Muted: e.Muted,
|
||||
})
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
var volumeLineRe = regexp.MustCompile(`^Volume:\s+([0-9.]+)(\s+\[MUTED\])?`)
|
||||
|
||||
// GetVolume returns id's current volume (0-100) and mute state.
|
||||
func GetVolume(id string) (int, bool, error) {
|
||||
out, err := run("get-volume", id)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
m := volumeLineRe.FindStringSubmatch(strings.TrimSpace(out))
|
||||
if m == nil {
|
||||
return 0, false, fmt.Errorf("unexpected wpctl get-volume output: %q", out)
|
||||
}
|
||||
v, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return int(v*100 + 0.5), m[2] != "", nil
|
||||
}
|
||||
|
||||
// SetVolume sets id's volume to an absolute percentage, clamped to [0, 100].
|
||||
func SetVolume(id string, pct int) error {
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
_, err := run("set-volume", id, strconv.Itoa(pct)+"%")
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMute sets id's mute state explicitly.
|
||||
func SetMute(id string, mute bool) error {
|
||||
state := "0"
|
||||
if mute {
|
||||
state = "1"
|
||||
}
|
||||
_, err := run("set-mute", id, state)
|
||||
return err
|
||||
}
|
||||
|
||||
// ToggleMute flips id's current mute state.
|
||||
func ToggleMute(id string) error {
|
||||
_, err := run("set-mute", id, "toggle")
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDefault makes id the default sink or source for its kind.
|
||||
func SetDefault(id string) error {
|
||||
_, err := run("set-default", id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"math"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// levelSampleRate and levelFrameSamples pick a small chunk size (~100ms) so
|
||||
// the UI can poll for a fresh peak reading at a responsive rate without
|
||||
// reading much audio data.
|
||||
const (
|
||||
levelSampleRate = 16000
|
||||
levelFrameFrames = levelSampleRate / 10 // 100ms
|
||||
levelFrameBytes = levelFrameFrames * 4 // 4 bytes per float32 sample
|
||||
)
|
||||
|
||||
// LevelMonitor streams raw audio from a source and reports its peak
|
||||
// amplitude in short chunks, for a live input-level meter. Callers must call
|
||||
// Stop when done to release the underlying pw-cat process.
|
||||
type LevelMonitor struct {
|
||||
cmd *exec.Cmd
|
||||
stdout io.ReadCloser
|
||||
}
|
||||
|
||||
// StartLevelMonitor begins capturing raw audio from sourceID.
|
||||
func StartLevelMonitor(sourceID string) (*LevelMonitor, error) {
|
||||
target, err := nodeTarget(sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := exec.Command("pw-cat", // #nosec G204 -- target is a node.name read back from `wpctl inspect`, never shell-interpreted
|
||||
"--record",
|
||||
"--target", target,
|
||||
"--format", "f32",
|
||||
"--rate", strconv.Itoa(levelSampleRate),
|
||||
"--channels", "1",
|
||||
"--raw",
|
||||
"-",
|
||||
)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LevelMonitor{cmd: cmd, stdout: stdout}, nil
|
||||
}
|
||||
|
||||
// Next blocks until one ~100ms chunk of audio has been captured and returns
|
||||
// its peak amplitude as a value in [0, 1]. It returns an error once the
|
||||
// monitor is stopped or the source disappears.
|
||||
func (l *LevelMonitor) Next() (float64, error) {
|
||||
buf := make([]byte, levelFrameBytes)
|
||||
n, err := io.ReadFull(l.stdout, buf)
|
||||
if n == 0 {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var peak float32
|
||||
for i := 0; i+4 <= n; i += 4 {
|
||||
v := math.Float32frombits(binary.LittleEndian.Uint32(buf[i : i+4]))
|
||||
if v < 0 {
|
||||
v = -v
|
||||
}
|
||||
if v > peak {
|
||||
peak = v
|
||||
}
|
||||
}
|
||||
if peak > 1 {
|
||||
peak = 1
|
||||
}
|
||||
return float64(peak), nil
|
||||
}
|
||||
|
||||
// Stop terminates the capture process. Safe to call more than once.
|
||||
func (l *LevelMonitor) Stop() {
|
||||
if l.cmd.Process != nil {
|
||||
_ = l.cmd.Process.Kill()
|
||||
}
|
||||
_ = l.cmd.Wait()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
const sampleRate = 44100
|
||||
|
||||
// toneStep is one segment of the test chime: a tone at freqHz lasting dur,
|
||||
// with a short fade in/out to avoid clicks.
|
||||
type toneStep struct {
|
||||
freqHz float64
|
||||
dur float64 // seconds
|
||||
}
|
||||
|
||||
// testChime is a short two-tone "ding-dong" beep, the same shape used by
|
||||
// most desktop sound settings panels to preview an output device.
|
||||
var testChime = []toneStep{
|
||||
{freqHz: 880, dur: 0.15},
|
||||
{freqHz: 660, dur: 0.22},
|
||||
}
|
||||
|
||||
// PlayTestTone plays a short chime through the given sink, without changing
|
||||
// the system default output.
|
||||
func PlayTestTone(sinkID string) error {
|
||||
target, err := nodeTarget(sinkID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "pw-play", "--target", target, "-") // #nosec G204 -- target is a node.name read back from `wpctl inspect`, never shell-interpreted
|
||||
cmd.Stdin = bytes.NewReader(synthChime(testChime))
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// synthChime renders steps as a 16-bit PCM mono WAV file in memory.
|
||||
func synthChime(steps []toneStep) []byte {
|
||||
const amplitude = 0.35 * 32767
|
||||
const fade = 0.015 // seconds of fade in/out per step, to avoid clicks
|
||||
|
||||
var samples []int16
|
||||
for _, step := range steps {
|
||||
n := int(step.dur * sampleRate)
|
||||
for i := 0; i < n; i++ {
|
||||
t := float64(i) / sampleRate
|
||||
env := 1.0
|
||||
if t < fade {
|
||||
env = t / fade
|
||||
} else if remaining := step.dur - t; remaining < fade {
|
||||
env = remaining / fade
|
||||
}
|
||||
v := amplitude * env * math.Sin(2*math.Pi*step.freqHz*t)
|
||||
samples = append(samples, int16(v))
|
||||
}
|
||||
}
|
||||
|
||||
pcm := make([]byte, len(samples)*2)
|
||||
for i, s := range samples {
|
||||
binary.LittleEndian.PutUint16(pcm[i*2:], uint16(s))
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("RIFF")
|
||||
writeU32(&buf, uint32(36+len(pcm)))
|
||||
buf.WriteString("WAVE")
|
||||
buf.WriteString("fmt ")
|
||||
writeU32(&buf, 16)
|
||||
writeU16(&buf, 1) // PCM
|
||||
writeU16(&buf, 1) // mono
|
||||
writeU32(&buf, sampleRate)
|
||||
writeU32(&buf, sampleRate*2)
|
||||
writeU16(&buf, 2) // block align
|
||||
writeU16(&buf, 16) // bits per sample
|
||||
buf.WriteString("data")
|
||||
writeU32(&buf, uint32(len(pcm)))
|
||||
buf.Write(pcm)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func writeU32(buf *bytes.Buffer, v uint32) {
|
||||
var b [4]byte
|
||||
binary.LittleEndian.PutUint32(b[:], v)
|
||||
buf.Write(b[:])
|
||||
}
|
||||
|
||||
func writeU16(buf *bytes.Buffer, v uint16) {
|
||||
var b [2]byte
|
||||
binary.LittleEndian.PutUint16(b[:], v)
|
||||
buf.Write(b[:])
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// statusEntry is one line parsed out of a `wpctl status` section (Sinks,
|
||||
// Sources, or Streams).
|
||||
type statusEntry struct {
|
||||
ID string
|
||||
Default bool
|
||||
Name string
|
||||
Volume int // 0-100, only meaningful when HasVolume is true
|
||||
Muted bool
|
||||
HasVolume bool
|
||||
}
|
||||
|
||||
// entryLineRe matches one tree entry, e.g.:
|
||||
//
|
||||
// " │ * 57. Ryzen HD Audio Controller Speaker [vol: 0.90 MUTED]"
|
||||
// " 110. pw-play"
|
||||
//
|
||||
// Leading tree-drawing characters and the optional "*" (default marker) are
|
||||
// stripped; the remainder is the id and the free-form rest of the line.
|
||||
var entryLineRe = regexp.MustCompile(`^[\s│├└─]*(\*)?\s*(\d+)\.\s+(.+)$`)
|
||||
|
||||
var volBracketRe = regexp.MustCompile(`^(.*?)\s*\[vol:\s*([0-9.]+)(\s+MUTED)?\]$`)
|
||||
|
||||
// status returns the raw output of `wpctl status`.
|
||||
func status() (string, error) {
|
||||
return run("status")
|
||||
}
|
||||
|
||||
// parseSection extracts the entries listed under headerLine (e.g. "Sinks:",
|
||||
// "Sources:", "Streams:") within the "Audio" domain block of `wpctl status`
|
||||
// output, stopping at the first blank line. Port/link sub-lines (which
|
||||
// contain " > ") are tree children of a stream entry, not entries of their
|
||||
// own, and are skipped.
|
||||
func parseSection(out, headerLine string) []statusEntry {
|
||||
lines := strings.Split(out, "\n")
|
||||
|
||||
audioStart := -1
|
||||
for i, l := range lines {
|
||||
if strings.TrimSpace(l) == "Audio" {
|
||||
audioStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if audioStart == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sectionStart := -1
|
||||
for i := audioStart; i < len(lines); i++ {
|
||||
if strings.HasSuffix(strings.TrimSpace(lines[i]), headerLine) {
|
||||
sectionStart = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if sectionStart == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []statusEntry
|
||||
for i := sectionStart; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// A blank line ends the whole Audio block (e.g. after Streams:); a
|
||||
// line ending in ":" is the next section header (e.g. "Sources:").
|
||||
// Either marks the end of this section.
|
||||
if trimmed == "" || strings.HasSuffix(trimmed, ":") {
|
||||
break
|
||||
}
|
||||
m := entryLineRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id, isDefault, rest := m[2], m[1] == "*", strings.TrimSpace(m[3])
|
||||
if strings.Contains(rest, " > ") {
|
||||
continue // a port/link sub-line, not an entry
|
||||
}
|
||||
|
||||
entry := statusEntry{ID: id, Default: isDefault, Name: rest}
|
||||
if vm := volBracketRe.FindStringSubmatch(rest); vm != nil {
|
||||
entry.Name = strings.TrimSpace(vm[1])
|
||||
if v, err := strconv.ParseFloat(vm[2], 64); err == nil {
|
||||
entry.Volume = int(v*100 + 0.5)
|
||||
entry.HasVolume = true
|
||||
}
|
||||
entry.Muted = vm[3] != ""
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StreamKind int
|
||||
|
||||
const (
|
||||
StreamOutput StreamKind = iota // a Stream/Output/Audio node, e.g. a browser tab playing sound
|
||||
StreamInput // a Stream/Input/Audio node, e.g. an app recording from the mic
|
||||
)
|
||||
|
||||
// Stream is one running application audio stream (playback or capture), as
|
||||
// listed under wpctl status's Streams section.
|
||||
type Stream struct {
|
||||
ID string
|
||||
App string // human-readable application name
|
||||
Kind StreamKind
|
||||
Volume int // 0-100
|
||||
Muted bool
|
||||
}
|
||||
|
||||
// ListStreams returns every currently running application audio stream,
|
||||
// both playback and capture, output streams first.
|
||||
func ListStreams() ([]Stream, error) {
|
||||
out, err := status()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var streams []Stream
|
||||
for _, e := range parseSection(out, "Streams:") {
|
||||
props, err := inspect(e.ID)
|
||||
if err != nil {
|
||||
continue // the stream may have exited between `status` and `inspect`
|
||||
}
|
||||
var kind StreamKind
|
||||
switch props["media.class"] {
|
||||
case "Stream/Output/Audio":
|
||||
kind = StreamOutput
|
||||
case "Stream/Input/Audio":
|
||||
kind = StreamInput
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
app := props["application.name"]
|
||||
if app == "" {
|
||||
app = e.Name
|
||||
}
|
||||
|
||||
vol, muted, err := GetVolume(e.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
streams = append(streams, Stream{ID: e.ID, App: app, Kind: kind, Volume: vol, Muted: muted})
|
||||
}
|
||||
|
||||
// Output streams first, stable within each kind.
|
||||
out2 := make([]Stream, 0, len(streams))
|
||||
for _, s := range streams {
|
||||
if s.Kind == StreamOutput {
|
||||
out2 = append(out2, s)
|
||||
}
|
||||
}
|
||||
for _, s := range streams {
|
||||
if s.Kind == StreamInput {
|
||||
out2 = append(out2, s)
|
||||
}
|
||||
}
|
||||
return out2, nil
|
||||
}
|
||||
|
||||
// inspect runs `wpctl inspect <id>` and parses its "key = value" property
|
||||
// dump into a map. Values are unquoted; boolean/marker prefixes ("* ") are
|
||||
// stripped from keys.
|
||||
func inspect(id string) (map[string]string, error) {
|
||||
out, err := run("inspect", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
props := make(map[string]string)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
line = strings.TrimPrefix(line, "* ")
|
||||
key, value, ok := strings.Cut(line, " = ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
props[key] = strings.Trim(value, `"`)
|
||||
}
|
||||
return props, nil
|
||||
}
|
||||
|
||||
// nodeTarget resolves a wpctl global id (e.g. "57", as used by
|
||||
// get-volume/set-volume/set-default/...) to the PipeWire node.name pw-play
|
||||
// and pw-cat expect for their --target flag. The two tools use different id
|
||||
// spaces: passing a wpctl id straight to --target is silently ignored and
|
||||
// falls back to the default node instead of erroring.
|
||||
func nodeTarget(id string) (string, error) {
|
||||
props, err := inspect(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := props["node.name"]
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("no node.name found for id %s", id)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Package audio wraps wpctl (and the pw-play/pw-cat helper tools that ship
|
||||
// alongside it) to read and control PipeWire audio state: output/input
|
||||
// devices, their volume and mute, the default device, and per-application
|
||||
// streams. Every exported function shells out to a CLI tool; there is no
|
||||
// direct PipeWire/WirePlumber library integration.
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("wpctl not found in PATH")
|
||||
|
||||
const defaultTimeout = 15 * time.Second
|
||||
|
||||
// CheckAvailable reports whether wpctl is installed and PipeWire is
|
||||
// reachable. Call it before using the rest of the package.
|
||||
func CheckAvailable() error {
|
||||
if _, err := exec.LookPath("wpctl"); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := run("status"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func run(args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "wpctl", args...) // #nosec G204 -- args are fixed wpctl subcommands or IDs read back from `wpctl status`, never shell-interpreted
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("wpctl %s: %s", strings.Join(args, " "), msg)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user