diff --git a/.gitignore b/.gitignore index 71968c4d..f50cf568 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ releases/ *.bin logs/ preferences.yaml +.idea/ diff --git a/arduino/deej-5-sliders-vanilla/deej-5-sliders-vanilla.ino b/arduino/deej-5-sliders-vanilla/deej-5-sliders-vanilla.ino index e2177155..0fb3f365 100644 --- a/arduino/deej-5-sliders-vanilla/deej-5-sliders-vanilla.ino +++ b/arduino/deej-5-sliders-vanilla/deej-5-sliders-vanilla.ino @@ -1,10 +1,10 @@ -const int NUM_SLIDERS = 5; -const int analogInputs[NUM_SLIDERS] = {A0, A1, A2, A3, A4}; +const int NUM_POTS = 5; +const int analogInputs[NUM_POTS] = {A0, A1, A2, A3, A4}; -int analogSliderValues[NUM_SLIDERS]; +int analogSliderValues[NUM_POTS]; void setup() { - for (int i = 0; i < NUM_SLIDERS; i++) { + for (int i = 0; i < NUM_POTS; i++) { pinMode(analogInputs[i], INPUT); } @@ -19,7 +19,7 @@ void loop() { } void updateSliderValues() { - for (int i = 0; i < NUM_SLIDERS; i++) { + for (int i = 0; i < NUM_POTS; i++) { analogSliderValues[i] = analogRead(analogInputs[i]); } } @@ -27,10 +27,10 @@ void updateSliderValues() { void sendSliderValues() { String builtString = String(""); - for (int i = 0; i < NUM_SLIDERS; i++) { + for (int i = 0; i < NUM_POTS; i++) { builtString += String((int)analogSliderValues[i]); - if (i < NUM_SLIDERS - 1) { + if (i < NUM_POTS - 1) { builtString += String("|"); } } @@ -39,11 +39,11 @@ void sendSliderValues() { } void printSliderValues() { - for (int i = 0; i < NUM_SLIDERS; i++) { + for (int i = 0; i < NUM_POTS; i++) { String printedString = String("Slider #") + String(i + 1) + String(": ") + String(analogSliderValues[i]) + String(" mV"); Serial.write(printedString.c_str()); - if (i < NUM_SLIDERS - 1) { + if (i < NUM_POTS - 1) { Serial.write(" | "); } else { Serial.write("\n"); diff --git a/arduino/deej-sliders-encoders-combo/deej-sliders-encoders-combo.ino b/arduino/deej-sliders-encoders-combo/deej-sliders-encoders-combo.ino new file mode 100644 index 00000000..5419299d --- /dev/null +++ b/arduino/deej-sliders-encoders-combo/deej-sliders-encoders-combo.ino @@ -0,0 +1,212 @@ +#include +#include + +#define NUM_POTS 3 +#define NUM_BUTTONS 5 +#define NUM_ENCODERS 2 + +#define LONG_CLICK_TIME 1000 +#define MAX_TIME_BETWEEN_CLICKS 300 + +#define DATA_RESOLUTION 1024 +#define DENOIZE 8 + +#define DATA_SEND_THRESHOLD 5 +#define START_MARKER 0xAA +#define END_MARKER 0x55 + +const int potPins[NUM_POTS] = { A1, A2, A3 }; +const int buttonPins[NUM_BUTTONS] = { 14, 15, 18, 4, 7 }; +const int encoderPins[NUM_ENCODERS * 2] = { 2, 3, 5, 6 }; + +struct VolData { + int value; + bool toggleMute = false; +}; + +VolData lastSentValues[NUM_POTS + NUM_ENCODERS]; +VolData values[NUM_POTS + NUM_ENCODERS]; +Button* buttons[NUM_BUTTONS]; +RotaryEncoder* encoders[NUM_ENCODERS]; + +void setup() { + Serial.begin(9600); + delay(300); + Init(); +} + +void Init() { + for (int i = 0; i < NUM_POTS; i++) { + pinMode(potPins[i], INPUT); + } + + for (int i = 0; i < NUM_BUTTONS; i++) { + buttons[i] = new Button(buttonPins[i], MAX_TIME_BETWEEN_CLICKS, LONG_CLICK_TIME, true); + } + + for (int i = 0; i < NUM_ENCODERS; i++) { + encoders[i] = new RotaryEncoder(encoderPins[i * 2], encoderPins[i * 2 + 1], RotaryEncoder::LatchMode::TWO03); + } +} + +void loop() { + for (int i = 0; i < NUM_BUTTONS; i++) { + tickButton(i); + } + + for (int i = 0; i < NUM_ENCODERS; i++) { + tickEncoder(i); + } + + tickPots(); + + trySendValues(); + //printValues(); +} + +void setValue(uint8_t index, int newValue) { + int clamped = constrain(newValue, -(DATA_RESOLUTION - 1), DATA_RESOLUTION - 1); + values[index].value = clamped; +} + +int getValue(uint8_t index) { + return values[index].toggleMute ? 0 : values[index].value; +} + +void tickEncoder(uint8_t index) { + RotaryEncoder* encoder = encoders[index]; + + encoder->tick(); + + const RotaryEncoder::Direction direction = encoder->getDirection(); + + const int newValue = (int)direction * 22; + + const int valueIndex = NUM_POTS + index; + + if (values[valueIndex].value != newValue) { + setValue(valueIndex, newValue); + } +} + +void tickButton(uint8_t index) { + Button* button = buttons[index]; + + button->tick(); + + if (button->isPressed() && button->currentStateTime() >= 300 && !values[index].toggleMute) { + values[index].toggleMute = true; + return; + } + + if (button->isThereAnEvent()) { + values[index].toggleMute = !values[index].toggleMute; + + button->ClearEvent(); + } +} + +void tickPots() { + for (int i = 0; i < NUM_POTS; i++) { + int rawValue = analogRead(potPins[i]); + int mappedValue = rawValue; + + if (abs(abs(mappedValue) - abs(values[i].value)) > DENOIZE) { + if (mappedValue >= 1000) { + mappedValue = 1023; + } + if (mappedValue <= 10) { + mappedValue = 0; + } + + setValue(i, mappedValue); + } + } +} + +void trySendValues() { + bool shouldSendValues = false; + + for (int i = 0; i < NUM_POTS + NUM_ENCODERS; i++) { + const int minValue = min(lastSentValues[i].value, values[i].value); + const int maxValue = max(lastSentValues[i].value, values[i].value); + + // using threshold only for pots because encoders have synthetic data that can be compared raw + const bool changeSignificantEnough = i < NUM_POTS ? abs(maxValue - minValue) >= DATA_SEND_THRESHOLD : lastSentValues[i].value != values[i].value; + + if (changeSignificantEnough || values[i].toggleMute) { + shouldSendValues = true; + break; + } + } + + if (shouldSendValues) { + sendValues(); + + for (int i = 0; i < NUM_POTS + NUM_ENCODERS; i++) { + values[i].toggleMute = false; + } + } +} + +const uint8_t FRAME_SIZE = (NUM_POTS + NUM_ENCODERS); + +void sendValues() { + uint8_t buf[FRAME_SIZE * 2 + 2]; + + int idx = 0; + + buf[idx++] = START_MARKER; + + for (int i = 0; i < FRAME_SIZE; i++) { + uint16_t packed = ((values[i].toggleMute & 0x01) << 11) | ((uint16_t)values[i].value & 0x07FF); + + buf[idx++] = (packed >> 8) & 0xFF; + buf[idx++] = packed & 0xFF; + + lastSentValues[i] = values[i]; + } + + buf[idx++] = END_MARKER; + + Serial.write(buf, idx); +} + +void printValues() { + bool shouldSendValues = false; + + for (int i = 0; i < NUM_POTS + NUM_ENCODERS; i++) { + const int minValue = min(lastSentValues[i].value, values[i].value); + const int maxValue = max(lastSentValues[i].value, values[i].value); + + // using threshold only for pots because encoders have synthetic data that can be compared raw + const bool changeSignificantEnough = i < NUM_POTS ? abs(maxValue - minValue) >= DATA_SEND_THRESHOLD : lastSentValues[i].value != values[i].value; + + if (changeSignificantEnough || values[i].toggleMute) { + shouldSendValues = true; + break; + } + } + + if (!shouldSendValues) { + return; + } + + for (int i = 0; i < NUM_POTS + NUM_ENCODERS; i++) { + String printedString = String("Value #") + String(i + 1) + String(": ") + String(getValue(i)); + + if (values[i].toggleMute) { + printedString += "(m)"; + } + + Serial.write(printedString.c_str()); + + if (i < NUM_POTS + NUM_ENCODERS - 1) { + Serial.write(" | "); + } else { + Serial.write("\n"); + } + + lastSentValues[i] = values[i]; + } +} diff --git a/config.yaml b/config.yaml index d8092d5a..db536f0e 100644 --- a/config.yaml +++ b/config.yaml @@ -7,21 +7,28 @@ # windows only - you can use 'system' to control the "system sounds" volume # important: slider indexes start at 0, regardless of which analog pins you're using! slider_mapping: - 0: master - 1: chrome.exe - 2: spotify.exe - 3: - - pathofexile_x64.exe - - rocketleague.exe - 4: discord.exe + 0: + - spotify.exe + - JellyfinMediaPlayer.exe + - vivaldi.exe + 1: discord.exe + 2: UnrealEditor-Win64-DebugGame.exe + 3: master + 4: deej.current + +# an array of slider indices that should be considered additive. These sliders' values would not replace the volume but be added to the current volume instead. +# it's primarily used for rotary encoders +additive_indices: [3, 4] +use_log_volume: true # set this to true if you want the controls inverted (i.e. top is 0%, bottom is 100%) invert_sliders: false # settings for connecting to the arduino board -com_port: COM4 +com_port: COM5 baud_rate: 9600 # adjust the amount of signal noise reduction depending on your hardware quality # supported values are "low" (excellent hardware), "default" (regular hardware) or "high" (bad, noisy hardware) -noise_reduction: default +# new value: "extraLow" (0.01 - for cleaning on the hardware) +noise_reduction: extraLow diff --git a/go.mod b/go.mod index 538a283d..010a82a4 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,197 @@ module github.com/omriharel/deej -go 1.14 +go 1.23.0 + +toolchain go1.23.5 require ( github.com/fsnotify/fsnotify v1.4.9 github.com/gen2brain/beeep v0.0.0-20200420150314-13046a26d502 - github.com/getlantern/ops v0.0.0-20200403153110-8476b16edcd6 // indirect github.com/getlantern/systray v0.0.0-20200324212034-d3ab4fd25d99 github.com/go-ole/go-ole v1.2.4 - github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 github.com/jfreymuth/pulse v0.0.0-20200608153616-84b2d752b9d4 - github.com/lxn/walk v0.0.0-20191128110447-55ccb3a9f5c1 // indirect github.com/lxn/win v0.0.0-20191128105842-2da648fda5b4 github.com/mitchellh/go-ps v1.0.0 github.com/moutend/go-wca v0.1.2-0.20190422112502-0fa027b3d89a github.com/spf13/viper v1.7.1 github.com/thoas/go-funk v0.7.0 go.uber.org/zap v1.15.0 - golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 // indirect +) + +require ( + cloud.google.com/go v0.46.3 // indirect + cloud.google.com/go/bigquery v1.0.1 // indirect + cloud.google.com/go/datastore v1.0.0 // indirect + cloud.google.com/go/firestore v1.1.0 // indirect + cloud.google.com/go/pubsub v1.0.1 // indirect + cloud.google.com/go/storage v1.0.0 // indirect + dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 // indirect + github.com/BurntSushi/toml v0.3.1 // indirect + github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 // indirect + github.com/OneOfOne/xxhash v1.2.2 // indirect + github.com/akavel/rsrc v0.10.2 // indirect + github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect + github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect + github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e // indirect + github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da // indirect + github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 // indirect + github.com/beorn7/perks v1.0.0 // indirect + github.com/bgentry/speakeasy v0.1.0 // indirect + github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/client9/misspell v0.3.4 // indirect + github.com/coreos/bbolt v1.3.2 // indirect + github.com/coreos/etcd v3.3.13+incompatible // indirect + github.com/coreos/go-semver v0.3.0 // indirect + github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e // indirect + github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect + github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954 // indirect + github.com/fatih/color v1.7.0 // indirect + github.com/getlantern/appdir v0.0.0-20180320102544-7c0f9d241ea7 // indirect + github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect + github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect + github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect + github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect + github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect + github.com/getlantern/ops v0.0.0-20200403153110-8476b16edcd6 // indirect + github.com/getlantern/uuid v1.2.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 // indirect + github.com/go-kit/kit v0.8.0 // indirect + github.com/go-logfmt/logfmt v0.4.0 // indirect + github.com/go-stack/stack v1.8.0 // indirect + github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect + github.com/godbus/dbus v4.1.0+incompatible // indirect + github.com/gogo/protobuf v1.2.1 // indirect + github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect + github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef // indirect + github.com/golang/mock v1.3.1 // indirect + github.com/golang/protobuf v1.3.2 // indirect + github.com/google/btree v1.0.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/martian v2.1.0+incompatible // indirect + github.com/google/pprof v0.0.0-20190515194954-54271f7e092f // indirect + github.com/google/renameio v0.1.0 // indirect + github.com/googleapis/gax-go/v2 v2.0.5 // indirect + github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect + github.com/gopherjs/gopherwasm v1.1.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.0.0 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway v1.9.0 // indirect + github.com/hashicorp/consul/api v1.1.0 // indirect + github.com/hashicorp/consul/sdk v0.1.1 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.1 // indirect + github.com/hashicorp/go-immutable-radix v1.0.0 // indirect + github.com/hashicorp/go-msgpack v0.5.3 // indirect + github.com/hashicorp/go-multierror v1.0.0 // indirect + github.com/hashicorp/go-rootcerts v1.0.0 // indirect + github.com/hashicorp/go-sockaddr v1.0.0 // indirect + github.com/hashicorp/go-syslog v1.0.0 // indirect + github.com/hashicorp/go-uuid v1.0.1 // indirect + github.com/hashicorp/go.net v0.0.1 // indirect + github.com/hashicorp/golang-lru v0.5.1 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect + github.com/hashicorp/mdns v1.0.0 // indirect + github.com/hashicorp/memberlist v0.1.3 // indirect + github.com/hashicorp/serf v0.8.2 // indirect + github.com/jonboulle/clockwork v0.1.0 // indirect + github.com/json-iterator/go v1.1.6 // indirect + github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/julienschmidt/httprouter v1.2.0 // indirect + github.com/kisielk/errcheck v1.1.0 // indirect + github.com/kisielk/gotool v1.0.0 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect + github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/kr/pty v1.1.1 // indirect + github.com/kr/text v0.1.0 // indirect + github.com/lxn/walk v0.0.0-20191128110447-55ccb3a9f5c1 // indirect + github.com/magiconair/properties v1.8.1 // indirect + github.com/mattn/go-colorable v0.0.9 // indirect + github.com/mattn/go-isatty v0.0.3 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/miekg/dns v1.0.14 // indirect + github.com/mitchellh/cli v1.0.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-testing-interface v1.0.0 // indirect + github.com/mitchellh/gox v0.4.0 // indirect + github.com/mitchellh/iochan v1.0.0 // indirect + github.com/mitchellh/mapstructure v1.1.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223 // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c // indirect + github.com/pelletier/go-toml v1.2.0 // indirect + github.com/pkg/errors v0.8.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/posener/complete v1.1.1 // indirect + github.com/prometheus/client_golang v0.9.3 // indirect + github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 // indirect + github.com/prometheus/common v0.4.0 // indirect + github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 // indirect + github.com/prometheus/tsdb v0.7.1 // indirect + github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af // indirect + github.com/rogpeppe/go-internal v1.3.0 // indirect + github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f // indirect + github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect + github.com/sirupsen/logrus v1.2.0 // indirect + github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect + github.com/smartystreets/goconvey v1.6.4 // indirect + github.com/soheilhy/cmux v0.1.4 // indirect + github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect + github.com/spf13/afero v1.1.2 // indirect + github.com/spf13/cast v1.3.0 // indirect + github.com/spf13/jwalterweatherman v1.0.0 // indirect + github.com/spf13/pflag v1.0.3 // indirect + github.com/stretchr/objx v0.1.1 // indirect + github.com/stretchr/testify v1.4.0 // indirect + github.com/subosito/gotenv v1.2.0 // indirect + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect + github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 // indirect + github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect + github.com/yuin/goldmark v1.4.13 // indirect + go.etcd.io/bbolt v1.3.2 // indirect + go.opencensus.io v0.22.0 // indirect + go.uber.org/atomic v1.6.0 // indirect + go.uber.org/multierr v1.5.0 // indirect + go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/image v0.0.0-20190802002840-cff245a6509b // indirect + golang.org/x/lint v0.0.0-20190930215403-16217165b5de // indirect + golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect + golang.org/x/tools v0.34.0 // indirect + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 // indirect + google.golang.org/api v0.13.0 // indirect + google.golang.org/appengine v1.6.1 // indirect + google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a // indirect + google.golang.org/grpc v1.21.1 // indirect + gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect + gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + gopkg.in/errgo.v2 v2.1.0 // indirect + gopkg.in/ini.v1 v1.51.0 // indirect + gopkg.in/resty.v1 v1.12.0 // indirect + gopkg.in/yaml.v2 v2.2.4 // indirect + honnef.co/go/tools v0.0.1-2019.2.3 // indirect + rsc.io/binaryregexp v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index d6dff5bc..00e4a3fd 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= +github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= @@ -89,6 +91,7 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -235,6 +238,7 @@ github.com/thoas/go-funk v0.7.0 h1:GmirKrs6j6zJbhJIficOsz2aAI7700KsU/5YrdHRM1Y= github.com/thoas/go-funk v0.7.0/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= @@ -254,11 +258,14 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -273,6 +280,7 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -287,6 +295,7 @@ golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -295,6 +304,7 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -315,10 +325,16 @@ golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0 golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 h1:5B6i6EAiSYyejWfvc5Rc9BbI3rzIsrrXfAQBWnYfn+w= golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -342,6 +358,8 @@ golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5 h1:hKsoRgsbwY1NafxrwTs+k64 golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc h1:NCy3Ohtk6Iny5V/reW2Ktypo4zIpWBdRJ1uFMjBxdg8= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= diff --git a/pkg/deej/config.go b/pkg/deej/config.go index 5f6a4e43..8462afb1 100644 --- a/pkg/deej/config.go +++ b/pkg/deej/config.go @@ -18,11 +18,15 @@ import ( type CanonicalConfig struct { SliderMapping *sliderMap + AdditiveIndices []int + ConnectionInfo struct { COMPort string BaudRate int } + UseLogVolume bool + InvertSliders bool NoiseReductionLevel string @@ -48,11 +52,13 @@ const ( configType = "yaml" + configKeyAdditive = "additive_indices" configKeySliderMapping = "slider_mapping" configKeyInvertSliders = "invert_sliders" configKeyCOMPort = "com_port" configKeyBaudRate = "baud_rate" configKeyNoiseReductionLevel = "noise_reduction" + configKeyUseLogVolume = "use_log_volume" defaultCOMPort = "COM4" defaultBaudRate = 9600 @@ -85,6 +91,7 @@ func NewConfig(logger *zap.SugaredLogger, notifier Notifier) (*CanonicalConfig, userConfig.SetConfigType(configType) userConfig.AddConfigPath(userConfigPath) + userConfig.SetDefault(configKeyAdditive, []int{}) userConfig.SetDefault(configKeySliderMapping, map[string][]string{}) userConfig.SetDefault(configKeyInvertSliders, false) userConfig.SetDefault(configKeyCOMPort, defaultCOMPort) @@ -145,8 +152,10 @@ func (cc *CanonicalConfig) Load() error { cc.logger.Info("Loaded config successfully") cc.logger.Infow("Config values", "sliderMapping", cc.SliderMapping, + "additiveIndices", cc.AdditiveIndices, "connectionInfo", cc.ConnectionInfo, - "invertSliders", cc.InvertSliders) + "invertSliders", cc.InvertSliders, + "UseLogVolume", cc.UseLogVolume) return nil } @@ -174,7 +183,6 @@ func (cc *CanonicalConfig) WatchConfigFileChanges() { // establish watch using viper as opposed to doing it ourselves, though our internal cooldown is still required cc.userConfig.WatchConfig() cc.userConfig.OnConfigChange(func(event fsnotify.Event) { - // when we get a write event... if event.Op&fsnotify.Write == fsnotify.Write { @@ -216,13 +224,16 @@ func (cc *CanonicalConfig) StopWatchingConfigFile() { } func (cc *CanonicalConfig) populateFromVipers() error { - // merge the slider mappings from the user and internal configs cc.SliderMapping = sliderMapFromConfigs( cc.userConfig.GetStringMapStringSlice(configKeySliderMapping), cc.internalConfig.GetStringMapStringSlice(configKeySliderMapping), ) + cc.AdditiveIndices = cc.userConfig.GetIntSlice(configKeyAdditive) + + cc.logger.Debugw("encoders found", "indices", cc.AdditiveIndices) + // get the rest of the config fields - viper saves us a lot of effort here cc.ConnectionInfo.COMPort = cc.userConfig.GetString(configKeyCOMPort) @@ -237,6 +248,7 @@ func (cc *CanonicalConfig) populateFromVipers() error { } cc.InvertSliders = cc.userConfig.GetBool(configKeyInvertSliders) + cc.UseLogVolume = cc.userConfig.GetBool(configKeyUseLogVolume) cc.NoiseReductionLevel = cc.userConfig.GetString(configKeyNoiseReductionLevel) cc.logger.Debug("Populated config fields from vipers") diff --git a/pkg/deej/serial.go b/pkg/deej/serial.go index 88e70b26..e1081b39 100644 --- a/pkg/deej/serial.go +++ b/pkg/deej/serial.go @@ -5,15 +5,15 @@ import ( "errors" "fmt" "io" + "math" "regexp" - "strconv" "strings" "time" "github.com/jacobsa/go-serial/serial" - "go.uber.org/zap" - "github.com/omriharel/deej/pkg/deej/util" + "go.uber.org/zap" + "golang.org/x/exp/slices" ) // SerialIO provides a deej-aware abstraction layer to managing serial I/O @@ -29,19 +29,30 @@ type SerialIO struct { connOptions serial.OpenOptions conn io.ReadWriteCloser - lastKnownNumSliders int - currentSliderPercentValues []float32 + lastKnownNumSliders int + currentVolumeDatas []VolumeData + + sliderMoveConsumers []chan SliderEvent +} + +type VolumeData struct { + Value float32 + Mute bool +} - sliderMoveConsumers []chan SliderMoveEvent +type ArduinoData struct { + Value int + ToggleMute bool } -// SliderMoveEvent represents a single slider move captured by deej -type SliderMoveEvent struct { +// SliderEvent represents a single slider move captured by deej +type SliderEvent struct { SliderID int PercentValue float32 + ToggleMute bool } -var expectedLinePattern = regexp.MustCompile(`^\d{1,4}(\|\d{1,4})*\r\n$`) +var expectedLinePattern = regexp.MustCompile(`^-?\d{1,4}(\|-?\d{1,4})*\r\n$`) // NewSerialIO creates a SerialIO instance that uses the provided deej // instance's connection info to establish communications with the arduino chip @@ -54,7 +65,7 @@ func NewSerialIO(deej *Deej, logger *zap.SugaredLogger) (*SerialIO, error) { stopChannel: make(chan bool), connected: false, conn: nil, - sliderMoveConsumers: []chan SliderMoveEvent{}, + sliderMoveConsumers: []chan SliderEvent{}, } logger.Debug("Created serial i/o instance") @@ -67,7 +78,6 @@ func NewSerialIO(deej *Deej, logger *zap.SugaredLogger) (*SerialIO, error) { // Start attempts to connect to our arduino chip func (sio *SerialIO) Start() error { - // don't allow multiple concurrent connections if sio.connected { sio.logger.Warn("Already connected, can't start another without closing first") @@ -112,14 +122,14 @@ func (sio *SerialIO) Start() error { // read lines or await a stop go func() { connReader := bufio.NewReader(sio.conn) - lineChannel := sio.readLine(namedLogger, connReader) + bytesChannel := sio.readBytes(namedLogger, connReader) for { select { case <-sio.stopChannel: sio.close(namedLogger) - case line := <-lineChannel: - sio.handleLine(namedLogger, line) + case bytes := <-bytesChannel: + sio.handleBytes(namedLogger, bytes) } } }() @@ -139,8 +149,8 @@ func (sio *SerialIO) Stop() { // SubscribeToSliderMoveEvents returns an unbuffered channel that receives // a sliderMoveEvent struct every time a slider moves -func (sio *SerialIO) SubscribeToSliderMoveEvents() chan SliderMoveEvent { - ch := make(chan SliderMoveEvent) +func (sio *SerialIO) SubscribeToSliderMoveEvents() chan SliderEvent { + ch := make(chan SliderEvent) sio.sliderMoveConsumers = append(sio.sliderMoveConsumers, ch) return ch @@ -198,73 +208,93 @@ func (sio *SerialIO) close(logger *zap.SugaredLogger) { sio.connected = false } -func (sio *SerialIO) readLine(logger *zap.SugaredLogger, reader *bufio.Reader) chan string { - ch := make(chan string) +func (sio *SerialIO) readBytes(logger *zap.SugaredLogger, reader *bufio.Reader) chan []byte { + ch := make(chan []byte) go func() { for { - line, err := reader.ReadString('\n') - if err != nil { + b, _ := reader.ReadByte() - if sio.deej.Verbose() { - logger.Warnw("Failed to read line from serial", "error", err, "line", line) - } + if b != 0xAA { + continue + } - // just ignore the line, the read loop will stop after this + frameSize := len(sio.deej.config.SliderMapping.m)*2 + 1 + payload := make([]byte, frameSize) + + if _, err := io.ReadFull(reader, payload); err != nil { + logger.Warnw("Failed to read bytes from serial", "error", err) + close(ch) return } if sio.deej.Verbose() { - logger.Debugw("Read new line", "line", line) + logger.Debugw("got frame", "len", len(payload), "hex", fmt.Sprintf("% X", payload)) + } + + if payload[len(payload)-1] != 0x55 { + logger.Debugw("wrong end byte", "byte", payload[frameSize-1]) + continue } - // deliver the line to the channel - ch <- line + ch <- payload[:frameSize-1] } }() return ch } -func (sio *SerialIO) handleLine(logger *zap.SugaredLogger, line string) { +func (sio *SerialIO) handleBytes(logger *zap.SugaredLogger, bytes []byte) { + data := []ArduinoData{} - // this function receives an unsanitized line which is guaranteed to end with LF, - // but most lines will end with CRLF. it may also have garbage instead of - // deej-formatted values, so we must check for that! just ignore bad ones - if !expectedLinePattern.MatchString(line) { + if len(bytes) != len(sio.deej.config.SliderMapping.m)*2 { + logger.Warnw("Wrong number of bytes received", "bytes number", len(bytes)) return } - // trim the suffix - line = strings.TrimSuffix(line, "\r\n") + for i := 0; i < len(bytes); i += 2 { + data = append(data, ArduinoData{}) + newDataIdx := len(data) - 1 + + packed := uint16(bytes[i])<<8 | uint16(bytes[i+1]) + + data[newDataIdx].ToggleMute = (packed>>11)&0x01 != 0 + + rawValue := packed & 0x07FF + + if rawValue&0x0400 != 0 { + data[newDataIdx].Value = int(int16(rawValue | 0xF800)) + } else { + data[newDataIdx].Value = int(rawValue) + } + } + + logger.Debugw("Reconstructed data", "data", data) - // split on pipe (|), this gives a slice of numerical strings between "0" and "1023" - splitLine := strings.Split(line, "|") - numSliders := len(splitLine) + numSliders := len(data) // update our slider count, if needed - this will send slider move events for all if numSliders != sio.lastKnownNumSliders { logger.Infow("Detected sliders", "amount", numSliders) sio.lastKnownNumSliders = numSliders - sio.currentSliderPercentValues = make([]float32, numSliders) + sio.currentVolumeDatas = make([]VolumeData, numSliders) // reset everything to be an impossible value to force the slider move event later - for idx := range sio.currentSliderPercentValues { - sio.currentSliderPercentValues[idx] = -1.0 + for idx := range sio.currentVolumeDatas { + sio.currentVolumeDatas[idx].Value = -1.0 } } // for each slider: - moveEvents := []SliderMoveEvent{} - for sliderIdx, stringValue := range splitLine { + sliderEvents := []SliderEvent{} + for sliderIdx, arduinoData := range data { - // convert string values to integers ("1023" -> 1023) - number, _ := strconv.Atoi(stringValue) + number := arduinoData.Value // turns out the first line could come out dirty sometimes (i.e. "4558|925|41|643|220") // so let's check the first number for correctness just in case if sliderIdx == 0 && number > 1023 { - sio.logger.Debugw("Got malformed line from serial, ignoring", "line", line) + sio.logger.Debugw("Got malformed line from serial, ignoring", "data", arduinoData) return } @@ -279,29 +309,81 @@ func (sio *SerialIO) handleLine(logger *zap.SugaredLogger, line string) { normalizedScalar = 1 - normalizedScalar } + additive := slices.Contains(sio.deej.config.AdditiveIndices, sliderIdx) + + if additive { + finalVolume := sio.deej.sessions.getCurrentVolume(sliderIdx) + + if number != 0 { + finalVolume += normalizedScalar + + if finalVolume < 0 { + finalVolume = 0 + } + if finalVolume > 1 { + finalVolume = 1 + } + } + + normalizedScalar = finalVolume + } + + if sio.deej.config.UseLogVolume && !additive { + normalizedScalar = LinearToLog(normalizedScalar) + logger.Infow("Bent linear to Log", "Linear", dirtyFloat, "Log", normalizedScalar) + } + // check if it changes the desired state (could just be a jumpy raw slider value) - if util.SignificantlyDifferent(sio.currentSliderPercentValues[sliderIdx], normalizedScalar, sio.deej.config.NoiseReductionLevel) { + // significantlyDifferent := util.SignificantlyDifferent(sio.currentVolumeDatas[sliderIdx].Value, normalizedScalar, sio.deej.config.NoiseReductionLevel) + significantlyDifferent := true + + if significantlyDifferent || arduinoData.ToggleMute { // if it does, update the saved value and create a move event - sio.currentSliderPercentValues[sliderIdx] = normalizedScalar + sio.currentVolumeDatas[sliderIdx].Value = normalizedScalar + sio.currentVolumeDatas[sliderIdx].Mute = !sio.currentVolumeDatas[sliderIdx].Mute - moveEvents = append(moveEvents, SliderMoveEvent{ + sliderEvents = append(sliderEvents, SliderEvent{ SliderID: sliderIdx, PercentValue: normalizedScalar, + ToggleMute: arduinoData.ToggleMute, }) if sio.deej.Verbose() { - logger.Debugw("Slider moved", "event", moveEvents[len(moveEvents)-1]) + logger.Debugw("Slider event", "event", sliderEvents[len(sliderEvents)-1]) } } } // deliver move events if there are any, towards all potential consumers - if len(moveEvents) > 0 { + if len(sliderEvents) > 0 { for _, consumer := range sio.sliderMoveConsumers { - for _, moveEvent := range moveEvents { + for _, moveEvent := range sliderEvents { consumer <- moveEvent } } } } + +const ( + minDB = -60.0 + maxDB = 0.0 +) + +// FromLinear converts a 0.0–1.0 UI value into an amplitude multiplier. +func LinearToLog(x float32) float32 { + // Clamp just in case. + if x <= 0 { + return 0 + } else if x >= 1 { + return 1 + } + + // Map the linear position to decibels. + db := minDB + x*(maxDB-minDB) // lerp + + // Convert dB to amplitude. (dB = 20 * log10(A)) + amp := math.Pow(10.0, float64(db/20.0)) + + return float32(amp) +} diff --git a/pkg/deej/session.go b/pkg/deej/session.go index b1808368..1f1db153 100644 --- a/pkg/deej/session.go +++ b/pkg/deej/session.go @@ -11,9 +11,8 @@ type Session interface { GetVolume() float32 SetVolume(v float32) error - // TODO: future mute support - // GetMute() bool - // SetMute(m bool) error + GetMute() bool + SetMute(m bool) error Key() string Release() diff --git a/pkg/deej/session_finder_windows.go b/pkg/deej/session_finder_windows.go index be0fdd0c..52125729 100644 --- a/pkg/deej/session_finder_windows.go +++ b/pkg/deej/session_finder_windows.go @@ -3,6 +3,7 @@ package deej import ( "errors" "fmt" + "runtime" "strings" "syscall" "time" @@ -55,6 +56,9 @@ func newSessionFinder(logger *zap.SugaredLogger) (SessionFinder, error) { } func (sf *wcaSessionFinder) GetAllSessions() ([]Session, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + sessions := []Session{} // we must call this every time we're about to list devices, i think. could be wrong @@ -148,7 +152,6 @@ func (sf *wcaSessionFinder) GetAllSessions() ([]Session, error) { } func (sf *wcaSessionFinder) Release() error { - // skip unregistering the mmnotificationclient, as it's not implemented in go-wca if sf.mmDeviceEnumerator != nil { sf.mmDeviceEnumerator.Release() @@ -160,7 +163,6 @@ func (sf *wcaSessionFinder) Release() error { } func (sf *wcaSessionFinder) getDeviceEnumerator() error { - // get the IMMDeviceEnumerator (only once) if sf.mmDeviceEnumerator == nil { if err := wca.CoCreateInstance( @@ -179,7 +181,6 @@ func (sf *wcaSessionFinder) getDeviceEnumerator() error { } func (sf *wcaSessionFinder) getDefaultAudioEndpoints() (*wca.IMMDevice, *wca.IMMDevice, error) { - // get the default audio endpoints as IMMDevice instances var mmOutDevice *wca.IMMDevice var mmInDevice *wca.IMMDevice @@ -222,7 +223,6 @@ func (sf *wcaSessionFinder) registerDefaultDeviceChangeCallback() error { } func (sf *wcaSessionFinder) getMasterSession(mmDevice *wca.IMMDevice, key string, loggerKey string) (*masterSession, error) { - var audioEndpointVolume *wca.IAudioEndpointVolume if err := mmDevice.Activate(wca.IID_IAudioEndpointVolume, wca.CLSCTX_ALL, nil, &audioEndpointVolume); err != nil { @@ -241,7 +241,6 @@ func (sf *wcaSessionFinder) getMasterSession(mmDevice *wca.IMMDevice, key string } func (sf *wcaSessionFinder) enumerateAndAddSessions(sessions *[]Session) error { - // get list of devices var deviceCollection *wca.IMMDeviceCollection @@ -355,7 +354,6 @@ func (sf *wcaSessionFinder) enumerateAndAddSessions(sessions *[]Session) error { newSession, err := sf.getMasterSession(endpoint, endpointFriendlyName, fmt.Sprintf(deviceSessionFormat, endpointDescription)) - if err != nil { sf.logger.Warnw("Failed to get master session for device", "deviceIdx", deviceIdx, @@ -376,7 +374,6 @@ func (sf *wcaSessionFinder) enumerateAndAddProcessSessions( endpointFriendlyName string, sessions *[]Session, ) error { - sf.logger.Debugw("Enumerating and adding process sessions for audio output device", "deviceFriendlyName", endpointFriendlyName) @@ -515,7 +512,6 @@ func (sf *wcaSessionFinder) defaultDeviceChangedCallback( EDataFlow, eRole uint32, lpcwstr uintptr, ) (hResult uintptr) { - // filter out calls that happen in rapid succession now := time.Now() @@ -536,6 +532,7 @@ func (sf *wcaSessionFinder) defaultDeviceChangedCallback( return } + func (sf *wcaSessionFinder) noopCallback() (hResult uintptr) { return } diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go index 4f6b1980..842ff7ac 100644 --- a/pkg/deej/session_map.go +++ b/pkg/deej/session_map.go @@ -97,7 +97,6 @@ func (m *sessionMap) release() error { // assumes the session map is clean! // only call on a new session map or as part of refreshSessions which calls reset func (m *sessionMap) getAndAddSessions() error { - // mark that we're refreshing before anything else m.lastSessionRefresh = time.Now() m.unmappedSessions = nil @@ -143,7 +142,7 @@ func (m *sessionMap) setupOnSliderMove() { for { select { case event := <-sliderEventsChannel: - m.handleSliderMoveEvent(event) + m.handleSliderEvent(event) } } }() @@ -151,7 +150,6 @@ func (m *sessionMap) setupOnSliderMove() { // performance: explain why force == true at every such use to avoid unintended forced refresh spams func (m *sessionMap) refreshSessions(force bool) { - // make sure enough time passed since the last refresh, unless force is true in which case always clear if !force && m.lastSessionRefresh.Add(minTimeBetweenSessionRefreshes).After(time.Now()) { return @@ -171,7 +169,6 @@ func (m *sessionMap) refreshSessions(force bool) { // special sessions (master, system, mic) and device-specific sessions always count as mapped, // even when absent from the config. this makes sense for every current feature that uses "unmapped sessions" func (m *sessionMap) sessionMapped(session Session) bool { - // count master/system/mic as mapped if funk.ContainsString([]string{masterSessionName, systemSessionName, inputSessionName}, session.Key()) { return true @@ -206,8 +203,40 @@ func (m *sessionMap) sessionMapped(session Session) bool { return matchFound } -func (m *sessionMap) handleSliderMoveEvent(event SliderMoveEvent) { +func (m *sessionMap) getCurrentVolume(sliderIdx int) float32 { + targets, ok := m.deej.config.SliderMapping.get(sliderIdx) + + if !ok { + m.logger.Warnw("SessionMap getCurrentVolume: couldn't find mapping for slider", "sliderIdx", sliderIdx) + return -1 + } + + for _, target := range targets { + + // resolve the target name by cleaning it up and applying any special transformations. + // depending on the transformation applied, this can result in more than one target name + resolvedTargets := m.resolveTarget(target) + + // for each resolved target... + for _, resolvedTarget := range resolvedTargets { + + // check the map for matching sessions + sessions, ok := m.get(resolvedTarget) + + // no sessions matching this target - move on + if !ok { + continue + } + + return sessions[0].GetVolume() + } + } + + m.logger.Warnw("SessionMap getCurrentVolume: couldn't find the session. returning -1", "sliderIdx", sliderIdx) + return -1 +} +func (m *sessionMap) handleSliderEvent(event SliderEvent) { // first of all, ensure our session map isn't moldy if m.lastSessionRefresh.Add(maxTimeBetweenSessionRefreshes).Before(time.Now()) { m.logger.Debug("Stale session map detected on slider move, refreshing") @@ -247,12 +276,27 @@ func (m *sessionMap) handleSliderMoveEvent(event SliderMoveEvent) { // iterate all matching sessions and adjust the volume of each one for _, session := range sessions { + if target == specialTargetTransformPrefix+specialTargetCurrentWindow { + if m.sessionMapped(session) { + continue + } + } + if session.GetVolume() != event.PercentValue { if err := session.SetVolume(event.PercentValue); err != nil { m.logger.Warnw("Failed to set target session volume", "error", err) adjustmentFailed = true } } + + if event.ToggleMute { + sessionMute := session.GetMute() + + if err := session.SetMute(!sessionMute); err != nil { + m.logger.Warnw("Failed to set target session mute", "error", err) + adjustmentFailed = true + } + } } } } @@ -263,7 +307,6 @@ func (m *sessionMap) handleSliderMoveEvent(event SliderMoveEvent) { if !targetFound { m.refreshSessions(false) } else if adjustmentFailed { - // performance: the reason that forcing a refresh here is okay is that we'll only get here // when a session's SetVolume call errored, such as in the case of a stale master session // (or another, more catastrophic failure happens) @@ -276,7 +319,6 @@ func (m *sessionMap) targetHasSpecialTransform(target string) bool { } func (m *sessionMap) resolveTarget(target string) []string { - // start by ignoring the case target = strings.ToLower(target) @@ -289,14 +331,12 @@ func (m *sessionMap) resolveTarget(target string) []string { } func (m *sessionMap) applyTargetTransform(specialTargetName string) []string { - // select the transformation based on its name switch specialTargetName { // get current active window case specialTargetCurrentWindow: currentWindowProcessNames, err := util.GetCurrentWindowProcessNames() - // silently ignore errors here, as this is on deej's "hot path" (and it could just mean the user's running linux) if err != nil { return nil diff --git a/pkg/deej/session_windows.go b/pkg/deej/session_windows.go index 7cd5940d..895b1305 100644 --- a/pkg/deej/session_windows.go +++ b/pkg/deej/session_windows.go @@ -11,8 +11,10 @@ import ( "go.uber.org/zap" ) -var errNoSuchProcess = errors.New("No such process") -var errRefreshSessions = errors.New("Trigger session refresh") +var ( + errNoSuchProcess = errors.New("No such process") + errRefreshSessions = errors.New("Trigger session refresh") +) type wcaSession struct { baseSession @@ -43,7 +45,6 @@ func newWCASession( pid uint32, eventCtx *ole.GUID, ) (*wcaSession, error) { - s := &wcaSession{ control: control, volume: volume, @@ -93,7 +94,6 @@ func newMasterSession( key string, loggerKey string, ) (*masterSession, error) { - s := &masterSession{ volume: volume, eventCtx: eventCtx, @@ -109,6 +109,31 @@ func newMasterSession( return s, nil } +func (s *wcaSession) GetMute() bool { + var mute bool + + if err := s.volume.GetMute(&mute); err != nil { + s.logger.Warnw("Failed to get session mute", "error", err) + } + + return mute +} + +func (s *wcaSession) SetMute(m bool) error { + if err := s.volume.SetMute(m, s.eventCtx); err != nil { + s.logger.Warnw("Failed to set session mute", "error", err) + return fmt.Errorf("set session mute: %w", err) + } + + // mitigate expired sessions by checking the state whenever we change volumes + if err := s.RefreshExpiredSessions(); err != nil { + s.logger.Warnw("Expired Session", "error", err) + return err + } + + return nil +} + func (s *wcaSession) GetVolume() float32 { var level float32 @@ -119,13 +144,7 @@ func (s *wcaSession) GetVolume() float32 { return level } -func (s *wcaSession) SetVolume(v float32) error { - if err := s.volume.SetMasterVolume(v, s.eventCtx); err != nil { - s.logger.Warnw("Failed to set session volume", "error", err) - return fmt.Errorf("adjust session volume: %w", err) - } - - // mitigate expired sessions by checking the state whenever we change volumes +func (s *wcaSession) RefreshExpiredSessions() error { var state uint32 if err := s.control.GetState(&state); err != nil { @@ -138,6 +157,21 @@ func (s *wcaSession) SetVolume(v float32) error { return errRefreshSessions } + return nil +} + +func (s *wcaSession) SetVolume(v float32) error { + if err := s.volume.SetMasterVolume(v, s.eventCtx); err != nil { + s.logger.Warnw("Failed to set session volume", "error", err) + return fmt.Errorf("adjust session volume: %w", err) + } + + // mitigate expired sessions by checking the state whenever we change volumes + if err := s.RefreshExpiredSessions(); err != nil { + s.logger.Warnw("Expired Session", "error", err) + return err + } + s.logger.Debugw("Adjusting session volume", "to", fmt.Sprintf("%.2f", v)) return nil @@ -183,6 +217,35 @@ func (s *masterSession) SetVolume(v float32) error { return nil } +func (s *masterSession) SetMute(m bool) error { + if s.stale { + s.logger.Warnw("Session expired because default device has changed, triggering session refresh") + return errRefreshSessions + } + + if err := s.volume.SetMute(m, s.eventCtx); err != nil { + s.logger.Warnw("Failed to set session mute", + "error", err, + "mute", m) + + return fmt.Errorf("adjust session mute: %w", err) + } + + s.logger.Debugw("Adjusting session mute", "to", m) + + return nil +} + +func (s *masterSession) GetMute() bool { + var mute bool + + if err := s.volume.GetMute(&mute); err != nil { + s.logger.Warnw("Failed to get session mute", "error", err) + } + + return mute +} + func (s *masterSession) Release() { s.logger.Debug("Releasing audio session") diff --git a/pkg/deej/util/util.go b/pkg/deej/util/util.go index dac5596d..d328cb54 100644 --- a/pkg/deej/util/util.go +++ b/pkg/deej/util/util.go @@ -54,7 +54,6 @@ func GetCurrentWindowProcessNames() ([]string, error) { // OpenExternal spawns a detached window with the provided command and argument func OpenExternal(logger *zap.SugaredLogger, cmd string, arg string) error { - // use cmd for windows, bash for linux execCommandArgs := []string{"cmd.exe", "/C", "start", "/b", cmd, arg} if Linux() { @@ -83,10 +82,10 @@ func NormalizeScalar(v float32) float32 { // SignificantlyDifferent returns true if there's a significant enough volume difference between two given values func SignificantlyDifferent(old float32, new float32, noiseReductionLevel string) bool { - const ( - noiseReductionHigh = "high" - noiseReductionLow = "low" + noiseReductionHigh = "high" + noiseReductionLow = "low" + noiseReductionExtraLow = "extraLow" ) // this threshold is solely responsible for dealing with hardware interference when @@ -102,6 +101,9 @@ func SignificantlyDifferent(old float32, new float32, noiseReductionLevel string case noiseReductionLow: significantDifferenceThreshold = 0.015 break + case noiseReductionExtraLow: + significantDifferenceThreshold = 0.01 + break default: significantDifferenceThreshold = 0.025 break