Add support for PipeWire

Warning: Alpha quality. Please manage your expectations.
pull/141/head 0.11.0
lawl 3 years ago
parent 9b651eb701
commit fad3e1d4f4

@ -1,3 +1,3 @@
default:
$(CC) -Wall -Werror -O2 -c -fPIC ../ringbuf.c ../rnnoise/*.c module.c
$(CC) -shared -Wl,--version-script=export.txt -o rnnoise_ladspa.so *.o
$(CC) -shared -lm -Wl,--version-script=export.txt -o rnnoise_ladspa.so *.o

@ -14,9 +14,9 @@
#include "../ringbuf.h"
#include "../rnnoise/rnnoise.h"
#define SF_VAD 0
#define SF_INPUT 1
#define SF_OUTPUT 2
#define SF_INPUT 0
#define SF_OUTPUT 1
#define SF_VAD 2
#define FRAMESIZE_NSAMPLES 480
#define FRAMESIZE_BYTES (480 * sizeof(float))
@ -187,9 +187,7 @@ ON_LOAD_ROUTINE {
g_psDescriptor->PortRangeHints =
(const LADSPA_PortRangeHint *)psPortRangeHints;
psPortRangeHints[SF_VAD].HintDescriptor =
(LADSPA_HINT_BOUNDED_BELOW | LADSPA_HINT_BOUNDED_ABOVE |
LADSPA_HINT_SAMPLE_RATE | LADSPA_HINT_LOGARITHMIC |
LADSPA_HINT_DEFAULT_440);
(LADSPA_HINT_BOUNDED_BELOW | LADSPA_HINT_BOUNDED_ABOVE);
psPortRangeHints[SF_VAD].LowerBound = 0;
psPortRangeHints[SF_VAD].UpperBound = 95;
psPortRangeHints[SF_INPUT].HintDescriptor = 0;

@ -45,7 +45,6 @@ func doCLI(opt CLIOpts, config *config, librnnoise string) {
}
paClient, err := pulseaudio.NewClient()
if err != nil {
fmt.Fprintf(os.Stderr, "Couldn't create pulseaudio client: %v\n", err)
os.Exit(1)
@ -53,6 +52,13 @@ func doCLI(opt CLIOpts, config *config, librnnoise string) {
defer paClient.Close()
ctx := ntcontext{}
info, err := serverInfo(paClient)
if err != nil {
fmt.Fprintf(os.Stderr, "Couldn't fetch audio server info: %s\n", err)
}
ctx.serverInfo = info
ctx.config = config
ctx.librnnoise = librnnoise

@ -6,6 +6,8 @@ import (
"io/ioutil"
"log"
"os"
"regexp"
"strconv"
"strings"
"time"
@ -123,12 +125,10 @@ func getSources(client *pulseaudio.Client) []device {
outputs := make([]device, 0)
for i := range sources {
if strings.Contains(sources[i].Name, "nui_") {
if strings.Contains(sources[i].Name, "nui_") || strings.Contains(sources[i].Name, "NoiseTorch") {
continue
}
log.Printf("Input %s, %+v\n", sources[i].Name, sources[i])
var inp device
inp.ID = sources[i].Name
@ -153,7 +153,7 @@ func getSinks(client *pulseaudio.Client) []device {
inputs := make([]device, 0)
for i := range sources {
if strings.Contains(sources[i].Name, "nui_") {
if strings.Contains(sources[i].Name, "nui_") || strings.Contains(sources[i].Name, "NoiseTorch") {
continue
}
@ -190,6 +190,14 @@ func paConnectionWatchdog(ctx *ntcontext) {
fmt.Fprintf(os.Stderr, "Couldn't create pulseaudio client: %v\n", err)
}
info, err := serverInfo(paClient)
if err != nil {
log.Printf("Couldn't fetch audio server info: %s\n", err)
}
ctx.serverInfo = info
log.Printf("Connected to audio server. Server name '%s'\n", info.name)
ctx.paClient = paClient
go updateNoiseSupressorLoaded(ctx)
@ -203,6 +211,70 @@ func paConnectionWatchdog(ctx *ntcontext) {
}
}
func serverInfo(paClient *pulseaudio.Client) (audioserverinfo, error) {
info, err := paClient.ServerInfo()
if err != nil {
log.Printf("Couldn't fetch pulse server info: %v\n", err)
fmt.Fprintf(os.Stderr, "Couldn't fetch pulse server info: %v\n", err)
}
pkgname := info.PackageName
log.Printf("Audioserver package name: %s\n", pkgname)
log.Printf("Audioserver package version: %s\n", info.PackageVersion)
isPipewire := strings.Contains(pkgname, "PipeWire")
var servername string
var servertype uint
var major, minor, patch int
var versionRegex *regexp.Regexp
var versionString string
var outdatedPipeWire bool
if isPipewire {
servername = "PipeWire"
servertype = servertype_pipewire
versionRegex = regexp.MustCompile(`.*?on PipeWire (\d+)\.(\d+)\.(\d+).*?`)
versionString = pkgname
log.Printf("Detected PipeWire\n")
} else {
servername = "PulseAudio"
servertype = servertype_pulse
versionRegex = regexp.MustCompile(`.*?(\d+)\.(\d+)\.(\d+).*?`)
versionString = info.PackageVersion
log.Printf("Detected PulseAudio\n")
}
res := versionRegex.FindStringSubmatch(versionString)
if len(res) != 4 {
return audioserverinfo{}, fmt.Errorf("couldn't parse server version, regexp didn't match.")
}
major, err = strconv.Atoi(res[1])
if err != nil {
return audioserverinfo{}, err
}
minor, err = strconv.Atoi(res[2])
if err != nil {
return audioserverinfo{}, err
}
patch, err = strconv.Atoi(res[3])
if err != nil {
return audioserverinfo{}, err
}
if isPipewire && major <= 0 && minor <= 3 && patch < 28 {
log.Printf("pipewire version %d.%d.%d too old.\n", major, minor, patch)
outdatedPipeWire = true
}
return audioserverinfo{
servertype: servertype,
name: servername,
major: major,
minor: minor,
patch: patch,
outdatedPipeWire: outdatedPipeWire}, nil
}
func preselectDevice(ctx *ntcontext, devices []device, preselectID string,
fallbackFunc func(client *pulseaudio.Client) (string, error)) []device {

@ -39,56 +39,74 @@ func supressorState(ctx *ntcontext) int {
c := ctx.paClient
var inpLoaded, outLoaded, inputInc, outputInc bool
if ctx.config.FilterInput {
_, nullsink, err := findModule(c, "module-null-sink", "sink_name=nui_mic_denoised_out")
if err != nil {
log.Printf("Couldn't fetch module list to check for module-null-sink: %v\n", err)
}
_, ladspasink, err := findModule(c, "module-ladspa-sink", "sink_name=nui_mic_raw_in sink_master=nui_mic_denoised_out")
if err != nil {
log.Printf("Couldn't fetch module list to check for module-ladspa-sink: %v\n", err)
}
_, loopback, err := findModule(c, "module-loopback", "sink=nui_mic_raw_in")
if err != nil {
log.Printf("Couldn't fetch module list to check for module-loopback: %v\n", err)
}
_, remap, err := findModule(c, "module-remap-source", "master=nui_mic_denoised_out.monitor source_name=nui_mic_remap")
if err != nil {
log.Printf("Couldn't fetch module list to check for module-remap-source: %v\n", err)
}
if ctx.serverInfo.servertype == servertype_pipewire {
_, ladspasource, err := findModule(c, "module-ladspa-source", "source_name='NoiseTorch Microphone'")
if err != nil {
log.Printf("Couldn't fetch module list to check for module-ladspa-source: %v\n", err)
}
inpLoaded = ladspasource
inputInc = false
} else {
_, nullsink, err := findModule(c, "module-null-sink", "sink_name=nui_mic_denoised_out")
if err != nil {
log.Printf("Couldn't fetch module list to check for module-null-sink: %v\n", err)
}
_, ladspasink, err := findModule(c, "module-ladspa-sink", "sink_name=nui_mic_raw_in sink_master=nui_mic_denoised_out")
if err != nil {
log.Printf("Couldn't fetch module list to check for module-ladspa-sink: %v\n", err)
}
_, loopback, err := findModule(c, "module-loopback", "sink=nui_mic_raw_in")
if err != nil {
log.Printf("Couldn't fetch module list to check for module-loopback: %v\n", err)
}
_, remap, err := findModule(c, "module-remap-source", "master=nui_mic_denoised_out.monitor source_name=nui_mic_remap")
if err != nil {
log.Printf("Couldn't fetch module list to check for module-remap-source: %v\n", err)
}
if nullsink && ladspasink && loopback && remap {
inpLoaded = true
} else if nullsink || ladspasink || loopback || remap {
inputInc = true
if nullsink && ladspasink && loopback && remap {
inpLoaded = true
} else if nullsink || ladspasink || loopback || remap {
inputInc = true
}
}
} else {
inpLoaded = true
}
if ctx.config.FilterOutput {
_, out, err := findModule(c, "module-null-sink", "sink_name=nui_out_out_sink")
if err != nil {
log.Printf("Couldn't fetch module list to check for output module-ladspa-sink: %v\n", err)
}
_, lad, err := findModule(c, "module-ladspa-sink", "sink_name=nui_out_ladspa")
if err != nil {
log.Printf("Couldn't fetch module list to check for output module-ladspa-sink: %v\n", err)
}
_, loop, err := findModule(c, "module-loopback", "source=nui_out_out_sink.monitor")
if err != nil {
log.Printf("Couldn't fetch module list to check for output module-ladspa-sink: %v\n", err)
}
_, outin, err := findModule(c, "module-null-sink", "sink_name=nui_out_in_sink")
if err != nil {
log.Printf("Couldn't fetch module list to check for output module-ladspa-sink: %v\n", err)
}
_, loop2, err := findModule(c, "module-loopback", "source=nui_out_in_sink.monitor")
if err != nil {
log.Printf("Couldn't fetch module list to check for output module-ladspa-sink: %v\n", err)
}
if ctx.serverInfo.servertype == servertype_pipewire {
_, ladspasink, err := findModule(c, "module-ladspa-sink", "sink_name='NoiseTorch Headphones'")
if err != nil {
log.Printf("Couldn't fetch module list to check for module-ladspa-sink: %v\n", err)
}
outLoaded = ladspasink
outputInc = false
} else {
_, out, err := findModule(c, "module-null-sink", "sink_name=nui_out_out_sink")
if err != nil {
log.Printf("Couldn't fetch module list to check for output module-ladspa-sink: %v\n", err)
}
_, lad, err := findModule(c, "module-ladspa-sink", "sink_name=nui_out_ladspa")
if err != nil {
log.Printf("Couldn't fetch module list to check for output module-ladspa-sink: %v\n", err)
}
_, loop, err := findModule(c, "module-loopback", "source=nui_out_out_sink.monitor")
if err != nil {
log.Printf("Couldn't fetch module list to check for output module-ladspa-sink: %v\n", err)
}
_, outin, err := findModule(c, "module-null-sink", "sink_name=nui_out_in_sink")
if err != nil {
log.Printf("Couldn't fetch module list to check for output module-ladspa-sink: %v\n", err)
}
_, loop2, err := findModule(c, "module-loopback", "source=nui_out_in_sink.monitor")
if err != nil {
log.Printf("Couldn't fetch module list to check for output module-ladspa-sink: %v\n", err)
}
outLoaded = out && lad && loop && outin && loop2
outputInc = out || lad || loop || outin || loop2
outLoaded = out && lad && loop && outin && loop2
outputInc = out || lad || loop || outin || loop2
}
} else {
outLoaded = true
}
@ -105,110 +123,200 @@ func supressorState(ctx *ntcontext) int {
}
func loadSupressor(ctx *ntcontext, inp *device, out *device) error {
if ctx.serverInfo.servertype == servertype_pulse {
log.Printf("Querying pulse rlimit\n")
pid, err := getPulsePid()
if err != nil {
return err
}
lim, err := getRlimit(pid)
if err != nil {
return err
}
log.Printf("Rlimit: %+v. Trying to remove.\n", lim)
removeRlimit(pid)
log.Printf("Querying pulse rlimit\n")
defer setRlimit(pid, &lim) // lowering RLIMIT doesn't require root
newLim, err := getRlimit(pid)
if err != nil {
return err
}
log.Printf("Rlimit: %+v\n", newLim)
}
if inp.checked {
var err error
if ctx.serverInfo.servertype == servertype_pipewire {
err = loadPipeWireInput(ctx, inp)
} else {
err = loadPulseInput(ctx, inp)
}
if err != nil {
log.Printf("Error loading input: %v\n", err)
return err
}
}
if out.checked {
var err error
if ctx.serverInfo.servertype == servertype_pipewire {
err = loadPipeWireOutput(ctx, out)
} else {
err = loadPulseOutput(ctx, out)
}
if err != nil {
log.Printf("Error loading output: %v\n", err)
return err
}
}
return nil
}
func loadPipeWireInput(ctx *ntcontext, inp *device) error {
c := ctx.paClient
log.Printf("Loading supressor for pipewire\n")
idx, err := c.LoadModule("module-ladspa-source",
fmt.Sprintf("source_name='NoiseTorch Microphone' master=%s "+
"rate=48000 channels=1 "+
"label=noisetorch plugin=%s control=%d", inp.ID, ctx.librnnoise, ctx.config.Threshold))
pid, err := getPulsePid()
if err != nil {
return err
}
log.Printf("Loaded ladspa source as idx: %d\n", idx)
return nil
}
func loadPipeWireOutput(ctx *ntcontext, out *device) error {
c := ctx.paClient
log.Printf("Loading supressor for pipewire\n")
idx, err := c.LoadModule("module-ladspa-sink",
fmt.Sprintf("sink_name='NoiseTorch Headphones' master=%s "+
"rate=48000 channels=1 "+
"label=noisetorch plugin=%s control=%d", out.ID, ctx.librnnoise, ctx.config.Threshold))
lim, err := getRlimit(pid)
if err != nil {
return err
}
log.Printf("Rlimit: %+v. Trying to remove.\n", lim)
removeRlimit(pid)
log.Printf("Loaded ladspa source as idx: %d\n", idx)
return nil
}
defer setRlimit(pid, &lim) // lowering RLIMIT doesn't require root
func loadPulseInput(ctx *ntcontext, inp *device) error {
c := ctx.paClient
log.Printf("Loading supressor for pulse\n")
idx, err := c.LoadModule("module-null-sink", "sink_name=nui_mic_denoised_out rate=48000")
if err != nil {
return err
}
log.Printf("Loaded null sink as idx: %d\n", idx)
newLim, err := getRlimit(pid)
idx, err = c.LoadModule("module-ladspa-sink",
fmt.Sprintf("sink_name=nui_mic_raw_in sink_master=nui_mic_denoised_out "+
"label=noisetorch plugin=%s control=%d", ctx.librnnoise, ctx.config.Threshold))
if err != nil {
return err
}
log.Printf("Rlimit: %+v\n", newLim)
log.Printf("Loaded ladspa sink as idx: %d\n", idx)
if inp.checked {
log.Printf("Loading supressor\n")
idx, err := c.LoadModule("module-null-sink", "sink_name=nui_mic_denoised_out rate=48000")
if inp.dynamicLatency {
idx, err = c.LoadModule("module-loopback",
fmt.Sprintf("source=%s sink=nui_mic_raw_in channels=1 latency_msec=1 source_dont_move=true sink_dont_move=true", inp.ID))
if err != nil {
return err
}
log.Printf("Loaded null sink as idx: %d\n", idx)
idx, err = c.LoadModule("module-ladspa-sink",
fmt.Sprintf("sink_name=nui_mic_raw_in sink_master=nui_mic_denoised_out "+
"label=noisetorch plugin=%s control=%d", ctx.librnnoise, ctx.config.Threshold))
log.Printf("Loaded loopback as idx: %d\n", idx)
} else {
idx, err = c.LoadModule("module-loopback",
fmt.Sprintf("source=%s sink=nui_mic_raw_in channels=1 latency_msec=50 source_dont_move=true sink_dont_move=true adjust_time=1", inp.ID))
if err != nil {
return err
}
log.Printf("Loaded ladspa sink as idx: %d\n", idx)
log.Printf("Loaded fixed latency loopback as idx: %d\n", idx)
}
if inp.dynamicLatency {
idx, err = c.LoadModule("module-loopback",
fmt.Sprintf("source=%s sink=nui_mic_raw_in channels=1 latency_msec=1 source_dont_move=true sink_dont_move=true", inp.ID))
if err != nil {
return err
}
log.Printf("Loaded loopback as idx: %d\n", idx)
} else {
idx, err = c.LoadModule("module-loopback",
fmt.Sprintf("source=%s sink=nui_mic_raw_in channels=1 latency_msec=50 source_dont_move=true sink_dont_move=true adjust_time=1", inp.ID))
if err != nil {
return err
}
log.Printf("Loaded fixed latency loopback as idx: %d\n", idx)
}
idx, err = c.LoadModule("module-remap-source", `master=nui_mic_denoised_out.monitor `+
`source_name=nui_mic_remap source_properties="device.description='NoiseTorch Microphone'"`)
if err != nil {
return err
}
log.Printf("Loaded remap source as idx: %d\n", idx)
return nil
}
idx, err = c.LoadModule("module-remap-source", `master=nui_mic_denoised_out.monitor `+
`source_name=nui_mic_remap source_properties="device.description='NoiseTorch Microphone'"`)
if err != nil {
return err
}
log.Printf("Loaded remap source as idx: %d\n", idx)
func loadPulseOutput(ctx *ntcontext, out *device) error {
c := ctx.paClient
_, err := c.LoadModule("module-null-sink", `sink_name=nui_out_out_sink`)
if err != nil {
return err
}
if out.checked {
_, err = c.LoadModule("module-null-sink", `sink_name=nui_out_in_sink sink_properties="device.description='NoiseTorch Headphones'"`)
if err != nil {
return err
}
_, err := c.LoadModule("module-null-sink", `sink_name=nui_out_out_sink`)
if err != nil {
return err
}
_, err = c.LoadModule("module-ladspa-sink", fmt.Sprintf(`sink_name=nui_out_ladspa sink_master=nui_out_out_sink `+
`label=noisetorch channels=1 plugin=%s control=%d rate=%d`,
ctx.librnnoise, ctx.config.Threshold, 48000))
if err != nil {
return err
}
_, err = c.LoadModule("module-null-sink", `sink_name=nui_out_in_sink sink_properties="device.description='NoiseTorch Headphones'"`)
if err != nil {
return err
}
_, err = c.LoadModule("module-loopback",
fmt.Sprintf("source=nui_out_out_sink.monitor sink=%s channels=2 latency_msec=50 source_dont_move=true sink_dont_move=true", out.ID))
if err != nil {
return err
}
_, err = c.LoadModule("module-ladspa-sink", fmt.Sprintf(`sink_name=nui_out_ladspa sink_master=nui_out_out_sink `+
`label=noisetorch channels=1 plugin=%s control=%d rate=%d`,
ctx.librnnoise, ctx.config.Threshold, 48000))
if err != nil {
return err
}
_, err = c.LoadModule("module-loopback",
fmt.Sprintf("source=nui_out_in_sink.monitor sink=nui_out_ladspa channels=1 latency_msec=50 source_dont_move=true sink_dont_move=true"))
if err != nil {
return err
}
return nil
}
_, err = c.LoadModule("module-loopback",
fmt.Sprintf("source=nui_out_out_sink.monitor sink=%s channels=2 latency_msec=50 source_dont_move=true sink_dont_move=true", out.ID))
if err != nil {
return err
}
func unloadSupressor(ctx *ntcontext) error {
if ctx.serverInfo.servertype == servertype_pipewire {
return unloadSupressorPipeWire(ctx)
} else {
return unloadSupressorPulse(ctx)
}
}
_, err = c.LoadModule("module-loopback",
fmt.Sprintf("source=nui_out_in_sink.monitor sink=nui_out_ladspa channels=1 latency_msec=50 source_dont_move=true sink_dont_move=true"))
if err != nil {
return err
}
func unloadSupressorPipeWire(ctx *ntcontext) error {
log.Printf("Unloading modules for pipewire\n")
log.Printf("Searching for module-ladspa-source\n")
c := ctx.paClient
m, found, err := findModule(c, "module-ladspa-source", "source_name='NoiseTorch Microphone'")
if err != nil {
return err
}
if found {
log.Printf("Found module-ladspa-source at id [%d], sending unload command\n", m.Index)
c.UnloadModule(m.Index)
}
log.Printf("Searching for module-ladspa-sink\n")
m, found, err = findModule(c, "module-ladspa-sink", "sink_name='NoiseTorch Headphones'")
if err != nil {
return err
}
if found {
log.Printf("Found module-ladspa-sink at id [%d], sending unload command\n", m.Index)
c.UnloadModule(m.Index)
}
return nil
}
func unloadSupressor(ctx *ntcontext) error {
log.Printf("Unloading pulseaudio modules\n")
func unloadSupressorPulse(ctx *ntcontext) error {
log.Printf("Unloading modules for pulseaudio\n")
if pid, err := getPulsePid(); err == nil {
if lim, err := getRlimit(pid); err == nil {
@ -318,6 +426,7 @@ func unloadSupressor(ctx *ntcontext) error {
// Finds a module by exactly matching the module name, and checking if the second string is a substring of the argument
func findModule(c *pulseaudio.Client, name string, argMatch string) (module pulseaudio.Module, found bool, err error) {
lst, err := c.ModuleList()
if err != nil {
return pulseaudio.Module{}, false, err
}

50
ui.go

@ -33,8 +33,24 @@ type ntcontext struct {
haveCapabilities bool
capsMismatch bool
views *ViewStack
serverInfo audioserverinfo
}
//TODO pull some of these strucs out of UI, they don't belong here
type audioserverinfo struct {
servertype uint
name string
major int
minor int
patch int
outdatedPipeWire bool
}
const (
servertype_pulse = iota
servertype_pipewire
)
var green = color.RGBA{34, 187, 69, 255}
var red = color.RGBA{255, 70, 70, 255}
var orange = color.RGBA{255, 140, 0, 255}
@ -84,6 +100,11 @@ func mainView(ctx *ntcontext, w *nucular.Window) {
w.LabelColored("Inconsistent state, please unload first.", "RC", orange)
}
if ctx.serverInfo.servertype == servertype_pipewire {
w.Row(20).Dynamic(1)
w.Label("Running in PipeWire mode. PipeWire support is currently alpha quality. Please report bugs.", "LC")
}
if ctx.update.available && !ctx.update.triggered {
w.Row(20).Ratio(0.9, 0.1)
w.LabelColored("Update available! Click to install version: "+ctx.update.serverVersion, "LC", green)
@ -376,23 +397,23 @@ func capabilitiesView(ctx *ntcontext, w *nucular.Window) {
if w.ButtonText("Grant capability (requires root)") {
err := pkexecSetcapSelf()
if err != nil {
ctx.views.Push(makeErrorView(ctx, w, err.Error()))
ctx.views.Push(makeErrorView(ctx, err.Error()))
return
}
self, err := os.Executable()
if err != nil {
ctx.views.Push(makeErrorView(ctx, w, err.Error()))
ctx.views.Push(makeErrorView(ctx, err.Error()))
return
}
err = syscall.Exec(self, []string{""}, os.Environ())
if err != nil {
ctx.views.Push(makeErrorView(ctx, w, err.Error()))
ctx.views.Push(makeErrorView(ctx, err.Error()))
return
}
}
}
func makeErrorView(ctx *ntcontext, w *nucular.Window, errorMsg string) ViewFunc {
func makeErrorView(ctx *ntcontext, errorMsg string) ViewFunc {
return func(ctx *ntcontext, w *nucular.Window) {
w.Row(15).Dynamic(1)
w.Label("Error", "CB")
@ -407,6 +428,21 @@ func makeErrorView(ctx *ntcontext, w *nucular.Window, errorMsg string) ViewFunc
}
}
func makeFatalErrorView(ctx *ntcontext, errorMsg string) ViewFunc {
return func(ctx *ntcontext, w *nucular.Window) {
w.Row(15).Dynamic(1)
w.Label("Fatal Error", "CB")
w.Row(15).Dynamic(1)
w.Label(errorMsg, "CB")
w.Row(40).Dynamic(1)
w.Row(25).Dynamic(1)
if w.ButtonText("Quit") {
os.Exit(1)
return
}
}
}
func resetUI(ctx *ntcontext) {
ctx.views = NewViewStack()
ctx.views.Push(mainView)
@ -414,6 +450,12 @@ func resetUI(ctx *ntcontext) {
if !ctx.haveCapabilities {
ctx.views.Push(capabilitiesView)
}
if ctx.serverInfo.outdatedPipeWire {
ctx.views.Push(makeFatalErrorView(ctx,
fmt.Sprintf("Your PipeWire version is too old. Detected %d.%d.%d. Require at least 0.3.28.",
ctx.serverInfo.major, ctx.serverInfo.minor, ctx.serverInfo.patch)))
}
}
func loadPatreonImg() *image.RGBA {

Loading…
Cancel
Save