Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-13 00:58:37 +02:00
commit 59b31e5ca3
103 changed files with 8577 additions and 0 deletions
+99
View File
@@ -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
}
+87
View File
@@ -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()
}
+97
View File
@@ -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[:])
}
+97
View File
@@ -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
}
+113
View File
@@ -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
}
+54
View File
@@ -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
}